From 8b2856d11af97f61f91ae3c103f138c2d8cb099e Mon Sep 17 00:00:00 2001 From: pauhull <22707808+phm07@users.noreply.github.com> Date: Thu, 2 May 2024 14:32:41 +0200 Subject: [PATCH] wip --- cmd/hcloud/main.go | 4 +- go.mod | 7 +- go.sum | 12 +- internal/cli/root.go | 5 +- internal/cmd/base/create.go | 2 +- internal/cmd/cmpl/suggestions.go | 11 ++ internal/cmd/config/add.go | 68 +++++++ internal/cmd/config/add_test.go | 152 +++++++++++++++ internal/cmd/config/config.go | 7 +- internal/cmd/config/get.go | 58 ++++++ internal/cmd/config/get_test.go | 59 ++++++ internal/cmd/config/list.go | 113 +++++++++++ internal/cmd/config/list_test.go | 146 +++++++++++++++ internal/cmd/config/remove.go | 68 +++++++ internal/cmd/config/remove_test.go | 93 +++++++++ internal/cmd/config/set.go | 33 +++- internal/cmd/config/set_test.go | 129 +++++++++++++ internal/cmd/config/testdata/cli.toml | 19 ++ internal/cmd/config/unset.go | 60 ++++++ internal/cmd/config/unset_test.go | 123 ++++++++++++ internal/cmd/firewall/add_rule.go | 2 +- internal/cmd/firewall/delete_rule.go | 2 +- internal/cmd/firewall/describe.go | 2 +- internal/cmd/firewall/describe_test.go | 2 +- internal/cmd/server/ssh.go | 2 +- internal/cmd/server/ssh_test.go | 2 +- internal/cmd/util/util.go | 20 ++ internal/cmd/util/validation_test.go | 7 +- internal/state/config/config.go | 219 +++++++++++----------- internal/state/config/config_mock.go | 39 +--- internal/state/config/context.go | 4 +- internal/state/config/options.go | 135 +++++++++---- internal/state/config/preferences.go | 134 ++++++++++--- internal/state/config/preferences_test.go | 14 +- internal/state/helpers.go | 2 +- internal/state/state.go | 22 ++- internal/testutil/fixture.go | 18 +- 37 files changed, 1549 insertions(+), 246 deletions(-) create mode 100644 internal/cmd/config/add.go create mode 100644 internal/cmd/config/add_test.go create mode 100644 internal/cmd/config/get.go create mode 100644 internal/cmd/config/get_test.go create mode 100644 internal/cmd/config/list.go create mode 100644 internal/cmd/config/list_test.go create mode 100644 internal/cmd/config/remove.go create mode 100644 internal/cmd/config/remove_test.go create mode 100644 internal/cmd/config/set_test.go create mode 100644 internal/cmd/config/testdata/cli.toml create mode 100644 internal/cmd/config/unset.go create mode 100644 internal/cmd/config/unset_test.go diff --git a/cmd/hcloud/main.go b/cmd/hcloud/main.go index 6417a195..96b3f3eb 100644 --- a/cmd/hcloud/main.go +++ b/cmd/hcloud/main.go @@ -40,8 +40,8 @@ func init() { func main() { cfg := config.NewConfig() - if err := config.ReadConfig(cfg); err != nil { - log.Fatalf("unable to read config file %s\n", err) + if err := config.ReadConfig(cfg, nil); err != nil { + 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..adf2cc2b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -20,13 +20,12 @@ func NewRootCommand(s state.State) *cobra.Command { DisableFlagsInUseLine: true, } - cmd.PersistentFlags().AddFlagSet(config.FlagSet) + cmd.PersistentFlags().AddFlagSet(s.Config().FlagSet()) cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { var err error out := os.Stdout - if quiet := config.OptionQuiet.Value(); quiet { - //if quiet := viper.GetBool("quiet"); quiet { + if quiet := config.OptionQuiet.Get(s.Config()); quiet { out, err = os.Open(os.DevNull) if err != nil { return err diff --git a/internal/cmd/base/create.go b/internal/cmd/base/create.go index 36e6795b..ee45495f 100644 --- a/internal/cmd/base/create.go +++ b/internal/cmd/base/create.go @@ -43,7 +43,7 @@ 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.Value() + quiet := config.OptionQuiet.Get(s.Config()) isSchema := outputFlags.IsSet("json") || outputFlags.IsSet("yaml") if isSchema && !quiet { 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..1d838aef --- /dev/null +++ b/internal/cmd/config/add.go @@ -0,0 +1,68 @@ +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 + } + + return s.Config().Write(os.Stdout) +} diff --git a/internal/cmd/config/add_test.go b/internal/cmd/config/add_test.go new file mode 100644 index 00000000..7ee76fa0 --- /dev/null +++ b/internal/cmd/config/add_test.go @@ -0,0 +1,152 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + + configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/testutil" +) + +func TestAdd(t *testing.T) { + type testCase struct { + name string + args []string + expOut string + expErr string + preRun func() + postRun func() + } + + testCases := []testCase{ + { + name: "add to existing", + args: []string{"default-ssh-keys", "a", "b", "c"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3", "a", "b", "c"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "global add to empty", + args: []string{"--global", "default-ssh-keys", "a", "b", "c"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + default_ssh_keys = ["a", "b", "c"] + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "global add to empty duplicate", + args: []string{"--global", "default-ssh-keys", "c", "b", "c", "a", "a"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + default_ssh_keys = ["a", "b", "c"] + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + preRun: func() { + _ = os.Setenv("HCLOUD_CONTEXT", "other_context") + }, + postRun: func() { + _ = os.Unsetenv("HCLOUD_CONTEXT") + }, + name: "add to other context", + args: []string{"default-ssh-keys", "I", "II", "III"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + default_ssh_keys = ["I", "II", "III"] + poll_interval = "1.234s" +`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + if tt.preRun != nil { + tt.preRun() + } + if tt.postRun != nil { + defer tt.postRun() + } + + fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + defer fx.Finish() + + cmd := configCmd.NewAddCommand(fx.State()) + + out, errOut, err := fx.Run(cmd, tt.args) + + assert.NoError(t, err) + assert.Equal(t, tt.expErr, errOut) + assert.Equal(t, tt.expOut, out) + }) + } +} 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..f24e954f --- /dev/null +++ b/internal/cmd/config/get.go @@ -0,0 +1,58 @@ +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + + "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 { + // set context to nil and then reload + config.OptionContext.OverrideAny(s.Config(), nil) + s.Config().Reset() + if err := config.ReadConfig(s.Config(), nil); 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.GetAsAny(s.Config()) + 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..773a0fb3 --- /dev/null +++ b/internal/cmd/config/get_test.go @@ -0,0 +1,59 @@ +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", + }, + { + key: "default-ssh-keys", + expOut: "[1 2 3]\n", + }, + } + + for _, tt := range testCases { + t.Run(tt.key, func(t *testing.T) { + fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + defer fx.Finish() + + cmd := configCmd.NewGetCommand(fx.State()) + + // sets flags and env variables + setTestValues(fx.Config) + 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..ffb678df --- /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 { + // set context to nil and then reload + config.OptionContext.OverrideAny(s.Config(), nil) + s.Config().Reset() + if err := config.ReadConfig(s.Config(), nil); 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.GetAsAny(s.Config()) + if opt.HasFlag(config.OptionFlagSensitive) && !allowSensitive { + val = "[redacted]" + } + if !all && !opt.Changed(s.Config()) { + continue + } + options = append(options, option{name, val, originToString(s.Config().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..74b65a29 --- /dev/null +++ b/internal/cmd/config/list_test.go @@ -0,0 +1,146 @@ +package config_test + +import ( + "os" + "testing" + + "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.NewFixtureWithConfigFile(t, "testdata/cli.toml") + defer fx.Finish() + + cmd := configCmd.NewListCommand(fx.State()) + + setTestValues(fx.Config) + 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(cfg config.Config) { + _ = os.Setenv("HCLOUD_POLL_INTERVAL", "1234ms") + _ = os.Setenv("HCLOUD_DEBUG", "true") + _ = cfg.FlagSet().Set("endpoint", "https://test-endpoint.com") + _ = cfg.FlagSet().Set("quiet", "true") +} diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go new file mode 100644 index 00000000..6642fb8e --- /dev/null +++ b/internal/cmd/config/remove.go @@ -0,0 +1,68 @@ +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 + } + + return s.Config().Write(os.Stdout) +} diff --git a/internal/cmd/config/remove_test.go b/internal/cmd/config/remove_test.go new file mode 100644 index 00000000..890d5c46 --- /dev/null +++ b/internal/cmd/config/remove_test.go @@ -0,0 +1,93 @@ +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 TestRemove(t *testing.T) { + type testCase struct { + name string + args []string + expOut string + expErr string + preRun func() + postRun func() + } + + testCases := []testCase{ + { + name: "remove from existing", + args: []string{"default-ssh-keys", "2", "3"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "remove all from existing", + args: []string{"default-ssh-keys", "1", "2", "3"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + if tt.preRun != nil { + tt.preRun() + } + if tt.postRun != nil { + defer tt.postRun() + } + + fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + defer fx.Finish() + + cmd := configCmd.NewRemoveCommand(fx.State()) + + out, errOut, err := fx.Run(cmd, tt.args) + + assert.NoError(t, err) + assert.Equal(t, tt.expErr, errOut) + assert.Equal(t, tt.expOut, out) + }) + } +} diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index fc72d90f..d1b080cd 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -5,19 +5,38 @@ import ( "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 @@ -33,17 +52,13 @@ func runSet(s state.State, cmd *cobra.Command, args []string) error { } 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)") - } + 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 } diff --git a/internal/cmd/config/set_test.go b/internal/cmd/config/set_test.go new file mode 100644 index 00000000..3e06a714 --- /dev/null +++ b/internal/cmd/config/set_test.go @@ -0,0 +1,129 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + + configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/testutil" +) + +func TestSet(t *testing.T) { + type testCase struct { + name string + args []string + expOut string + expErr string + preRun func() + postRun func() + } + + testCases := []testCase{ + { + name: "set in current context", + args: []string{"debug-file", "debug.log"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + debug_file = "debug.log" + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "set in other context", + preRun: func() { + // usually you would do this with a flag, but it is only defined on the root command, + // so we can't use it here + _ = os.Setenv("HCLOUD_CONTEXT", "other_context") + }, + postRun: func() { + _ = os.Unsetenv("HCLOUD_CONTEXT") + }, + args: []string{"default-ssh-keys", "a", "b", "c"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + default_ssh_keys = ["a", "b", "c"] + poll_interval = "1.234s" +`, + }, + { + name: "set globally", + args: []string{"--global", "poll-interval", "50ms"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + poll_interval = "50ms" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + if tt.preRun != nil { + tt.preRun() + } + if tt.postRun != nil { + defer tt.postRun() + } + + fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + defer fx.Finish() + + cmd := configCmd.NewSetCommand(fx.State()) + + out, errOut, err := fx.Run(cmd, tt.args) + + assert.NoError(t, err) + assert.Equal(t, tt.expErr, errOut) + assert.Equal(t, tt.expOut, out) + }) + } +} diff --git a/internal/cmd/config/testdata/cli.toml b/internal/cmd/config/testdata/cli.toml new file mode 100644 index 00000000..0712693f --- /dev/null +++ b/internal/cmd/config/testdata/cli.toml @@ -0,0 +1,19 @@ +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" diff --git a/internal/cmd/config/unset.go b/internal/cmd/config/unset.go new file mode 100644 index 00000000..744be036 --- /dev/null +++ b/internal/cmd/config/unset.go @@ -0,0 +1,60 @@ +package config + +import ( + "fmt" + "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 + } + + return s.Config().Write(nil) +} diff --git a/internal/cmd/config/unset_test.go b/internal/cmd/config/unset_test.go new file mode 100644 index 00000000..e41010d8 --- /dev/null +++ b/internal/cmd/config/unset_test.go @@ -0,0 +1,123 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + + configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/testutil" +) + +func TestUnset(t *testing.T) { + type testCase struct { + name string + args []string + expOut string + expErr string + preRun func() + postRun func() + } + + testCases := []testCase{ + { + name: "unset in current context", + args: []string{"quiet"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "unset in other context", + preRun: func() { + // usually you would do this with a flag, but it is only defined on the root command, + // so we can't use it here + _ = os.Setenv("HCLOUD_CONTEXT", "other_context") + }, + postRun: func() { + _ = os.Unsetenv("HCLOUD_CONTEXT") + }, + args: []string{"poll-interval"}, + expOut: `active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" +`, + }, + { + name: "unset globally", + args: []string{"debug", "--global"}, + expOut: `active_context = "test_context" + +[preferences] + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + default_ssh_keys = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + if tt.preRun != nil { + tt.preRun() + } + if tt.postRun != nil { + defer tt.postRun() + } + + fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + defer fx.Finish() + + cmd := configCmd.NewUnsetCommand(fx.State()) + + out, errOut, err := fx.Run(cmd, tt.args) + + assert.NoError(t, err) + assert.Equal(t, tt.expErr, errOut) + assert.Equal(t, tt.expOut, out) + }) + } +} 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/server/ssh.go b/internal/cmd/server/ssh.go index 9f9e41af..7990f2a8 100644 --- a/internal/cmd/server/ssh.go +++ b/internal/cmd/server/ssh.go @@ -58,7 +58,7 @@ var SSHCmd = base.Cmd{ } sshArgs := []string{"-l", user, "-p", strconv.Itoa(port), ipAddress.String()} - sshCommand := exec.Command(config.OptionSSHPath.Value(), append(sshArgs, args[1:]...)...) + sshCommand := exec.Command(config.OptionSSHPath.Get(s.Config()), append(sshArgs, args[1:]...)...) sshCommand.Stdin = os.Stdin sshCommand.Stdout = os.Stdout sshCommand.Stderr = os.Stderr diff --git a/internal/cmd/server/ssh_test.go b/internal/cmd/server/ssh_test.go index 7a38911a..a9bc5c4d 100644 --- a/internal/cmd/server/ssh_test.go +++ b/internal/cmd/server/ssh_test.go @@ -29,7 +29,7 @@ func TestSSH(t *testing.T) { Get(gomock.Any(), srv.Name). Return(&srv, nil, nil) - config.OptionSSHPath.SetValue("echo") + config.OptionSSHPath.Override(fx.Config, "echo") } testutil.TestCommand(t, &server.SSHCmd, map[string]testutil.TestCase{ 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/cmd/util/validation_test.go b/internal/cmd/util/validation_test.go index 1d169fd3..de637e7d 100644 --- a/internal/cmd/util/validation_test.go +++ b/internal/cmd/util/validation_test.go @@ -1,4 +1,4 @@ -package util +package util_test import ( "testing" @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/testutil" ) @@ -90,9 +91,9 @@ func TestValidate(t *testing.T) { stdout, stderr, err := testutil.CaptureOutStreams(func() error { cmd := &cobra.Command{Use: test.use} if test.lenient { - return ValidateLenient(cmd, test.args) + return util.ValidateLenient(cmd, test.args) } else { - return Validate(cmd, test.args) + return util.Validate(cmd, test.args) } }) diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 0ae07ba8..294541e3 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -6,19 +6,19 @@ 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 { // Write writes the config to the given writer. If w is nil, the config is written to the config file. Write(w io.Writer) error - ParseConfig() error + Reset() + ParseConfigFile(f any) error ActiveContext() Context SetActiveContext(Context) @@ -26,135 +26,153 @@ type Config interface { SetContexts([]Context) Preferences() Preferences + Viper() *viper.Viper + FlagSet() *pflag.FlagSet } type schema struct { ActiveContext string `toml:"active_context"` - Preferences preferences `toml:"preferences"` + Preferences Preferences `toml:"preferences"` Contexts []*context `toml:"contexts"` } type config struct { + v *viper.Viper + fs *pflag.FlagSet path string activeContext *context contexts []*context - preferences preferences + preferences Preferences + schema schema } -var FlagSet *pflag.FlagSet - -func init() { - ResetFlags() +func NewConfig() Config { + cfg := &config{} + cfg.Reset() + return cfg } -func ResetFlags() { - FlagSet = pflag.NewFlagSet("hcloud", pflag.ContinueOnError) - for _, o := range opts { - o.AddToFlagSet(FlagSet) +func (cfg *config) Reset() { + cfg.v = viper.New() + cfg.v.SetConfigType("toml") + cfg.v.SetEnvPrefix("HCLOUD") + cfg.v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + cfg.fs = pflag.NewFlagSet("hcloud", pflag.ContinueOnError) + for _, o := range Options { + o.addToFlagSet(cfg.fs) } - if err := viper.BindPFlags(FlagSet); err != nil { + if err := cfg.v.BindPFlags(cfg.fs); err != nil { panic(err) } } -func NewConfig() Config { - return &config{} -} - -func ReadConfig(cfg Config) error { - - viper.SetConfigType("toml") - viper.SetEnvPrefix("HCLOUD") +// ReadConfig reads the config from the flags, env and the given config file f. +// See [ParseConfigFile] for the supported types of f. +func ReadConfig(cfg Config, f any) error { // error is ignored since invalid flags are already handled by cobra - _ = FlagSet.Parse(os.Args[1:]) + _ = cfg.FlagSet().Parse(os.Args[1:]) // load env already so we can determine the active context - viper.AutomaticEnv() - - // load active context - if err := cfg.ParseConfig(); err != nil { - return err - } + cfg.Viper().AutomaticEnv() - return nil + return cfg.ParseConfigFile(f) } -func (cfg *config) ParseConfig() error { - var s schema +// ParseConfigFile parses the given config file f. +// f can be of the following types: +// - nil: the default config file is used +// - string: the path to the config file +// - io.Reader: the config is read from the reader +// - []byte: the config is read from the byte slice +// - any other type: an error is returned +func (cfg *config) ParseConfigFile(f any) error { + var ( + cfgBytes []byte + err error + ) - cfg.path = OptionConfig.Value() + cfg.path = OptionConfig.Get(cfg) + path, ok := f.(string) + if path != "" && ok { + cfg.path = path + } - // read config file - cfgBytes, err := os.ReadFile(cfg.path) - if err != nil { - return err + if f == nil || ok { + // read config from file + cfgBytes, err = os.ReadFile(cfg.path) + if err != nil { + return err + } + } else { + switch f := f.(type) { + case io.Reader: + cfgBytes, err = io.ReadAll(f) + if err != nil { + return err + } + case []byte: + cfgBytes = f + default: + return fmt.Errorf("invalid config file type %T", f) + } } - if err := toml.Unmarshal(cfgBytes, &s); err != nil { + + if err := toml.Unmarshal(cfgBytes, &cfg.schema); err != nil { return err } - // read config file into viper (particularly active_context) - if err := viper.ReadConfig(bytes.NewReader(cfgBytes)); err != nil { - return err + if cfg.schema.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 = cfg.v.ReadConfig(bytes.NewReader([]byte(fmt.Sprintf("context = %q\n", cfg.schema.ActiveContext)))) + if err != nil { + return err + } } // read active context from viper - if ctx := OptionContext.Value(); ctx != "" { - s.ActiveContext = ctx + activeContext := cfg.schema.ActiveContext + if ctx := OptionContext.Get(cfg); ctx != "" { + activeContext = ctx } - cfg.contexts = s.Contexts - for i, ctx := range s.Contexts { - if ctx.ContextName == s.ActiveContext { + cfg.contexts = cfg.schema.Contexts + for i, ctx := range cfg.contexts { + if ctx.ContextName == activeContext { cfg.activeContext = cfg.contexts[i] + break } } - if s.ActiveContext != "" && cfg.activeContext == nil { - _, _ = fmt.Fprintf(os.Stderr, "Warning: active context %q not found\n", s.ActiveContext) + if cfg.schema.ActiveContext != "" && cfg.activeContext == nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: active context %q not found\n", cfg.schema.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 = cfg.schema.Preferences + if err = cfg.preferences.merge(cfg.v); err != nil { return err } - // load context preferences if cfg.activeContext != nil { - if err = cfg.loadPreferences(cfg.activeContext.ContextPreferences); err != nil { + // Merge preferences into viper + if err = cfg.activeContext.ContextPreferences.merge(cfg.v); err != nil { return err } - // read context into viper (particularly the token) - ctxBytes, err := toml.Marshal(cfg.activeContext) - if 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 = cfg.v.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) @@ -167,15 +185,16 @@ func (cfg *config) Write(w io.Writer) (err error) { w = f } - var activeContext string - if cfg.activeContext != nil { - activeContext = cfg.activeContext.ContextName - } + s := cfg.schema - s := schema{ - ActiveContext: activeContext, - Preferences: cfg.preferences, - 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 +233,16 @@ func (cfg *config) SetContexts(contexts []Context) { func (cfg *config) Preferences() Preferences { if cfg.preferences == nil { - cfg.preferences = make(preferences) + cfg.preferences = make(Preferences) + cfg.schema.Preferences = cfg.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))) - } +func (cfg *config) Viper() *viper.Viper { + return cfg.v +} - return opts +func (cfg *config) FlagSet() *pflag.FlagSet { + return cfg.fs } diff --git a/internal/state/config/config_mock.go b/internal/state/config/config_mock.go index b973038c..ac72d0e3 100644 --- a/internal/state/config/config_mock.go +++ b/internal/state/config/config_mock.go @@ -1,41 +1,20 @@ package config -import "io" +import ( + "io" + "os" +) // We do not need to generate a gomock for the Config, since you can set config // values during tests with viper.Set() type MockConfig struct { - activeContext Context - contexts []Context + Config } -func (*MockConfig) Write(io.Writer) error { - return nil +func (c *MockConfig) Write(_ io.Writer) error { + // MockConfig always writes to stdout for testing purposes + return c.Config.Write(os.Stdout) } -func (*MockConfig) ParseConfig() error { - return nil -} - -func (m *MockConfig) ActiveContext() Context { - return m.activeContext -} - -func (m *MockConfig) SetActiveContext(ctx Context) { - m.activeContext = ctx -} - -func (m *MockConfig) Contexts() []Context { - return m.contexts -} - -func (m *MockConfig) SetContexts(ctxs []Context) { - m.contexts = ctxs -} - -func (*MockConfig) Preferences() Preferences { - return preferences{} -} - -var _ Config = &MockConfig{} +var _ Config = (*MockConfig)(nil) 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..4838d8e3 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -2,72 +2,138 @@ 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 + GetAsAny(c Config) any + OverrideAny(c Config, v any) + Changed(c Config) 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) +func (o *Option[T]) Get(c Config) T { + val := c.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]) SetValue(v T) { - viper.Set(o.Name, v) +func (o *Option[T]) GetAsAny(c Config) any { + return o.Get(c) } -func (o *Option[T]) HasSource(src OptionSource) bool { +func (o *Option[T]) Override(c Config, v T) { + c.Viper().Set(o.Name, v) +} + +func (o *Option[T]) OverrideAny(c Config, v any) { + c.Viper().Set(o.Name, v) +} + +func (o *Option[T]) Changed(c Config) bool { + return c.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 +150,8 @@ 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 return o } diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go index 0f7c1a12..0e0036d0 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(v *viper.Viper) 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 v.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/helpers.go b/internal/state/helpers.go index 38ca40b5..b2f745c3 100644 --- a/internal/state/helpers.go +++ b/internal/state/helpers.go @@ -65,7 +65,7 @@ func (c *state) ActionsProgresses(cmd *cobra.Command, ctx context.Context, actio } func (c *state) EnsureToken(_ *cobra.Command, _ []string) error { - token := config.OptionToken.Value() + token := config.OptionToken.Get(c.config) if token == "" { return errors.New("no active context or token (see `hcloud context --help`)") } diff --git a/internal/state/state.go b/internal/state/state.go index 5e72f925..3e7edf77 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.Get(c.config) + opts = append(opts, hcloud.WithToken(token)) + if ep := config.OptionEndpoint.Get(c.config); ep != "" { + opts = append(opts, hcloud.WithEndpoint(ep)) + } + if config.OptionDebug.Get(c.config) { + if filePath := config.OptionDebugFile.Get(c.config); 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) + if pollInterval > 0 { + opts = append(opts, hcloud.WithBackoffFunc(hcloud.ConstantBackoff(pollInterval))) + } + opts = append(opts, hcloud.WithApplication("hcloud-cli", version.Version)) return hcapi2.NewClient(opts...) } diff --git a/internal/testutil/fixture.go b/internal/testutil/fixture.go index 5def1ed5..d90c3226 100644 --- a/internal/testutil/fixture.go +++ b/internal/testutil/fixture.go @@ -8,7 +8,6 @@ import ( "github.com/golang/mock/gomock" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/hetznercloud/cli/internal/hcapi2" hcapi2_mock "github.com/hetznercloud/cli/internal/hcapi2/mock" @@ -25,15 +24,18 @@ type Fixture struct { Config *config.MockConfig } -// NewFixture creates a new Fixture. +// NewFixture creates a new Fixture with default config file. func NewFixture(t *testing.T) *Fixture { - ctrl := gomock.NewController(t) + return NewFixtureWithConfigFile(t, nil) +} - viper.Reset() - config.ResetFlags() - cfg := &config.MockConfig{} +// NewFixtureWithConfigFile creates a new Fixture with the given config file. +// See Config#ParseConfigFile for the supported types of f. +func NewFixtureWithConfigFile(t *testing.T, f any) *Fixture { + ctrl := gomock.NewController(t) - if err := config.ReadConfig(cfg); err != nil { + cfg := config.NewConfig() + if err := config.ReadConfig(cfg, f); err != nil { t.Fatal(err) } @@ -42,7 +44,7 @@ func NewFixture(t *testing.T) *Fixture { Client: hcapi2_mock.NewMockClient(ctrl), ActionWaiter: state.NewMockActionWaiter(ctrl), TokenEnsurer: state.NewMockTokenEnsurer(ctrl), - Config: cfg, + Config: &config.MockConfig{Config: cfg}, } }