diff --git a/cmd/hcloud/main.go b/cmd/hcloud/main.go index 6417a195..7be0c41b 100644 --- a/cmd/hcloud/main.go +++ b/cmd/hcloud/main.go @@ -41,7 +41,7 @@ func main() { cfg := config.NewConfig() if err := config.ReadConfig(cfg); err != nil { - log.Fatalf("unable to read config file %s\n", err) + log.Fatalf("unable to read config file: %s\n", err) } s, err := state.New(cfg) diff --git a/go.mod b/go.mod index 58977193..7c561b75 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/hetznercloud/cli go 1.21 +replace github.com/spf13/viper => github.com/phm07/viper v0.0.0-20240424133512-73ebad00c669 + require ( + github.com/BurntSushi/toml v1.3.2 github.com/boumenot/gocover-cobertura v1.2.0 github.com/cheggaaa/pb/v3 v3.1.5 github.com/dustin/go-humanize v1.0.1 @@ -12,7 +15,6 @@ require ( github.com/google/go-cmp v0.6.0 github.com/guptarohit/asciigraph v0.7.1 github.com/hetznercloud/hcloud-go/v2 v2.7.2 - github.com/pelletier/go-toml/v2 v2.2.1 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 @@ -35,6 +37,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.19.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect @@ -56,7 +59,7 @@ require ( golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.17.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/protobuf v1.32.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b997c11f..02324acf 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -39,8 +41,6 @@ github.com/guptarohit/asciigraph v0.7.1 h1:K+JWbRc04XEfv8BSZgNuvhCmpbvX4+9NYd/Ux github.com/guptarohit/asciigraph v0.7.1/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hetznercloud/hcloud-go/v2 v2.7.1 h1:D4domwRSLOyBL/bwzd1O7hunBbKmeEHZTa7GmCYrniY= -github.com/hetznercloud/hcloud-go/v2 v2.7.1/go.mod h1:49tIV+pXRJTUC7fbFZ03s45LKqSQdOPP5y91eOnJo/k= github.com/hetznercloud/hcloud-go/v2 v2.7.2 h1:UlE7n1GQZacCfyjv9tDVUN7HZfOXErPIfM/M039u9A0= github.com/hetznercloud/hcloud-go/v2 v2.7.2/go.mod h1:49tIV+pXRJTUC7fbFZ03s45LKqSQdOPP5y91eOnJo/k= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -64,6 +64,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/phm07/viper v0.0.0-20240424133512-73ebad00c669 h1:/RkERYB9EOE1AkgDmGheEbPkDt8usI0EGryLsQGvG2c= +github.com/phm07/viper v0.0.0-20240424133512-73ebad00c669/go.mod h1:Hqr8J4/Q1O00v/4zIIggDIidAoD4w8Oqtzc+Ew8QO+I= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -94,8 +96,6 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -164,8 +164,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/cli/root.go b/internal/cli/root.go index 7387bbfa..d7395518 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -26,7 +26,6 @@ func NewRootCommand(s state.State) *cobra.Command { var err error out := os.Stdout if quiet := config.OptionQuiet.Value(); quiet { - //if quiet := viper.GetBool("quiet"); quiet { out, err = os.Open(os.DevNull) if err != nil { return err diff --git a/internal/cmd/cmpl/suggestions.go b/internal/cmd/cmpl/suggestions.go index a52245f6..9d770ddd 100644 --- a/internal/cmd/cmpl/suggestions.go +++ b/internal/cmd/cmpl/suggestions.go @@ -93,3 +93,14 @@ func SuggestArgs( return f(cmd, args, toComplete) } } + +// NoFileCompletion returns a function that provides completion suggestions without +// file completion. +func NoFileCompletion(f func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective)) func( + *cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + + return func(command *cobra.Command, i []string, s string) ([]string, cobra.ShellCompDirective) { + candidates, _ := f(command, i, s) + return candidates, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go new file mode 100644 index 00000000..9381d7ab --- /dev/null +++ b/internal/cmd/config/add.go @@ -0,0 +1,69 @@ +package config + +import ( + "fmt" + "os" + "reflect" + + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cmd/cmpl" + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" +) + +func NewAddCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "add ...", + Short: "Set a configuration value", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + RunE: state.Wrap(s, runAdd), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( + cmpl.SuggestCandidatesF(func() []string { + var keys []string + for key, opt := range config.Options { + if opt.HasFlag(config.OptionFlagPreference) { + keys = append(keys, key) + } + } + return keys + }), + cmpl.SuggestCandidatesCtx(func(_ *cobra.Command, args []string) []string { + var comps []string + if opt, ok := config.Options[args[0]]; ok { + comps = opt.Completions() + } + return comps + }), + )), + } + cmd.Flags().Bool("global", false, "Set the value globally (for all contexts)") + return cmd +} + +func runAdd(s state.State, cmd *cobra.Command, args []string) error { + global, _ := cmd.Flags().GetBool("global") + + var prefs config.Preferences + + if global { + prefs = s.Config().Preferences() + } else { + ctx := s.Config().ActiveContext() + if reflect.ValueOf(ctx).IsNil() { + return fmt.Errorf("no active context (use --global flag to set a global option)") + } + prefs = ctx.Preferences() + } + + key, values := args[0], args[1:] + if err := prefs.Add(key, values); err != nil { + return err + } + + _ = s.Config().Write(nil) + return s.Config().Write(os.Stdout) +} diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 76f52433..9cfb8b50 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -16,7 +16,12 @@ func NewCommand(s state.State) *cobra.Command { DisableFlagsInUseLine: true, } cmd.AddCommand( - newSetCommand(s), + NewSetCommand(s), + NewGetCommand(s), + NewListCommand(s), + NewUnsetCommand(s), + NewAddCommand(s), + NewRemoveCommand(s), ) return cmd } diff --git a/internal/cmd/config/get.go b/internal/cmd/config/get.go new file mode 100644 index 00000000..96ac5994 --- /dev/null +++ b/internal/cmd/config/get.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" +) + +func NewGetCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a configuration value", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + RunE: state.Wrap(s, runGet), + } + cmd.Flags().Bool("global", false, "Get the value globally") + cmd.Flags().Bool("allow-sensitive", false, "Allow showing sensitive values") + return cmd +} + +func runGet(s state.State, cmd *cobra.Command, args []string) error { + global, _ := cmd.Flags().GetBool("global") + allowSensitive, _ := cmd.Flags().GetBool("allow-sensitive") + + if global { + viper.Reset() + config.ResetFlags() + viper.Set("context", nil) + if err := s.Config().ParseConfig(); err != nil { + return err + } + } + + key := args[0] + var opt config.IOption + for name, o := range config.Options { + if name == key { + opt = o + break + } + } + if opt == nil { + return fmt.Errorf("unknown key: %s", key) + } + + val := opt.ValueAny() + if opt.HasFlag(config.OptionFlagSensitive) && !allowSensitive { + return fmt.Errorf("'%s' is sensitive. use --allow-sensitive to show the value", key) + } + cmd.Println(val) + return nil +} diff --git a/internal/cmd/config/get_test.go b/internal/cmd/config/get_test.go new file mode 100644 index 00000000..82b6402d --- /dev/null +++ b/internal/cmd/config/get_test.go @@ -0,0 +1,54 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/testutil" +) + +func TestGet(t *testing.T) { + type testCase struct { + key string + args []string + expOut string + expErr string + } + + testCases := []testCase{ + { + key: "context", + expOut: "test_context\n", + }, + { + key: "debug", + expOut: "true\n", + }, + { + key: "endpoint", + expOut: "https://test-endpoint.com\n", + }, + { + key: "poll-interval", + expOut: "1.234s\n", + }, + } + + for _, tt := range testCases { + t.Run(tt.key, func(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + cmd := configCmd.NewGetCommand(fx.State()) + + setTestValues() + out, errOut, err := fx.Run(cmd, append(tt.args, tt.key)) + + assert.NoError(t, err) + assert.Equal(t, tt.expErr, errOut) + assert.Equal(t, tt.expOut, out) + }) + } +} diff --git a/internal/cmd/config/list.go b/internal/cmd/config/list.go new file mode 100644 index 00000000..ceb03411 --- /dev/null +++ b/internal/cmd/config/list.go @@ -0,0 +1,113 @@ +package config + +import ( + "slices" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/hetznercloud/cli/internal/cmd/output" + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" +) + +var outputColumns = []string{"key", "value", "origin"} + +func NewListCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List configuration values", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + RunE: state.Wrap(s, runList), + } + cmd.Flags().BoolP("all", "a", false, "Also show default values") + cmd.Flags().BoolP("global", "g", false, "Only show global values") + cmd.Flags().Bool("allow-sensitive", false, "Allow showing sensitive values") + + output.AddFlag(cmd, output.OptionNoHeader(), output.OptionColumns(outputColumns), output.OptionJSON(), output.OptionYAML()) + return cmd +} + +func runList(s state.State, cmd *cobra.Command, _ []string) error { + all, _ := cmd.Flags().GetBool("all") + global, _ := cmd.Flags().GetBool("global") + allowSensitive, _ := cmd.Flags().GetBool("allow-sensitive") + outOpts := output.FlagsForCommand(cmd) + + if global { + viper.Reset() + config.ResetFlags() + viper.Set("context", nil) + if err := s.Config().ParseConfig(); err != nil { + return err + } + } + + type option struct { + Key string `json:"key"` + Value any `json:"value"` + Origin string `json:"origin"` + } + + var options []option + for name, opt := range config.Options { + val := opt.ValueAny() + if opt.HasFlag(config.OptionFlagSensitive) && !allowSensitive { + val = "[redacted]" + } + if !all && !opt.Changed() { + continue + } + options = append(options, option{name, val, originToString(viper.Origin(name))}) + } + + // Sort options for reproducible output + slices.SortFunc(options, func(a, b option) int { + return strings.Compare(a.Key, b.Key) + }) + + if outOpts.IsSet("json") || outOpts.IsSet("yaml") { + schema := util.Wrap("options", options) + if outOpts.IsSet("json") { + return util.DescribeJSON(schema) + } else { + return util.DescribeYAML(schema) + } + } + + cols := outputColumns + if outOpts.IsSet("columns") { + cols = outOpts["columns"] + } + + t := output.NewTable() + t.AddAllowedFields(option{}) + if !outOpts.IsSet("noheader") { + t.WriteHeader(cols) + } + for _, opt := range options { + t.Write(cols, opt) + } + return t.Flush() +} + +func originToString(orig viper.ValueOrigin) string { + switch orig { + case viper.ValueOriginFlag: + return "flag" + case viper.ValueOriginEnv: + return "environment" + case viper.ValueOriginConfig: + return "config file" + case viper.ValueOriginKVStore: + return "key-value store" + case viper.ValueOriginOverride: + return "override" + default: + return "default" + } +} diff --git a/internal/cmd/config/list_test.go b/internal/cmd/config/list_test.go new file mode 100644 index 00000000..521fc4f3 --- /dev/null +++ b/internal/cmd/config/list_test.go @@ -0,0 +1,157 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + + configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/state/config" + "github.com/hetznercloud/cli/internal/testutil" +) + +func TestList(t *testing.T) { + type testCase struct { + name string + args []string + expOut string + expErr string + } + + testCases := []testCase{ + { + name: "default", + args: []string{}, + expOut: `KEY VALUE ORIGIN +context test_context config file +debug yes environment +default-ssh-keys [1 2 3] config file +endpoint https://test-endpoint.com flag +poll-interval 1.234s environment +quiet yes flag +token [redacted] config file +`, + }, + { + name: "no origin", + args: []string{"-o=columns=key,value"}, + expOut: `KEY VALUE +context test_context +debug yes +default-ssh-keys [1 2 3] +endpoint https://test-endpoint.com +poll-interval 1.234s +quiet yes +token [redacted] +`, + }, + { + name: "no header", + args: []string{"-o=noheader"}, + expOut: `context test_context config file +debug yes environment +default-ssh-keys [1 2 3] config file +endpoint https://test-endpoint.com flag +poll-interval 1.234s environment +quiet yes flag +token [redacted] config file +`, + }, + { + name: "allow sensitive", + args: []string{"--allow-sensitive"}, + expOut: `KEY VALUE ORIGIN +context test_context config file +debug yes environment +default-ssh-keys [1 2 3] config file +endpoint https://test-endpoint.com flag +poll-interval 1.234s environment +quiet yes flag +token super secret token config file +`, + }, + { + name: "json", + args: []string{"-o=json"}, + expOut: `{ + "options": [ + { + "key": "context", + "value": "test_context", + "origin": "config file" + }, + { + "key": "debug", + "value": true, + "origin": "environment" + }, + { + "key": "default-ssh-keys", + "value": [ + "1", + "2", + "3" + ], + "origin": "config file" + }, + { + "key": "endpoint", + "value": "https://test-endpoint.com", + "origin": "flag" + }, + { + "key": "poll-interval", + "value": 1234000000, + "origin": "environment" + }, + { + "key": "quiet", + "value": true, + "origin": "flag" + }, + { + "key": "token", + "value": "[redacted]", + "origin": "config file" + } + ] +} +`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + cmd := configCmd.NewListCommand(fx.State()) + + setTestValues() + out, errOut, err := fx.Run(cmd, tt.args) + + assert.NoError(t, err) + assert.Equal(t, tt.expErr, errOut) + assert.Equal(t, tt.expOut, out) + }) + } +} + +func setTestValues() { + // set values for testing + + _ = viper.MergeConfigMap(map[string]interface{}{ + "token": "super secret token", + "context": "test_context", + "default-ssh-keys": []string{"1", "2", "3"}, + }) + + viper.AutomaticEnv() + _ = os.Setenv("HCLOUD_POLL_INTERVAL", "1234ms") + _ = os.Setenv("HCLOUD_DEBUG", "true") + + _ = config.FlagSet.Set("endpoint", "https://test-endpoint.com") + _ = config.FlagSet.Set("quiet", "true") +} diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go new file mode 100644 index 00000000..4aa318ac --- /dev/null +++ b/internal/cmd/config/remove.go @@ -0,0 +1,69 @@ +package config + +import ( + "fmt" + "os" + "reflect" + + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cmd/cmpl" + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" +) + +func NewRemoveCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "remove ...", + Short: "Remove a configuration value", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + RunE: state.Wrap(s, runRemove), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( + cmpl.SuggestCandidatesF(func() []string { + var keys []string + for key, opt := range config.Options { + if opt.HasFlag(config.OptionFlagPreference) { + keys = append(keys, key) + } + } + return keys + }), + cmpl.SuggestCandidatesCtx(func(_ *cobra.Command, args []string) []string { + var comps []string + if opt, ok := config.Options[args[0]]; ok { + comps = opt.Completions() + } + return comps + }), + )), + } + cmd.Flags().Bool("global", false, "Remove the value(s) globally (for all contexts)") + return cmd +} + +func runRemove(s state.State, cmd *cobra.Command, args []string) error { + global, _ := cmd.Flags().GetBool("global") + + var prefs config.Preferences + + if global { + prefs = s.Config().Preferences() + } else { + ctx := s.Config().ActiveContext() + if reflect.ValueOf(ctx).IsNil() { + return fmt.Errorf("no active context (use --global to remove an option globally)") + } + prefs = ctx.Preferences() + } + + key, values := args[0], args[1:] + if err := prefs.Remove(key, values); err != nil { + return err + } + + _ = s.Config().Write(nil) + return s.Config().Write(os.Stdout) +} diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index fc72d90f..d4bb2dd0 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -2,22 +2,43 @@ package config import ( "fmt" + "os" + "reflect" "github.com/spf13/cobra" + "github.com/hetznercloud/cli/internal/cmd/cmpl" "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/state" "github.com/hetznercloud/cli/internal/state/config" ) -func newSetCommand(s state.State) *cobra.Command { +func NewSetCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ - Use: "set ", + Use: "set ...", Short: "Set a configuration value", Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, RunE: state.Wrap(s, runSet), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( + cmpl.SuggestCandidatesF(func() []string { + var keys []string + for key, opt := range config.Options { + if opt.HasFlag(config.OptionFlagPreference) { + keys = append(keys, key) + } + } + return keys + }), + cmpl.SuggestCandidatesCtx(func(_ *cobra.Command, args []string) []string { + var comps []string + if opt, ok := config.Options[args[0]]; ok { + comps = opt.Completions() + } + return comps + }), + )), } cmd.Flags().Bool("global", false, "Set the value globally (for all contexts)") return cmd @@ -32,20 +53,17 @@ func runSet(s state.State, cmd *cobra.Command, args []string) error { prefs = s.Config().Preferences() } else { ctx := s.Config().ActiveContext() - if ctx == nil { - if ctxName := config.OptionContext.Value(); ctxName != "" { - return fmt.Errorf("active context \"%s\" not found", ctxName) - } else { - return fmt.Errorf("no active context (use --global flag to set a global option)") - } + if reflect.ValueOf(ctx).IsNil() { + return fmt.Errorf("no active context (use --global flag to set a global option)") } prefs = ctx.Preferences() } - key, value := args[0], args[1] - if err := prefs.Set(key, value); err != nil { + key, values := args[0], args[1:] + if err := prefs.Set(key, values); err != nil { return err } + s.Config().Write(os.Stdout) return s.Config().Write(nil) } diff --git a/internal/cmd/config/unset.go b/internal/cmd/config/unset.go new file mode 100644 index 00000000..9996f2c5 --- /dev/null +++ b/internal/cmd/config/unset.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + "os" + "reflect" + + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cmd/cmpl" + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" +) + +func NewUnsetCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "unset ", + Short: "Unset a configuration value", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + RunE: state.Wrap(s, runUnset), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( + cmpl.SuggestCandidatesF(func() []string { + var keys []string + for key, opt := range config.Options { + if opt.HasFlag(config.OptionFlagPreference) { + keys = append(keys, key) + } + } + return keys + }), + )), + } + cmd.Flags().Bool("global", false, "Unset the value globally (for all contexts)") + return cmd +} + +func runUnset(s state.State, cmd *cobra.Command, args []string) error { + global, _ := cmd.Flags().GetBool("global") + + var prefs config.Preferences + + if global { + prefs = s.Config().Preferences() + } else { + ctx := s.Config().ActiveContext() + if reflect.ValueOf(ctx).IsNil() { + return fmt.Errorf("no active context (use --global flag to unset a global option)") + } + prefs = ctx.Preferences() + } + + key := args[0] + if err := prefs.Unset(key); err != nil { + return err + } + + s.Config().Write(os.Stdout) + return s.Config().Write(nil) +} diff --git a/internal/cmd/firewall/add_rule.go b/internal/cmd/firewall/add_rule.go index 6a57e55e..6569a134 100644 --- a/internal/cmd/firewall/add_rule.go +++ b/internal/cmd/firewall/add_rule.go @@ -30,7 +30,7 @@ var AddRuleCmd = base.Cmd{ cmd.RegisterFlagCompletionFunc("protocol", cmpl.SuggestCandidates("icmp", "udp", "tcp", "esp", "gre")) cmd.MarkFlagRequired("protocol") - cmd.Flags().StringArray("source-ips", []string{}, "Source IPs (CIDR Notation) (required when direction is in)") + cmd.Flags().StringArray("source-ips", []string{}, "Origin IPs (CIDR Notation) (required when direction is in)") cmd.Flags().StringArray("destination-ips", []string{}, "Destination IPs (CIDR Notation) (required when direction is out)") diff --git a/internal/cmd/firewall/delete_rule.go b/internal/cmd/firewall/delete_rule.go index 87eb2d62..a75b1d49 100644 --- a/internal/cmd/firewall/delete_rule.go +++ b/internal/cmd/firewall/delete_rule.go @@ -31,7 +31,7 @@ var DeleteRuleCmd = base.Cmd{ cmd.RegisterFlagCompletionFunc("protocol", cmpl.SuggestCandidates("icmp", "udp", "tcp", "esp", "gre")) cmd.MarkFlagRequired("protocol") - cmd.Flags().StringArray("source-ips", []string{}, "Source IPs (CIDR Notation) (required when direction is in)") + cmd.Flags().StringArray("source-ips", []string{}, "Origin IPs (CIDR Notation) (required when direction is in)") cmd.Flags().StringArray("destination-ips", []string{}, "Destination IPs (CIDR Notation) (required when direction is out)") diff --git a/internal/cmd/firewall/describe.go b/internal/cmd/firewall/describe.go index bdcf3ab9..3a1ec947 100644 --- a/internal/cmd/firewall/describe.go +++ b/internal/cmd/firewall/describe.go @@ -59,7 +59,7 @@ var DescribeCmd = base.DescribeCmd{ var ips []net.IPNet switch rule.Direction { case hcloud.FirewallRuleDirectionIn: - cmd.Print(" Source IPs:\n") + cmd.Print(" Origin IPs:\n") ips = rule.SourceIPs case hcloud.FirewallRuleDirectionOut: cmd.Print(" Destination IPs:\n") diff --git a/internal/cmd/firewall/describe_test.go b/internal/cmd/firewall/describe_test.go index 32563384..c5262b9d 100644 --- a/internal/cmd/firewall/describe_test.go +++ b/internal/cmd/firewall/describe_test.go @@ -68,7 +68,7 @@ Rules: Description: ssh Protocol: tcp Port: 22 - Source IPs: + Origin IPs: Applied To: - Type: server Server ID: 321 diff --git a/internal/cmd/util/util.go b/internal/cmd/util/util.go index 05c8301e..543748b7 100644 --- a/internal/cmd/util/util.go +++ b/internal/cmd/util/util.go @@ -1,9 +1,11 @@ package util import ( + "cmp" "encoding/json" "fmt" "os" + "slices" "sort" "strings" "text/template" @@ -209,3 +211,21 @@ func AddGroup(cmd *cobra.Command, id string, title string, groupCmds ...*cobra.C func ToKebabCase(s string) string { return strings.ReplaceAll(strings.ToLower(s), " ", "-") } + +// SliceDiff returns the difference between the two passed slices. The returned slice contains all elements that are present in a but not in b. +// Note that it does not preserve order. +func SliceDiff[S ~[]E, E cmp.Ordered](a, b []E) []E { + m := make(map[E]struct{}) + for _, x := range a { + m[x] = struct{}{} + } + for _, x := range b { + delete(m, x) + } + var diff S + for x := range m { + diff = append(diff, x) + } + slices.Sort(diff) + return diff +} diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 0ae07ba8..b83213db 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -6,12 +6,11 @@ import ( "fmt" "io" "os" + "strings" - "github.com/pelletier/go-toml/v2" + "github.com/BurntSushi/toml" "github.com/spf13/pflag" "github.com/spf13/viper" - - "github.com/hetznercloud/hcloud-go/v2/hcloud" ) type Config interface { @@ -30,7 +29,7 @@ type Config interface { type schema struct { ActiveContext string `toml:"active_context"` - Preferences preferences `toml:"preferences"` + Preferences Preferences `toml:"preferences"` Contexts []*context `toml:"contexts"` } @@ -38,7 +37,7 @@ type config struct { path string activeContext *context contexts []*context - preferences preferences + preferences Preferences } var FlagSet *pflag.FlagSet @@ -49,8 +48,8 @@ func init() { func ResetFlags() { FlagSet = pflag.NewFlagSet("hcloud", pflag.ContinueOnError) - for _, o := range opts { - o.AddToFlagSet(FlagSet) + for _, o := range Options { + o.addToFlagSet(FlagSet) } if err := viper.BindPFlags(FlagSet); err != nil { panic(err) @@ -65,6 +64,7 @@ func ReadConfig(cfg Config) error { viper.SetConfigType("toml") viper.SetEnvPrefix("HCLOUD") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) // error is ignored since invalid flags are already handled by cobra _ = FlagSet.Parse(os.Args[1:]) @@ -90,13 +90,20 @@ func (cfg *config) ParseConfig() error { if err != nil { return err } + if err := toml.Unmarshal(cfgBytes, &s); err != nil { return err } - // read config file into viper (particularly active_context) - if err := viper.ReadConfig(bytes.NewReader(cfgBytes)); err != nil { - return err + if s.ActiveContext != "" { + // ReadConfig resets the current config and reads the new values + // We don't use viper.Set here because of the value hierarchy. We want the env and flags to + // be able to override the currently active context. viper.Set would take precedence over + // env and flags. + err = viper.ReadConfig(bytes.NewReader([]byte(fmt.Sprintf("context = %q\n", s.ActiveContext)))) + if err != nil { + return err + } } // read active context from viper @@ -115,46 +122,27 @@ func (cfg *config) ParseConfig() error { _, _ = fmt.Fprintf(os.Stderr, "Warning: active context %q not found\n", s.ActiveContext) } - // load global preferences first so that contexts can override them - if err = cfg.loadPreferences(cfg.preferences); err != nil { + // merge global preferences first so that contexts can override them + cfg.preferences = s.Preferences + if err = cfg.preferences.merge(); err != nil { return err } - // load context preferences if cfg.activeContext != nil { - if err = cfg.loadPreferences(cfg.activeContext.ContextPreferences); err != nil { - return err - } - // read context into viper (particularly the token) - ctxBytes, err := toml.Marshal(cfg.activeContext) - if err != nil { + // Merge preferences into viper + if err = cfg.activeContext.ContextPreferences.merge(); err != nil { return err } - if err = viper.ReadConfig(bytes.NewReader(ctxBytes)); err != nil { + // Merge token into viper + // We use viper.MergeConfig here for the same reason as above, except for + // that we merge the config instead of replacing it. + if err = viper.MergeConfig(bytes.NewReader([]byte(fmt.Sprintf(`token = "%s"`, cfg.activeContext.ContextToken)))); err != nil { return err } } return nil } -func (cfg *config) loadPreferences(prefs preferences) error { - if err := prefs.validate(); err != nil { - return err - } - ctxBytes, err := toml.Marshal(prefs) - if err != nil { - return err - } - return viper.MergeConfig(bytes.NewReader(ctxBytes)) -} - -func addOption[T any](flagFunc func(string, T, string) *T, key string, defaultVal T, usage string) { - if flagFunc != nil { - flagFunc(key, defaultVal, usage) - } - viper.SetDefault(key, defaultVal) -} - func (cfg *config) Write(w io.Writer) (err error) { if w == nil { f, err := os.OpenFile(cfg.path, os.O_WRONLY|os.O_APPEND|os.O_TRUNC, 0600) @@ -178,6 +166,16 @@ func (cfg *config) Write(w io.Writer) (err error) { Contexts: cfg.contexts, } + // this is so that we don't marshal empty preferences (this could happen e.g. after the last key is removed) + if s.Preferences != nil && len(s.Preferences) == 0 { + s.Preferences = nil + } + for _, ctx := range s.Contexts { + if ctx.ContextPreferences != nil && len(ctx.ContextPreferences) == 0 { + ctx.ContextPreferences = nil + } + } + return toml.NewEncoder(w).Encode(s) } @@ -214,32 +212,7 @@ func (cfg *config) SetContexts(contexts []Context) { func (cfg *config) Preferences() Preferences { if cfg.preferences == nil { - cfg.preferences = make(preferences) + cfg.preferences = make(Preferences) } return cfg.preferences } - -func GetHcloudOpts(cfg Config) []hcloud.ClientOption { - var opts []hcloud.ClientOption - - token := OptionToken.Value() - - opts = append(opts, hcloud.WithToken(token)) - if ep := OptionEndpoint.Value(); ep != "" { - opts = append(opts, hcloud.WithEndpoint(ep)) - } - if OptionDebug.Value() { - if filePath := OptionDebugFile.Value(); filePath == "" { - opts = append(opts, hcloud.WithDebugWriter(os.Stderr)) - } else { - writer, _ := os.Create(filePath) - opts = append(opts, hcloud.WithDebugWriter(writer)) - } - } - pollInterval := OptionPollInterval.Value() - if pollInterval > 0 { - opts = append(opts, hcloud.WithBackoffFunc(hcloud.ConstantBackoff(pollInterval))) - } - - return opts -} diff --git a/internal/state/config/config_mock.go b/internal/state/config/config_mock.go index b973038c..efb16c60 100644 --- a/internal/state/config/config_mock.go +++ b/internal/state/config/config_mock.go @@ -35,7 +35,7 @@ func (m *MockConfig) SetContexts(ctxs []Context) { } func (*MockConfig) Preferences() Preferences { - return preferences{} + return Preferences{} } var _ Config = &MockConfig{} diff --git a/internal/state/config/context.go b/internal/state/config/context.go index a2f4b829..f1b28a4e 100644 --- a/internal/state/config/context.go +++ b/internal/state/config/context.go @@ -16,7 +16,7 @@ func NewContext(name, token string) Context { type context struct { ContextName string `toml:"name"` ContextToken string `toml:"token"` - ContextPreferences preferences `toml:"preferences"` + ContextPreferences Preferences `toml:"preferences"` } func (ctx *context) Name() string { @@ -31,7 +31,7 @@ func (ctx *context) Token() string { func (ctx *context) Preferences() Preferences { if ctx.ContextPreferences == nil { - ctx.ContextPreferences = make(preferences) + ctx.ContextPreferences = make(Preferences) } return ctx.ContextPreferences } diff --git a/internal/state/config/options.go b/internal/state/config/options.go index 5b45bd54..55a40b3f 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -2,72 +2,139 @@ package config import ( "fmt" + "reflect" + "strconv" "time" + "github.com/spf13/cast" "github.com/spf13/pflag" "github.com/spf13/viper" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" ) -type OptionSource int +type OptionFlag int const ( - // OptionSourcePreference indicates that the option can be set in the config file, globally or per context (in the preferences section) - OptionSourcePreference OptionSource = 1 << iota - // OptionSourceConfig indicates that the option can be set in the config file, but only globally or per context (not in the preferences section) - OptionSourceConfig - // OptionSourceFlag indicates that the option can be set via a command line flag - OptionSourceFlag - // OptionSourceEnv indicates that the option can be set via an environment variable - OptionSourceEnv + // OptionFlagPreference indicates that the option can be set in the config file, globally or per context (in the preferences section) + OptionFlagPreference OptionFlag = 1 << iota + // OptionFlagConfig indicates that the option can be set in the config file, but only globally or per context (not in the preferences section) + OptionFlagConfig + // OptionFlagPFlag indicates that the option can be set via a command line flag + OptionFlagPFlag + // OptionFlagEnv indicates that the option can be set via an environment variable + OptionFlagEnv + // OptionFlagSensitive indicates that the option holds sensitive data and should not be printed + OptionFlagSensitive + + DefaultPreferenceFlags = OptionFlagPreference | OptionFlagConfig | OptionFlagPFlag | OptionFlagEnv ) -type opt interface { - AddToFlagSet(fs *pflag.FlagSet) - HasSource(src OptionSource) bool +type IOption interface { + addToFlagSet(fs *pflag.FlagSet) + HasFlag(src OptionFlag) bool + ValueAny() any + SetValueAny(v any) + Changed() bool + Completions() []string + IsSlice() bool T() any } -var opts = make(map[string]opt) +var Options = make(map[string]IOption) +// Note: &^ is the bit clear operator and is used to remove flags from the default flag set var ( - OptionConfig = newOpt("config", "Config file path", DefaultConfigPath(), OptionSourceFlag|OptionSourceEnv) - OptionToken = newOpt("token", "Hetzner Cloud API token", "", OptionSourceConfig|OptionSourceEnv) - OptionEndpoint = newOpt("endpoint", "Hetzner Cloud API endpoint", "", OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) - OptionDebug = newOpt("debug", "Enable debug output", false, OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) - OptionDebugFile = newOpt("debug-file", "Write debug output to file", "", OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) - OptionContext = newOpt("context", "Active context", "", OptionSourceConfig|OptionSourceFlag|OptionSourceEnv) - OptionPollInterval = newOpt("poll-interval", "Interval at which to poll information, for example action progress", 500*time.Millisecond, OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) - OptionQuiet = newOpt("quiet", "Only print error messages", false, OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) - OptionDefaultSSHKeys = newOpt("default-ssh-keys", "Default SSH keys for new servers", []string{}, OptionSourcePreference|OptionSourceEnv) - OptionSSHPath = newOpt("ssh-path", "Path to the ssh binary", "ssh", OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) + OptionConfig = newOpt("config", "Config file path", DefaultConfigPath(), OptionFlagPFlag|OptionFlagEnv) + OptionToken = newOpt("token", "Hetzner Cloud API token", "", OptionFlagConfig|OptionFlagEnv|OptionFlagSensitive) + OptionContext = newOpt("context", "Active context", "", OptionFlagConfig|OptionFlagEnv|OptionFlagPFlag) + OptionEndpoint = newOpt("endpoint", "Hetzner Cloud API endpoint", hcloud.Endpoint, DefaultPreferenceFlags) + OptionDebug = newOpt("debug", "Enable debug output", false, DefaultPreferenceFlags) + OptionDebugFile = newOpt("debug-file", "Write debug output to file", "", DefaultPreferenceFlags) + OptionPollInterval = newOpt("poll-interval", "Interval at which to poll information, for example action progress", 500*time.Millisecond, DefaultPreferenceFlags) + OptionQuiet = newOpt("quiet", "Only print error messages", false, DefaultPreferenceFlags) + OptionDefaultSSHKeys = newOpt("default-ssh-keys", "Default SSH keys for new servers", []string{}, DefaultPreferenceFlags&^OptionFlagPFlag) + OptionSSHPath = newOpt("ssh-path", "Path to the ssh binary", "ssh", DefaultPreferenceFlags) ) type Option[T any] struct { Name string Usage string Default T - Source OptionSource + Source OptionFlag } func (o *Option[T]) Value() T { - return viper.Get(o.Name).(T) + val := viper.Get(o.Name) + if val == nil { + return o.Default + } + 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 + } + case bool: + if v, ok := val.(string); ok { + b, err := strconv.ParseBool(v) + if err != nil { + panic(err) + } + val = b + } + case []string: + if v, ok := val.([]any); ok { + val = cast.ToStringSlice(v) + } + } + return val.(T) +} + +func (o *Option[T]) ValueAny() any { + return o.Value() } func (o *Option[T]) SetValue(v T) { viper.Set(o.Name, v) } -func (o *Option[T]) HasSource(src OptionSource) bool { +func (o *Option[T]) SetValueAny(v any) { + viper.Set(o.Name, v) +} + +func (o *Option[T]) Changed() bool { + return viper.IsSet(o.Name) +} + +func (o *Option[T]) HasFlag(src OptionFlag) bool { return o.Source&src != 0 } +func (o *Option[T]) IsSlice() bool { + return reflect.TypeOf(o.T()).Kind() == reflect.Slice +} + +func (o *Option[T]) Completions() []string { + var t T + switch any(t).(type) { + case bool: + return []string{"true", "false"} + } + return nil +} + func (o *Option[T]) T() any { var t T return t } -func (o *Option[T]) AddToFlagSet(fs *pflag.FlagSet) { - if !o.HasSource(OptionSourceFlag) { +func (o *Option[T]) addToFlagSet(fs *pflag.FlagSet) { + if !o.HasFlag(OptionFlagPFlag) { return } switch v := any(o.Default).(type) { @@ -84,9 +151,9 @@ func (o *Option[T]) AddToFlagSet(fs *pflag.FlagSet) { } } -func newOpt[T any](name, usage string, def T, source OptionSource) *Option[T] { +func newOpt[T any](name, usage string, def T, source OptionFlag) *Option[T] { o := &Option[T]{Name: name, Usage: usage, Default: def, Source: source} - opts[name] = o - viper.SetDefault(name, def) + Options[name] = o + //viper.SetDefault(name, def) return o } diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go index 0f7c1a12..e7a0897a 100644 --- a/internal/state/config/preferences.go +++ b/internal/state/config/preferences.go @@ -1,37 +1,36 @@ package config import ( + "bytes" "fmt" + "reflect" + "slices" "strings" "time" -) -type Preferences interface { - Set(key string, value string) error -} + "github.com/BurntSushi/toml" + "github.com/spf13/cast" + "github.com/spf13/viper" -// preferences are options that can be set in the config file, globally or per context -type preferences map[string]any + "github.com/hetznercloud/cli/internal/cmd/util" +) -func (p preferences) validate() error { - for key := range p { - opt, ok := opts[key] - if !ok || !opt.HasSource(OptionSourcePreference) { - return fmt.Errorf("unknown preference: %s", key) - } - } - return nil -} +// Preferences are options that can be set in the config file, globally or per context +type Preferences map[string]any -func (p preferences) Set(key string, value string) error { - opt, ok := opts[key] - if !ok || !opt.HasSource(OptionSourcePreference) { +func (p Preferences) Set(key string, values []string) error { + opt, ok := Options[key] + if !ok || !opt.HasFlag(OptionFlagPreference) { return fmt.Errorf("unknown preference: %s", key) } var val any switch t := opt.T().(type) { case bool: + if len(values) != 1 { + return fmt.Errorf("expected exactly one value") + } + value := values[0] switch strings.ToLower(value) { case "true", "t", "yes", "y", "1": val = true @@ -41,15 +40,25 @@ func (p preferences) Set(key string, value string) error { return fmt.Errorf("invalid boolean value: %s", value) } case string: - val = value + if len(values) != 1 { + return fmt.Errorf("expected exactly one value") + } + val = values[0] case time.Duration: + if len(values) != 1 { + return fmt.Errorf("expected exactly one value") + } + value := values[0] var err error val, err = time.ParseDuration(value) if err != nil { return fmt.Errorf("invalid duration value: %s", value) } case []string: - val = strings.Split(value, ",") + newVal := values[:] + slices.Sort(newVal) + newVal = slices.Compact(newVal) + val = newVal default: return fmt.Errorf("unsupported type %T", t) } @@ -60,4 +69,87 @@ func (p preferences) Set(key string, value string) error { return nil } -var _ Preferences = preferences{} +func (p Preferences) Unset(key string) error { + opt, ok := Options[key] + if !ok || !opt.HasFlag(OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) + } + + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + delete(p, configKey) + return nil +} + +func (p Preferences) Add(key string, values []string) error { + opt, ok := Options[key] + if !ok || !opt.HasFlag(OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) + } + + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + val := p[configKey] + switch opt.T().(type) { + case []string: + newVal := cast.ToStringSlice(val) + newVal = append(newVal, values...) + slices.Sort(newVal) + newVal = slices.Compact(newVal) + val = newVal + default: + return fmt.Errorf("%s is not a list", key) + } + + p[configKey] = val + return nil +} + +func (p Preferences) Remove(key string, values []string) error { + opt, ok := Options[key] + if !ok || !opt.HasFlag(OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) + } + + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + val := p[configKey] + switch opt.T().(type) { + case []string: + val = util.SliceDiff[[]string](cast.ToStringSlice(val), values) + default: + return fmt.Errorf("%s is not a list", key) + } + + if reflect.ValueOf(val).Len() == 0 { + delete(p, configKey) + } else { + p[configKey] = val + } + return nil +} + +func (p Preferences) merge() error { + if err := p.validate(); err != nil { + return err + } + m := make(map[string]any) + for k, v := range p { + m[strings.ReplaceAll(k, "_", "-")] = v + } + var buf bytes.Buffer + err := toml.NewEncoder(&buf).Encode(m) + if err != nil { + return err + } + return viper.MergeConfig(&buf) +} + +func (p Preferences) validate() error { + for key := range p { + opt, ok := Options[strings.ReplaceAll(key, "_", "-")] + if !ok || !opt.HasFlag(OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) + } + } + return nil +} + +var _ Preferences = Preferences{} diff --git a/internal/state/config/preferences_test.go b/internal/state/config/preferences_test.go index f2451b03..75778632 100644 --- a/internal/state/config/preferences_test.go +++ b/internal/state/config/preferences_test.go @@ -8,24 +8,24 @@ import ( func TestUnknownPreference(t *testing.T) { t.Run("existing", func(t *testing.T) { - clear(opts) - newOpt("foo", "", "", OptionSourcePreference) + clear(Options) + newOpt("foo", "", "", OptionFlagPreference) - p := preferences{"foo": ""} + p := Preferences{"foo": ""} assert.NoError(t, p.validate()) }) t.Run("existing but no preference", func(t *testing.T) { - clear(opts) + clear(Options) newOpt("foo", "", "", 0) - p := preferences{"foo": ""} + p := Preferences{"foo": ""} assert.EqualError(t, p.validate(), "unknown preference: foo") }) t.Run("not existing", func(t *testing.T) { - clear(opts) - p := preferences{"foo": ""} + clear(Options) + p := Preferences{"foo": ""} assert.EqualError(t, p.validate(), "unknown preference: foo") }) } diff --git a/internal/state/state.go b/internal/state/state.go index 5e72f925..b1e6882f 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -2,6 +2,7 @@ package state import ( "context" + "os" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state/config" @@ -45,7 +46,26 @@ func (c *state) Config() config.Config { } func (c *state) newClient() hcapi2.Client { - opts := config.GetHcloudOpts(c.Config()) + var opts []hcloud.ClientOption + + token := config.OptionToken.Value() + opts = append(opts, hcloud.WithToken(token)) + if ep := config.OptionEndpoint.Value(); ep != "" { + opts = append(opts, hcloud.WithEndpoint(ep)) + } + if config.OptionDebug.Value() { + if filePath := config.OptionDebugFile.Value(); filePath == "" { + opts = append(opts, hcloud.WithDebugWriter(os.Stderr)) + } else { + writer, _ := os.Create(filePath) + opts = append(opts, hcloud.WithDebugWriter(writer)) + } + } + pollInterval := config.OptionPollInterval.Value() + if pollInterval > 0 { + opts = append(opts, hcloud.WithBackoffFunc(hcloud.ConstantBackoff(pollInterval))) + } + opts = append(opts, hcloud.WithApplication("hcloud-cli", version.Version)) return hcapi2.NewClient(opts...) }