From d2869d74394caeb50d7a87723474ce0ca1052220 Mon Sep 17 00:00:00 2001 From: pauhull <22707808+pauhull@users.noreply.github.com> Date: Wed, 24 Jan 2024 13:34:59 +0100 Subject: [PATCH 01/37] feat: add global/per-context configuration preferences, add configuration subcommand --- cmd/hcloud/main.go | 12 +- internal/cli/root.go | 17 +- internal/cmd/base/create.go | 3 +- internal/cmd/config/config.go | 22 ++ internal/cmd/config/set.go | 51 ++++ internal/cmd/context/active.go | 2 +- internal/cmd/context/create.go | 6 +- internal/cmd/context/delete.go | 2 +- internal/cmd/context/list.go | 6 +- internal/cmd/context/use.go | 2 +- internal/cmd/server/ssh.go | 3 +- internal/cmd/server/ssh_test.go | 3 +- internal/hcapi2/mock/client.go | 7 +- internal/state/config/config.go | 291 +++++++++++++--------- internal/state/config/config_mock.go | 41 +++ internal/state/config/context.go | 65 +++++ internal/state/config/options.go | 92 +++++++ internal/state/config/preferences.go | 63 +++++ internal/state/config/preferences_test.go | 31 +++ internal/state/config/zz_config_mock.go | 140 ----------- internal/state/helpers.go | 3 +- internal/state/state.go | 68 +---- internal/testutil/fixture.go | 11 +- 23 files changed, 593 insertions(+), 348 deletions(-) create mode 100644 internal/cmd/config/config.go create mode 100644 internal/cmd/config/set.go create mode 100644 internal/state/config/config_mock.go create mode 100644 internal/state/config/context.go create mode 100644 internal/state/config/options.go create mode 100644 internal/state/config/preferences.go create mode 100644 internal/state/config/preferences_test.go delete mode 100644 internal/state/config/zz_config_mock.go diff --git a/cmd/hcloud/main.go b/cmd/hcloud/main.go index 1d98ebeb..6417a195 100644 --- a/cmd/hcloud/main.go +++ b/cmd/hcloud/main.go @@ -8,6 +8,7 @@ import ( "github.com/hetznercloud/cli/internal/cmd/all" "github.com/hetznercloud/cli/internal/cmd/certificate" "github.com/hetznercloud/cli/internal/cmd/completion" + configCmd "github.com/hetznercloud/cli/internal/cmd/config" "github.com/hetznercloud/cli/internal/cmd/context" "github.com/hetznercloud/cli/internal/cmd/datacenter" "github.com/hetznercloud/cli/internal/cmd/firewall" @@ -37,14 +38,10 @@ func init() { } func main() { - configPath := os.Getenv("HCLOUD_CONFIG") - if configPath == "" { - configPath = config.DefaultConfigPath() - } - cfg, err := config.ReadConfig(configPath) - if err != nil { - log.Fatalf("unable to read config file %q: %s\n", configPath, err) + cfg := config.NewConfig() + if err := config.ReadConfig(cfg); err != nil { + log.Fatalf("unable to read config file %s\n", err) } s, err := state.New(cfg) @@ -78,6 +75,7 @@ func main() { version.NewCommand(s), completion.NewCommand(s), context.NewCommand(s), + configCmd.NewCommand(s), ) if err := rootCommand.Execute(); err != nil { diff --git a/internal/cli/root.go b/internal/cli/root.go index 34731808..7387bbfa 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,12 +2,11 @@ package cli import ( "os" - "time" "github.com/spf13/cobra" "github.com/hetznercloud/cli/internal/state" - "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/cli/internal/state/config" ) func NewRootCommand(s state.State) *cobra.Command { @@ -20,18 +19,14 @@ func NewRootCommand(s state.State) *cobra.Command { SilenceErrors: true, DisableFlagsInUseLine: true, } - cmd.PersistentFlags().Duration("poll-interval", 500*time.Millisecond, "Interval at which to poll information, for example action progress") - cmd.PersistentFlags().Bool("quiet", false, "Only print error messages") - cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - pollInterval, err := cmd.Flags().GetDuration("poll-interval") - if err != nil { - return err - } - s.Client().WithOpts(hcloud.WithPollBackoffFunc(hcloud.ConstantBackoff(pollInterval))) + cmd.PersistentFlags().AddFlagSet(config.FlagSet) + cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + var err error out := os.Stdout - if quiet, _ := cmd.Flags().GetBool("quiet"); quiet { + 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/base/create.go b/internal/cmd/base/create.go index 934f5928..36e6795b 100644 --- a/internal/cmd/base/create.go +++ b/internal/cmd/base/create.go @@ -9,6 +9,7 @@ import ( "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" ) // CreateCmd allows defining commands for resource creation @@ -42,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, _ := cmd.Flags().GetBool("quiet") + quiet := config.OptionQuiet.Value() isSchema := outputFlags.IsSet("json") || outputFlags.IsSet("yaml") if isSchema && !quiet { diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go new file mode 100644 index 00000000..76f52433 --- /dev/null +++ b/internal/cmd/config/config.go @@ -0,0 +1,22 @@ +package config + +import ( + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/state" +) + +func NewCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage configuration", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + } + cmd.AddCommand( + newSetCommand(s), + ) + return cmd +} diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go new file mode 100644 index 00000000..fc72d90f --- /dev/null +++ b/internal/cmd/config/set.go @@ -0,0 +1,51 @@ +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 newSetCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "set ", + Short: "Set a configuration value", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + RunE: state.Wrap(s, runSet), + } + cmd.Flags().Bool("global", false, "Set the value globally (for all contexts)") + return cmd +} + +func runSet(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 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)") + } + } + prefs = ctx.Preferences() + } + + key, value := args[0], args[1] + if err := prefs.Set(key, value); err != nil { + return err + } + + return s.Config().Write(nil) +} diff --git a/internal/cmd/context/active.go b/internal/cmd/context/active.go index ea3e4065..80b9b286 100644 --- a/internal/cmd/context/active.go +++ b/internal/cmd/context/active.go @@ -27,7 +27,7 @@ func runActive(s state.State, cmd *cobra.Command, _ []string) error { _, _ = fmt.Fprintln(os.Stderr, "Warning: HCLOUD_TOKEN is set. The active context will have no effect.") } if ctx := s.Config().ActiveContext(); ctx != nil { - cmd.Println(ctx.Name) + cmd.Println(ctx.Name()) } return nil } diff --git a/internal/cmd/context/create.go b/internal/cmd/context/create.go index ae1a3889..cfc25836 100644 --- a/internal/cmd/context/create.go +++ b/internal/cmd/context/create.go @@ -43,8 +43,6 @@ func runCreate(s state.State, cmd *cobra.Command, args []string) error { return errors.New("name already used") } - context := &config.Context{Name: name} - var token string envToken := os.Getenv("HCLOUD_TOKEN") @@ -83,12 +81,12 @@ func runCreate(s state.State, cmd *cobra.Command, args []string) error { } } - context.Token = token + context := config.NewContext(name, token) cfg.SetContexts(append(cfg.Contexts(), context)) cfg.SetActiveContext(context) - if err := cfg.Write(); err != nil { + if err := cfg.Write(nil); err != nil { return err } diff --git a/internal/cmd/context/delete.go b/internal/cmd/context/delete.go index 56a77530..0ee96a2a 100644 --- a/internal/cmd/context/delete.go +++ b/internal/cmd/context/delete.go @@ -37,5 +37,5 @@ func runDelete(s state.State, _ *cobra.Command, args []string) error { cfg.SetActiveContext(nil) } config.RemoveContext(cfg, context) - return cfg.Write() + return cfg.Write(nil) } diff --git a/internal/cmd/context/list.go b/internal/cmd/context/list.go index a1b191b5..7b325a5d 100644 --- a/internal/cmd/context/list.go +++ b/internal/cmd/context/list.go @@ -58,11 +58,11 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { cfg := s.Config() for _, context := range cfg.Contexts() { presentation := ContextPresentation{ - Name: context.Name, - Token: context.Token, + Name: context.Name(), + Token: context.Token(), Active: " ", } - if ctx := cfg.ActiveContext(); ctx != nil && ctx.Name == context.Name { + if context == cfg.ActiveContext() { presentation.Active = "*" } diff --git a/internal/cmd/context/use.go b/internal/cmd/context/use.go index 6dbf0d64..f0a26e41 100644 --- a/internal/cmd/context/use.go +++ b/internal/cmd/context/use.go @@ -36,5 +36,5 @@ func runUse(s state.State, _ *cobra.Command, args []string) error { return fmt.Errorf("context not found: %v", name) } cfg.SetActiveContext(context) - return cfg.Write() + return cfg.Write(nil) } diff --git a/internal/cmd/server/ssh.go b/internal/cmd/server/ssh.go index 99ff0636..9f9e41af 100644 --- a/internal/cmd/server/ssh.go +++ b/internal/cmd/server/ssh.go @@ -14,6 +14,7 @@ import ( "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" ) var SSHCmd = base.Cmd{ @@ -57,7 +58,7 @@ var SSHCmd = base.Cmd{ } sshArgs := []string{"-l", user, "-p", strconv.Itoa(port), ipAddress.String()} - sshCommand := exec.Command(s.Config().SSHPath(), append(sshArgs, args[1:]...)...) + sshCommand := exec.Command(config.OptionSSHPath.Value(), 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 73dddd23..7a38911a 100644 --- a/internal/cmd/server/ssh_test.go +++ b/internal/cmd/server/ssh_test.go @@ -7,6 +7,7 @@ import ( "github.com/golang/mock/gomock" "github.com/hetznercloud/cli/internal/cmd/server" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" ) @@ -28,7 +29,7 @@ func TestSSH(t *testing.T) { Get(gomock.Any(), srv.Name). Return(&srv, nil, nil) - fx.Config.EXPECT().SSHPath().Return("echo") + config.OptionSSHPath.SetValue("echo") } testutil.TestCommand(t, &server.SSHCmd, map[string]testutil.TestCase{ diff --git a/internal/hcapi2/mock/client.go b/internal/hcapi2/mock/client.go index 25053cff..31638321 100644 --- a/internal/hcapi2/mock/client.go +++ b/internal/hcapi2/mock/client.go @@ -4,6 +4,7 @@ import ( "github.com/golang/mock/gomock" "github.com/hetznercloud/cli/internal/hcapi2" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/hcloud-go/v2/hcloud" ) @@ -122,6 +123,10 @@ func (c *MockClient) PlacementGroup() hcapi2.PlacementGroupClient { return c.PlacementGroupClient } -func (*MockClient) WithOpts(...hcloud.ClientOption) { +func (*MockClient) WithOpts(_ ...hcloud.ClientOption) { + // no-op +} + +func (*MockClient) FromConfig(_ config.Config) { // no-op } diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 4187e65c..0ae07ba8 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -1,178 +1,245 @@ package config import ( + "bytes" + "errors" "fmt" + "io" "os" - "path/filepath" - toml "github.com/pelletier/go-toml/v2" -) + "github.com/pelletier/go-toml/v2" + "github.com/spf13/pflag" + "github.com/spf13/viper" -//go:generate go run github.com/golang/mock/mockgen -package config -destination zz_config_mock.go github.com/hetznercloud/cli/internal/state/config Config + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) type Config interface { - Write() error + // 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 - ActiveContext() *Context - SetActiveContext(*Context) - Contexts() []*Context - SetContexts([]*Context) - Endpoint() string - SetEndpoint(string) + ActiveContext() Context + SetActiveContext(Context) + Contexts() []Context + SetContexts([]Context) - SSHPath() string + Preferences() Preferences } -type Context struct { - Name string - Token string +type schema struct { + ActiveContext string `toml:"active_context"` + Preferences preferences `toml:"preferences"` + Contexts []*context `toml:"contexts"` } type config struct { path string - endpoint string - activeContext *Context `toml:"active_context,omitempty"` - contexts []*Context `toml:"contexts"` + activeContext *context + contexts []*context + preferences preferences } -func ReadConfig(path string) (Config, error) { - cfg := &config{path: path} +var FlagSet *pflag.FlagSet - _, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return cfg, nil - } - return cfg, err - } +func init() { + ResetFlags() +} - data, err := os.ReadFile(path) - if err != nil { - return nil, err +func ResetFlags() { + FlagSet = pflag.NewFlagSet("hcloud", pflag.ContinueOnError) + for _, o := range opts { + o.AddToFlagSet(FlagSet) + } + if err := viper.BindPFlags(FlagSet); err != nil { + panic(err) } +} + +func NewConfig() Config { + return &config{} +} + +func ReadConfig(cfg Config) error { - if err = cfg.unmarshal(data); err != nil { - return nil, err + viper.SetConfigType("toml") + viper.SetEnvPrefix("HCLOUD") + + // error is ignored since invalid flags are already handled by cobra + _ = 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 } - return cfg, nil + return nil } -func (cfg *config) Write() error { - data, err := cfg.marshal() +func (cfg *config) ParseConfig() error { + var s schema + + cfg.path = OptionConfig.Value() + + // read config file + cfgBytes, err := os.ReadFile(cfg.path) if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(cfg.path), 0777); err != nil { + 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 err := os.WriteFile(cfg.path, data, 0600); err != nil { + + // read active context from viper + if ctx := OptionContext.Value(); ctx != "" { + s.ActiveContext = ctx + } + + cfg.contexts = s.Contexts + for i, ctx := range s.Contexts { + if ctx.ContextName == s.ActiveContext { + cfg.activeContext = cfg.contexts[i] + } + } + + if s.ActiveContext != "" && cfg.activeContext == nil { + _, _ = 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 { 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 { + return err + } + if err = viper.ReadConfig(bytes.NewReader(ctxBytes)); err != nil { + return err + } + } return nil } -func (cfg *config) ActiveContext() *Context { - return cfg.activeContext +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 (cfg *config) SetActiveContext(context *Context) { - cfg.activeContext = context +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) Contexts() []*Context { - return cfg.contexts -} +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) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, f.Close()) + }() + w = f + } -func (cfg *config) SetContexts(contexts []*Context) { - cfg.contexts = contexts -} + var activeContext string + if cfg.activeContext != nil { + activeContext = cfg.activeContext.ContextName + } -func (cfg *config) Endpoint() string { - return cfg.endpoint -} + s := schema{ + ActiveContext: activeContext, + Preferences: cfg.preferences, + Contexts: cfg.contexts, + } -func (cfg *config) SetEndpoint(endpoint string) { - cfg.endpoint = endpoint + return toml.NewEncoder(w).Encode(s) } -func (cfg *config) SSHPath() string { - return "ssh" +func (cfg *config) ActiveContext() Context { + return cfg.activeContext } -func ContextNames(cfg Config) []string { - ctxs := cfg.Contexts() - names := make([]string, len(ctxs)) - for i, ctx := range ctxs { - names[i] = ctx.Name +func (cfg *config) SetActiveContext(ctx Context) { + if ctx, ok := ctx.(*context); !ok { + panic("invalid context type") + } else { + cfg.activeContext = ctx } - return names } -func ContextByName(cfg Config, name string) *Context { - for _, c := range cfg.Contexts() { - if c.Name == name { - return c - } +func (cfg *config) Contexts() []Context { + ctxs := make([]Context, 0, len(cfg.contexts)) + for _, c := range cfg.contexts { + ctxs = append(ctxs, c) } - return nil + return ctxs } -func RemoveContext(cfg Config, context *Context) { - var filtered []*Context - for _, c := range cfg.Contexts() { - if c != context { - filtered = append(filtered, c) +func (cfg *config) SetContexts(contexts []Context) { + cfg.contexts = make([]*context, 0, len(cfg.contexts)) + for _, c := range contexts { + if c, ok := c.(*context); !ok { + panic("invalid context type") + } else { + cfg.contexts = append(cfg.contexts, c) } } - cfg.SetContexts(filtered) } -type rawConfig struct { - ActiveContext string `toml:"active_context,omitempty"` - Contexts []rawConfigContext `toml:"contexts"` +func (cfg *config) Preferences() Preferences { + if cfg.preferences == nil { + cfg.preferences = make(preferences) + } + return cfg.preferences } -type rawConfigContext struct { - Name string `toml:"name"` - Token string `toml:"token"` -} +func GetHcloudOpts(cfg Config) []hcloud.ClientOption { + var opts []hcloud.ClientOption -func (cfg *config) marshal() ([]byte, error) { - var raw rawConfig - if cfg.activeContext != nil { - raw.ActiveContext = cfg.activeContext.Name - } - for _, context := range cfg.contexts { - raw.Contexts = append(raw.Contexts, rawConfigContext{ - Name: context.Name, - Token: context.Token, - }) - } - return toml.Marshal(raw) -} + token := OptionToken.Value() -func (cfg *config) unmarshal(data []byte) error { - var raw rawConfig - if err := toml.Unmarshal(data, &raw); err != nil { - return err + opts = append(opts, hcloud.WithToken(token)) + if ep := OptionEndpoint.Value(); ep != "" { + opts = append(opts, hcloud.WithEndpoint(ep)) } - for _, rawContext := range raw.Contexts { - cfg.contexts = append(cfg.contexts, &Context{ - Name: rawContext.Name, - Token: rawContext.Token, - }) - } - if raw.ActiveContext != "" { - for _, c := range cfg.contexts { - if c.Name == raw.ActiveContext { - cfg.activeContext = c - break - } - } - if cfg.activeContext == nil { - return fmt.Errorf("active context %s not found", raw.ActiveContext) + 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)) } } - return nil + 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 new file mode 100644 index 00000000..b973038c --- /dev/null +++ b/internal/state/config/config_mock.go @@ -0,0 +1,41 @@ +package config + +import "io" + +// 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 +} + +func (*MockConfig) Write(io.Writer) error { + return nil +} + +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{} diff --git a/internal/state/config/context.go b/internal/state/config/context.go new file mode 100644 index 00000000..a2f4b829 --- /dev/null +++ b/internal/state/config/context.go @@ -0,0 +1,65 @@ +package config + +type Context interface { + Name() string + Token() string + Preferences() Preferences +} + +func NewContext(name, token string) Context { + return &context{ + ContextName: name, + ContextToken: token, + } +} + +type context struct { + ContextName string `toml:"name"` + ContextToken string `toml:"token"` + ContextPreferences preferences `toml:"preferences"` +} + +func (ctx *context) Name() string { + return ctx.ContextName +} + +// Token returns the token for the context. +// If you just need the token regardless of the context, please use [OptionToken] instead. +func (ctx *context) Token() string { + return ctx.ContextToken +} + +func (ctx *context) Preferences() Preferences { + if ctx.ContextPreferences == nil { + ctx.ContextPreferences = make(preferences) + } + return ctx.ContextPreferences +} + +func ContextNames(cfg Config) []string { + ctxs := cfg.Contexts() + names := make([]string, len(ctxs)) + for i, ctx := range ctxs { + names[i] = ctx.Name() + } + return names +} + +func ContextByName(cfg Config, name string) Context { + for _, c := range cfg.Contexts() { + if c.Name() == name { + return c + } + } + return nil +} + +func RemoveContext(cfg Config, context Context) { + var filtered []Context + for _, c := range cfg.Contexts() { + if c != context { + filtered = append(filtered, c) + } + } + cfg.SetContexts(filtered) +} diff --git a/internal/state/config/options.go b/internal/state/config/options.go new file mode 100644 index 00000000..5b45bd54 --- /dev/null +++ b/internal/state/config/options.go @@ -0,0 +1,92 @@ +package config + +import ( + "fmt" + "time" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +type OptionSource 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 +) + +type opt interface { + AddToFlagSet(fs *pflag.FlagSet) + HasSource(src OptionSource) bool + T() any +} + +var opts = make(map[string]opt) + +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) +) + +type Option[T any] struct { + Name string + Usage string + Default T + Source OptionSource +} + +func (o *Option[T]) Value() T { + return viper.Get(o.Name).(T) +} + +func (o *Option[T]) SetValue(v T) { + viper.Set(o.Name, v) +} + +func (o *Option[T]) HasSource(src OptionSource) bool { + return o.Source&src != 0 +} + +func (o *Option[T]) T() any { + var t T + return t +} + +func (o *Option[T]) AddToFlagSet(fs *pflag.FlagSet) { + if !o.HasSource(OptionSourceFlag) { + return + } + switch v := any(o.Default).(type) { + case bool: + fs.Bool(o.Name, v, o.Usage) + case string: + fs.String(o.Name, v, o.Usage) + case time.Duration: + fs.Duration(o.Name, v, o.Usage) + case []string: + fs.StringSlice(o.Name, v, o.Usage) + default: + panic(fmt.Sprintf("unsupported type %T", v)) + } +} + +func newOpt[T any](name, usage string, def T, source OptionSource) *Option[T] { + o := &Option[T]{Name: name, Usage: usage, Default: def, Source: source} + opts[name] = o + viper.SetDefault(name, def) + return o +} diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go new file mode 100644 index 00000000..0f7c1a12 --- /dev/null +++ b/internal/state/config/preferences.go @@ -0,0 +1,63 @@ +package config + +import ( + "fmt" + "strings" + "time" +) + +type Preferences interface { + Set(key string, value string) error +} + +// preferences are options that can be set in the config file, globally or per context +type preferences map[string]any + +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 +} + +func (p preferences) Set(key string, value string) error { + opt, ok := opts[key] + if !ok || !opt.HasSource(OptionSourcePreference) { + return fmt.Errorf("unknown preference: %s", key) + } + + var val any + switch t := opt.T().(type) { + case bool: + switch strings.ToLower(value) { + case "true", "t", "yes", "y", "1": + val = true + case "false", "f", "no", "n", "0": + val = false + default: + return fmt.Errorf("invalid boolean value: %s", value) + } + case string: + val = value + case time.Duration: + 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, ",") + default: + return fmt.Errorf("unsupported type %T", t) + } + + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + + p[configKey] = val + return nil +} + +var _ Preferences = preferences{} diff --git a/internal/state/config/preferences_test.go b/internal/state/config/preferences_test.go new file mode 100644 index 00000000..f2451b03 --- /dev/null +++ b/internal/state/config/preferences_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUnknownPreference(t *testing.T) { + t.Run("existing", func(t *testing.T) { + clear(opts) + newOpt("foo", "", "", OptionSourcePreference) + + p := preferences{"foo": ""} + assert.NoError(t, p.validate()) + }) + + t.Run("existing but no preference", func(t *testing.T) { + clear(opts) + newOpt("foo", "", "", 0) + + p := preferences{"foo": ""} + assert.EqualError(t, p.validate(), "unknown preference: foo") + }) + + t.Run("not existing", func(t *testing.T) { + clear(opts) + p := preferences{"foo": ""} + assert.EqualError(t, p.validate(), "unknown preference: foo") + }) +} diff --git a/internal/state/config/zz_config_mock.go b/internal/state/config/zz_config_mock.go deleted file mode 100644 index 63416270..00000000 --- a/internal/state/config/zz_config_mock.go +++ /dev/null @@ -1,140 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/hetznercloud/cli/internal/state/config (interfaces: Config) - -// Package config is a generated GoMock package. -package config - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockConfig is a mock of Config interface. -type MockConfig struct { - ctrl *gomock.Controller - recorder *MockConfigMockRecorder -} - -// MockConfigMockRecorder is the mock recorder for MockConfig. -type MockConfigMockRecorder struct { - mock *MockConfig -} - -// NewMockConfig creates a new mock instance. -func NewMockConfig(ctrl *gomock.Controller) *MockConfig { - mock := &MockConfig{ctrl: ctrl} - mock.recorder = &MockConfigMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockConfig) EXPECT() *MockConfigMockRecorder { - return m.recorder -} - -// ActiveContext mocks base method. -func (m *MockConfig) ActiveContext() *Context { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActiveContext") - ret0, _ := ret[0].(*Context) - return ret0 -} - -// ActiveContext indicates an expected call of ActiveContext. -func (mr *MockConfigMockRecorder) ActiveContext() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveContext", reflect.TypeOf((*MockConfig)(nil).ActiveContext)) -} - -// Contexts mocks base method. -func (m *MockConfig) Contexts() []*Context { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Contexts") - ret0, _ := ret[0].([]*Context) - return ret0 -} - -// Contexts indicates an expected call of Contexts. -func (mr *MockConfigMockRecorder) Contexts() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Contexts", reflect.TypeOf((*MockConfig)(nil).Contexts)) -} - -// Endpoint mocks base method. -func (m *MockConfig) Endpoint() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Endpoint") - ret0, _ := ret[0].(string) - return ret0 -} - -// Endpoint indicates an expected call of Endpoint. -func (mr *MockConfigMockRecorder) Endpoint() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Endpoint", reflect.TypeOf((*MockConfig)(nil).Endpoint)) -} - -// SSHPath mocks base method. -func (m *MockConfig) SSHPath() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SSHPath") - ret0, _ := ret[0].(string) - return ret0 -} - -// SSHPath indicates an expected call of SSHPath. -func (mr *MockConfigMockRecorder) SSHPath() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHPath", reflect.TypeOf((*MockConfig)(nil).SSHPath)) -} - -// SetActiveContext mocks base method. -func (m *MockConfig) SetActiveContext(arg0 *Context) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetActiveContext", arg0) -} - -// SetActiveContext indicates an expected call of SetActiveContext. -func (mr *MockConfigMockRecorder) SetActiveContext(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetActiveContext", reflect.TypeOf((*MockConfig)(nil).SetActiveContext), arg0) -} - -// SetContexts mocks base method. -func (m *MockConfig) SetContexts(arg0 []*Context) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetContexts", arg0) -} - -// SetContexts indicates an expected call of SetContexts. -func (mr *MockConfigMockRecorder) SetContexts(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetContexts", reflect.TypeOf((*MockConfig)(nil).SetContexts), arg0) -} - -// SetEndpoint mocks base method. -func (m *MockConfig) SetEndpoint(arg0 string) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetEndpoint", arg0) -} - -// SetEndpoint indicates an expected call of SetEndpoint. -func (mr *MockConfigMockRecorder) SetEndpoint(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEndpoint", reflect.TypeOf((*MockConfig)(nil).SetEndpoint), arg0) -} - -// Write mocks base method. -func (m *MockConfig) Write() error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Write") - ret0, _ := ret[0].(error) - return ret0 -} - -// Write indicates an expected call of Write. -func (mr *MockConfigMockRecorder) Write() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockConfig)(nil).Write)) -} diff --git a/internal/state/helpers.go b/internal/state/helpers.go index 759d19c9..9b5f86ed 100644 --- a/internal/state/helpers.go +++ b/internal/state/helpers.go @@ -13,7 +13,8 @@ func Wrap(s State, f func(State, *cobra.Command, []string) error) func(*cobra.Co } func (c *state) EnsureToken(_ *cobra.Command, _ []string) error { - if c.token == "" { + token := config.OptionToken.Value() + if token == "" { return errors.New("no active context or token (see `hcloud context --help`)") } return nil diff --git a/internal/state/state.go b/internal/state/state.go index b1314a7c..5e72f925 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -2,8 +2,6 @@ package state import ( "context" - "log" - "os" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state/config" @@ -24,34 +22,16 @@ type State interface { type state struct { context.Context - token string - endpoint string - debug bool - debugFilePath string - client hcapi2.Client - config config.Config + client hcapi2.Client + config config.Config } func New(cfg config.Config) (State, error) { - var ( - token string - endpoint string - ) - if ctx := cfg.ActiveContext(); ctx != nil { - token = ctx.Token - } - if ep := cfg.Endpoint(); ep != "" { - endpoint = ep - } - s := &state{ - Context: context.Background(), - config: cfg, - token: token, - endpoint: endpoint, + Context: context.Background(), + config: cfg, } - s.readEnv() s.client = s.newClient() return s, nil } @@ -64,44 +44,8 @@ func (c *state) Config() config.Config { return c.config } -func (c *state) readEnv() { - if s := os.Getenv("HCLOUD_TOKEN"); s != "" { - c.token = s - } - if s := os.Getenv("HCLOUD_ENDPOINT"); s != "" { - c.endpoint = s - } - if s := os.Getenv("HCLOUD_DEBUG"); s != "" { - c.debug = true - } - if s := os.Getenv("HCLOUD_DEBUG_FILE"); s != "" { - c.debugFilePath = s - } - if s := os.Getenv("HCLOUD_CONTEXT"); s != "" && c.config != nil { - if cfgCtx := config.ContextByName(c.config, s); cfgCtx != nil { - c.config.SetActiveContext(cfgCtx) - c.token = cfgCtx.Token - } else { - log.Printf("warning: context %q specified in HCLOUD_CONTEXT does not exist\n", s) - } - } -} - func (c *state) newClient() hcapi2.Client { - opts := []hcloud.ClientOption{ - hcloud.WithToken(c.token), - hcloud.WithApplication("hcloud-cli", version.Version), - } - if c.endpoint != "" { - opts = append(opts, hcloud.WithEndpoint(c.endpoint)) - } - if c.debug { - if c.debugFilePath == "" { - opts = append(opts, hcloud.WithDebugWriter(os.Stderr)) - } else { - writer, _ := os.Create(c.debugFilePath) - opts = append(opts, hcloud.WithDebugWriter(writer)) - } - } + opts := config.GetHcloudOpts(c.Config()) + 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 fb5d5310..5def1ed5 100644 --- a/internal/testutil/fixture.go +++ b/internal/testutil/fixture.go @@ -8,6 +8,7 @@ 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" @@ -28,12 +29,20 @@ type Fixture struct { func NewFixture(t *testing.T) *Fixture { ctrl := gomock.NewController(t) + viper.Reset() + config.ResetFlags() + cfg := &config.MockConfig{} + + if err := config.ReadConfig(cfg); err != nil { + t.Fatal(err) + } + return &Fixture{ MockController: ctrl, Client: hcapi2_mock.NewMockClient(ctrl), ActionWaiter: state.NewMockActionWaiter(ctrl), TokenEnsurer: state.NewMockTokenEnsurer(ctrl), - Config: config.NewMockConfig(ctrl), + Config: cfg, } } From 599cbcb8ca97204ef5c66b707483b070354342e6 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 02/37] wip --- cmd/hcloud/main.go | 4 +- 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 +- 35 files changed, 1538 insertions(+), 238 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/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 fe071aa7..3896dcaf 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 8de861ae..7e9b7fc2 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 e48f79c7..90c58a59 100644 --- a/internal/cmd/util/util.go +++ b/internal/cmd/util/util.go @@ -1,10 +1,12 @@ package util import ( + "cmp" "encoding/json" "fmt" "os" "reflect" + "slices" "sort" "strings" "text/template" @@ -233,3 +235,21 @@ func FilterNil[T any](values []T) []T { } return filtered } + +// 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 9b5f86ed..c9a9ebc2 100644 --- a/internal/state/helpers.go +++ b/internal/state/helpers.go @@ -13,7 +13,7 @@ func Wrap(s State, f func(State, *cobra.Command, []string) error) func(*cobra.Co } func (c *state) EnsureToken(_ *cobra.Command, _ []string) error { - token := config.OptionToken.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}, } } From 180acf5d66c08d157762dc1a3e15378bc02daf52 Mon Sep 17 00:00:00 2001 From: pauhull <22707808+phm07@users.noreply.github.com> Date: Fri, 3 May 2024 10:05:02 +0200 Subject: [PATCH 03/37] add documentation --- internal/cmd/config/config.go | 49 ++++++++++++++++++++++++++++++-- internal/state/config/options.go | 1 + 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 9cfb8b50..8e88e57d 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -9,8 +9,53 @@ import ( func NewCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ - Use: "config", - Short: "Manage configuration", + Use: "config", + Short: "Manage configuration", + Long: `This command allows you to manage options for the Hetzner Cloud CLI. Options can be set inside the +configuration file, through environment variables or with flags. Most options are 'preferences' - +these options can be set globally and can additionally be overridden for each context. + +Below is a list of all non-preference options: + +|-------------------|--------------------------------|-----------------|-------------------------|------------------| +| Option | Description | Config key | Environment variable | Flag | +|-------------------|--------------------------------|-----------------|-------------------------|------------------| +| config | Config file path | | HCLOUD_CONFIG | --config | +|-------------------|--------------------------------|-----------------|-------------------------|------------------| +| token | API token | token | HCLOUD_TOKEN | | +|-------------------|--------------------------------|-----------------|-------------------------|------------------| +| context | Currently active context | active_context | HCLOUD_CONTEXT | --context | +|-------------------|--------------------------------|-----------------|-------------------------|------------------| + +Since the above options are not preferences, they cannot be modified with 'hcloud config set' or +'hcloud config unset'. However, you are able to retrieve them using 'hcloud config get' and 'hcloud config list'. +Following options are preferences and can be used with these commands: + +|-------------------|-------------------------------|------------------|-------------------------|------------------| +| Option | Description | Config key | Environment variable | Flag | +|-------------------|-------------------------------|------------------|-------------------------|------------------| +| endpoint | API Endpoint to use | endpoint | HCLOUD_ENDPOINT | --endpoint | +|-------------------|-------------------------------|------------------|-------------------------|------------------| +| debug | Enable debug output | debug | HCLOUD_DEBUG | --debug | +|-------------------|-------------------------------|------------------|-------------------------|------------------| +| debug-file | File to write debug output to | debug_file | HCLOUD_DEBUG_FILE | --debug-file | +|-------------------|-------------------------------|------------------|-------------------------|------------------| +| poll-interval | Time between requests | endpoint | HCLOUD_POLL_INTERVAL | --poll-interval | +| | when polling | | | | +|-------------------|-------------------------------|------------------|-------------------------|------------------| +| quiet | If true, only error messages | quiet | HCLOUD_QUIET | --quiet | +| | are printed | | | | +|-------------------|-------------------------------|------------------|-------------------------|------------------| +| default-ssh-keys | Default SSH keys for server | default_ssh_keys | HCLOUD_DEFAULT_SSH_KEYS | | +| | creation | | | | +|-------------------|-------------------------------|------------------|-------------------------|------------------| +| ssh-path | Path to the SSH binary (used | ssh_path | HCLOUD_SSH_PATH | --ssh-path | +| | for 'hcloud server ssh') | | | | +|-------------------|-------------------------------|------------------|-------------------------|------------------| + +Options will be persisted in the configuration file. To find out where your configuration file is located +on disk, run 'hcloud config get config'. +`, Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, diff --git a/internal/state/config/options.go b/internal/state/config/options.go index 4838d8e3..e9377305 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -14,6 +14,7 @@ import ( type OptionFlag int +// [⚠️] If you add an option, don't forget to document it in internal/cmd/config/config.go const ( // OptionFlagPreference indicates that the option can be set in the config file, globally or per context (in the preferences section) OptionFlagPreference OptionFlag = 1 << iota From ec59f6e6049c8fc56b933184b7ea13aa71f5759f Mon Sep 17 00:00:00 2001 From: pauhull <22707808+phm07@users.noreply.github.com> Date: Fri, 3 May 2024 11:37:44 +0200 Subject: [PATCH 04/37] generate help tables --- go.sum | 2 + internal/cmd/config/add.go | 2 +- internal/cmd/config/config.go | 48 ++---- internal/cmd/config/helptext/generate.go | 63 ++++++++ internal/cmd/config/helptext/other.txt | 11 ++ internal/cmd/config/helptext/preferences.txt | 26 +++ internal/cmd/config/remove.go | 2 +- internal/state/config/options.go | 160 ++++++++++++++++--- 8 files changed, 260 insertions(+), 54 deletions(-) create mode 100644 internal/cmd/config/helptext/generate.go create mode 100644 internal/cmd/config/helptext/other.txt create mode 100644 internal/cmd/config/helptext/preferences.txt diff --git a/go.sum b/go.sum index 4526b6ef..4a121e4c 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/hetznercloud/hcloud-go/v2 v2.8.0 h1:vfbfL/JfV8dIZUX7ANHWEbKNqgFWsETqv github.com/hetznercloud/hcloud-go/v2 v2.8.0/go.mod h1:jvpP3qAWMIZ3WQwQLYa97ia6t98iPCgsJNwRts+Jnrk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.5.8 h1:8BCzJdSvUbaDuRba4YVh+SKMGcAAKdkcF3SVFbrHAtQ= +github.com/jedib0t/go-pretty/v6 v6.5.8/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go index 1d838aef..b81336d7 100644 --- a/internal/cmd/config/add.go +++ b/internal/cmd/config/add.go @@ -16,7 +16,7 @@ import ( func NewAddCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ Use: "add ...", - Short: "Set a configuration value", + Short: "Add values to a list", Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 8e88e57d..f1966881 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -1,12 +1,20 @@ package config import ( + _ "embed" + "github.com/spf13/cobra" "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/state" ) +//go:embed helptext/other.txt +var nonPreferenceOptions string + +//go:embed helptext/preferences.txt +var preferenceOptions string + func NewCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ Use: "config", @@ -17,42 +25,14 @@ these options can be set globally and can additionally be overridden for each co Below is a list of all non-preference options: -|-------------------|--------------------------------|-----------------|-------------------------|------------------| -| Option | Description | Config key | Environment variable | Flag | -|-------------------|--------------------------------|-----------------|-------------------------|------------------| -| config | Config file path | | HCLOUD_CONFIG | --config | -|-------------------|--------------------------------|-----------------|-------------------------|------------------| -| token | API token | token | HCLOUD_TOKEN | | -|-------------------|--------------------------------|-----------------|-------------------------|------------------| -| context | Currently active context | active_context | HCLOUD_CONTEXT | --context | -|-------------------|--------------------------------|-----------------|-------------------------|------------------| - +` + nonPreferenceOptions + + ` Since the above options are not preferences, they cannot be modified with 'hcloud config set' or 'hcloud config unset'. However, you are able to retrieve them using 'hcloud config get' and 'hcloud config list'. Following options are preferences and can be used with these commands: -|-------------------|-------------------------------|------------------|-------------------------|------------------| -| Option | Description | Config key | Environment variable | Flag | -|-------------------|-------------------------------|------------------|-------------------------|------------------| -| endpoint | API Endpoint to use | endpoint | HCLOUD_ENDPOINT | --endpoint | -|-------------------|-------------------------------|------------------|-------------------------|------------------| -| debug | Enable debug output | debug | HCLOUD_DEBUG | --debug | -|-------------------|-------------------------------|------------------|-------------------------|------------------| -| debug-file | File to write debug output to | debug_file | HCLOUD_DEBUG_FILE | --debug-file | -|-------------------|-------------------------------|------------------|-------------------------|------------------| -| poll-interval | Time between requests | endpoint | HCLOUD_POLL_INTERVAL | --poll-interval | -| | when polling | | | | -|-------------------|-------------------------------|------------------|-------------------------|------------------| -| quiet | If true, only error messages | quiet | HCLOUD_QUIET | --quiet | -| | are printed | | | | -|-------------------|-------------------------------|------------------|-------------------------|------------------| -| default-ssh-keys | Default SSH keys for server | default_ssh_keys | HCLOUD_DEFAULT_SSH_KEYS | | -| | creation | | | | -|-------------------|-------------------------------|------------------|-------------------------|------------------| -| ssh-path | Path to the SSH binary (used | ssh_path | HCLOUD_SSH_PATH | --ssh-path | -| | for 'hcloud server ssh') | | | | -|-------------------|-------------------------------|------------------|-------------------------|------------------| - +` + preferenceOptions + + ` Options will be persisted in the configuration file. To find out where your configuration file is located on disk, run 'hcloud config get config'. `, @@ -70,3 +50,7 @@ on disk, run 'hcloud config get config'. ) return cmd } + +func gen() { + +} diff --git a/internal/cmd/config/helptext/generate.go b/internal/cmd/config/helptext/generate.go new file mode 100644 index 00000000..cfda083f --- /dev/null +++ b/internal/cmd/config/helptext/generate.go @@ -0,0 +1,63 @@ +package main + +import ( + "os" + "slices" + "strings" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + + "github.com/hetznercloud/cli/internal/state/config" +) + +//go:generate go run $GOFILE + +func main() { + generateTable("preferences.txt", config.OptionFlagPreference, true) + generateTable("other.txt", config.OptionFlagPreference, false) +} + +func generateTable(outFile string, filterFlag config.OptionFlag, hasFlag bool) { + f, err := os.OpenFile(outFile, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + panic(err) + } + defer func() { + if err := f.Close(); err != nil { + panic(err) + } + }() + + t := table.NewWriter() + t.SetStyle(table.StyleLight) + t.SetColumnConfigs([]table.ColumnConfig{ + { + Name: "Description", + WidthMax: 20, + WidthMaxEnforcer: text.WrapSoft, + }, + }) + + t.SetOutputMirror(f) + t.AppendHeader(table.Row{"Option", "Description", "Config key", "Environment variable", "Flag"}) + + var opts []config.IOption + for _, opt := range config.Options { + if opt.HasFlag(filterFlag) != hasFlag { + continue + } + opts = append(opts, opt) + } + + slices.SortFunc(opts, func(a, b config.IOption) int { + return strings.Compare(a.GetName(), b.GetName()) + }) + + for _, opt := range opts { + t.AppendRow(table.Row{opt.GetName(), opt.GetDescription(), opt.ConfigKey(), opt.EnvVar(), opt.FlagName()}) + t.AppendSeparator() + } + + t.Render() +} diff --git a/internal/cmd/config/helptext/other.txt b/internal/cmd/config/helptext/other.txt new file mode 100644 index 00000000..7d419f0e --- /dev/null +++ b/internal/cmd/config/helptext/other.txt @@ -0,0 +1,11 @@ +┌─────────┬──────────────────────┬────────────────┬──────────────────────┬───────────┐ +│ OPTION │ DESCRIPTION │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ +├─────────┼──────────────────────┼────────────────┼──────────────────────┼───────────┤ +│ config │ Config file path │ │ HCLOUD_CONFIG │ --config │ +├─────────┼──────────────────────┼────────────────┼──────────────────────┼───────────┤ +│ context │ Currently active │ active_context │ HCLOUD_CONTEXT │ --context │ +│ │ context │ │ │ │ +├─────────┼──────────────────────┼────────────────┼──────────────────────┼───────────┤ +│ token │ Hetzner Cloud API │ token │ HCLOUD_TOKEN │ │ +│ │ token │ │ │ │ +└─────────┴──────────────────────┴────────────────┴──────────────────────┴───────────┘ diff --git a/internal/cmd/config/helptext/preferences.txt b/internal/cmd/config/helptext/preferences.txt new file mode 100644 index 00000000..0ca9c246 --- /dev/null +++ b/internal/cmd/config/helptext/preferences.txt @@ -0,0 +1,26 @@ +┌──────────────────┬──────────────────────┬──────────────────┬─────────────────────────┬─────────────────┐ +│ OPTION │ DESCRIPTION │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ +├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ +│ debug │ Enable debug output │ debug │ HCLOUD_DEBUG │ --debug │ +├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ +│ debug-file │ File to write debug │ debug_file │ HCLOUD_DEBUG_FILE │ --debug-file │ +│ │ output to │ │ │ │ +├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ +│ default-ssh-keys │ Default SSH keys for │ default_ssh_keys │ HCLOUD_DEFAULT_SSH_KEYS │ │ +│ │ new servers │ │ │ │ +├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ +│ endpoint │ Hetzner Cloud API │ endpoint │ HCLOUD_ENDPOINT │ --endpoint │ +│ │ endpoint │ │ │ │ +├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ +│ poll-interval │ Interval at which to │ poll_interval │ HCLOUD_POLL_INTERVAL │ --poll-interval │ +│ │ poll information, │ │ │ │ +│ │ for example action │ │ │ │ +│ │ progress │ │ │ │ +├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ +│ quiet │ If true, only print │ quiet │ HCLOUD_QUIET │ --quiet │ +│ │ error messages │ │ │ │ +├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ +│ ssh-path │ Path to the SSH │ ssh_path │ HCLOUD_SSH_PATH │ --ssh-path │ +│ │ binary (used by │ │ │ │ +│ │ 'hcloud server ssh') │ │ │ │ +└──────────────────┴──────────────────────┴──────────────────┴─────────────────────────┴─────────────────┘ diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go index 6642fb8e..4247012b 100644 --- a/internal/cmd/config/remove.go +++ b/internal/cmd/config/remove.go @@ -16,7 +16,7 @@ import ( func NewRemoveCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ Use: "remove ...", - Short: "Remove a configuration value", + Short: "Remove values from a list", Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, diff --git a/internal/state/config/options.go b/internal/state/config/options.go index e9377305..5cd17b99 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -4,6 +4,7 @@ import ( "fmt" "reflect" "strconv" + "strings" "time" "github.com/spf13/cast" @@ -32,6 +33,11 @@ const ( type IOption interface { addToFlagSet(fs *pflag.FlagSet) + GetName() string + GetDescription() string + ConfigKey() string + EnvVar() string + FlagName() string HasFlag(src OptionFlag) bool GetAsAny(c Config) any OverrideAny(c Config, v any) @@ -41,27 +47,103 @@ type IOption interface { T() any } +type overrides struct { + configKey string + envVar string + flagName string +} + 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(), 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) + OptionConfig = newOpt( + "config", + "Config file path", + DefaultConfigPath(), + OptionFlagPFlag|OptionFlagEnv, + nil, + ) + + OptionToken = newOpt( + "token", + "Hetzner Cloud API token", + "", + OptionFlagConfig|OptionFlagEnv|OptionFlagSensitive, + nil, + ) + + OptionContext = newOpt( + "context", + "Currently active context", + "", + OptionFlagConfig|OptionFlagEnv|OptionFlagPFlag, + &overrides{configKey: "active_context"}, + ) + + OptionEndpoint = newOpt( + "endpoint", + "Hetzner Cloud API endpoint", + hcloud.Endpoint, + DefaultPreferenceFlags, + nil, + ) + + OptionDebug = newOpt( + "debug", + "Enable debug output", + false, + DefaultPreferenceFlags, + nil, + ) + + OptionDebugFile = newOpt( + "debug-file", + "File to write debug output to", + "", + DefaultPreferenceFlags, + nil, + ) + + OptionPollInterval = newOpt( + "poll-interval", + "Interval at which to poll information, for example action progress", + 500*time.Millisecond, + DefaultPreferenceFlags, + nil, + ) + + OptionQuiet = newOpt( + "quiet", + "If true, only print error messages", + false, + DefaultPreferenceFlags, + nil, + ) + + OptionDefaultSSHKeys = newOpt( + "default-ssh-keys", + "Default SSH keys for new servers", + []string{}, + DefaultPreferenceFlags&^OptionFlagPFlag, + nil, + ) + + OptionSSHPath = newOpt( + "ssh-path", + "Path to the SSH binary (used by 'hcloud server ssh')", + "ssh", + DefaultPreferenceFlags, + nil, + ) ) type Option[T any] struct { - Name string - Usage string - Default T - Source OptionFlag + Name string + Description string + Default T + Source OptionFlag + overrides *overrides } func (o *Option[T]) Get(c Config) T { @@ -119,6 +201,44 @@ func (o *Option[T]) IsSlice() bool { return reflect.TypeOf(o.T()).Kind() == reflect.Slice } +func (o *Option[T]) GetName() string { + return o.Name +} + +func (o *Option[T]) GetDescription() string { + return o.Description +} + +func (o *Option[T]) ConfigKey() string { + if !o.HasFlag(OptionFlagConfig) { + return "" + } + if o.overrides != nil && o.overrides.configKey != "" { + return o.overrides.configKey + } + return strings.ReplaceAll(strings.ToLower(o.Name), "-", "_") +} + +func (o *Option[T]) EnvVar() string { + if !o.HasFlag(OptionFlagEnv) { + return "" + } + if o.overrides != nil && o.overrides.envVar != "" { + return o.overrides.envVar + } + return "HCLOUD_" + strings.ReplaceAll(strings.ToUpper(o.Name), "-", "_") +} + +func (o *Option[T]) FlagName() string { + if !o.HasFlag(OptionFlagPFlag) { + return "" + } + if o.overrides != nil && o.overrides.flagName != "" { + return o.overrides.flagName + } + return "--" + o.Name +} + func (o *Option[T]) Completions() []string { var t T switch any(t).(type) { @@ -139,20 +259,20 @@ func (o *Option[T]) addToFlagSet(fs *pflag.FlagSet) { } switch v := any(o.Default).(type) { case bool: - fs.Bool(o.Name, v, o.Usage) + fs.Bool(o.Name, v, o.Description) case string: - fs.String(o.Name, v, o.Usage) + fs.String(o.Name, v, o.Description) case time.Duration: - fs.Duration(o.Name, v, o.Usage) + fs.Duration(o.Name, v, o.Description) case []string: - fs.StringSlice(o.Name, v, o.Usage) + fs.StringSlice(o.Name, v, o.Description) default: panic(fmt.Sprintf("unsupported type %T", v)) } } -func newOpt[T any](name, usage string, def T, source OptionFlag) *Option[T] { - o := &Option[T]{Name: name, Usage: usage, Default: def, Source: source} +func newOpt[T any](name, description string, def T, source OptionFlag, ov *overrides) *Option[T] { + o := &Option[T]{Name: name, Description: description, Default: def, Source: source, overrides: ov} Options[name] = o return o } From 3b7ce723e296c1d76ef5b665b0e50c67a553c3e9 Mon Sep 17 00:00:00 2001 From: pauhull <22707808+phm07@users.noreply.github.com> Date: Fri, 3 May 2024 17:13:01 +0200 Subject: [PATCH 05/37] Better user facing messages --- internal/cmd/config/add.go | 28 +++++++++++--- internal/cmd/config/get.go | 1 + internal/cmd/config/list.go | 1 + internal/cmd/config/remove.go | 28 +++++++++++--- internal/cmd/config/set.go | 17 +++++++-- internal/cmd/config/set_test.go | 9 +++-- internal/cmd/config/unset.go | 24 +++++++++--- internal/cmd/config/unset_test.go | 48 ++++++++++++++++++++++-- internal/cmd/util/util.go | 36 ++++++++++++++++++ internal/state/config/options.go | 4 +- internal/state/config/preferences.go | 56 ++++++++++++++++------------ 11 files changed, 199 insertions(+), 53 deletions(-) diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go index b81336d7..92725361 100644 --- a/internal/cmd/config/add.go +++ b/internal/cmd/config/add.go @@ -3,7 +3,6 @@ package config import ( "fmt" "os" - "reflect" "github.com/spf13/cobra" @@ -20,6 +19,7 @@ func NewAddCommand(s state.State) *cobra.Command { Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, + SilenceUsage: true, RunE: state.Wrap(s, runAdd), ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( cmpl.SuggestCandidatesF(func() []string { @@ -47,22 +47,38 @@ func NewAddCommand(s state.State) *cobra.Command { func runAdd(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") - var prefs config.Preferences + var ( + added []any + err error + ctx config.Context + prefs config.Preferences + ) if global { prefs = s.Config().Preferences() } else { - ctx := s.Config().ActiveContext() - if reflect.ValueOf(ctx).IsNil() { + ctx = s.Config().ActiveContext() + if ctx == nil { 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 { + if added, err = prefs.Add(key, values); err != nil { return err } - return s.Config().Write(os.Stdout) + if len(added) == 0 { + _, _ = fmt.Fprintln(os.Stderr, "Warning: no new values were added") + } else if len(added) < len(values) { + _, _ = fmt.Fprintln(os.Stderr, "Warning: some values were already present or duplicate") + } + + if ctx == nil { + cmd.Printf("Added '%v' to '%s' globally\n", added, key) + } else { + cmd.Printf("Added '%v' to '%s' in context '%s'\n", added, key, ctx.Name()) + } + return s.Config().Write(nil) } diff --git a/internal/cmd/config/get.go b/internal/cmd/config/get.go index f24e954f..7c7edffd 100644 --- a/internal/cmd/config/get.go +++ b/internal/cmd/config/get.go @@ -17,6 +17,7 @@ func NewGetCommand(s state.State) *cobra.Command { Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, + SilenceUsage: true, RunE: state.Wrap(s, runGet), } cmd.Flags().Bool("global", false, "Get the value globally") diff --git a/internal/cmd/config/list.go b/internal/cmd/config/list.go index ffb678df..c70e83f7 100644 --- a/internal/cmd/config/list.go +++ b/internal/cmd/config/list.go @@ -22,6 +22,7 @@ func NewListCommand(s state.State) *cobra.Command { Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, + SilenceUsage: true, RunE: state.Wrap(s, runList), } cmd.Flags().BoolP("all", "a", false, "Also show default values") diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go index 4247012b..59b0126a 100644 --- a/internal/cmd/config/remove.go +++ b/internal/cmd/config/remove.go @@ -3,7 +3,6 @@ package config import ( "fmt" "os" - "reflect" "github.com/spf13/cobra" @@ -20,6 +19,7 @@ func NewRemoveCommand(s state.State) *cobra.Command { Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, + SilenceUsage: true, RunE: state.Wrap(s, runRemove), ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( cmpl.SuggestCandidatesF(func() []string { @@ -47,22 +47,38 @@ func NewRemoveCommand(s state.State) *cobra.Command { func runRemove(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") - var prefs config.Preferences + var ( + removed []any + err error + ctx config.Context + prefs config.Preferences + ) if global { prefs = s.Config().Preferences() } else { - ctx := s.Config().ActiveContext() - if reflect.ValueOf(ctx).IsNil() { + ctx = s.Config().ActiveContext() + if ctx == nil { 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 { + if removed, err = prefs.Remove(key, values); err != nil { return err } - return s.Config().Write(os.Stdout) + if len(removed) == 0 { + _, _ = fmt.Fprintln(os.Stderr, "Warning: no values were removed") + } else if len(removed) < len(values) { + _, _ = fmt.Fprintln(os.Stderr, "Warning: some values were not removed") + } + + if ctx == nil { + cmd.Printf("Removed '%v' from '%s' globally\n", removed, key) + } else { + cmd.Printf("Removed '%v' from '%s' in context '%s'\n", removed, key, ctx.Name()) + } + return s.Config().Write(nil) } diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index d1b080cd..57e5818f 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -18,6 +18,7 @@ func NewSetCommand(s state.State) *cobra.Command { Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, + SilenceUsage: true, RunE: state.Wrap(s, runSet), ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( cmpl.SuggestCandidatesF(func() []string { @@ -45,12 +46,17 @@ func NewSetCommand(s state.State) *cobra.Command { func runSet(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") - var prefs config.Preferences + var ( + val any + err error + ctx config.Context + prefs config.Preferences + ) if global { prefs = s.Config().Preferences() } else { - ctx := s.Config().ActiveContext() + ctx = s.Config().ActiveContext() if ctx == nil { return fmt.Errorf("no active context (use --global flag to set a global option)") } @@ -58,9 +64,14 @@ func runSet(s state.State, cmd *cobra.Command, args []string) error { } key, values := args[0], args[1:] - if err := prefs.Set(key, values); err != nil { + if val, err = prefs.Set(key, values); err != nil { return err } + if ctx == nil { + cmd.Printf("Set '%s' to '%v' globally\n", key, val) + } else { + cmd.Printf("Set '%s' to '%v' in context '%s'\n", key, val, ctx.Name()) + } return s.Config().Write(nil) } diff --git a/internal/cmd/config/set_test.go b/internal/cmd/config/set_test.go index 3e06a714..506de6df 100644 --- a/internal/cmd/config/set_test.go +++ b/internal/cmd/config/set_test.go @@ -24,7 +24,8 @@ func TestSet(t *testing.T) { { name: "set in current context", args: []string{"debug-file", "debug.log"}, - expOut: `active_context = "test_context" + expOut: `Set 'debug-file' to 'debug.log' in context 'test_context' +active_context = "test_context" [preferences] debug = true @@ -57,7 +58,8 @@ func TestSet(t *testing.T) { _ = os.Unsetenv("HCLOUD_CONTEXT") }, args: []string{"default-ssh-keys", "a", "b", "c"}, - expOut: `active_context = "test_context" + expOut: `Set 'default-ssh-keys' to '[a b c]' in context 'other_context' +active_context = "test_context" [preferences] debug = true @@ -82,7 +84,8 @@ func TestSet(t *testing.T) { { name: "set globally", args: []string{"--global", "poll-interval", "50ms"}, - expOut: `active_context = "test_context" + expOut: `Set 'poll-interval' to '50ms' globally +active_context = "test_context" [preferences] debug = true diff --git a/internal/cmd/config/unset.go b/internal/cmd/config/unset.go index 744be036..f4452684 100644 --- a/internal/cmd/config/unset.go +++ b/internal/cmd/config/unset.go @@ -2,7 +2,7 @@ package config import ( "fmt" - "reflect" + "os" "github.com/spf13/cobra" @@ -19,6 +19,7 @@ func NewUnsetCommand(s state.State) *cobra.Command { Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, + SilenceUsage: true, RunE: state.Wrap(s, runUnset), ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( cmpl.SuggestCandidatesF(func() []string { @@ -39,22 +40,35 @@ func NewUnsetCommand(s state.State) *cobra.Command { func runUnset(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") - var prefs config.Preferences + var ( + ok bool + err error + ctx config.Context + prefs config.Preferences + ) if global { prefs = s.Config().Preferences() } else { - ctx := s.Config().ActiveContext() - if reflect.ValueOf(ctx).IsNil() { + ctx = s.Config().ActiveContext() + if ctx == nil { 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 { + if ok, err = prefs.Unset(key); err != nil { return err } + if !ok { + _, _ = fmt.Fprintf(os.Stderr, "Warning: key '%s' was not set\n", key) + } + if ctx == nil { + cmd.Printf("Unset '%s' globally\n", key) + } else { + cmd.Printf("Unset '%s' in context '%s'\n", key, ctx.Name()) + } return s.Config().Write(nil) } diff --git a/internal/cmd/config/unset_test.go b/internal/cmd/config/unset_test.go index e41010d8..3857148c 100644 --- a/internal/cmd/config/unset_test.go +++ b/internal/cmd/config/unset_test.go @@ -16,6 +16,7 @@ func TestUnset(t *testing.T) { args []string expOut string expErr string + err string preRun func() postRun func() } @@ -24,7 +25,8 @@ func TestUnset(t *testing.T) { { name: "unset in current context", args: []string{"quiet"}, - expOut: `active_context = "test_context" + expOut: `Unset 'quiet' in context 'test_context' +active_context = "test_context" [preferences] debug = true @@ -55,7 +57,8 @@ func TestUnset(t *testing.T) { _ = os.Unsetenv("HCLOUD_CONTEXT") }, args: []string{"poll-interval"}, - expOut: `active_context = "test_context" + expOut: `Unset 'poll-interval' in context 'other_context' +active_context = "test_context" [preferences] debug = true @@ -77,7 +80,8 @@ func TestUnset(t *testing.T) { { name: "unset globally", args: []string{"debug", "--global"}, - expOut: `active_context = "test_context" + expOut: `Unset 'debug' globally +active_context = "test_context" [preferences] poll_interval = "1.234s" @@ -90,6 +94,38 @@ func TestUnset(t *testing.T) { endpoint = "https://test-endpoint.com" quiet = true +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "unset non existing", + args: []string{"non-existing"}, + err: "unknown preference: non-existing", + expErr: "Error: unknown preference: non-existing\n", + }, + { + name: "unset not set", + args: []string{"debug-file"}, + expErr: "Warning: key 'debug-file' was not set\n", + expOut: `Unset 'debug-file' in context 'test_context' +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" @@ -115,7 +151,11 @@ func TestUnset(t *testing.T) { out, errOut, err := fx.Run(cmd, tt.args) - assert.NoError(t, err) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.err) + } assert.Equal(t, tt.expErr, errOut) assert.Equal(t, tt.expOut, out) }) diff --git a/internal/cmd/util/util.go b/internal/cmd/util/util.go index 90c58a59..dc60e725 100644 --- a/internal/cmd/util/util.go +++ b/internal/cmd/util/util.go @@ -253,3 +253,39 @@ func SliceDiff[S ~[]E, E cmp.Ordered](a, b []E) []E { slices.Sort(diff) return diff } + +func AnyToAnySlice(a any) []any { + val := reflect.ValueOf(a) + if val.Kind() != reflect.Slice { + return nil + } + s := make([]any, val.Len()) + for i := 0; i < val.Len(); i++ { + s[i] = val.Index(i).Interface() + } + return s +} + +func AnyToStringSlice(a any) []string { + var s []string + for _, v := range AnyToAnySlice(a) { + s = append(s, fmt.Sprint(v)) + } + return s +} + +func ToStringSlice(a []any) []string { + var s []string + for _, v := range a { + s = append(s, fmt.Sprint(v)) + } + return s +} + +func ToAnySlice[T any](a []T) []any { + var s []any + for _, v := range a { + s = append(s, any(v)) + } + return s +} diff --git a/internal/state/config/options.go b/internal/state/config/options.go index 5cd17b99..db9786c2 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -7,9 +7,9 @@ import ( "strings" "time" - "github.com/spf13/cast" "github.com/spf13/pflag" + "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/hcloud-go/v2/hcloud" ) @@ -171,7 +171,7 @@ func (o *Option[T]) Get(c Config) T { } case []string: if v, ok := val.([]any); ok { - val = cast.ToStringSlice(v) + val = util.ToStringSlice(v) } } return val.(T) diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go index 0e0036d0..db6a5cf9 100644 --- a/internal/state/config/preferences.go +++ b/internal/state/config/preferences.go @@ -9,7 +9,6 @@ import ( "time" "github.com/BurntSushi/toml" - "github.com/spf13/cast" "github.com/spf13/viper" "github.com/hetznercloud/cli/internal/cmd/util" @@ -18,17 +17,17 @@ import ( // 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, values []string) error { +func (p Preferences) Set(key string, values []string) (any, error) { opt, ok := Options[key] if !ok || !opt.HasFlag(OptionFlagPreference) { - return fmt.Errorf("unknown preference: %s", key) + return nil, 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") + return nil, fmt.Errorf("expected exactly one value") } value := values[0] switch strings.ToLower(value) { @@ -37,22 +36,22 @@ func (p Preferences) Set(key string, values []string) error { case "false", "f", "no", "n", "0": val = false default: - return fmt.Errorf("invalid boolean value: %s", value) + return nil, fmt.Errorf("invalid boolean value: %s", value) } case string: if len(values) != 1 { - return fmt.Errorf("expected exactly one value") + return nil, fmt.Errorf("expected exactly one value") } val = values[0] case time.Duration: if len(values) != 1 { - return fmt.Errorf("expected exactly one value") + return nil, 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) + return nil, fmt.Errorf("invalid duration value: %s", value) } case []string: newVal := values[:] @@ -60,62 +59,71 @@ func (p Preferences) Set(key string, values []string) error { newVal = slices.Compact(newVal) val = newVal default: - return fmt.Errorf("unsupported type %T", t) + return nil, fmt.Errorf("unsupported type %T", t) } configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") p[configKey] = val - return nil + return val, nil } -func (p Preferences) Unset(key string) error { +func (p Preferences) Unset(key string) (bool, error) { opt, ok := Options[key] if !ok || !opt.HasFlag(OptionFlagPreference) { - return fmt.Errorf("unknown preference: %s", key) + return false, fmt.Errorf("unknown preference: %s", key) } configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + _, ok = p[configKey] delete(p, configKey) - return nil + return ok, nil } -func (p Preferences) Add(key string, values []string) error { +func (p Preferences) Add(key string, values []string) ([]any, error) { opt, ok := Options[key] if !ok || !opt.HasFlag(OptionFlagPreference) { - return fmt.Errorf("unknown preference: %s", key) + return nil, fmt.Errorf("unknown preference: %s", key) } + var added []any + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") val := p[configKey] switch opt.T().(type) { case []string: - newVal := cast.ToStringSlice(val) - newVal = append(newVal, values...) + before := util.AnyToStringSlice(val) + newVal := append(before, values...) slices.Sort(newVal) newVal = slices.Compact(newVal) val = newVal + added = util.ToAnySlice(util.SliceDiff[[]string](newVal, before)) default: - return fmt.Errorf("%s is not a list", key) + return nil, fmt.Errorf("%s is not a list", key) } p[configKey] = val - return nil + return added, nil } -func (p Preferences) Remove(key string, values []string) error { +func (p Preferences) Remove(key string, values []string) ([]any, error) { opt, ok := Options[key] if !ok || !opt.HasFlag(OptionFlagPreference) { - return fmt.Errorf("unknown preference: %s", key) + return nil, fmt.Errorf("unknown preference: %s", key) } + var removed []any + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") val := p[configKey] switch opt.T().(type) { case []string: - val = util.SliceDiff[[]string](cast.ToStringSlice(val), values) + before := util.AnyToStringSlice(val) + diff := util.SliceDiff[[]string](before, values) + val = diff + removed = util.ToAnySlice(util.SliceDiff[[]string](before, diff)) default: - return fmt.Errorf("%s is not a list", key) + return nil, fmt.Errorf("%s is not a list", key) } if reflect.ValueOf(val).Len() == 0 { @@ -123,7 +131,7 @@ func (p Preferences) Remove(key string, values []string) error { } else { p[configKey] = val } - return nil + return removed, nil } func (p Preferences) merge(v *viper.Viper) error { From 88a2ecfffbedc956a5543acc175f08c85c2d10e2 Mon Sep 17 00:00:00 2001 From: pauhull <22707808+phm07@users.noreply.github.com> Date: Fri, 3 May 2024 17:18:52 +0200 Subject: [PATCH 06/37] Better help texts --- internal/cmd/config/add.go | 1 + internal/cmd/config/config.go | 13 ++++++++++--- internal/cmd/config/get.go | 1 + internal/cmd/config/remove.go | 1 + internal/cmd/config/set.go | 1 + internal/cmd/config/unset.go | 1 + 6 files changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go index 92725361..5f5a9af1 100644 --- a/internal/cmd/config/add.go +++ b/internal/cmd/config/add.go @@ -16,6 +16,7 @@ func NewAddCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ Use: "add ...", Short: "Add values to a list", + Long: "Add values to a list. For a list of all available configuration options, run 'hcloud help config'.", Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index f1966881..7a0c1c9f 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -20,10 +20,17 @@ func NewCommand(s state.State) *cobra.Command { Use: "config", Short: "Manage configuration", Long: `This command allows you to manage options for the Hetzner Cloud CLI. Options can be set inside the -configuration file, through environment variables or with flags. Most options are 'preferences' - -these options can be set globally and can additionally be overridden for each context. +configuration file, through environment variables or with flags. -Below is a list of all non-preference options: +The hierarchy for configuration sources is as follows (from highest to lowest priority): +1. Flags +2. Environment variables +3. Configuration file (context) +4. Configuration file (global) +5. Default values + +Most options are 'preferences' - these options can be set globally and can additionally be overridden +for each context. Below is a list of all non-preference options: ` + nonPreferenceOptions + ` diff --git a/internal/cmd/config/get.go b/internal/cmd/config/get.go index 7c7edffd..195dc6a8 100644 --- a/internal/cmd/config/get.go +++ b/internal/cmd/config/get.go @@ -14,6 +14,7 @@ func NewGetCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ Use: "get ", Short: "Get a configuration value", + Long: "Get a configuration value. For a list of all available configuration options, run 'hcloud help config'.", Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go index 59b0126a..75e66bed 100644 --- a/internal/cmd/config/remove.go +++ b/internal/cmd/config/remove.go @@ -16,6 +16,7 @@ func NewRemoveCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ Use: "remove ...", Short: "Remove values from a list", + Long: "Remove values from a list. For a list of all available configuration options, run 'hcloud help config'.", Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index 57e5818f..1134b16c 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -15,6 +15,7 @@ func NewSetCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ Use: "set ...", Short: "Set a configuration value", + Long: "Set a configuration value. For a list of all available configuration options, run 'hcloud help config'.", Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, diff --git a/internal/cmd/config/unset.go b/internal/cmd/config/unset.go index f4452684..f1e5f89b 100644 --- a/internal/cmd/config/unset.go +++ b/internal/cmd/config/unset.go @@ -16,6 +16,7 @@ func NewUnsetCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ Use: "unset ", Short: "Unset a configuration value", + Long: "Unset a configuration value. For a list of all available configuration options, run 'hcloud help config'.", Args: util.Validate, TraverseChildren: true, DisableFlagsInUseLine: true, From 9329686a26f14dd7708808b37f4d7d17d783dc26 Mon Sep 17 00:00:00 2001 From: pauhull <22707808+phm07@users.noreply.github.com> Date: Fri, 10 May 2024 16:14:43 +0200 Subject: [PATCH 07/37] refactor, add tests --- cmd/hcloud/main.go | 2 +- go.mod | 20 +- go.sum | 43 +++- internal/cmd/config/add.go | 26 +- internal/cmd/config/add_test.go | 235 ++++++++++++++----- internal/cmd/config/config.go | 4 - internal/cmd/config/get_test.go | 62 ++++- internal/cmd/config/list.go | 27 +-- internal/cmd/config/list_test.go | 128 +++++----- internal/cmd/config/remove.go | 29 ++- internal/cmd/config/remove_test.go | 201 +++++++++++++--- internal/cmd/config/set.go | 53 ++++- internal/cmd/config/set_test.go | 274 ++++++++++++++++++---- internal/cmd/config/testdata/cli.toml | 19 -- internal/cmd/config/unset.go | 13 +- internal/cmd/config/unset_test.go | 255 +++++++++++++++----- internal/cmd/context/active.go | 2 +- internal/cmd/server/ssh.go | 5 +- internal/cmd/server/ssh_test.go | 3 +- internal/state/config/config.go | 16 +- internal/state/config/options.go | 36 ++- internal/state/config/preferences.go | 181 ++++++-------- internal/state/config/preferences_test.go | 4 +- internal/state/helpers.go | 2 + internal/testutil/fixture.go | 2 +- 25 files changed, 1169 insertions(+), 473 deletions(-) delete mode 100644 internal/cmd/config/testdata/cli.toml diff --git a/cmd/hcloud/main.go b/cmd/hcloud/main.go index 96b3f3eb..1fa10f8f 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, nil); err != nil { - log.Fatalf("unable to read config file: %s\n", err) + log.Fatalf("unable to read config file \"%s\": %s\n", cfg.Path(), err) } s, err := state.New(cfg) diff --git a/go.mod b/go.mod index 46226e76..7202ff92 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,11 @@ require ( github.com/golang/mock v1.6.0 github.com/guptarohit/asciigraph v0.7.1 github.com/hetznercloud/hcloud-go/v2 v2.8.0 + github.com/jedib0t/go-pretty/v6 v6.5.9 github.com/pelletier/go-toml/v2 v2.2.2 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.23.0 golang.org/x/term v0.20.0 @@ -24,17 +26,30 @@ require ( github.com/VividCortex/ewma v1.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // 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 github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.20.0 // indirect @@ -42,5 +57,6 @@ require ( 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 + 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 4a121e4c..2c304648 100644 --- a/go.sum +++ b/go.sum @@ -10,14 +10,19 @@ github.com/cheggaaa/pb/v3 v3.1.5 h1:QuuUzeM2WsAqG2gMqtzaWithDJv0i+i6UlnwSCI4QLk= github.com/cheggaaa/pb/v3 v3.1.5/go.mod h1:CrxkeghYTXi1lQBEI7jSn+3svI3cuc19haAj6jM60XI= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= @@ -32,18 +37,22 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/guptarohit/asciigraph v0.7.1 h1:K+JWbRc04XEfv8BSZgNuvhCmpbvX4+9NYd/UxXVnAuk= 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.8.0 h1:vfbfL/JfV8dIZUX7ANHWEbKNqgFWsETqvt/EctvoFJ0= github.com/hetznercloud/hcloud-go/v2 v2.8.0/go.mod h1:jvpP3qAWMIZ3WQwQLYa97ia6t98iPCgsJNwRts+Jnrk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jedib0t/go-pretty/v6 v6.5.8 h1:8BCzJdSvUbaDuRba4YVh+SKMGcAAKdkcF3SVFbrHAtQ= -github.com/jedib0t/go-pretty/v6 v6.5.8/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= +github.com/jedib0t/go-pretty/v6 v6.5.9 h1:ACteMBRrrmm1gMsXe9PSTOClQ63IXDUt03H5U+UV8OU= +github.com/jedib0t/go-pretty/v6 v6.5.9/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -51,10 +60,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= @@ -68,26 +80,47 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 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= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= @@ -136,6 +169,8 @@ google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh 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= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go index 5f5a9af1..265a47eb 100644 --- a/internal/cmd/config/add.go +++ b/internal/cmd/config/add.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "slices" "github.com/spf13/cobra" @@ -50,7 +51,6 @@ func runAdd(s state.State, cmd *cobra.Command, args []string) error { var ( added []any - err error ctx config.Context prefs config.Preferences ) @@ -59,24 +59,40 @@ func runAdd(s state.State, cmd *cobra.Command, args []string) error { prefs = s.Config().Preferences() } else { ctx = s.Config().ActiveContext() - if ctx == nil { + if util.IsNil(ctx) { return fmt.Errorf("no active context (use --global flag to set a global option)") } prefs = ctx.Preferences() } key, values := args[0], args[1:] - if added, err = prefs.Add(key, values); err != nil { - return err + opt, ok := config.Options[key] + if !ok || !opt.HasFlag(config.OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) } + val, _ := prefs.Get(key) + switch opt.T().(type) { + case []string: + before := util.AnyToStringSlice(val) + newVal := append(before, values...) + slices.Sort(newVal) + newVal = slices.Compact(newVal) + val = newVal + added = util.ToAnySlice(util.SliceDiff[[]string](newVal, before)) + default: + return fmt.Errorf("%s is not a list", key) + } + + prefs.Set(key, val) + if len(added) == 0 { _, _ = fmt.Fprintln(os.Stderr, "Warning: no new values were added") } else if len(added) < len(values) { _, _ = fmt.Fprintln(os.Stderr, "Warning: some values were already present or duplicate") } - if ctx == nil { + if util.IsNil(ctx) { cmd.Printf("Added '%v' to '%s' globally\n", added, key) } else { cmd.Printf("Added '%v' to '%s' in context '%s'\n", added, key, ctx.Name()) diff --git a/internal/cmd/config/add_test.go b/internal/cmd/config/add_test.go index 7ee76fa0..04c56fb2 100644 --- a/internal/cmd/config/add_test.go +++ b/internal/cmd/config/add_test.go @@ -7,13 +7,60 @@ import ( "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 TestAdd(t *testing.T) { + + _, deleteArrayOption := config.NewTestOption[[]string]( + "array-option", + "array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteArrayOption() + + _, deleteNestedArrayOption := config.NewTestOption[[]string]( + "nested.array-option", + "nested array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteNestedArrayOption() + + testConfig := `active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1', '2', '3'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1', '2', '3'] + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +` + type testCase struct { name string args []string + config string expOut string expErr string preRun func() @@ -22,77 +69,130 @@ func TestAdd(t *testing.T) { testCases := []testCase{ { - name: "add to existing", - args: []string{"default-ssh-keys", "a", "b", "c"}, - expOut: `active_context = "test_context" + name: "add to existing", + args: []string{"array-option", "a", "b", "c"}, + config: testConfig, + expOut: `Added '[a b c]' to 'array-option' in context 'test_context' +active_context = 'test_context' [preferences] - debug = true - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[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 +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1', '2', '3', 'a', 'b', 'c'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1', '2', '3'] [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 `, }, { - name: "global add to empty", - args: []string{"--global", "default-ssh-keys", "a", "b", "c"}, - expOut: `active_context = "test_context" + name: "add to nested", + args: []string{"nested.array-option", "a", "b", "c"}, + config: testConfig, + expOut: `Added '[a b c]' to 'nested.array-option' in context 'test_context' +active_context = 'test_context' [preferences] - debug = true - default_ssh_keys = ["a", "b", "c"] - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" - quiet = true +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1', '2', '3'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1', '2', '3', 'a', 'b', 'c'] [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 `, }, { - name: "global add to empty duplicate", - args: []string{"--global", "default-ssh-keys", "c", "b", "c", "a", "a"}, - expOut: `active_context = "test_context" + name: "global add to empty", + args: []string{"--global", "array-option", "a", "b", "c"}, + config: testConfig, + expOut: `Added '[a b c]' to 'array-option' globally +active_context = 'test_context' [preferences] - debug = true - default_ssh_keys = ["a", "b", "c"] - poll_interval = "1.234s" +array_option = ['a', 'b', 'c'] +debug = true +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" - quiet = true +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1', '2', '3'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1', '2', '3'] + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +`, + }, + { + name: "global add to empty duplicate", + args: []string{"--global", "array-option", "c", "b", "c", "a", "a"}, + config: testConfig, + expErr: "Warning: some values were already present or duplicate\n", + expOut: `Added '[a b c]' to 'array-option' globally +active_context = 'test_context' + +[preferences] +array_option = ['a', 'b', 'c'] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1', '2', '3'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1', '2', '3'] [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 `, }, { @@ -102,28 +202,35 @@ func TestAdd(t *testing.T) { postRun: func() { _ = os.Unsetenv("HCLOUD_CONTEXT") }, - name: "add to other context", - args: []string{"default-ssh-keys", "I", "II", "III"}, - expOut: `active_context = "test_context" + name: "add to other context", + args: []string{"array-option", "I", "II", "III"}, + config: testConfig, + expOut: `Added '[I II III]' to 'array-option' in context 'other_context' +active_context = 'test_context' [preferences] - debug = true - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" - quiet = true +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1', '2', '3'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1', '2', '3'] [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - default_ssh_keys = ["I", "II", "III"] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +array_option = ['I', 'II', 'III'] +poll_interval = 1234000000 `, }, } @@ -137,7 +244,7 @@ func TestAdd(t *testing.T) { defer tt.postRun() } - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(tt.config)) defer fx.Finish() cmd := configCmd.NewAddCommand(fx.State()) diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 7a0c1c9f..414b1bb8 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -57,7 +57,3 @@ on disk, run 'hcloud config get config'. ) return cmd } - -func gen() { - -} diff --git a/internal/cmd/config/get_test.go b/internal/cmd/config/get_test.go index 773a0fb3..b2c7614a 100644 --- a/internal/cmd/config/get_test.go +++ b/internal/cmd/config/get_test.go @@ -6,13 +6,48 @@ import ( "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 TestGet(t *testing.T) { + + _, deleteDeeplyNestedOption := config.NewTestOption( + "deeply.nested.option", + "deeply nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteDeeplyNestedOption() + + testConfig := `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.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +` + type testCase struct { key string args []string + err string expOut string expErr string } @@ -35,14 +70,29 @@ func TestGet(t *testing.T) { expOut: "1.234s\n", }, { - key: "default-ssh-keys", - expOut: "[1 2 3]\n", + key: "deeply.nested.option", + expOut: "bar\n", + }, + { + key: "non-existing-key", + err: "unknown key: non-existing-key", + expErr: "Error: unknown key: non-existing-key\n", + }, + { + key: "token", + err: "'token' is sensitive. use --allow-sensitive to show the value", + expErr: "Error: 'token' is sensitive. use --allow-sensitive to show the value\n", + }, + { + key: "token", + args: []string{"--allow-sensitive"}, + expOut: "super secret token\n", }, } for _, tt := range testCases { t.Run(tt.key, func(t *testing.T) { - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(testConfig)) defer fx.Finish() cmd := configCmd.NewGetCommand(fx.State()) @@ -51,7 +101,11 @@ func TestGet(t *testing.T) { setTestValues(fx.Config) out, errOut, err := fx.Run(cmd, append(tt.args, tt.key)) - assert.NoError(t, err) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.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 index c70e83f7..82aec349 100644 --- a/internal/cmd/config/list.go +++ b/internal/cmd/config/list.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/hetznercloud/cli/internal/cmd/output" "github.com/hetznercloud/cli/internal/cmd/util" @@ -13,7 +12,7 @@ import ( "github.com/hetznercloud/cli/internal/state/config" ) -var outputColumns = []string{"key", "value", "origin"} +var outputColumns = []string{"key", "value"} func NewListCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ @@ -49,9 +48,8 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { } type option struct { - Key string `json:"key"` - Value any `json:"value"` - Origin string `json:"origin"` + Key string `json:"key"` + Value any `json:"value"` } var options []option @@ -63,7 +61,7 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { if !all && !opt.Changed(s.Config()) { continue } - options = append(options, option{name, val, originToString(s.Config().Viper().Origin(name))}) + options = append(options, option{name, val}) } // Sort options for reproducible output @@ -95,20 +93,3 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { } 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 index 74b65a29..8888d48f 100644 --- a/internal/cmd/config/list_test.go +++ b/internal/cmd/config/list_test.go @@ -12,6 +12,39 @@ import ( ) func TestList(t *testing.T) { + + _, deleteDeeplyNestedOption := config.NewTestOption( + "deeply.nested.option", + "deeply nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteDeeplyNestedOption() + + testConfig := `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.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +` + type testCase struct { name string args []string @@ -23,52 +56,52 @@ func TestList(t *testing.T) { { 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 + expOut: `KEY VALUE +context test_context +debug yes +deeply.nested.option bar +endpoint https://test-endpoint.com +poll-interval 1.234s +quiet yes +token [redacted] `, }, { - 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: "only key", + args: []string{"-o=columns=key"}, + expOut: `KEY +context +debug +deeply.nested.option +endpoint +poll-interval +quiet +token `, }, { 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 + expOut: `context test_context +debug yes +deeply.nested.option bar +endpoint https://test-endpoint.com +poll-interval 1.234s +quiet yes +token [redacted] `, }, { 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 + expOut: `KEY VALUE +context test_context +debug yes +deeply.nested.option bar +endpoint https://test-endpoint.com +poll-interval 1.234s +quiet yes +token super secret token `, }, { @@ -78,42 +111,31 @@ token super secret token config file "options": [ { "key": "context", - "value": "test_context", - "origin": "config file" + "value": "test_context" }, { "key": "debug", - "value": true, - "origin": "environment" + "value": true }, { - "key": "default-ssh-keys", - "value": [ - "1", - "2", - "3" - ], - "origin": "config file" + "key": "deeply.nested.option", + "value": "bar" }, { "key": "endpoint", - "value": "https://test-endpoint.com", - "origin": "flag" + "value": "https://test-endpoint.com" }, { "key": "poll-interval", - "value": 1234000000, - "origin": "environment" + "value": 1234000000 }, { "key": "quiet", - "value": true, - "origin": "flag" + "value": true }, { "key": "token", - "value": "[redacted]", - "origin": "config file" + "value": "[redacted]" } ] } @@ -123,7 +145,7 @@ token super secret token config file for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(testConfig)) defer fx.Finish() cmd := configCmd.NewListCommand(fx.State()) diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go index 75e66bed..5659dde6 100644 --- a/internal/cmd/config/remove.go +++ b/internal/cmd/config/remove.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "reflect" "github.com/spf13/cobra" @@ -50,7 +51,6 @@ func runRemove(s state.State, cmd *cobra.Command, args []string) error { var ( removed []any - err error ctx config.Context prefs config.Preferences ) @@ -59,15 +59,34 @@ func runRemove(s state.State, cmd *cobra.Command, args []string) error { prefs = s.Config().Preferences() } else { ctx = s.Config().ActiveContext() - if ctx == nil { + if util.IsNil(ctx) { return fmt.Errorf("no active context (use --global to remove an option globally)") } prefs = ctx.Preferences() } key, values := args[0], args[1:] - if removed, err = prefs.Remove(key, values); err != nil { - return err + opt, ok := config.Options[key] + if !ok || !opt.HasFlag(config.OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) + } + + val, _ := prefs.Get(key) + + switch opt.T().(type) { + case []string: + before := util.AnyToStringSlice(val) + diff := util.SliceDiff[[]string](before, values) + val = diff + removed = util.ToAnySlice(util.SliceDiff[[]string](before, diff)) + default: + return fmt.Errorf("%s is not a list", key) + } + + if reflect.ValueOf(val).Len() == 0 { + prefs.Unset(key) + } else { + prefs.Set(key, val) } if len(removed) == 0 { @@ -76,7 +95,7 @@ func runRemove(s state.State, cmd *cobra.Command, args []string) error { _, _ = fmt.Fprintln(os.Stderr, "Warning: some values were not removed") } - if ctx == nil { + if util.IsNil(ctx) { cmd.Printf("Removed '%v' from '%s' globally\n", removed, key) } else { cmd.Printf("Removed '%v' from '%s' in context '%s'\n", removed, key, ctx.Name()) diff --git a/internal/cmd/config/remove_test.go b/internal/cmd/config/remove_test.go index 890d5c46..49bb5458 100644 --- a/internal/cmd/config/remove_test.go +++ b/internal/cmd/config/remove_test.go @@ -6,67 +6,202 @@ import ( "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 TestRemove(t *testing.T) { + + _, deleteArrayOption := config.NewTestOption[[]string]( + "array-option", + "array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteArrayOption() + + _, deleteNestedArrayOption := config.NewTestOption[[]string]( + "nested.array-option", + "nested array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteNestedArrayOption() + + testConfig := `active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1', '2', '3'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1', '2', '3'] + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +` + type testCase struct { name string args []string + config string expOut string expErr string + err string preRun func() postRun func() } testCases := []testCase{ { - name: "remove from existing", - args: []string{"default-ssh-keys", "2", "3"}, - expOut: `active_context = "test_context" + name: "remove from existing", + args: []string{"array-option", "2", "3"}, + config: testConfig, + expOut: `Removed '[2 3]' from 'array-option' in context 'test_context' +active_context = 'test_context' [preferences] - debug = true - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1"] - endpoint = "https://test-endpoint.com" - quiet = true +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1', '2', '3'] [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 `, }, { - name: "remove all from existing", - args: []string{"default-ssh-keys", "1", "2", "3"}, - expOut: `active_context = "test_context" + name: "remove all from existing", + args: []string{"array-option", "1", "2", "3"}, + config: testConfig, + expOut: `Removed '[1 2 3]' from 'array-option' in context 'test_context' +active_context = 'test_context' [preferences] - debug = true - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - endpoint = "https://test-endpoint.com" - quiet = true +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1', '2', '3'] [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 `, }, + { + name: "remove from non-existing", + args: []string{"i-do-not-exist", "1", "2", "3"}, + config: testConfig, + err: "unknown preference: i-do-not-exist", + expErr: "Error: unknown preference: i-do-not-exist\n", + }, + { + name: "remove from nested", + args: []string{"nested.array-option", "2", "3"}, + config: testConfig, + expOut: `Removed '[2 3]' from 'nested.array-option' in context 'test_context' +active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1', '2', '3'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +array_option = ['1'] + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +`, + }, + { + name: "remove all from nested", + args: []string{"nested.array-option", "1", "2", "3"}, + config: testConfig, + expOut: `Removed '[1 2 3]' from 'nested.array-option' in context 'test_context' +active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['1', '2', '3'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +`, + }, + { + name: "remove from non-list", + args: []string{"debug", "true"}, + config: testConfig, + err: "debug is not a list", + expErr: "Error: debug is not a list\n", + }, } for _, tt := range testCases { @@ -78,14 +213,18 @@ func TestRemove(t *testing.T) { defer tt.postRun() } - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(tt.config)) defer fx.Finish() cmd := configCmd.NewRemoveCommand(fx.State()) out, errOut, err := fx.Run(cmd, tt.args) - assert.NoError(t, err) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.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 1134b16c..2f563176 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -2,6 +2,9 @@ package config import ( "fmt" + "slices" + "strings" + "time" "github.com/spf13/cobra" @@ -49,7 +52,6 @@ func runSet(s state.State, cmd *cobra.Command, args []string) error { var ( val any - err error ctx config.Context prefs config.Preferences ) @@ -58,18 +60,59 @@ func runSet(s state.State, cmd *cobra.Command, args []string) error { prefs = s.Config().Preferences() } else { ctx = s.Config().ActiveContext() - if ctx == nil { + if util.IsNil(ctx) { return fmt.Errorf("no active context (use --global flag to set a global option)") } prefs = ctx.Preferences() } key, values := args[0], args[1:] - if val, err = prefs.Set(key, values); err != nil { - return err + opt, ok := config.Options[key] + if !ok || !opt.HasFlag(config.OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) } - if ctx == nil { + 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 + case "false", "f", "no", "n", "0": + val = false + default: + return fmt.Errorf("invalid boolean value: %s", value) + } + case string: + 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: + newVal := values[:] + slices.Sort(newVal) + newVal = slices.Compact(newVal) + val = newVal + default: + return fmt.Errorf("unsupported type %T", t) + } + + prefs.Set(key, val) + + if util.IsNil(ctx) { cmd.Printf("Set '%s' to '%v' globally\n", key, val) } else { cmd.Printf("Set '%s' to '%v' in context '%s'\n", key, val, ctx.Name()) diff --git a/internal/cmd/config/set_test.go b/internal/cmd/config/set_test.go index 506de6df..d217ed8e 100644 --- a/internal/cmd/config/set_test.go +++ b/internal/cmd/config/set_test.go @@ -7,44 +7,98 @@ import ( "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 TestSet(t *testing.T) { + _, deleteNestedOption := config.NewTestOption( + "nested.option", + "nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteNestedOption() + + _, deleteDeeplyNestedOption := config.NewTestOption( + "deeply.nested.option", + "deeply nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteDeeplyNestedOption() + + _, deleteArrayOption := config.NewTestOption[[]string]( + "array-option", + "array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteArrayOption() + + testConfig := `active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[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 = 1234000000 +` + type testCase struct { name string args []string + config string expOut string expErr string + err string preRun func() postRun func() } testCases := []testCase{ { - name: "set in current context", - args: []string{"debug-file", "debug.log"}, + name: "set in current context", + args: []string{"debug-file", "debug.log"}, + config: testConfig, expOut: `Set 'debug-file' to 'debug.log' in context 'test_context' -active_context = "test_context" +active_context = 'test_context' [preferences] - debug = true - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[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 +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +debug_file = 'debug.log' +endpoint = 'https://test-endpoint.com' +quiet = true [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 `, }, { @@ -57,53 +111,171 @@ active_context = "test_context" postRun: func() { _ = os.Unsetenv("HCLOUD_CONTEXT") }, - args: []string{"default-ssh-keys", "a", "b", "c"}, - expOut: `Set 'default-ssh-keys' to '[a b c]' in context 'other_context' -active_context = "test_context" + args: []string{"debug", "false"}, + config: testConfig, + expOut: `Set 'debug' to 'false' in context 'other_context' +active_context = 'test_context' [preferences] - debug = true - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" - quiet = true +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] - default_ssh_keys = ["a", "b", "c"] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +debug = false +poll_interval = 1234000000 `, }, { - name: "set globally", - args: []string{"--global", "poll-interval", "50ms"}, + name: "set globally", + args: []string{"--global", "poll-interval", "50ms"}, + config: testConfig, expOut: `Set 'poll-interval' to '50ms' globally -active_context = "test_context" +active_context = 'test_context' [preferences] - debug = true - poll_interval = "50ms" +debug = true +poll_interval = 50000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" - quiet = true +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 = 1234000000 +`, + }, + { + name: "set nested", + args: []string{"nested.option", "bar"}, + config: testConfig, + expOut: `Set 'nested.option' to 'bar' in context 'test_context' +active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +option = 'bar' [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +`, + }, + { + name: "set deeply nested", + args: []string{"deeply.nested.option", "bar"}, + config: testConfig, + expOut: `Set 'deeply.nested.option' to 'bar' in context 'test_context' +active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.deeply] +[contexts.preferences.deeply.nested] +option = 'bar' + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +`, + }, + { + name: "set array option", + args: []string{"array-option", "a", "b", "c"}, + config: testConfig, + expOut: `Set 'array-option' to '[a b c]' in context 'test_context' +active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +array_option = ['a', 'b', 'c'] +endpoint = 'https://test-endpoint.com' +quiet = true + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +`, + }, + { + name: "set non existing", + args: []string{"non-existing", "value"}, + config: testConfig, + expErr: "Error: unknown preference: non-existing\n", + err: "unknown preference: non-existing", + }, + { + name: "set non-preference", + args: []string{"token", "value"}, + config: testConfig, + expErr: "Error: unknown preference: token\n", + err: "unknown preference: token", + }, + { + name: "set in empty config global", + args: []string{"debug", "false", "--global"}, + expOut: `Set 'debug' to 'false' globally +active_context = '' +contexts = [] + +[preferences] +debug = false `, }, } @@ -117,14 +289,18 @@ active_context = "test_context" defer tt.postRun() } - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(tt.config)) defer fx.Finish() cmd := configCmd.NewSetCommand(fx.State()) out, errOut, err := fx.Run(cmd, tt.args) - assert.NoError(t, err) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.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 deleted file mode 100644 index 0712693f..00000000 --- a/internal/cmd/config/testdata/cli.toml +++ /dev/null @@ -1,19 +0,0 @@ -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 index f1e5f89b..6c883347 100644 --- a/internal/cmd/config/unset.go +++ b/internal/cmd/config/unset.go @@ -42,8 +42,6 @@ func runUnset(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") var ( - ok bool - err error ctx config.Context prefs config.Preferences ) @@ -52,21 +50,24 @@ func runUnset(s state.State, cmd *cobra.Command, args []string) error { prefs = s.Config().Preferences() } else { ctx = s.Config().ActiveContext() - if ctx == nil { + if util.IsNil(ctx) { return fmt.Errorf("no active context (use --global flag to unset a global option)") } prefs = ctx.Preferences() } key := args[0] - if ok, err = prefs.Unset(key); err != nil { - return err + opt, ok := config.Options[key] + if !ok || !opt.HasFlag(config.OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) } + ok = prefs.Unset(key) + if !ok { _, _ = fmt.Fprintf(os.Stderr, "Warning: key '%s' was not set\n", key) } - if ctx == nil { + if util.IsNil(ctx) { cmd.Printf("Unset '%s' globally\n", key) } else { cmd.Printf("Unset '%s' in context '%s'\n", key, ctx.Name()) diff --git a/internal/cmd/config/unset_test.go b/internal/cmd/config/unset_test.go index 3857148c..2aff3ab5 100644 --- a/internal/cmd/config/unset_test.go +++ b/internal/cmd/config/unset_test.go @@ -7,13 +7,63 @@ import ( "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 TestUnset(t *testing.T) { + + _, deleteNestedOption := config.NewTestOption( + "nested.option", + "nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteNestedOption() + + _, deleteDeeplyNestedOption := config.NewTestOption( + "deeply.nested.option", + "deeply nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteDeeplyNestedOption() + + testConfig := `active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.deeply] +[contexts.preferences.deeply.nested] +option = 'bar' + +[contexts.preferences.nested] +option = 'foo' + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +` + type testCase struct { name string args []string + config string expOut string expErr string err string @@ -23,27 +73,36 @@ func TestUnset(t *testing.T) { testCases := []testCase{ { - name: "unset in current context", - args: []string{"quiet"}, + name: "unset in current context", + args: []string{"quiet"}, + config: testConfig, expOut: `Unset 'quiet' in context 'test_context' -active_context = "test_context" +active_context = 'test_context' [preferences] - debug = true - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' + +[contexts.preferences.deeply] +[contexts.preferences.deeply.nested] +option = 'bar' + +[contexts.preferences.nested] +option = 'foo' [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 `, }, { @@ -56,81 +115,169 @@ active_context = "test_context" postRun: func() { _ = os.Unsetenv("HCLOUD_CONTEXT") }, - args: []string{"poll-interval"}, + args: []string{"poll-interval"}, + config: testConfig, expOut: `Unset 'poll-interval' in context 'other_context' -active_context = "test_context" +active_context = 'test_context' [preferences] - debug = true - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" - quiet = true +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.deeply] +[contexts.preferences.deeply.nested] +option = 'bar' + +[contexts.preferences.nested] +option = 'foo' [[contexts]] - name = "other_context" - token = "another super secret token" +name = 'other_context' +token = 'another super secret token' `, }, { - name: "unset globally", - args: []string{"debug", "--global"}, + name: "unset globally", + args: []string{"debug", "--global"}, + config: testConfig, expOut: `Unset 'debug' globally -active_context = "test_context" +active_context = 'test_context' [preferences] - poll_interval = "1.234s" +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" - quiet = true +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.deeply] +[contexts.preferences.deeply.nested] +option = 'bar' + +[contexts.preferences.nested] +option = 'foo' [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 `, }, { name: "unset non existing", args: []string{"non-existing"}, + config: testConfig, err: "unknown preference: non-existing", expErr: "Error: unknown preference: non-existing\n", }, { name: "unset not set", args: []string{"debug-file"}, + config: testConfig, expErr: "Warning: key 'debug-file' was not set\n", expOut: `Unset 'debug-file' in context 'test_context' -active_context = "test_context" +active_context = 'test_context' [preferences] - debug = true - poll_interval = "1.234s" +debug = true +poll_interval = 1234000000 [[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" - quiet = true +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.deeply] +[contexts.preferences.deeply.nested] +option = 'bar' + +[contexts.preferences.nested] +option = 'foo' + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +`, + }, + { + name: "unset nested", + args: []string{"nested.option"}, + config: testConfig, + expOut: `Unset 'nested.option' in context 'test_context' +active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.deeply] +[contexts.preferences.deeply.nested] +option = 'bar' [[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +`, + }, + { + name: "unset deeply nested", + args: []string{"deeply.nested.option"}, + config: testConfig, + expOut: `Unset 'deeply.nested.option' in context 'test_context' +active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +name = 'test_context' +token = 'super secret token' + +[contexts.preferences] +endpoint = 'https://test-endpoint.com' +quiet = true + +[contexts.preferences.nested] +option = 'foo' + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 `, }, } @@ -144,7 +291,7 @@ active_context = "test_context" defer tt.postRun() } - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(tt.config)) defer fx.Finish() cmd := configCmd.NewUnsetCommand(fx.State()) diff --git a/internal/cmd/context/active.go b/internal/cmd/context/active.go index 80b9b286..96584ada 100644 --- a/internal/cmd/context/active.go +++ b/internal/cmd/context/active.go @@ -26,7 +26,7 @@ func runActive(s state.State, cmd *cobra.Command, _ []string) error { if os.Getenv("HCLOUD_TOKEN") != "" { _, _ = fmt.Fprintln(os.Stderr, "Warning: HCLOUD_TOKEN is set. The active context will have no effect.") } - if ctx := s.Config().ActiveContext(); ctx != nil { + if ctx := s.Config().ActiveContext(); !util.IsNil(ctx) { cmd.Println(ctx.Name()) } return nil diff --git a/internal/cmd/server/ssh.go b/internal/cmd/server/ssh.go index 7990f2a8..ce4085cc 100644 --- a/internal/cmd/server/ssh.go +++ b/internal/cmd/server/ssh.go @@ -14,9 +14,10 @@ import ( "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state" - "github.com/hetznercloud/cli/internal/state/config" ) +var SSHPath = "ssh" + var SSHCmd = base.Cmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ @@ -58,7 +59,7 @@ var SSHCmd = base.Cmd{ } sshArgs := []string{"-l", user, "-p", strconv.Itoa(port), ipAddress.String()} - sshCommand := exec.Command(config.OptionSSHPath.Get(s.Config()), append(sshArgs, args[1:]...)...) + sshCommand := exec.Command(SSHPath, 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 a9bc5c4d..5fe57cf4 100644 --- a/internal/cmd/server/ssh_test.go +++ b/internal/cmd/server/ssh_test.go @@ -7,7 +7,6 @@ import ( "github.com/golang/mock/gomock" "github.com/hetznercloud/cli/internal/cmd/server" - "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" ) @@ -29,7 +28,7 @@ func TestSSH(t *testing.T) { Get(gomock.Any(), srv.Name). Return(&srv, nil, nil) - config.OptionSSHPath.Override(fx.Config, "echo") + server.SSHPath = "echo" } testutil.TestCommand(t, &server.SSHCmd, map[string]testutil.TestCase{ diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 294541e3..880c1585 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -8,9 +8,11 @@ import ( "os" "strings" - "github.com/BurntSushi/toml" + "github.com/pelletier/go-toml/v2" "github.com/spf13/pflag" "github.com/spf13/viper" + + "github.com/hetznercloud/cli/internal/cmd/util" ) type Config interface { @@ -28,6 +30,7 @@ type Config interface { Preferences() Preferences Viper() *viper.Viper FlagSet() *pflag.FlagSet + Path() string } type schema struct { @@ -205,10 +208,16 @@ func (cfg *config) ActiveContext() Context { } func (cfg *config) SetActiveContext(ctx Context) { + if util.IsNil(ctx) { + cfg.activeContext = nil + cfg.schema.ActiveContext = "" + return + } if ctx, ok := ctx.(*context); !ok { panic("invalid context type") } else { cfg.activeContext = ctx + cfg.schema.ActiveContext = ctx.ContextName } } @@ -229,6 +238,7 @@ func (cfg *config) SetContexts(contexts []Context) { cfg.contexts = append(cfg.contexts, c) } } + cfg.schema.Contexts = cfg.contexts } func (cfg *config) Preferences() Preferences { @@ -246,3 +256,7 @@ func (cfg *config) Viper() *viper.Viper { func (cfg *config) FlagSet() *pflag.FlagSet { return cfg.fs } + +func (cfg *config) Path() string { + return cfg.path +} diff --git a/internal/state/config/options.go b/internal/state/config/options.go index db9786c2..03e57df0 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -15,7 +15,6 @@ import ( type OptionFlag int -// [⚠️] If you add an option, don't forget to document it in internal/cmd/config/config.go const ( // OptionFlagPreference indicates that the option can be set in the config file, globally or per context (in the preferences section) OptionFlagPreference OptionFlag = 1 << iota @@ -120,29 +119,13 @@ var ( DefaultPreferenceFlags, nil, ) - - OptionDefaultSSHKeys = newOpt( - "default-ssh-keys", - "Default SSH keys for new servers", - []string{}, - DefaultPreferenceFlags&^OptionFlagPFlag, - nil, - ) - - OptionSSHPath = newOpt( - "ssh-path", - "Path to the SSH binary (used by 'hcloud server ssh')", - "ssh", - DefaultPreferenceFlags, - nil, - ) ) type Option[T any] struct { Name string Description string Default T - Source OptionFlag + Flags OptionFlag overrides *overrides } @@ -161,6 +144,9 @@ func (o *Option[T]) Get(c Config) T { } val = d } + if v, ok := val.(int64); ok { + val = time.Duration(v) + } case bool: if v, ok := val.(string); ok { b, err := strconv.ParseBool(v) @@ -194,7 +180,7 @@ func (o *Option[T]) Changed(c Config) bool { } func (o *Option[T]) HasFlag(src OptionFlag) bool { - return o.Source&src != 0 + return o.Flags&src != 0 } func (o *Option[T]) IsSlice() bool { @@ -271,8 +257,16 @@ func (o *Option[T]) addToFlagSet(fs *pflag.FlagSet) { } } -func newOpt[T any](name, description string, def T, source OptionFlag, ov *overrides) *Option[T] { - o := &Option[T]{Name: name, Description: description, Default: def, Source: source, overrides: ov} +func newOpt[T any](name, description string, def T, flags OptionFlag, ov *overrides) *Option[T] { + o := &Option[T]{Name: name, Description: description, Default: def, Flags: flags, overrides: ov} Options[name] = o return o } + +// NewTestOption is a helper function to create an option for testing purposes +func NewTestOption[T any](name, description string, def T, flags OptionFlag, ov *overrides) (*Option[T], func()) { + opt := newOpt(name, description, def, flags, ov) + return opt, func() { + delete(Options, name) + } +} diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go index db6a5cf9..ae3e849b 100644 --- a/internal/state/config/preferences.go +++ b/internal/state/config/preferences.go @@ -3,135 +3,79 @@ package config import ( "bytes" "fmt" - "reflect" - "slices" "strings" - "time" - "github.com/BurntSushi/toml" + "github.com/pelletier/go-toml/v2" "github.com/spf13/viper" - - "github.com/hetznercloud/cli/internal/cmd/util" ) // 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, values []string) (any, error) { - opt, ok := Options[key] - if !ok || !opt.HasFlag(OptionFlagPreference) { - return nil, fmt.Errorf("unknown preference: %s", key) - } - - var val any - switch t := opt.T().(type) { - case bool: - if len(values) != 1 { - return nil, fmt.Errorf("expected exactly one value") - } - value := values[0] - switch strings.ToLower(value) { - case "true", "t", "yes", "y", "1": - val = true - case "false", "f", "no", "n", "0": - val = false - default: - return nil, fmt.Errorf("invalid boolean value: %s", value) - } - case string: - if len(values) != 1 { - return nil, fmt.Errorf("expected exactly one value") - } - val = values[0] - case time.Duration: - if len(values) != 1 { - return nil, fmt.Errorf("expected exactly one value") +func (p Preferences) Get(key string) (any, bool) { + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + var m map[string]any = p + path := strings.Split(configKey, ".") + for i, key := range path { + if i == len(path)-1 { + val, ok := m[key] + return val, ok } - value := values[0] - var err error - val, err = time.ParseDuration(value) - if err != nil { - return nil, fmt.Errorf("invalid duration value: %s", value) + if next, ok := m[key].(map[string]any); !ok { + break + } else { + m = next } - case []string: - newVal := values[:] - slices.Sort(newVal) - newVal = slices.Compact(newVal) - val = newVal - default: - return nil, fmt.Errorf("unsupported type %T", t) - } - - configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") - - p[configKey] = val - return val, nil -} - -func (p Preferences) Unset(key string) (bool, error) { - opt, ok := Options[key] - if !ok || !opt.HasFlag(OptionFlagPreference) { - return false, fmt.Errorf("unknown preference: %s", key) } - - configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") - _, ok = p[configKey] - delete(p, configKey) - return ok, nil + return nil, false } -func (p Preferences) Add(key string, values []string) ([]any, error) { - opt, ok := Options[key] - if !ok || !opt.HasFlag(OptionFlagPreference) { - return nil, fmt.Errorf("unknown preference: %s", key) - } - - var added []any - +func (p Preferences) Set(key string, val any) { configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") - val := p[configKey] - switch opt.T().(type) { - case []string: - before := util.AnyToStringSlice(val) - newVal := append(before, values...) - slices.Sort(newVal) - newVal = slices.Compact(newVal) - val = newVal - added = util.ToAnySlice(util.SliceDiff[[]string](newVal, before)) - default: - return nil, fmt.Errorf("%s is not a list", key) + var m map[string]any = p + path := strings.Split(configKey, ".") + for i, key := range path { + if i == len(path)-1 { + m[key] = val + return + } + if next, ok := m[key].(map[string]any); !ok { + next = make(map[string]any) + m[key] = next + m = next + } else { + m = next + } } - - p[configKey] = val - return added, nil } -func (p Preferences) Remove(key string, values []string) ([]any, error) { - opt, ok := Options[key] - if !ok || !opt.HasFlag(OptionFlagPreference) { - return nil, fmt.Errorf("unknown preference: %s", key) - } - - var removed []any - +func (p Preferences) Unset(key string) bool { configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") - val := p[configKey] - switch opt.T().(type) { - case []string: - before := util.AnyToStringSlice(val) - diff := util.SliceDiff[[]string](before, values) - val = diff - removed = util.ToAnySlice(util.SliceDiff[[]string](before, diff)) - default: - return nil, fmt.Errorf("%s is not a list", key) - } - - if reflect.ValueOf(val).Len() == 0 { - delete(p, configKey) - } else { - p[configKey] = val + var m map[string]any = p + path := strings.Split(configKey, ".") + parents := make([]map[string]any, 0, len(path)-1) + for i, key := range path { + parents = append(parents, m) + if i == len(path)-1 { + _, ok := m[key] + delete(m, key) + // delete parent maps if they are empty + for i := len(parents) - 1; i >= 0; i-- { + if len(parents[i]) == 0 { + if i > 0 { + delete(parents[i-1], path[i-1]) + } + } + } + return ok + } + if next, ok := m[key].(map[string]any); !ok { + return false + } else { + m = next + } } - return removed, nil + return false } func (p Preferences) merge(v *viper.Viper) error { @@ -151,13 +95,22 @@ func (p Preferences) merge(v *viper.Viper) error { } func (p Preferences) validate() error { - for key := range p { - opt, ok := Options[strings.ReplaceAll(key, "_", "-")] + return validate(p, "") +} + +func validate(m map[string]any, prefix string) error { + for configKey, val := range m { + key := prefix + strings.ReplaceAll(configKey, "_", "-") + if val, ok := val.(map[string]any); ok { + if err := validate(val, key+"."); err != nil { + return err + } + continue + } + opt, ok := Options[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 75778632..287ca79e 100644 --- a/internal/state/config/preferences_test.go +++ b/internal/state/config/preferences_test.go @@ -9,7 +9,7 @@ import ( func TestUnknownPreference(t *testing.T) { t.Run("existing", func(t *testing.T) { clear(Options) - newOpt("foo", "", "", OptionFlagPreference) + newOpt("foo", "", "", OptionFlagPreference, nil) p := Preferences{"foo": ""} assert.NoError(t, p.validate()) @@ -17,7 +17,7 @@ func TestUnknownPreference(t *testing.T) { t.Run("existing but no preference", func(t *testing.T) { clear(Options) - newOpt("foo", "", "", 0) + newOpt("foo", "", "", 0, nil) p := Preferences{"foo": ""} assert.EqualError(t, p.validate(), "unknown preference: foo") diff --git a/internal/state/helpers.go b/internal/state/helpers.go index c9a9ebc2..7ff91ff7 100644 --- a/internal/state/helpers.go +++ b/internal/state/helpers.go @@ -4,6 +4,8 @@ import ( "errors" "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/state/config" ) func Wrap(s State, f func(State, *cobra.Command, []string) error) func(*cobra.Command, []string) error { diff --git a/internal/testutil/fixture.go b/internal/testutil/fixture.go index d90c3226..60c52b85 100644 --- a/internal/testutil/fixture.go +++ b/internal/testutil/fixture.go @@ -26,7 +26,7 @@ type Fixture struct { // NewFixture creates a new Fixture with default config file. func NewFixture(t *testing.T) *Fixture { - return NewFixtureWithConfigFile(t, nil) + return NewFixtureWithConfigFile(t, []byte{}) } // NewFixtureWithConfigFile creates a new Fixture with the given config file. From 27f6040791b0e6b919d68503c2ea2f3793f45b6e Mon Sep 17 00:00:00 2001 From: pauhull <22707808+phm07@users.noreply.github.com> Date: Fri, 10 May 2024 16:21:17 +0200 Subject: [PATCH 08/37] generate --- internal/cmd/config/helptext/preferences.txt | 45 +++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/internal/cmd/config/helptext/preferences.txt b/internal/cmd/config/helptext/preferences.txt index 0ca9c246..e5f52a78 100644 --- a/internal/cmd/config/helptext/preferences.txt +++ b/internal/cmd/config/helptext/preferences.txt @@ -1,26 +1,19 @@ -┌──────────────────┬──────────────────────┬──────────────────┬─────────────────────────┬─────────────────┐ -│ OPTION │ DESCRIPTION │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ -├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ -│ debug │ Enable debug output │ debug │ HCLOUD_DEBUG │ --debug │ -├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ -│ debug-file │ File to write debug │ debug_file │ HCLOUD_DEBUG_FILE │ --debug-file │ -│ │ output to │ │ │ │ -├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ -│ default-ssh-keys │ Default SSH keys for │ default_ssh_keys │ HCLOUD_DEFAULT_SSH_KEYS │ │ -│ │ new servers │ │ │ │ -├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ -│ endpoint │ Hetzner Cloud API │ endpoint │ HCLOUD_ENDPOINT │ --endpoint │ -│ │ endpoint │ │ │ │ -├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ -│ poll-interval │ Interval at which to │ poll_interval │ HCLOUD_POLL_INTERVAL │ --poll-interval │ -│ │ poll information, │ │ │ │ -│ │ for example action │ │ │ │ -│ │ progress │ │ │ │ -├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ -│ quiet │ If true, only print │ quiet │ HCLOUD_QUIET │ --quiet │ -│ │ error messages │ │ │ │ -├──────────────────┼──────────────────────┼──────────────────┼─────────────────────────┼─────────────────┤ -│ ssh-path │ Path to the SSH │ ssh_path │ HCLOUD_SSH_PATH │ --ssh-path │ -│ │ binary (used by │ │ │ │ -│ │ 'hcloud server ssh') │ │ │ │ -└──────────────────┴──────────────────────┴──────────────────┴─────────────────────────┴─────────────────┘ +┌───────────────┬──────────────────────┬───────────────┬──────────────────────┬─────────────────┐ +│ OPTION │ DESCRIPTION │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ +├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ +│ debug │ Enable debug output │ debug │ HCLOUD_DEBUG │ --debug │ +├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ +│ debug-file │ File to write debug │ debug_file │ HCLOUD_DEBUG_FILE │ --debug-file │ +│ │ output to │ │ │ │ +├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ +│ endpoint │ Hetzner Cloud API │ endpoint │ HCLOUD_ENDPOINT │ --endpoint │ +│ │ endpoint │ │ │ │ +├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ +│ poll-interval │ Interval at which to │ poll_interval │ HCLOUD_POLL_INTERVAL │ --poll-interval │ +│ │ poll information, │ │ │ │ +│ │ for example action │ │ │ │ +│ │ progress │ │ │ │ +├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ +│ quiet │ If true, only print │ quiet │ HCLOUD_QUIET │ --quiet │ +│ │ error messages │ │ │ │ +└───────────────┴──────────────────────┴───────────────┴──────────────────────┴─────────────────┘ From 640c3bc32b801e45502b6c63294568519f67ff8e Mon Sep 17 00:00:00 2001 From: pauhull <22707808+phm07@users.noreply.github.com> Date: Fri, 10 May 2024 16:36:35 +0200 Subject: [PATCH 09/37] tests for preferences --- internal/state/config/preferences.go | 10 +- internal/state/config/preferences_test.go | 166 ++++++++++++++++++++-- 2 files changed, 158 insertions(+), 18 deletions(-) diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go index ae3e849b..0a6a00ba 100644 --- a/internal/state/config/preferences.go +++ b/internal/state/config/preferences.go @@ -78,8 +78,12 @@ func (p Preferences) Unset(key string) bool { return false } +func (p Preferences) Validate() error { + return validate(p, "") +} + func (p Preferences) merge(v *viper.Viper) error { - if err := p.validate(); err != nil { + if err := p.Validate(); err != nil { return err } m := make(map[string]any) @@ -94,10 +98,6 @@ func (p Preferences) merge(v *viper.Viper) error { return v.MergeConfig(&buf) } -func (p Preferences) validate() error { - return validate(p, "") -} - func validate(m map[string]any, prefix string) error { for configKey, val := range m { key := prefix + strings.ReplaceAll(configKey, "_", "-") diff --git a/internal/state/config/preferences_test.go b/internal/state/config/preferences_test.go index 287ca79e..c604db06 100644 --- a/internal/state/config/preferences_test.go +++ b/internal/state/config/preferences_test.go @@ -1,31 +1,171 @@ -package config +package config_test import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/hetznercloud/cli/internal/state/config" ) -func TestUnknownPreference(t *testing.T) { +func TestPreferences_Get(t *testing.T) { + t.Parallel() + + p := config.Preferences{ + "foo": "bar", + "baz": "qux", + "quux": map[string]any{ + "corge": "grault", + "garply": map[string]any{ + "waldo": []string{"fred", "plugh"}, + "xyzzy": 2, + }, + }, + } + + v, ok := p.Get("foo") + assert.True(t, ok) + assert.Equal(t, "bar", v) + + v, ok = p.Get("baz") + assert.True(t, ok) + assert.Equal(t, "qux", v) + + v, ok = p.Get("buz") + assert.False(t, ok) + assert.Nil(t, v) + + v, ok = p.Get("quux.corge") + assert.True(t, ok) + assert.Equal(t, "grault", v) + + v, ok = p.Get("quux.garply.waldo") + assert.True(t, ok) + assert.Equal(t, []string{"fred", "plugh"}, v) + + v, ok = p.Get("quux.garply.xyzzy") + assert.True(t, ok) + assert.Equal(t, 2, v) + + v, ok = p.Get("quux.garply") + assert.True(t, ok) + assert.Equal(t, map[string]any{ + "waldo": []string{"fred", "plugh"}, + "xyzzy": 2, + }, v) +} + +func TestPreferences_Set(t *testing.T) { + t.Parallel() + + p := config.Preferences{} + p.Set("foo", "bar") + p.Set("baz", "qux") + p.Set("quux.corge", "grault") + p.Set("quux.garply.waldo", []string{"fred", "plugh"}) + p.Set("quux.garply.xyzzy", 2) + + assert.Equal(t, config.Preferences{ + "foo": "bar", + "baz": "qux", + "quux": map[string]any{ + "corge": "grault", + "garply": map[string]any{ + "waldo": []string{"fred", "plugh"}, + "xyzzy": 2, + }, + }, + }, p) +} + +func TestPreferences_Unset(t *testing.T) { + t.Parallel() + + p := config.Preferences{ + "foo": "bar", + "baz": "qux", + "quux": map[string]any{ + "corge": "grault", + "garply": map[string]any{ + "waldo": []string{"fred", "plugh"}, + "xyzzy": 2, + }, + }, + } + + assert.True(t, p.Unset("foo")) + assert.Equal(t, config.Preferences{ + "baz": "qux", + "quux": map[string]any{ + "corge": "grault", + "garply": map[string]any{ + "waldo": []string{"fred", "plugh"}, + "xyzzy": 2, + }, + }, + }, p) + + assert.False(t, p.Unset("buz")) + assert.Equal(t, config.Preferences{ + "baz": "qux", + "quux": map[string]any{ + "corge": "grault", + "garply": map[string]any{ + "waldo": []string{"fred", "plugh"}, + "xyzzy": 2, + }, + }, + }, p) + + assert.True(t, p.Unset("quux.corge")) + assert.Equal(t, config.Preferences{ + "baz": "qux", + "quux": map[string]any{ + "garply": map[string]any{ + "waldo": []string{"fred", "plugh"}, + "xyzzy": 2, + }, + }, + }, p) + + assert.True(t, p.Unset("quux.garply.waldo")) + assert.Equal(t, config.Preferences{ + "baz": "qux", + "quux": map[string]any{ + "garply": map[string]any{ + "xyzzy": 2, + }, + }, + }, p) + + assert.True(t, p.Unset("quux.garply.xyzzy")) + assert.Equal(t, config.Preferences{ + "baz": "qux", + }, p) + + assert.True(t, p.Unset("baz")) + assert.Equal(t, config.Preferences{}, p) +} + +func TestPreferences_Validate(t *testing.T) { t.Run("existing", func(t *testing.T) { - clear(Options) - newOpt("foo", "", "", OptionFlagPreference, nil) + _, cleanup := config.NewTestOption("foo", "", "", config.OptionFlagPreference, nil) + defer cleanup() - p := Preferences{"foo": ""} - assert.NoError(t, p.validate()) + p := config.Preferences{"foo": ""} + assert.NoError(t, p.Validate()) }) t.Run("existing but no preference", func(t *testing.T) { - clear(Options) - newOpt("foo", "", "", 0, nil) + _, cleanup := config.NewTestOption("foo", "", "", 0, nil) + defer cleanup() - p := Preferences{"foo": ""} - assert.EqualError(t, p.validate(), "unknown preference: foo") + p := config.Preferences{"foo": ""} + assert.EqualError(t, p.Validate(), "unknown preference: foo") }) t.Run("not existing", func(t *testing.T) { - clear(Options) - p := Preferences{"foo": ""} - assert.EqualError(t, p.validate(), "unknown preference: foo") + p := config.Preferences{"foo": ""} + assert.EqualError(t, p.Validate(), "unknown preference: foo") }) } From ad7311f497f19d2267e2586e221d680516ec0015 Mon Sep 17 00:00:00 2001 From: pauhull <22707808+phm07@users.noreply.github.com> Date: Fri, 10 May 2024 17:34:56 +0200 Subject: [PATCH 10/37] expose Schema --- internal/state/config/config.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 880c1585..811326a9 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -31,9 +31,10 @@ type Config interface { Viper() *viper.Viper FlagSet() *pflag.FlagSet Path() string + Schema() *Schema } -type schema struct { +type Schema struct { ActiveContext string `toml:"active_context"` Preferences Preferences `toml:"preferences"` Contexts []*context `toml:"contexts"` @@ -46,7 +47,7 @@ type config struct { activeContext *context contexts []*context preferences Preferences - schema schema + schema Schema } func NewConfig() Config { @@ -260,3 +261,7 @@ func (cfg *config) FlagSet() *pflag.FlagSet { func (cfg *config) Path() string { return cfg.path } + +func (cfg *config) Schema() *Schema { + return &cfg.schema +} From fdbcf7adbc11c4d77ebf9a69992e6e06a4cb9cc2 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Tue, 21 May 2024 15:06:28 +0200 Subject: [PATCH 11/37] handle non-existing config file --- internal/state/config/config.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 811326a9..3aa31ea7 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "path" "strings" "github.com/pelletier/go-toml/v2" @@ -37,7 +38,7 @@ type Config interface { type Schema struct { ActiveContext string `toml:"active_context"` Preferences Preferences `toml:"preferences"` - Contexts []*context `toml:"contexts"` + Contexts []*context `toml:"contexts,omitempty"` } type config struct { @@ -98,15 +99,15 @@ func (cfg *config) ParseConfigFile(f any) error { ) cfg.path = OptionConfig.Get(cfg) - path, ok := f.(string) - if path != "" && ok { - cfg.path = path + cfgPath, ok := f.(string) + if cfgPath != "" && ok { + cfg.path = cfgPath } if f == nil || ok { // read config from file cfgBytes, err = os.ReadFile(cfg.path) - if err != nil { + if err != nil && !errors.Is(err, os.ErrNotExist) { return err } } else { @@ -179,7 +180,11 @@ func (cfg *config) ParseConfigFile(f any) error { 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) + dir := path.Dir(cfg.path) + if err = os.MkdirAll(dir, 0750); err != nil { + return err + } + f, err := os.OpenFile(cfg.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND|os.O_TRUNC, 0600) if err != nil { return err } From fa5e1e9c6399e52d67e7aa73668057cc03acd55d Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Tue, 21 May 2024 15:36:22 +0200 Subject: [PATCH 12/37] fix tests --- internal/cmd/config/set_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cmd/config/set_test.go b/internal/cmd/config/set_test.go index d217ed8e..6d345d89 100644 --- a/internal/cmd/config/set_test.go +++ b/internal/cmd/config/set_test.go @@ -272,7 +272,6 @@ poll_interval = 1234000000 args: []string{"debug", "false", "--global"}, expOut: `Set 'debug' to 'false' globally active_context = '' -contexts = [] [preferences] debug = false From 11d11d56b3797d7c18c11c56f038fda9dd7b5f4c Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Thu, 23 May 2024 13:30:17 +0200 Subject: [PATCH 13/37] update documentation --- README.md | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 06174ba6..809025a5 100644 --- a/README.md +++ b/README.md @@ -142,18 +142,31 @@ You can control output via the `-o` option: The template’s input is the resource’s corresponding struct in the [hcloud-go](https://godoc.org/github.com/hetznercloud/hcloud-go/hcloud) library. -## Configure hcloud using environment variables - -You can use the following environment variables to configure `hcloud`: - -* `HCLOUD_TOKEN` -* `HCLOUD_CONTEXT` -* `HCLOUD_CONFIG` - -When using `hcloud` in scripts, for example, it may be cumbersome to work with -contexts. Instead of creating a context, you can set the token via the `HCLOUD_TOKEN` -environment variable. When combined with tools like [direnv](https://direnv.net), you -can configure a per-directory context by setting `HCLOUD_CONTEXT=my-context` via `.envrc`. +## Configuring hcloud + +The hcloud CLI tool allows configuration using following methods: +1. Configuration file +2. Environment variables +3. Command line flags + +A higher number means a higher priority. For example, a command line flag will +always override an environment variable. + +The configuration file is located at `~/.config/hcloud/cli.toml` by default +(On Windows: `%APPDATA%\hcloud\cli.toml`). You can change the location by setting +the `HCLOUD_CONFIG` environment variable or the `--config` flag. The configuration file +stores global preferences, the currently active context, all contexts and +context-specific preferences. Contexts always store a token and can optionally have +additional preferences which take precedence over the globally set preferences. + +However, a config file is not required. If no config file is found, the CLI will +use the default configuration. Overriding options using environment variables allows +hcloud CLI to function in a stateless way. For example, setting `HCLOUD_TOKEN` is +already enough in many cases. + +You can use the `hcloud config` command to manage your configuration, for example +to get, list, set and unset configuration options and preferences. You can view a list +of all available options and preferences by running `hcloud config --help`. ## Examples From 57c7b76d29877767612b21904ce36337cf3e7bd4 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Thu, 23 May 2024 16:26:36 +0200 Subject: [PATCH 14/37] fix search & replace mistake --- 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 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/firewall/add_rule.go b/internal/cmd/firewall/add_rule.go index 3896dcaf..fe071aa7 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{}, "Origin IPs (CIDR Notation) (required when direction is in)") + cmd.Flags().StringArray("source-ips", []string{}, "Source 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 7e9b7fc2..8de861ae 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{}, "Origin IPs (CIDR Notation) (required when direction is in)") + cmd.Flags().StringArray("source-ips", []string{}, "Source 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 3a1ec947..bdcf3ab9 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(" Origin IPs:\n") + cmd.Print(" Source 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 c5262b9d..32563384 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 - Origin IPs: + Source IPs: Applied To: - Type: server Server ID: 321 From 9074f20ef71284ce1831867da2f3581d84354df6 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Thu, 23 May 2024 16:36:40 +0200 Subject: [PATCH 15/37] add tests for util methods --- internal/cmd/util/util_test.go | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/internal/cmd/util/util_test.go b/internal/cmd/util/util_test.go index 45b7f96c..bf9879de 100644 --- a/internal/cmd/util/util_test.go +++ b/internal/cmd/util/util_test.go @@ -294,3 +294,46 @@ func TestFilterNil(t *testing.T) { assert.Equal(t, []interface{}{0, ""}, util.FilterNil([]interface{}{0, nil, ""})) assert.Equal(t, []*testStruct{{1, 2, 3}, {}}, util.FilterNil([]*testStruct{{1, 2, 3}, nil, {}, (*testStruct)(nil)})) } + +func TestSliceDiff(t *testing.T) { + assert.Equal(t, []int{1, 2}, util.SliceDiff[[]int]([]int{1, 2, 3}, []int{3, 4})) + assert.Equal(t, []int{4}, util.SliceDiff[[]int]([]int{3, 4}, []int{1, 2, 3})) + assert.Empty(t, util.SliceDiff[[]int]([]int{1, 2, 3}, []int{1, 2, 3})) + assert.Empty(t, util.SliceDiff[[]int]([]int{}, []int{})) + assert.Equal(t, []string{"a", "b"}, util.SliceDiff[[]string]([]string{"a", "b", "c"}, []string{"c", "d"})) + assert.Equal(t, []string{"a"}, util.SliceDiff[[]string]([]string{"b", "a", "b", "b", "c", "c"}, []string{"b", "c"})) +} + +func TestAnyToAnySlice(t *testing.T) { + assert.Equal(t, []any{1, "foo", true}, util.AnyToAnySlice([]any{1, "foo", true})) + assert.Equal(t, []any{"a", "b", "c"}, util.AnyToAnySlice([]string{"a", "b", "c"})) + assert.Equal(t, []any{1, 2, 3}, util.AnyToAnySlice([]int{1, 2, 3})) + assert.Equal(t, []any{true, false}, util.AnyToAnySlice([]bool{true, false})) + assert.Nil(t, util.AnyToAnySlice(1)) + assert.Nil(t, util.AnyToAnySlice("abc")) + assert.Nil(t, util.AnyToAnySlice(nil)) +} + +func TestAnyToStringSlice(t *testing.T) { + assert.Equal(t, []string{"1", "foo", "true"}, util.AnyToStringSlice([]any{1, "foo", true})) + assert.Equal(t, []string{"a", "b", "c"}, util.AnyToStringSlice([]string{"a", "b", "c"})) + assert.Equal(t, []string{"1", "2", "3"}, util.AnyToStringSlice([]int{1, 2, 3})) + assert.Equal(t, []string{"true", "false"}, util.AnyToStringSlice([]bool{true, false})) + assert.Nil(t, util.AnyToStringSlice(1)) + assert.Nil(t, util.AnyToStringSlice("abc")) + assert.Nil(t, util.AnyToStringSlice(nil)) +} + +func TestToStringSlice(t *testing.T) { + assert.Equal(t, []string{"1", "foo", "true"}, util.ToStringSlice([]any{1, "foo", true})) + assert.Equal(t, []string{"a", "b", "c"}, util.ToStringSlice([]any{"a", "b", "c"})) + assert.Equal(t, []string{"1", "2", "3"}, util.ToStringSlice([]any{1, 2, 3})) + assert.Equal(t, []string{"true", "false"}, util.ToStringSlice([]any{true, false})) +} + +func TestToAnySlice(t *testing.T) { + assert.Equal(t, []any{1, "foo", true}, util.ToAnySlice([]any{1, "foo", true})) + assert.Equal(t, []any{"a", "b", "c"}, util.ToAnySlice([]string{"a", "b", "c"})) + assert.Equal(t, []any{1, 2, 3}, util.ToAnySlice([]int{1, 2, 3})) + assert.Equal(t, []any{true, false}, util.ToAnySlice([]bool{true, false})) +} From 0eff3c558cfb175711472c4b0c22ec7170ccb23a Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Thu, 23 May 2024 16:38:54 +0200 Subject: [PATCH 16/37] clean up state creation code --- internal/state/state.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/state/state.go b/internal/state/state.go index 3e7edf77..964f9461 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -46,10 +46,11 @@ func (c *state) Config() config.Config { } func (c *state) newClient() hcapi2.Client { - var opts []hcloud.ClientOption + opts := []hcloud.ClientOption{ + hcloud.WithToken(config.OptionToken.Get(c.config)), + hcloud.WithApplication("hcloud-cli", version.Version), + } - 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)) } @@ -66,6 +67,5 @@ func (c *state) newClient() hcapi2.Client { opts = append(opts, hcloud.WithBackoffFunc(hcloud.ConstantBackoff(pollInterval))) } - opts = append(opts, hcloud.WithApplication("hcloud-cli", version.Version)) return hcapi2.NewClient(opts...) } From f7c8bcd298ce5853613b85e832c8bb75ce2cc728 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 10:49:35 +0200 Subject: [PATCH 17/37] update comment --- internal/state/config/options.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/state/config/options.go b/internal/state/config/options.go index 03e57df0..1ea18b16 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -18,7 +18,7 @@ type OptionFlag int const ( // 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 indicates that the option can be configured inside the configuration file OptionFlagConfig // OptionFlagPFlag indicates that the option can be set via a command line flag OptionFlagPFlag From f96fafd87d22bafcc7ac9b92c33fd9e5b8365dc4 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 11:10:06 +0200 Subject: [PATCH 18/37] add code comments --- internal/state/config/config.go | 13 +++++++++++++ internal/state/config/options.go | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 3aa31ea7..1090028c 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -20,18 +20,31 @@ 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 + // Reset resets the config by creating a new viper instance and a new FlagSet Reset() + // ParseConfigFile parses the given config file f, environment variables and flags and reads the values into the config ParseConfigFile(f any) error + // ActiveContext returns the currently active context ActiveContext() Context + // SetActiveContext sets the currently active context and also modifies the schema to reflect this change + // This does NOT change any configuration values. Use [ReadConfig] to re-read the newly set active context. SetActiveContext(Context) + // Contexts returns a list of currently loaded contexts Contexts() []Context + // SetContexts sets the list of contexts and also modifies the schema to reflect this change SetContexts([]Context) + // Preferences returns the global preferences (as opposed to [Context.Preferences]) Preferences() Preferences + // Viper returns the currently active instance of viper Viper() *viper.Viper + // FlagSet returns the FlagSet that options are bound to FlagSet() *pflag.FlagSet + + // Path returns the path to the used config file Path() string + // Schema returns the TOML schema of the config file as a struct Schema() *Schema } diff --git a/internal/state/config/options.go b/internal/state/config/options.go index 1ea18b16..5bd5b27d 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -31,18 +31,31 @@ const ( ) type IOption interface { + // addToFlagSet adds the option to the provided flag set addToFlagSet(fs *pflag.FlagSet) + // GetName returns the name of the option GetName() string + // GetDescription returns the description of the option GetDescription() string + // ConfigKey returns the key used in the config file. If the option is not configurable via the config file, an empty string is returned ConfigKey() string + // EnvVar returns the name of the environment variable. If the option is not configurable via an environment variable, an empty string is returned EnvVar() string + // FlagName returns the name of the flag. If the option is not configurable via a flag, an empty string is returned FlagName() string + // HasFlag returns true if the option has the provided flag set HasFlag(src OptionFlag) bool + // GetAsAny reads the option value from the config and returns it as an any GetAsAny(c Config) any + // OverrideAny sets the option value in the config to the provided any value OverrideAny(c Config, v any) + // Changed returns true if the option has been changed from the default Changed(c Config) bool + // Completions returns a list of possible completions for the option (for example for boolean options: "true", "false") Completions() []string + // IsSlice returns true if the option is a slice IsSlice() bool + // T returns an instance of the type of the option as an any T() any } From ce35e5f058d8caaf16ed139e3214d690bf605308 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 13:20:08 +0200 Subject: [PATCH 19/37] clear env before tests to make sure not to leak HCLOUD_TOKEN --- internal/cmd/config/add_test.go | 1 + internal/cmd/config/get_test.go | 2 ++ internal/cmd/config/list_test.go | 1 + internal/cmd/config/remove_test.go | 2 ++ internal/cmd/config/set_test.go | 2 ++ internal/cmd/config/unset_test.go | 1 + 6 files changed, 9 insertions(+) diff --git a/internal/cmd/config/add_test.go b/internal/cmd/config/add_test.go index 04c56fb2..091e651c 100644 --- a/internal/cmd/config/add_test.go +++ b/internal/cmd/config/add_test.go @@ -12,6 +12,7 @@ import ( ) func TestAdd(t *testing.T) { + os.Clearenv() _, deleteArrayOption := config.NewTestOption[[]string]( "array-option", diff --git a/internal/cmd/config/get_test.go b/internal/cmd/config/get_test.go index b2c7614a..71d53bbf 100644 --- a/internal/cmd/config/get_test.go +++ b/internal/cmd/config/get_test.go @@ -1,6 +1,7 @@ package config_test import ( + "os" "testing" "github.com/stretchr/testify/assert" @@ -11,6 +12,7 @@ import ( ) func TestGet(t *testing.T) { + os.Clearenv() _, deleteDeeplyNestedOption := config.NewTestOption( "deeply.nested.option", diff --git a/internal/cmd/config/list_test.go b/internal/cmd/config/list_test.go index 8888d48f..5508637c 100644 --- a/internal/cmd/config/list_test.go +++ b/internal/cmd/config/list_test.go @@ -12,6 +12,7 @@ import ( ) func TestList(t *testing.T) { + os.Clearenv() _, deleteDeeplyNestedOption := config.NewTestOption( "deeply.nested.option", diff --git a/internal/cmd/config/remove_test.go b/internal/cmd/config/remove_test.go index 49bb5458..8bd9312a 100644 --- a/internal/cmd/config/remove_test.go +++ b/internal/cmd/config/remove_test.go @@ -1,6 +1,7 @@ package config_test import ( + "os" "testing" "github.com/stretchr/testify/assert" @@ -11,6 +12,7 @@ import ( ) func TestRemove(t *testing.T) { + os.Clearenv() _, deleteArrayOption := config.NewTestOption[[]string]( "array-option", diff --git a/internal/cmd/config/set_test.go b/internal/cmd/config/set_test.go index 6d345d89..e6929094 100644 --- a/internal/cmd/config/set_test.go +++ b/internal/cmd/config/set_test.go @@ -12,6 +12,8 @@ import ( ) func TestSet(t *testing.T) { + os.Clearenv() + _, deleteNestedOption := config.NewTestOption( "nested.option", "nested option", diff --git a/internal/cmd/config/unset_test.go b/internal/cmd/config/unset_test.go index 2aff3ab5..6634b326 100644 --- a/internal/cmd/config/unset_test.go +++ b/internal/cmd/config/unset_test.go @@ -12,6 +12,7 @@ import ( ) func TestUnset(t *testing.T) { + os.Clearenv() _, deleteNestedOption := config.NewTestOption( "nested.option", From ce4b9a6adc9fa0864d53cf041c39d9a934083a51 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 13:36:01 +0200 Subject: [PATCH 20/37] error if context is not found, fix active context retrieval, adjust context active error message --- internal/cmd/context/active.go | 2 +- internal/state/config/config.go | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/cmd/context/active.go b/internal/cmd/context/active.go index 96584ada..13a3fa3c 100644 --- a/internal/cmd/context/active.go +++ b/internal/cmd/context/active.go @@ -24,7 +24,7 @@ func newActiveCommand(s state.State) *cobra.Command { func runActive(s state.State, cmd *cobra.Command, _ []string) error { if os.Getenv("HCLOUD_TOKEN") != "" { - _, _ = fmt.Fprintln(os.Stderr, "Warning: HCLOUD_TOKEN is set. The active context will have no effect.") + _, _ = fmt.Fprintln(os.Stderr, "Warning: HCLOUD_TOKEN is set. The active context's token will have no effect.") } if ctx := s.Config().ActiveContext(); !util.IsNil(ctx) { cmd.Println(ctx.Name()) diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 1090028c..f4f56b3e 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -153,10 +153,7 @@ func (cfg *config) ParseConfigFile(f any) error { } // read active context from viper - activeContext := cfg.schema.ActiveContext - if ctx := OptionContext.Get(cfg); ctx != "" { - activeContext = ctx - } + activeContext := OptionContext.Get(cfg) cfg.contexts = cfg.schema.Contexts for i, ctx := range cfg.contexts { @@ -166,8 +163,8 @@ func (cfg *config) ParseConfigFile(f any) error { } } - if cfg.schema.ActiveContext != "" && cfg.activeContext == nil { - _, _ = fmt.Fprintf(os.Stderr, "Warning: active context %q not found\n", cfg.schema.ActiveContext) + if activeContext != "" && cfg.activeContext == nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: active context %q not found\n", activeContext) } // merge global preferences first so that contexts can override them From 8eacd1064b31d829148a3382ae69c7bcf2d3fc16 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 13:40:52 +0200 Subject: [PATCH 21/37] refactor path splitting --- internal/state/config/preferences.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go index 0a6a00ba..674bc5bc 100644 --- a/internal/state/config/preferences.go +++ b/internal/state/config/preferences.go @@ -13,9 +13,8 @@ import ( type Preferences map[string]any func (p Preferences) Get(key string) (any, bool) { - configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") var m map[string]any = p - path := strings.Split(configKey, ".") + path := splitPath(key) for i, key := range path { if i == len(path)-1 { val, ok := m[key] @@ -31,9 +30,8 @@ func (p Preferences) Get(key string) (any, bool) { } func (p Preferences) Set(key string, val any) { - configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") var m map[string]any = p - path := strings.Split(configKey, ".") + path := splitPath(key) for i, key := range path { if i == len(path)-1 { m[key] = val @@ -50,9 +48,8 @@ func (p Preferences) Set(key string, val any) { } func (p Preferences) Unset(key string) bool { - configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") var m map[string]any = p - path := strings.Split(configKey, ".") + path := splitPath(key) parents := make([]map[string]any, 0, len(path)-1) for i, key := range path { parents = append(parents, m) @@ -114,3 +111,8 @@ func validate(m map[string]any, prefix string) error { } return nil } + +func splitPath(key string) []string { + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + return strings.Split(configKey, ".") +} From 763fa5f28f9dcc11b4a0caff25499d7ea54dda21 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 13:49:51 +0200 Subject: [PATCH 22/37] add tests for preference validation --- internal/state/config/preferences_test.go | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/internal/state/config/preferences_test.go b/internal/state/config/preferences_test.go index c604db06..59671864 100644 --- a/internal/state/config/preferences_test.go +++ b/internal/state/config/preferences_test.go @@ -156,6 +156,14 @@ func TestPreferences_Validate(t *testing.T) { assert.NoError(t, p.Validate()) }) + t.Run("existing nested", func(t *testing.T) { + _, cleanup := config.NewTestOption("foo.bar", "", "", config.OptionFlagPreference, nil) + defer cleanup() + + p := config.Preferences{"foo": map[string]any{"bar": ""}} + assert.NoError(t, p.Validate()) + }) + t.Run("existing but no preference", func(t *testing.T) { _, cleanup := config.NewTestOption("foo", "", "", 0, nil) defer cleanup() @@ -164,8 +172,37 @@ func TestPreferences_Validate(t *testing.T) { assert.EqualError(t, p.Validate(), "unknown preference: foo") }) + t.Run("existing nested but no preference", func(t *testing.T) { + _, cleanup := config.NewTestOption("foo.bar", "", "", 0, nil) + defer cleanup() + + p := config.Preferences{"foo": map[string]any{"bar": ""}} + assert.EqualError(t, p.Validate(), "unknown preference: foo.bar") + }) + t.Run("not existing", func(t *testing.T) { p := config.Preferences{"foo": ""} assert.EqualError(t, p.Validate(), "unknown preference: foo") }) + + t.Run("not existing nested", func(t *testing.T) { + p := config.Preferences{"foo": map[string]any{"bar": ""}} + assert.EqualError(t, p.Validate(), "unknown preference: foo.bar") + }) + + t.Run("not existing deeply nested", func(t *testing.T) { + _, cleanup := config.NewTestOption("foo.bar", "", "", 0, nil) + defer cleanup() + + p := config.Preferences{"foo": map[string]any{"bar": map[string]any{"baz": ""}}} + assert.EqualError(t, p.Validate(), "unknown preference: foo.bar.baz") + }) + + t.Run("nested missing map", func(t *testing.T) { + _, cleanup := config.NewTestOption("foo.bar.baz", "", "", 0, nil) + defer cleanup() + + p := config.Preferences{"foo": map[string]any{"bar": "this should be a map"}} + assert.EqualError(t, p.Validate(), "unknown preference: foo.bar") + }) } From d8b02c92ea617287235f513e0843d84c1e71c040 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 13:54:39 +0200 Subject: [PATCH 23/37] only suggest list options for add/remove --- internal/cmd/config/add.go | 2 +- internal/cmd/config/remove.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go index 265a47eb..4cf27f01 100644 --- a/internal/cmd/config/add.go +++ b/internal/cmd/config/add.go @@ -27,7 +27,7 @@ func NewAddCommand(s state.State) *cobra.Command { cmpl.SuggestCandidatesF(func() []string { var keys []string for key, opt := range config.Options { - if opt.HasFlag(config.OptionFlagPreference) { + if opt.IsSlice() && opt.HasFlag(config.OptionFlagPreference) { keys = append(keys, key) } } diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go index 5659dde6..44d43a65 100644 --- a/internal/cmd/config/remove.go +++ b/internal/cmd/config/remove.go @@ -27,7 +27,7 @@ func NewRemoveCommand(s state.State) *cobra.Command { cmpl.SuggestCandidatesF(func() []string { var keys []string for key, opt := range config.Options { - if opt.HasFlag(config.OptionFlagPreference) { + if opt.IsSlice() && opt.HasFlag(config.OptionFlagPreference) { keys = append(keys, key) } } From a11108543b425dd6cc81c0d82fc7036c2bdf9727 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 13:57:18 +0200 Subject: [PATCH 24/37] refactor: replace util.IsNil(ctx) with global where possible --- internal/cmd/config/add.go | 2 +- internal/cmd/config/remove.go | 2 +- internal/cmd/config/set.go | 2 +- internal/cmd/config/unset.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go index 4cf27f01..96a23577 100644 --- a/internal/cmd/config/add.go +++ b/internal/cmd/config/add.go @@ -92,7 +92,7 @@ func runAdd(s state.State, cmd *cobra.Command, args []string) error { _, _ = fmt.Fprintln(os.Stderr, "Warning: some values were already present or duplicate") } - if util.IsNil(ctx) { + if global { cmd.Printf("Added '%v' to '%s' globally\n", added, key) } else { cmd.Printf("Added '%v' to '%s' in context '%s'\n", added, key, ctx.Name()) diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go index 44d43a65..1825098a 100644 --- a/internal/cmd/config/remove.go +++ b/internal/cmd/config/remove.go @@ -95,7 +95,7 @@ func runRemove(s state.State, cmd *cobra.Command, args []string) error { _, _ = fmt.Fprintln(os.Stderr, "Warning: some values were not removed") } - if util.IsNil(ctx) { + if global { cmd.Printf("Removed '%v' from '%s' globally\n", removed, key) } else { cmd.Printf("Removed '%v' from '%s' in context '%s'\n", removed, key, ctx.Name()) diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index 2f563176..c5a98077 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -112,7 +112,7 @@ func runSet(s state.State, cmd *cobra.Command, args []string) error { prefs.Set(key, val) - if util.IsNil(ctx) { + if global { cmd.Printf("Set '%s' to '%v' globally\n", key, val) } else { cmd.Printf("Set '%s' to '%v' in context '%s'\n", key, val, ctx.Name()) diff --git a/internal/cmd/config/unset.go b/internal/cmd/config/unset.go index 6c883347..02db1708 100644 --- a/internal/cmd/config/unset.go +++ b/internal/cmd/config/unset.go @@ -67,7 +67,7 @@ func runUnset(s state.State, cmd *cobra.Command, args []string) error { if !ok { _, _ = fmt.Fprintf(os.Stderr, "Warning: key '%s' was not set\n", key) } - if util.IsNil(ctx) { + if global { cmd.Printf("Unset '%s' globally\n", key) } else { cmd.Printf("Unset '%s' in context '%s'\n", key, ctx.Name()) From 78b53024fa65be0d4b11cfbcc646be2bef898b06 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 14:01:10 +0200 Subject: [PATCH 25/37] refactor --- internal/cmd/config/get.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/cmd/config/get.go b/internal/cmd/config/get.go index 195dc6a8..69cb7759 100644 --- a/internal/cmd/config/get.go +++ b/internal/cmd/config/get.go @@ -40,14 +40,8 @@ func runGet(s state.State, cmd *cobra.Command, args []string) error { } key := args[0] - var opt config.IOption - for name, o := range config.Options { - if name == key { - opt = o - break - } - } - if opt == nil { + opt, ok := config.Options[key] + if !ok { return fmt.Errorf("unknown key: %s", key) } From ceeb3ab9d131d2e4dda2dc6f7f195d6c441f30c2 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 29 May 2024 14:14:07 +0200 Subject: [PATCH 26/37] add Config.UseContext(*string), remove Config.Reset(), refactor --- internal/cmd/config/get.go | 5 +---- internal/cmd/config/list.go | 5 +---- internal/state/config/config.go | 22 +++++++++++++++++----- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/internal/cmd/config/get.go b/internal/cmd/config/get.go index 69cb7759..d8c01eea 100644 --- a/internal/cmd/config/get.go +++ b/internal/cmd/config/get.go @@ -31,10 +31,7 @@ func runGet(s state.State, cmd *cobra.Command, args []string) error { 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 { + if err := s.Config().UseContext(nil); err != nil { return err } } diff --git a/internal/cmd/config/list.go b/internal/cmd/config/list.go index 82aec349..f6deaa16 100644 --- a/internal/cmd/config/list.go +++ b/internal/cmd/config/list.go @@ -39,10 +39,7 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { 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 { + if err := s.Config().UseContext(nil); err != nil { return err } } diff --git a/internal/state/config/config.go b/internal/state/config/config.go index f4f56b3e..6719a80f 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -20,20 +20,22 @@ 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 - // Reset resets the config by creating a new viper instance and a new FlagSet - Reset() // ParseConfigFile parses the given config file f, environment variables and flags and reads the values into the config ParseConfigFile(f any) error // ActiveContext returns the currently active context ActiveContext() Context // SetActiveContext sets the currently active context and also modifies the schema to reflect this change - // This does NOT change any configuration values. Use [ReadConfig] to re-read the newly set active context. + // This does NOT change any configuration values. Use [config.Config.UseConfig] to read the actual context into memory. SetActiveContext(Context) // Contexts returns a list of currently loaded contexts Contexts() []Context // SetContexts sets the list of contexts and also modifies the schema to reflect this change SetContexts([]Context) + // UseContext temporarily switches context to the given context name and reloads the config, loading the values of the given context. + // If name is nil, the context is unloaded and only the global preferences are used. + // This change will not be written to the schema, so `active_context` will not be changed after writing. + UseContext(name *string) error // Preferences returns the global preferences (as opposed to [Context.Preferences]) Preferences() Preferences @@ -66,11 +68,11 @@ type config struct { func NewConfig() Config { cfg := &config{} - cfg.Reset() + cfg.reset() return cfg } -func (cfg *config) Reset() { +func (cfg *config) reset() { cfg.v = viper.New() cfg.v.SetConfigType("toml") cfg.v.SetEnvPrefix("HCLOUD") @@ -257,6 +259,16 @@ func (cfg *config) SetContexts(contexts []Context) { cfg.schema.Contexts = cfg.contexts } +func (cfg *config) UseContext(name *string) error { + if name == nil { + OptionContext.OverrideAny(cfg, nil) + } else { + OptionContext.OverrideAny(cfg, *name) + } + cfg.reset() + return ReadConfig(cfg, nil) +} + func (cfg *config) Preferences() Preferences { if cfg.preferences == nil { cfg.preferences = make(Preferences) From 0bbc71020092761001120b59fbcc7b45eeddeb23 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 10:54:28 +0200 Subject: [PATCH 27/37] config.NewConfig() -> config.New() --- cmd/hcloud/main.go | 2 +- internal/state/config/config.go | 2 +- internal/testutil/fixture.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/hcloud/main.go b/cmd/hcloud/main.go index 1fa10f8f..4f149dc9 100644 --- a/cmd/hcloud/main.go +++ b/cmd/hcloud/main.go @@ -39,7 +39,7 @@ func init() { func main() { - cfg := config.NewConfig() + cfg := config.New() if err := config.ReadConfig(cfg, nil); err != nil { log.Fatalf("unable to read config file \"%s\": %s\n", cfg.Path(), err) } diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 6719a80f..c8431c91 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -66,7 +66,7 @@ type config struct { schema Schema } -func NewConfig() Config { +func New() Config { cfg := &config{} cfg.reset() return cfg diff --git a/internal/testutil/fixture.go b/internal/testutil/fixture.go index 60c52b85..c531a6aa 100644 --- a/internal/testutil/fixture.go +++ b/internal/testutil/fixture.go @@ -34,7 +34,7 @@ func NewFixture(t *testing.T) *Fixture { func NewFixtureWithConfigFile(t *testing.T, f any) *Fixture { ctrl := gomock.NewController(t) - cfg := config.NewConfig() + cfg := config.New() if err := config.ReadConfig(cfg, f); err != nil { t.Fatal(err) } From 0f5e752234e69ed28c73c471ec61d575b43680d4 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 11:39:53 +0200 Subject: [PATCH 28/37] refactor config.ReadConfig --- cmd/hcloud/main.go | 2 +- internal/state/config/config.go | 31 ++++++++++++------------------- internal/testutil/fixture.go | 4 ++-- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/cmd/hcloud/main.go b/cmd/hcloud/main.go index 4f149dc9..8cd6acb7 100644 --- a/cmd/hcloud/main.go +++ b/cmd/hcloud/main.go @@ -40,7 +40,7 @@ func init() { func main() { cfg := config.New() - if err := config.ReadConfig(cfg, nil); err != nil { + if err := cfg.Read(nil); err != nil { log.Fatalf("unable to read config file \"%s\": %s\n", cfg.Path(), err) } diff --git a/internal/state/config/config.go b/internal/state/config/config.go index c8431c91..3716f3c7 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -20,8 +20,14 @@ 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 - // ParseConfigFile parses the given config file f, environment variables and flags and reads the values into the config - ParseConfigFile(f any) error + // Read reads the config from the flags, env and 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 + Read(f any) error // ActiveContext returns the currently active context ActiveContext() Context @@ -87,27 +93,14 @@ func (cfg *config) reset() { } } -// 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 { +func (cfg *config) Read(f any) error { // error is ignored since invalid flags are already handled by cobra - _ = cfg.FlagSet().Parse(os.Args[1:]) + _ = cfg.fs.Parse(os.Args[1:]) // load env already so we can determine the active context - cfg.Viper().AutomaticEnv() + cfg.v.AutomaticEnv() - return cfg.ParseConfigFile(f) -} - -// 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 @@ -266,7 +259,7 @@ func (cfg *config) UseContext(name *string) error { OptionContext.OverrideAny(cfg, *name) } cfg.reset() - return ReadConfig(cfg, nil) + return cfg.Read(nil) } func (cfg *config) Preferences() Preferences { diff --git a/internal/testutil/fixture.go b/internal/testutil/fixture.go index c531a6aa..04a5813d 100644 --- a/internal/testutil/fixture.go +++ b/internal/testutil/fixture.go @@ -30,12 +30,12 @@ func NewFixture(t *testing.T) *Fixture { } // NewFixtureWithConfigFile creates a new Fixture with the given config file. -// See Config#ParseConfigFile for the supported types of f. +// See [config.Config.Read] for the supported types of f. func NewFixtureWithConfigFile(t *testing.T, f any) *Fixture { ctrl := gomock.NewController(t) cfg := config.New() - if err := config.ReadConfig(cfg, f); err != nil { + if err := cfg.Read(f); err != nil { t.Fatal(err) } From f369c340150034765a3fa9c455b7f127102ad68a Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:14:30 +0200 Subject: [PATCH 29/37] remove unneeded flag --- internal/state/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 3716f3c7..faf98f0a 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -189,7 +189,7 @@ func (cfg *config) Write(w io.Writer) (err error) { if err = os.MkdirAll(dir, 0750); err != nil { return err } - f, err := os.OpenFile(cfg.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND|os.O_TRUNC, 0600) + f, err := os.OpenFile(cfg.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } From a61a8566ac54f3da0c98334631ada5cf590abd5d Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:57:26 +0200 Subject: [PATCH 30/37] add completion for config get --- internal/cmd/config/get.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/cmd/config/get.go b/internal/cmd/config/get.go index d8c01eea..8f3ca184 100644 --- a/internal/cmd/config/get.go +++ b/internal/cmd/config/get.go @@ -5,6 +5,7 @@ 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" @@ -20,6 +21,15 @@ func NewGetCommand(s state.State) *cobra.Command { DisableFlagsInUseLine: true, SilenceUsage: true, RunE: state.Wrap(s, runGet), + ValidArgsFunction: cmpl.NoFileCompletion( + cmpl.SuggestCandidatesF(func() []string { + var keys []string + for key := range config.Options { + keys = append(keys, key) + } + return keys + }), + ), } cmd.Flags().Bool("global", false, "Get the value globally") cmd.Flags().Bool("allow-sensitive", false, "Allow showing sensitive values") From ce0e5d3e00230ab4208c6b4d7f63314849eada6b Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 13:31:59 +0200 Subject: [PATCH 31/37] refactor, remove duplicate code --- internal/cmd/config/add.go | 24 +++++++----------------- internal/cmd/config/remove.go | 24 +++++++----------------- internal/cmd/config/set.go | 24 +++++++----------------- internal/cmd/config/unset.go | 25 ++++++------------------- internal/cmd/config/util.go | 29 +++++++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 70 deletions(-) create mode 100644 internal/cmd/config/util.go diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go index 96a23577..adeeac4b 100644 --- a/internal/cmd/config/add.go +++ b/internal/cmd/config/add.go @@ -49,28 +49,18 @@ func NewAddCommand(s state.State) *cobra.Command { func runAdd(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") - var ( - added []any - ctx config.Context - prefs config.Preferences - ) - - if global { - prefs = s.Config().Preferences() - } else { - ctx = s.Config().ActiveContext() - if util.IsNil(ctx) { - return fmt.Errorf("no active context (use --global flag to set a global option)") - } - prefs = ctx.Preferences() + ctx, prefs, err := getPreferences(s.Config(), global) + if err != nil { + return err } key, values := args[0], args[1:] - opt, ok := config.Options[key] - if !ok || !opt.HasFlag(config.OptionFlagPreference) { - return fmt.Errorf("unknown preference: %s", key) + opt, err := getPreference(key) + if err != nil { + return err } + var added []any val, _ := prefs.Get(key) switch opt.T().(type) { case []string: diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go index 1825098a..e908d248 100644 --- a/internal/cmd/config/remove.go +++ b/internal/cmd/config/remove.go @@ -49,30 +49,20 @@ func NewRemoveCommand(s state.State) *cobra.Command { func runRemove(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") - var ( - removed []any - ctx config.Context - prefs config.Preferences - ) - - if global { - prefs = s.Config().Preferences() - } else { - ctx = s.Config().ActiveContext() - if util.IsNil(ctx) { - return fmt.Errorf("no active context (use --global to remove an option globally)") - } - prefs = ctx.Preferences() + ctx, prefs, err := getPreferences(s.Config(), global) + if err != nil { + return err } key, values := args[0], args[1:] - opt, ok := config.Options[key] - if !ok || !opt.HasFlag(config.OptionFlagPreference) { - return fmt.Errorf("unknown preference: %s", key) + opt, err := getPreference(key) + if err != nil { + return err } val, _ := prefs.Get(key) + var removed []any switch opt.T().(type) { case []string: before := util.AnyToStringSlice(val) diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index c5a98077..4b2bb09f 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -50,28 +50,18 @@ func NewSetCommand(s state.State) *cobra.Command { func runSet(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") - var ( - val any - ctx config.Context - prefs config.Preferences - ) - - if global { - prefs = s.Config().Preferences() - } else { - ctx = s.Config().ActiveContext() - if util.IsNil(ctx) { - return fmt.Errorf("no active context (use --global flag to set a global option)") - } - prefs = ctx.Preferences() + ctx, prefs, err := getPreferences(s.Config(), global) + if err != nil { + return err } key, values := args[0], args[1:] - opt, ok := config.Options[key] - if !ok || !opt.HasFlag(config.OptionFlagPreference) { - return fmt.Errorf("unknown preference: %s", key) + opt, err := getPreference(key) + if err != nil { + return err } + var val any switch t := opt.T().(type) { case bool: if len(values) != 1 { diff --git a/internal/cmd/config/unset.go b/internal/cmd/config/unset.go index 02db1708..0a1ad2d9 100644 --- a/internal/cmd/config/unset.go +++ b/internal/cmd/config/unset.go @@ -41,30 +41,17 @@ func NewUnsetCommand(s state.State) *cobra.Command { func runUnset(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") - var ( - ctx config.Context - prefs config.Preferences - ) - - if global { - prefs = s.Config().Preferences() - } else { - ctx = s.Config().ActiveContext() - if util.IsNil(ctx) { - return fmt.Errorf("no active context (use --global flag to unset a global option)") - } - prefs = ctx.Preferences() + ctx, prefs, err := getPreferences(s.Config(), global) + if err != nil { + return err } key := args[0] - opt, ok := config.Options[key] - if !ok || !opt.HasFlag(config.OptionFlagPreference) { - return fmt.Errorf("unknown preference: %s", key) + if _, err = getPreference(key); err != nil { + return err } - ok = prefs.Unset(key) - - if !ok { + if !prefs.Unset(key) { _, _ = fmt.Fprintf(os.Stderr, "Warning: key '%s' was not set\n", key) } if global { diff --git a/internal/cmd/config/util.go b/internal/cmd/config/util.go new file mode 100644 index 00000000..6a7a1bab --- /dev/null +++ b/internal/cmd/config/util.go @@ -0,0 +1,29 @@ +package config + +import ( + "fmt" + + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/state/config" +) + +func getPreferences(cfg config.Config, global bool) (ctx config.Context, prefs config.Preferences, _ error) { + if global { + prefs = cfg.Preferences() + } else { + ctx = cfg.ActiveContext() + if util.IsNil(ctx) { + return nil, nil, fmt.Errorf("no active context (use --global flag to set a global option)") + } + prefs = ctx.Preferences() + } + return +} + +func getPreference(key string) (config.IOption, error) { + opt, ok := config.Options[key] + if !ok || !opt.HasFlag(config.OptionFlagPreference) { + return nil, fmt.Errorf("unknown preference: %s", key) + } + return opt, nil +} From 7279a4fa0d78db471c300d96442dec2c5f37a2d6 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 14:07:42 +0200 Subject: [PATCH 32/37] add OptionFlagSlice, refactor, remove duplicated code --- internal/cmd/config/add.go | 18 ++---------- internal/cmd/config/get.go | 12 ++------ internal/cmd/config/helptext/generate.go | 2 +- internal/cmd/config/list.go | 2 +- internal/cmd/config/remove.go | 18 ++---------- internal/cmd/config/set.go | 18 ++---------- internal/cmd/config/unset.go | 12 +------- internal/cmd/config/util.go | 22 ++++++++++++++- internal/state/config/options.go | 25 +++++++--------- internal/state/config/options_test.go | 36 ++++++++++++++++++++++++ internal/state/config/preferences.go | 2 +- 11 files changed, 79 insertions(+), 88 deletions(-) create mode 100644 internal/state/config/options_test.go diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go index adeeac4b..d7c9b2b0 100644 --- a/internal/cmd/config/add.go +++ b/internal/cmd/config/add.go @@ -24,22 +24,8 @@ func NewAddCommand(s state.State) *cobra.Command { SilenceUsage: 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.IsSlice() && 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 - }), + cmpl.SuggestCandidates(getOptionNames(config.OptionFlagPreference|config.OptionFlagSlice)...), + cmpl.SuggestCandidatesCtx(suggestOptionCompletions), )), } cmd.Flags().Bool("global", false, "Set the value globally (for all contexts)") diff --git a/internal/cmd/config/get.go b/internal/cmd/config/get.go index 8f3ca184..bbee9d7b 100644 --- a/internal/cmd/config/get.go +++ b/internal/cmd/config/get.go @@ -21,15 +21,7 @@ func NewGetCommand(s state.State) *cobra.Command { DisableFlagsInUseLine: true, SilenceUsage: true, RunE: state.Wrap(s, runGet), - ValidArgsFunction: cmpl.NoFileCompletion( - cmpl.SuggestCandidatesF(func() []string { - var keys []string - for key := range config.Options { - keys = append(keys, key) - } - return keys - }), - ), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestCandidates(getOptionNames(0)...)), } cmd.Flags().Bool("global", false, "Get the value globally") cmd.Flags().Bool("allow-sensitive", false, "Allow showing sensitive values") @@ -53,7 +45,7 @@ func runGet(s state.State, cmd *cobra.Command, args []string) error { } val := opt.GetAsAny(s.Config()) - if opt.HasFlag(config.OptionFlagSensitive) && !allowSensitive { + if opt.HasFlags(config.OptionFlagSensitive) && !allowSensitive { return fmt.Errorf("'%s' is sensitive. use --allow-sensitive to show the value", key) } cmd.Println(val) diff --git a/internal/cmd/config/helptext/generate.go b/internal/cmd/config/helptext/generate.go index cfda083f..07f2d31d 100644 --- a/internal/cmd/config/helptext/generate.go +++ b/internal/cmd/config/helptext/generate.go @@ -44,7 +44,7 @@ func generateTable(outFile string, filterFlag config.OptionFlag, hasFlag bool) { var opts []config.IOption for _, opt := range config.Options { - if opt.HasFlag(filterFlag) != hasFlag { + if opt.HasFlags(filterFlag) != hasFlag { continue } opts = append(opts, opt) diff --git a/internal/cmd/config/list.go b/internal/cmd/config/list.go index f6deaa16..8b49361b 100644 --- a/internal/cmd/config/list.go +++ b/internal/cmd/config/list.go @@ -52,7 +52,7 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { var options []option for name, opt := range config.Options { val := opt.GetAsAny(s.Config()) - if opt.HasFlag(config.OptionFlagSensitive) && !allowSensitive { + if opt.HasFlags(config.OptionFlagSensitive) && !allowSensitive { val = "[redacted]" } if !all && !opt.Changed(s.Config()) { diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go index e908d248..fab0a84f 100644 --- a/internal/cmd/config/remove.go +++ b/internal/cmd/config/remove.go @@ -24,22 +24,8 @@ func NewRemoveCommand(s state.State) *cobra.Command { SilenceUsage: 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.IsSlice() && 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 - }), + cmpl.SuggestCandidates(getOptionNames(config.OptionFlagPreference|config.OptionFlagSlice)...), + cmpl.SuggestCandidatesCtx(suggestOptionCompletions), )), } cmd.Flags().Bool("global", false, "Remove the value(s) globally (for all contexts)") diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index 4b2bb09f..0e1cb531 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -25,22 +25,8 @@ func NewSetCommand(s state.State) *cobra.Command { SilenceUsage: 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 - }), + cmpl.SuggestCandidates(getOptionNames(config.OptionFlagPreference)...), + cmpl.SuggestCandidatesCtx(suggestOptionCompletions), )), } cmd.Flags().Bool("global", false, "Set the value globally (for all contexts)") diff --git a/internal/cmd/config/unset.go b/internal/cmd/config/unset.go index 0a1ad2d9..f24f54b5 100644 --- a/internal/cmd/config/unset.go +++ b/internal/cmd/config/unset.go @@ -22,17 +22,7 @@ func NewUnsetCommand(s state.State) *cobra.Command { DisableFlagsInUseLine: true, SilenceUsage: 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 - }), - )), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestCandidates(getOptionNames(config.OptionFlagPreference)...)), } cmd.Flags().Bool("global", false, "Unset the value globally (for all contexts)") return cmd diff --git a/internal/cmd/config/util.go b/internal/cmd/config/util.go index 6a7a1bab..afbc3ef8 100644 --- a/internal/cmd/config/util.go +++ b/internal/cmd/config/util.go @@ -3,6 +3,8 @@ package config import ( "fmt" + "github.com/spf13/cobra" + "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/state/config" ) @@ -22,8 +24,26 @@ func getPreferences(cfg config.Config, global bool) (ctx config.Context, prefs c func getPreference(key string) (config.IOption, error) { opt, ok := config.Options[key] - if !ok || !opt.HasFlag(config.OptionFlagPreference) { + if !ok || !opt.HasFlags(config.OptionFlagPreference) { return nil, fmt.Errorf("unknown preference: %s", key) } return opt, nil } + +func getOptionNames(flags config.OptionFlag) []string { + var names []string + for name, opt := range config.Options { + if opt.HasFlags(flags) { + names = append(names, name) + } + } + return names +} + +func suggestOptionCompletions(_ *cobra.Command, args []string) []string { + var comps []string + if opt, ok := config.Options[args[0]]; ok { + comps = opt.Completions() + } + return comps +} diff --git a/internal/state/config/options.go b/internal/state/config/options.go index 5bd5b27d..2e2ee111 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -2,7 +2,6 @@ package config import ( "fmt" - "reflect" "strconv" "strings" "time" @@ -26,6 +25,8 @@ const ( OptionFlagEnv // OptionFlagSensitive indicates that the option holds sensitive data and should not be printed OptionFlagSensitive + // OptionFlagSlice indicates that the option value is a slice + OptionFlagSlice DefaultPreferenceFlags = OptionFlagPreference | OptionFlagConfig | OptionFlagPFlag | OptionFlagEnv ) @@ -43,8 +44,8 @@ type IOption interface { EnvVar() string // FlagName returns the name of the flag. If the option is not configurable via a flag, an empty string is returned FlagName() string - // HasFlag returns true if the option has the provided flag set - HasFlag(src OptionFlag) bool + // HasFlags returns true if the option has all the provided flags set + HasFlags(src OptionFlag) bool // GetAsAny reads the option value from the config and returns it as an any GetAsAny(c Config) any // OverrideAny sets the option value in the config to the provided any value @@ -53,8 +54,6 @@ type IOption interface { Changed(c Config) bool // Completions returns a list of possible completions for the option (for example for boolean options: "true", "false") Completions() []string - // IsSlice returns true if the option is a slice - IsSlice() bool // T returns an instance of the type of the option as an any T() any } @@ -192,12 +191,8 @@ func (o *Option[T]) Changed(c Config) bool { return c.Viper().IsSet(o.Name) } -func (o *Option[T]) HasFlag(src OptionFlag) bool { - return o.Flags&src != 0 -} - -func (o *Option[T]) IsSlice() bool { - return reflect.TypeOf(o.T()).Kind() == reflect.Slice +func (o *Option[T]) HasFlags(src OptionFlag) bool { + return (^o.Flags)&src == 0 } func (o *Option[T]) GetName() string { @@ -209,7 +204,7 @@ func (o *Option[T]) GetDescription() string { } func (o *Option[T]) ConfigKey() string { - if !o.HasFlag(OptionFlagConfig) { + if !o.HasFlags(OptionFlagConfig) { return "" } if o.overrides != nil && o.overrides.configKey != "" { @@ -219,7 +214,7 @@ func (o *Option[T]) ConfigKey() string { } func (o *Option[T]) EnvVar() string { - if !o.HasFlag(OptionFlagEnv) { + if !o.HasFlags(OptionFlagEnv) { return "" } if o.overrides != nil && o.overrides.envVar != "" { @@ -229,7 +224,7 @@ func (o *Option[T]) EnvVar() string { } func (o *Option[T]) FlagName() string { - if !o.HasFlag(OptionFlagPFlag) { + if !o.HasFlags(OptionFlagPFlag) { return "" } if o.overrides != nil && o.overrides.flagName != "" { @@ -253,7 +248,7 @@ func (o *Option[T]) T() any { } func (o *Option[T]) addToFlagSet(fs *pflag.FlagSet) { - if !o.HasFlag(OptionFlagPFlag) { + if !o.HasFlags(OptionFlagPFlag) { return } switch v := any(o.Default).(type) { diff --git a/internal/state/config/options_test.go b/internal/state/config/options_test.go new file mode 100644 index 00000000..3b017c68 --- /dev/null +++ b/internal/state/config/options_test.go @@ -0,0 +1,36 @@ +package config + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOptions(t *testing.T) { + for _, opt := range Options { + kind := reflect.TypeOf(opt.T()).Kind() + if kind == reflect.Slice && !opt.HasFlags(OptionFlagSlice) { + t.Errorf("option %s is a slice but does not have the slice flag", opt.GetName()) + } + if kind != reflect.Slice && opt.HasFlags(OptionFlagSlice) { + t.Errorf("option %s is not a slice but has the slice flag", opt.GetName()) + } + if opt.HasFlags(OptionFlagPFlag | OptionFlagSensitive) { + t.Errorf("%s: sensitive options shouldn't have pflags", opt.GetName()) + } + } +} + +func TestOption_HasFlags(t *testing.T) { + opt := &Option[any]{Flags: OptionFlagSensitive | OptionFlagPFlag | OptionFlagSlice} + assert.True(t, opt.HasFlags(OptionFlagSensitive)) + assert.True(t, opt.HasFlags(OptionFlagPFlag)) + assert.True(t, opt.HasFlags(OptionFlagSlice)) + assert.True(t, opt.HasFlags(OptionFlagSensitive|OptionFlagPFlag)) + assert.True(t, opt.HasFlags(OptionFlagSensitive|OptionFlagSlice)) + assert.True(t, opt.HasFlags(OptionFlagPFlag|OptionFlagSlice)) + assert.True(t, opt.HasFlags(OptionFlagSensitive|OptionFlagPFlag|OptionFlagSlice)) + assert.False(t, opt.HasFlags(OptionFlagConfig)) + assert.False(t, opt.HasFlags(OptionFlagConfig|OptionFlagSensitive)) +} diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go index 674bc5bc..3633d563 100644 --- a/internal/state/config/preferences.go +++ b/internal/state/config/preferences.go @@ -105,7 +105,7 @@ func validate(m map[string]any, prefix string) error { continue } opt, ok := Options[key] - if !ok || !opt.HasFlag(OptionFlagPreference) { + if !ok || !opt.HasFlags(OptionFlagPreference) { return fmt.Errorf("unknown preference: %s", key) } } From b81121a4c37a2230661a49c14a4d05afdb516fa6 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 14:37:48 +0200 Subject: [PATCH 33/37] move parsing logic to options.go --- internal/cmd/config/set.go | 45 ++---------------------------- internal/state/config/options.go | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 42 deletions(-) diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index 0e1cb531..8f2e8eae 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -1,11 +1,6 @@ package config import ( - "fmt" - "slices" - "strings" - "time" - "github.com/spf13/cobra" "github.com/hetznercloud/cli/internal/cmd/cmpl" @@ -47,43 +42,9 @@ func runSet(s state.State, cmd *cobra.Command, args []string) error { return err } - 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 - case "false", "f", "no", "n", "0": - val = false - default: - return fmt.Errorf("invalid boolean value: %s", value) - } - case string: - 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: - newVal := values[:] - slices.Sort(newVal) - newVal = slices.Compact(newVal) - val = newVal - default: - return fmt.Errorf("unsupported type %T", t) + val, err := opt.Parse(values) + if err != nil { + return err } prefs.Set(key, val) diff --git a/internal/state/config/options.go b/internal/state/config/options.go index 2e2ee111..852c06cc 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "slices" "strconv" "strings" "time" @@ -54,6 +55,8 @@ type IOption interface { Changed(c Config) bool // Completions returns a list of possible completions for the option (for example for boolean options: "true", "false") Completions() []string + // Parse parses a string slice (for example command arguments) based on the option type and returns the parsed value as an any + Parse(values []string) (any, error) // T returns an instance of the type of the option as an any T() any } @@ -242,6 +245,51 @@ func (o *Option[T]) Completions() []string { return nil } +func (o *Option[T]) Parse(values []string) (any, error) { + var ( + val any + t T + ) + switch any(t).(type) { + case bool: + if len(values) != 1 { + return nil, fmt.Errorf("expected exactly one value") + } + value := values[0] + switch strings.ToLower(value) { + case "true", "t", "yes", "y", "1": + val = true + case "false", "f", "no", "n", "0": + val = false + default: + return nil, fmt.Errorf("invalid boolean value: %s", value) + } + case string: + if len(values) != 1 { + return nil, fmt.Errorf("expected exactly one value") + } + val = values[0] + case time.Duration: + if len(values) != 1 { + return nil, fmt.Errorf("expected exactly one value") + } + value := values[0] + var err error + val, err = time.ParseDuration(value) + if err != nil { + return nil, fmt.Errorf("invalid duration value: %s", value) + } + case []string: + newVal := values[:] + slices.Sort(newVal) + newVal = slices.Compact(newVal) + val = newVal + default: + return nil, fmt.Errorf("unsupported type %T", t) + } + return val, nil +} + func (o *Option[T]) T() any { var t T return t From 3f63626625fa6aff141be2aa8b6b9b6c8c74d961 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 15:37:35 +0200 Subject: [PATCH 34/37] Update README.md Co-authored-by: Jonas L. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 809025a5..0bee734e 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ context-specific preferences. Contexts always store a token and can optionally h additional preferences which take precedence over the globally set preferences. However, a config file is not required. If no config file is found, the CLI will -use the default configuration. Overriding options using environment variables allows +use the default configuration. Overriding options using environment variables allows the hcloud CLI to function in a stateless way. For example, setting `HCLOUD_TOKEN` is already enough in many cases. From 5e5ac8016e45455008c46aebc18543915700e7c8 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 15:46:40 +0200 Subject: [PATCH 35/37] refactor bool parsing --- internal/cmd/util/util.go | 13 ++++++++++ internal/cmd/util/util_test.go | 43 ++++++++++++++++++++++++++++++++ internal/state/config/options.go | 12 +++------ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/internal/cmd/util/util.go b/internal/cmd/util/util.go index dc60e725..a0e5e5be 100644 --- a/internal/cmd/util/util.go +++ b/internal/cmd/util/util.go @@ -289,3 +289,16 @@ func ToAnySlice[T any](a []T) []any { } return s } + +// ParseBoolLenient parses the passed string as a boolean. It is different from strconv.ParseBool in that it +// is case-insensitive and also accepts "yes"/"y" and "no"/"n" as valid values. +func ParseBoolLenient(s string) (bool, error) { + switch strings.ToLower(s) { + case "true", "t", "yes", "y", "1": + return true, nil + case "false", "f", "no", "n", "0": + return false, nil + default: + return false, fmt.Errorf("invalid boolean value: %s", s) + } +} diff --git a/internal/cmd/util/util_test.go b/internal/cmd/util/util_test.go index bf9879de..22f76ca6 100644 --- a/internal/cmd/util/util_test.go +++ b/internal/cmd/util/util_test.go @@ -337,3 +337,46 @@ func TestToAnySlice(t *testing.T) { assert.Equal(t, []any{1, 2, 3}, util.ToAnySlice([]int{1, 2, 3})) assert.Equal(t, []any{true, false}, util.ToAnySlice([]bool{true, false})) } + +func TestParseBoolLenient(t *testing.T) { + b, err := util.ParseBoolLenient("true") + assert.NoError(t, err) + assert.True(t, b) + b, err = util.ParseBoolLenient("True") + assert.NoError(t, err) + assert.True(t, b) + b, err = util.ParseBoolLenient("t") + assert.NoError(t, err) + assert.True(t, b) + b, err = util.ParseBoolLenient("yes") + assert.NoError(t, err) + assert.True(t, b) + b, err = util.ParseBoolLenient("y") + assert.NoError(t, err) + assert.True(t, b) + b, err = util.ParseBoolLenient("1") + assert.NoError(t, err) + assert.True(t, b) + b, err = util.ParseBoolLenient("false") + assert.NoError(t, err) + assert.False(t, b) + b, err = util.ParseBoolLenient("False") + assert.NoError(t, err) + assert.False(t, b) + b, err = util.ParseBoolLenient("f") + assert.NoError(t, err) + assert.False(t, b) + b, err = util.ParseBoolLenient("no") + assert.NoError(t, err) + assert.False(t, b) + b, err = util.ParseBoolLenient("n") + assert.NoError(t, err) + assert.False(t, b) + b, err = util.ParseBoolLenient("0") + assert.NoError(t, err) + assert.False(t, b) + b, err = util.ParseBoolLenient("invalid") + assert.EqualError(t, err, "invalid boolean value: invalid") + b, err = util.ParseBoolLenient("") + assert.EqualError(t, err, "invalid boolean value: ") +} diff --git a/internal/state/config/options.go b/internal/state/config/options.go index 852c06cc..0bb861fc 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -255,14 +255,10 @@ func (o *Option[T]) Parse(values []string) (any, error) { if len(values) != 1 { return nil, fmt.Errorf("expected exactly one value") } - value := values[0] - switch strings.ToLower(value) { - case "true", "t", "yes", "y", "1": - val = true - case "false", "f", "no", "n", "0": - val = false - default: - return nil, fmt.Errorf("invalid boolean value: %s", value) + var err error + val, err = util.ParseBoolLenient(values[0]) + if err != nil { + return nil, err } case string: if len(values) != 1 { From 458746b092dc1646211871f15d216e0fb6f0a883 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 5 Jun 2024 16:11:32 +0200 Subject: [PATCH 36/37] add type documentation for options --- internal/cmd/config/config.go | 9 ++++- internal/cmd/config/helptext/generate.go | 23 ++++++++++-- internal/cmd/config/helptext/other.txt | 22 ++++++------ internal/cmd/config/helptext/preferences.txt | 38 ++++++++++---------- 4 files changed, 59 insertions(+), 33 deletions(-) diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 414b1bb8..3aac6d82 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -29,6 +29,13 @@ The hierarchy for configuration sources is as follows (from highest to lowest pr 4. Configuration file (global) 5. Default values +Option values can have following types: + - string + - integer + - boolean (true/false, yes/no) + - duration (in the Go duration format, e.g. "1h30m") + - any of the above as a list + Most options are 'preferences' - these options can be set globally and can additionally be overridden for each context. Below is a list of all non-preference options: @@ -36,7 +43,7 @@ for each context. Below is a list of all non-preference options: ` Since the above options are not preferences, they cannot be modified with 'hcloud config set' or 'hcloud config unset'. However, you are able to retrieve them using 'hcloud config get' and 'hcloud config list'. -Following options are preferences and can be used with these commands: +Following options are preferences and can be used with set/unset/add/remove: ` + preferenceOptions + ` diff --git a/internal/cmd/config/helptext/generate.go b/internal/cmd/config/helptext/generate.go index 07f2d31d..0f48c431 100644 --- a/internal/cmd/config/helptext/generate.go +++ b/internal/cmd/config/helptext/generate.go @@ -1,9 +1,11 @@ package main import ( + "fmt" "os" "slices" "strings" + "time" "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" @@ -40,7 +42,7 @@ func generateTable(outFile string, filterFlag config.OptionFlag, hasFlag bool) { }) t.SetOutputMirror(f) - t.AppendHeader(table.Row{"Option", "Description", "Config key", "Environment variable", "Flag"}) + t.AppendHeader(table.Row{"Option", "Description", "Type", "Config key", "Environment variable", "Flag"}) var opts []config.IOption for _, opt := range config.Options { @@ -55,9 +57,26 @@ func generateTable(outFile string, filterFlag config.OptionFlag, hasFlag bool) { }) for _, opt := range opts { - t.AppendRow(table.Row{opt.GetName(), opt.GetDescription(), opt.ConfigKey(), opt.EnvVar(), opt.FlagName()}) + t.AppendRow(table.Row{opt.GetName(), opt.GetDescription(), getTypeName(opt), opt.ConfigKey(), opt.EnvVar(), opt.FlagName()}) t.AppendSeparator() } t.Render() } + +func getTypeName(opt config.IOption) string { + switch t := opt.T().(type) { + case bool: + return "boolean" + case int: + return "integer" + case string: + return "string" + case time.Duration: + return "duration" + case []string: + return "string list" + default: + panic(fmt.Sprintf("missing type name for %T", t)) + } +} diff --git a/internal/cmd/config/helptext/other.txt b/internal/cmd/config/helptext/other.txt index 7d419f0e..34566b6a 100644 --- a/internal/cmd/config/helptext/other.txt +++ b/internal/cmd/config/helptext/other.txt @@ -1,11 +1,11 @@ -┌─────────┬──────────────────────┬────────────────┬──────────────────────┬───────────┐ -│ OPTION │ DESCRIPTION │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ -├─────────┼──────────────────────┼────────────────┼──────────────────────┼───────────┤ -│ config │ Config file path │ │ HCLOUD_CONFIG │ --config │ -├─────────┼──────────────────────┼────────────────┼──────────────────────┼───────────┤ -│ context │ Currently active │ active_context │ HCLOUD_CONTEXT │ --context │ -│ │ context │ │ │ │ -├─────────┼──────────────────────┼────────────────┼──────────────────────┼───────────┤ -│ token │ Hetzner Cloud API │ token │ HCLOUD_TOKEN │ │ -│ │ token │ │ │ │ -└─────────┴──────────────────────┴────────────────┴──────────────────────┴───────────┘ +┌─────────┬──────────────────────┬────────┬────────────────┬──────────────────────┬───────────┐ +│ OPTION │ DESCRIPTION │ TYPE │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ +├─────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ +│ config │ Config file path │ string │ │ HCLOUD_CONFIG │ --config │ +├─────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ +│ context │ Currently active │ string │ active_context │ HCLOUD_CONTEXT │ --context │ +│ │ context │ │ │ │ │ +├─────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ +│ token │ Hetzner Cloud API │ string │ token │ HCLOUD_TOKEN │ │ +│ │ token │ │ │ │ │ +└─────────┴──────────────────────┴────────┴────────────────┴──────────────────────┴───────────┘ diff --git a/internal/cmd/config/helptext/preferences.txt b/internal/cmd/config/helptext/preferences.txt index e5f52a78..6a2da56c 100644 --- a/internal/cmd/config/helptext/preferences.txt +++ b/internal/cmd/config/helptext/preferences.txt @@ -1,19 +1,19 @@ -┌───────────────┬──────────────────────┬───────────────┬──────────────────────┬─────────────────┐ -│ OPTION │ DESCRIPTION │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ -├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ -│ debug │ Enable debug output │ debug │ HCLOUD_DEBUG │ --debug │ -├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ -│ debug-file │ File to write debug │ debug_file │ HCLOUD_DEBUG_FILE │ --debug-file │ -│ │ output to │ │ │ │ -├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ -│ endpoint │ Hetzner Cloud API │ endpoint │ HCLOUD_ENDPOINT │ --endpoint │ -│ │ endpoint │ │ │ │ -├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ -│ poll-interval │ Interval at which to │ poll_interval │ HCLOUD_POLL_INTERVAL │ --poll-interval │ -│ │ poll information, │ │ │ │ -│ │ for example action │ │ │ │ -│ │ progress │ │ │ │ -├───────────────┼──────────────────────┼───────────────┼──────────────────────┼─────────────────┤ -│ quiet │ If true, only print │ quiet │ HCLOUD_QUIET │ --quiet │ -│ │ error messages │ │ │ │ -└───────────────┴──────────────────────┴───────────────┴──────────────────────┴─────────────────┘ +┌───────────────┬──────────────────────┬──────────┬───────────────┬──────────────────────┬─────────────────┐ +│ OPTION │ DESCRIPTION │ TYPE │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ +├───────────────┼──────────────────────┼──────────┼───────────────┼──────────────────────┼─────────────────┤ +│ debug │ Enable debug output │ boolean │ debug │ HCLOUD_DEBUG │ --debug │ +├───────────────┼──────────────────────┼──────────┼───────────────┼──────────────────────┼─────────────────┤ +│ debug-file │ File to write debug │ string │ debug_file │ HCLOUD_DEBUG_FILE │ --debug-file │ +│ │ output to │ │ │ │ │ +├───────────────┼──────────────────────┼──────────┼───────────────┼──────────────────────┼─────────────────┤ +│ endpoint │ Hetzner Cloud API │ string │ endpoint │ HCLOUD_ENDPOINT │ --endpoint │ +│ │ endpoint │ │ │ │ │ +├───────────────┼──────────────────────┼──────────┼───────────────┼──────────────────────┼─────────────────┤ +│ poll-interval │ Interval at which to │ duration │ poll_interval │ HCLOUD_POLL_INTERVAL │ --poll-interval │ +│ │ poll information, │ │ │ │ │ +│ │ for example action │ │ │ │ │ +│ │ progress │ │ │ │ │ +├───────────────┼──────────────────────┼──────────┼───────────────┼──────────────────────┼─────────────────┤ +│ quiet │ If true, only print │ boolean │ quiet │ HCLOUD_QUIET │ --quiet │ +│ │ error messages │ │ │ │ │ +└───────────────┴──────────────────────┴──────────┴───────────────┴──────────────────────┴─────────────────┘ From bf5e9640be2ab1e8b67f7a1018b2a47f3867b77b Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Thu, 6 Jun 2024 10:25:41 +0200 Subject: [PATCH 37/37] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0bee734e..7b5b6e57 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ You can control output via the `-o` option: ## Configuring hcloud -The hcloud CLI tool allows configuration using following methods: +The hcloud CLI tool can be configured using following methods: 1. Configuration file 2. Environment variables 3. Command line flags