diff --git a/README.md b/README.md index 06174ba6..7b5b6e57 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 can be configured 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 the +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 diff --git a/cmd/hcloud/main.go b/cmd/hcloud/main.go index 1d98ebeb..8cd6acb7 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.New() + if err := cfg.Read(nil); err != nil { + log.Fatalf("unable to read config file \"%s\": %s\n", cfg.Path(), 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/go.mod b/go.mod index 4fa5d53e..ca1ffda7 100644 --- a/go.mod +++ b/go.mod @@ -12,35 +12,51 @@ require ( github.com/golang/mock v1.6.0 github.com/guptarohit/asciigraph v0.7.1 github.com/hetznercloud/hcloud-go/v2 v2.9.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.21.0 + golang.org/x/term v0.20.0 ) 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.1 // 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.25.0 // indirect - golang.org/x/sys v0.21.0 // indirect + golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect golang.org/x/tools v0.17.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9022fdea..e979ee1e 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,16 +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.9.0 h1:s0N6R7Zoi2DPfMtUF5o9VeUBzTtHVY6MIkHOQnfu/AY= github.com/hetznercloud/hcloud-go/v2 v2.9.0/go.mod h1:qtW/TuU7Bs16ibXl/ktJarWqU2LwHr7eGlwoilHxtgg= 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.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= @@ -49,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.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= @@ -66,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= @@ -108,11 +143,11 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= @@ -134,6 +169,8 @@ google.golang.org/protobuf v1.33.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/cli/root.go b/internal/cli/root.go index 34731808..adf2cc2b 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,13 @@ 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(s.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.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 934f5928..ee45495f 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.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..d7c9b2b0 --- /dev/null +++ b/internal/cmd/config/add.go @@ -0,0 +1,77 @@ +package config + +import ( + "fmt" + "os" + "slices" + + "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: "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, + SilenceUsage: true, + RunE: state.Wrap(s, runAdd), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( + cmpl.SuggestCandidates(getOptionNames(config.OptionFlagPreference|config.OptionFlagSlice)...), + cmpl.SuggestCandidatesCtx(suggestOptionCompletions), + )), + } + 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") + + ctx, prefs, err := getPreferences(s.Config(), global) + if err != nil { + return err + } + + key, values := args[0], args[1:] + opt, err := getPreference(key) + if err != nil { + return err + } + + var added []any + 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 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()) + } + return s.Config().Write(nil) +} diff --git a/internal/cmd/config/add_test.go b/internal/cmd/config/add_test.go new file mode 100644 index 00000000..091e651c --- /dev/null +++ b/internal/cmd/config/add_test.go @@ -0,0 +1,260 @@ +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 TestAdd(t *testing.T) { + os.Clearenv() + + _, 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() + postRun func() + } + + testCases := []testCase{ + { + 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 = 1234000000 + +[[contexts]] +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 = 1234000000 +`, + }, + { + 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 +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', 'a', 'b', 'c'] + +[[contexts]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +poll_interval = 1234000000 +`, + }, + { + 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] +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 = 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 = 1234000000 +`, + }, + { + preRun: func() { + _ = os.Setenv("HCLOUD_CONTEXT", "other_context") + }, + postRun: func() { + _ = os.Unsetenv("HCLOUD_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 = 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] +array_option = ['I', 'II', 'III'] +poll_interval = 1234000000 +`, + }, + } + + 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, []byte(tt.config)) + 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 new file mode 100644 index 00000000..3aac6d82 --- /dev/null +++ b/internal/cmd/config/config.go @@ -0,0 +1,66 @@ +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", + 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. + +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 + +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: + +` + 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 set/unset/add/remove: + +` + 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'. +`, + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + } + cmd.AddCommand( + 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..bbee9d7b --- /dev/null +++ b/internal/cmd/config/get.go @@ -0,0 +1,53 @@ +package config + +import ( + "fmt" + + "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 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, + SilenceUsage: true, + RunE: state.Wrap(s, runGet), + 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") + 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 { + if err := s.Config().UseContext(nil); err != nil { + return err + } + } + + key := args[0] + opt, ok := config.Options[key] + if !ok { + return fmt.Errorf("unknown key: %s", key) + } + + val := opt.GetAsAny(s.Config()) + if opt.HasFlags(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..71d53bbf --- /dev/null +++ b/internal/cmd/config/get_test.go @@ -0,0 +1,115 @@ +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 TestGet(t *testing.T) { + os.Clearenv() + + _, 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 + } + + 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: "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, []byte(testConfig)) + 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)) + + 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/helptext/generate.go b/internal/cmd/config/helptext/generate.go new file mode 100644 index 00000000..0f48c431 --- /dev/null +++ b/internal/cmd/config/helptext/generate.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "os" + "slices" + "strings" + "time" + + "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", "Type", "Config key", "Environment variable", "Flag"}) + + var opts []config.IOption + for _, opt := range config.Options { + if opt.HasFlags(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(), 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 new file mode 100644 index 00000000..34566b6a --- /dev/null +++ b/internal/cmd/config/helptext/other.txt @@ -0,0 +1,11 @@ +┌─────────┬──────────────────────┬────────┬────────────────┬──────────────────────┬───────────┐ +│ 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 new file mode 100644 index 00000000..6a2da56c --- /dev/null +++ b/internal/cmd/config/helptext/preferences.txt @@ -0,0 +1,19 @@ +┌───────────────┬──────────────────────┬──────────┬───────────────┬──────────────────────┬─────────────────┐ +│ 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 │ │ │ │ │ +└───────────────┴──────────────────────┴──────────┴───────────────┴──────────────────────┴─────────────────┘ diff --git a/internal/cmd/config/list.go b/internal/cmd/config/list.go new file mode 100644 index 00000000..8b49361b --- /dev/null +++ b/internal/cmd/config/list.go @@ -0,0 +1,92 @@ +package config + +import ( + "slices" + "strings" + + "github.com/spf13/cobra" + + "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"} + +func NewListCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List configuration values", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + SilenceUsage: 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 { + if err := s.Config().UseContext(nil); err != nil { + return err + } + } + + type option struct { + Key string `json:"key"` + Value any `json:"value"` + } + + var options []option + for name, opt := range config.Options { + val := opt.GetAsAny(s.Config()) + if opt.HasFlags(config.OptionFlagSensitive) && !allowSensitive { + val = "[redacted]" + } + if !all && !opt.Changed(s.Config()) { + continue + } + options = append(options, option{name, val}) + } + + // 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() +} diff --git a/internal/cmd/config/list_test.go b/internal/cmd/config/list_test.go new file mode 100644 index 00000000..5508637c --- /dev/null +++ b/internal/cmd/config/list_test.go @@ -0,0 +1,169 @@ +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) { + os.Clearenv() + + _, 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 + expOut string + expErr string + } + + testCases := []testCase{ + { + name: "default", + args: []string{}, + 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: "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 +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 +context test_context +debug yes +deeply.nested.option bar +endpoint https://test-endpoint.com +poll-interval 1.234s +quiet yes +token super secret token +`, + }, + { + name: "json", + args: []string{"-o=json"}, + expOut: `{ + "options": [ + { + "key": "context", + "value": "test_context" + }, + { + "key": "debug", + "value": true + }, + { + "key": "deeply.nested.option", + "value": "bar" + }, + { + "key": "endpoint", + "value": "https://test-endpoint.com" + }, + { + "key": "poll-interval", + "value": 1234000000 + }, + { + "key": "quiet", + "value": true + }, + { + "key": "token", + "value": "[redacted]" + } + ] +} +`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + fx := testutil.NewFixtureWithConfigFile(t, []byte(testConfig)) + 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..fab0a84f --- /dev/null +++ b/internal/cmd/config/remove.go @@ -0,0 +1,80 @@ +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 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, + SilenceUsage: true, + RunE: state.Wrap(s, runRemove), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( + cmpl.SuggestCandidates(getOptionNames(config.OptionFlagPreference|config.OptionFlagSlice)...), + cmpl.SuggestCandidatesCtx(suggestOptionCompletions), + )), + } + 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") + + ctx, prefs, err := getPreferences(s.Config(), global) + if err != nil { + return err + } + + key, values := args[0], args[1:] + 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 := 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 { + _, _ = 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 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()) + } + return s.Config().Write(nil) +} diff --git a/internal/cmd/config/remove_test.go b/internal/cmd/config/remove_test.go new file mode 100644 index 00000000..8bd9312a --- /dev/null +++ b/internal/cmd/config/remove_test.go @@ -0,0 +1,234 @@ +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 TestRemove(t *testing.T) { + os.Clearenv() + + _, 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{"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 = 1234000000 + +[[contexts]] +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 = 1234000000 +`, + }, + { + 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 = 1234000000 + +[[contexts]] +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 = 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 { + 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, []byte(tt.config)) + defer fx.Finish() + + cmd := configCmd.NewRemoveCommand(fx.State()) + + out, errOut, err := fx.Run(cmd, tt.args) + + 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 new file mode 100644 index 00000000..8f2e8eae --- /dev/null +++ b/internal/cmd/config/set.go @@ -0,0 +1,58 @@ +package config + +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 { + 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, + SilenceUsage: true, + RunE: state.Wrap(s, runSet), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestArgs( + cmpl.SuggestCandidates(getOptionNames(config.OptionFlagPreference)...), + cmpl.SuggestCandidatesCtx(suggestOptionCompletions), + )), + } + 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") + + ctx, prefs, err := getPreferences(s.Config(), global) + if err != nil { + return err + } + + key, values := args[0], args[1:] + opt, err := getPreference(key) + if err != nil { + return err + } + + val, err := opt.Parse(values) + if err != nil { + return err + } + + prefs.Set(key, val) + + 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()) + } + return s.Config().Write(nil) +} diff --git a/internal/cmd/config/set_test.go b/internal/cmd/config/set_test.go new file mode 100644 index 00000000..e6929094 --- /dev/null +++ b/internal/cmd/config/set_test.go @@ -0,0 +1,309 @@ +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 TestSet(t *testing.T) { + os.Clearenv() + + _, 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"}, + config: testConfig, + expOut: `Set 'debug-file' to 'debug.log' in context 'test_context' +active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 1234000000 + +[[contexts]] +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 = 1234000000 +`, + }, + { + 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{"debug", "false"}, + config: testConfig, + expOut: `Set 'debug' to 'false' in context 'other_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]] +name = 'other_context' +token = 'another super secret token' + +[contexts.preferences] +debug = false +poll_interval = 1234000000 +`, + }, + { + name: "set globally", + args: []string{"--global", "poll-interval", "50ms"}, + config: testConfig, + expOut: `Set 'poll-interval' to '50ms' globally +active_context = 'test_context' + +[preferences] +debug = true +poll_interval = 50000000 + +[[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 +`, + }, + { + 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 = 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 = '' + +[preferences] +debug = false +`, + }, + } + + 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, []byte(tt.config)) + defer fx.Finish() + + cmd := configCmd.NewSetCommand(fx.State()) + + out, errOut, err := fx.Run(cmd, tt.args) + + 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/unset.go b/internal/cmd/config/unset.go new file mode 100644 index 00000000..f24f54b5 --- /dev/null +++ b/internal/cmd/config/unset.go @@ -0,0 +1,53 @@ +package config + +import ( + "fmt" + "os" + + "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", + Long: "Unset a configuration value. For a list of all available configuration options, run 'hcloud help config'.", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + SilenceUsage: true, + RunE: state.Wrap(s, runUnset), + ValidArgsFunction: cmpl.NoFileCompletion(cmpl.SuggestCandidates(getOptionNames(config.OptionFlagPreference)...)), + } + 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") + + ctx, prefs, err := getPreferences(s.Config(), global) + if err != nil { + return err + } + + key := args[0] + if _, err = getPreference(key); err != nil { + return err + } + + if !prefs.Unset(key) { + _, _ = fmt.Fprintf(os.Stderr, "Warning: key '%s' was not set\n", key) + } + if global { + 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 new file mode 100644 index 00000000..6634b326 --- /dev/null +++ b/internal/cmd/config/unset_test.go @@ -0,0 +1,311 @@ +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 TestUnset(t *testing.T) { + os.Clearenv() + + _, 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 + preRun func() + postRun func() + } + + testCases := []testCase{ + { + name: "unset in current context", + args: []string{"quiet"}, + config: testConfig, + expOut: `Unset 'quiet' 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' + +[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 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"}, + config: testConfig, + expOut: `Unset 'poll-interval' in context 'other_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.preferences.nested] +option = 'foo' + +[[contexts]] +name = 'other_context' +token = 'another super secret token' +`, + }, + { + name: "unset globally", + args: []string{"debug", "--global"}, + config: testConfig, + expOut: `Unset 'debug' globally +active_context = 'test_context' + +[preferences] +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 +`, + }, + { + 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' + +[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 +`, + }, + { + 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 = 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 +`, + }, + } + + 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, []byte(tt.config)) + defer fx.Finish() + + cmd := configCmd.NewUnsetCommand(fx.State()) + + out, errOut, err := fx.Run(cmd, tt.args) + + 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/util.go b/internal/cmd/config/util.go new file mode 100644 index 00000000..afbc3ef8 --- /dev/null +++ b/internal/cmd/config/util.go @@ -0,0 +1,49 @@ +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + + "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.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/cmd/context/active.go b/internal/cmd/context/active.go index ea3e4065..13a3fa3c 100644 --- a/internal/cmd/context/active.go +++ b/internal/cmd/context/active.go @@ -24,10 +24,10 @@ 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(); ctx != nil { - cmd.Println(ctx.Name) + if ctx := s.Config().ActiveContext(); !util.IsNil(ctx) { + 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..ce4085cc 100644 --- a/internal/cmd/server/ssh.go +++ b/internal/cmd/server/ssh.go @@ -16,6 +16,8 @@ import ( "github.com/hetznercloud/cli/internal/state" ) +var SSHPath = "ssh" + var SSHCmd = base.Cmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ @@ -57,7 +59,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(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 73dddd23..5fe57cf4 100644 --- a/internal/cmd/server/ssh_test.go +++ b/internal/cmd/server/ssh_test.go @@ -28,7 +28,7 @@ func TestSSH(t *testing.T) { Get(gomock.Any(), srv.Name). Return(&srv, nil, nil) - fx.Config.EXPECT().SSHPath().Return("echo") + server.SSHPath = "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..a0e5e5be 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,70 @@ 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 +} + +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 +} + +// 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 45b7f96c..22f76ca6 100644 --- a/internal/cmd/util/util_test.go +++ b/internal/cmd/util/util_test.go @@ -294,3 +294,89 @@ 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})) +} + +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/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/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..faf98f0a 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -1,178 +1,287 @@ package config import ( + "bytes" + "errors" "fmt" + "io" "os" - "path/filepath" + "path" + "strings" - 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/cli/internal/cmd/util" +) 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 + + // 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 + // SetActiveContext sets the currently active context and also modifies the schema to reflect this change + // 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 - ActiveContext() *Context - SetActiveContext(*Context) - Contexts() []*Context - SetContexts([]*Context) - Endpoint() string - SetEndpoint(string) + // 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 - SSHPath() string + // 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 } -type Context struct { - Name string - Token string +type Schema struct { + ActiveContext string `toml:"active_context"` + Preferences Preferences `toml:"preferences"` + Contexts []*context `toml:"contexts,omitempty"` } type config struct { + v *viper.Viper + fs *pflag.FlagSet path string - endpoint string - activeContext *Context `toml:"active_context,omitempty"` - contexts []*Context `toml:"contexts"` + activeContext *context + contexts []*context + preferences Preferences + schema Schema } -func ReadConfig(path string) (Config, error) { - cfg := &config{path: path} +func New() Config { + cfg := &config{} + cfg.reset() + return cfg +} + +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 := cfg.v.BindPFlags(cfg.fs); err != nil { + panic(err) + } +} + +func (cfg *config) Read(f any) error { + + // error is ignored since invalid flags are already handled by cobra + _ = cfg.fs.Parse(os.Args[1:]) + + // load env already so we can determine the active context + cfg.v.AutomaticEnv() + + var ( + cfgBytes []byte + err error + ) + + cfg.path = OptionConfig.Get(cfg) + cfgPath, ok := f.(string) + if cfgPath != "" && ok { + cfg.path = cfgPath + } - _, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return cfg, nil + if f == nil || ok { + // read config from file + cfgBytes, err = os.ReadFile(cfg.path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + 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) } - return cfg, err } - data, err := os.ReadFile(path) - if err != nil { - return nil, err + if err := toml.Unmarshal(cfgBytes, &cfg.schema); err != nil { + return err } - if err = cfg.unmarshal(data); err != nil { - return nil, 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 + } } - return cfg, nil -} + // read active context from viper + activeContext := OptionContext.Get(cfg) -func (cfg *config) Write() error { - data, err := cfg.marshal() - if err != nil { - return err + cfg.contexts = cfg.schema.Contexts + for i, ctx := range cfg.contexts { + if ctx.ContextName == activeContext { + cfg.activeContext = cfg.contexts[i] + break + } } - if err := os.MkdirAll(filepath.Dir(cfg.path), 0777); err != nil { - return err + + if activeContext != "" && cfg.activeContext == nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: active context %q not found\n", activeContext) } - if err := os.WriteFile(cfg.path, data, 0600); 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 } + + if cfg.activeContext != nil { + // Merge preferences into viper + if err = cfg.activeContext.ContextPreferences.merge(cfg.v); err != nil { + return err + } + // 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) ActiveContext() *Context { - return cfg.activeContext -} +func (cfg *config) Write(w io.Writer) (err error) { + if w == nil { + 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_TRUNC, 0600) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, f.Close()) + }() + w = f + } -func (cfg *config) SetActiveContext(context *Context) { - cfg.activeContext = context -} + s := cfg.schema -func (cfg *config) Contexts() []*Context { - return 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 + } + } -func (cfg *config) SetContexts(contexts []*Context) { - cfg.contexts = contexts + return toml.NewEncoder(w).Encode(s) } -func (cfg *config) Endpoint() string { - return cfg.endpoint +func (cfg *config) ActiveContext() Context { + return cfg.activeContext } -func (cfg *config) SetEndpoint(endpoint string) { - cfg.endpoint = endpoint +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 + } } -func (cfg *config) SSHPath() string { - return "ssh" +func (cfg *config) Contexts() []Context { + ctxs := make([]Context, 0, len(cfg.contexts)) + for _, c := range cfg.contexts { + ctxs = append(ctxs, c) + } + return ctxs } -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) 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) + } } - return names + cfg.schema.Contexts = cfg.contexts } -func ContextByName(cfg Config, name string) *Context { - for _, c := range cfg.Contexts() { - if c.Name == name { - return c - } +func (cfg *config) UseContext(name *string) error { + if name == nil { + OptionContext.OverrideAny(cfg, nil) + } else { + OptionContext.OverrideAny(cfg, *name) } - return nil + cfg.reset() + return cfg.Read(nil) } -func RemoveContext(cfg Config, context *Context) { - var filtered []*Context - for _, c := range cfg.Contexts() { - if c != context { - filtered = append(filtered, c) - } +func (cfg *config) Preferences() Preferences { + if cfg.preferences == nil { + cfg.preferences = make(Preferences) + cfg.schema.Preferences = cfg.preferences } - cfg.SetContexts(filtered) + return cfg.preferences } -type rawConfig struct { - ActiveContext string `toml:"active_context,omitempty"` - Contexts []rawConfigContext `toml:"contexts"` +func (cfg *config) Viper() *viper.Viper { + return cfg.v } -type rawConfigContext struct { - Name string `toml:"name"` - Token string `toml:"token"` +func (cfg *config) FlagSet() *pflag.FlagSet { + return cfg.fs } -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) +func (cfg *config) Path() string { + return cfg.path } -func (cfg *config) unmarshal(data []byte) error { - var raw rawConfig - if err := toml.Unmarshal(data, &raw); err != nil { - return err - } - 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) - } - } - return nil +func (cfg *config) Schema() *Schema { + return &cfg.schema } diff --git a/internal/state/config/config_mock.go b/internal/state/config/config_mock.go new file mode 100644 index 00000000..ac72d0e3 --- /dev/null +++ b/internal/state/config/config_mock.go @@ -0,0 +1,20 @@ +package config + +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 { + Config +} + +func (c *MockConfig) Write(_ io.Writer) error { + // MockConfig always writes to stdout for testing purposes + return c.Config.Write(os.Stdout) +} + +var _ Config = (*MockConfig)(nil) diff --git a/internal/state/config/context.go b/internal/state/config/context.go new file mode 100644 index 00000000..f1b28a4e --- /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..0bb861fc --- /dev/null +++ b/internal/state/config/options.go @@ -0,0 +1,324 @@ +package config + +import ( + "fmt" + "slices" + "strconv" + "strings" + "time" + + "github.com/spf13/pflag" + + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +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 configured inside the configuration file + 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 + // OptionFlagSlice indicates that the option value is a slice + OptionFlagSlice + + DefaultPreferenceFlags = OptionFlagPreference | OptionFlagConfig | OptionFlagPFlag | OptionFlagEnv +) + +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 + // 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 + 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 + // 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 +} + +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, + 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, + ) +) + +type Option[T any] struct { + Name string + Description string + Default T + Flags OptionFlag + overrides *overrides +} + +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 + } + if v, ok := val.(int64); ok { + val = time.Duration(v) + } + 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 = util.ToStringSlice(v) + } + } + return val.(T) +} + +func (o *Option[T]) GetAsAny(c Config) any { + return o.Get(c) +} + +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]) HasFlags(src OptionFlag) bool { + return (^o.Flags)&src == 0 +} + +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.HasFlags(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.HasFlags(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.HasFlags(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) { + case bool: + return []string{"true", "false"} + } + 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") + } + var err error + val, err = util.ParseBoolLenient(values[0]) + if err != nil { + return nil, err + } + 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 +} + +func (o *Option[T]) addToFlagSet(fs *pflag.FlagSet) { + if !o.HasFlags(OptionFlagPFlag) { + return + } + switch v := any(o.Default).(type) { + case bool: + fs.Bool(o.Name, v, o.Description) + case string: + fs.String(o.Name, v, o.Description) + case time.Duration: + fs.Duration(o.Name, v, o.Description) + case []string: + fs.StringSlice(o.Name, v, o.Description) + default: + panic(fmt.Sprintf("unsupported type %T", v)) + } +} + +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/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 new file mode 100644 index 00000000..3633d563 --- /dev/null +++ b/internal/state/config/preferences.go @@ -0,0 +1,118 @@ +package config + +import ( + "bytes" + "fmt" + "strings" + + "github.com/pelletier/go-toml/v2" + "github.com/spf13/viper" +) + +// Preferences are options that can be set in the config file, globally or per context +type Preferences map[string]any + +func (p Preferences) Get(key string) (any, bool) { + var m map[string]any = p + path := splitPath(key) + for i, key := range path { + if i == len(path)-1 { + val, ok := m[key] + return val, ok + } + if next, ok := m[key].(map[string]any); !ok { + break + } else { + m = next + } + } + return nil, false +} + +func (p Preferences) Set(key string, val any) { + var m map[string]any = p + path := splitPath(key) + 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 + } + } +} + +func (p Preferences) Unset(key string) bool { + var m map[string]any = p + path := splitPath(key) + 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 false +} + +func (p Preferences) Validate() error { + return validate(p, "") +} + +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 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.HasFlags(OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) + } + } + return nil +} + +func splitPath(key string) []string { + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + return strings.Split(configKey, ".") +} diff --git a/internal/state/config/preferences_test.go b/internal/state/config/preferences_test.go new file mode 100644 index 00000000..59671864 --- /dev/null +++ b/internal/state/config/preferences_test.go @@ -0,0 +1,208 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/hetznercloud/cli/internal/state/config" +) + +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) { + _, cleanup := config.NewTestOption("foo", "", "", config.OptionFlagPreference, nil) + defer cleanup() + + p := config.Preferences{"foo": ""} + 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() + + p := config.Preferences{"foo": ""} + 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") + }) +} 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..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 { @@ -13,7 +15,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.Get(c.config) + 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..964f9461 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -2,7 +2,6 @@ package state import ( "context" - "log" "os" "github.com/hetznercloud/cli/internal/hcapi2" @@ -24,34 +23,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 +45,27 @@ 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.WithToken(config.OptionToken.Get(c.config)), hcloud.WithApplication("hcloud-cli", version.Version), } - if c.endpoint != "" { - opts = append(opts, hcloud.WithEndpoint(c.endpoint)) + + if ep := config.OptionEndpoint.Get(c.config); ep != "" { + opts = append(opts, hcloud.WithEndpoint(ep)) } - if c.debug { - if c.debugFilePath == "" { + 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(c.debugFilePath) + 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))) + } + return hcapi2.NewClient(opts...) } diff --git a/internal/testutil/fixture.go b/internal/testutil/fixture.go index fb5d5310..04a5813d 100644 --- a/internal/testutil/fixture.go +++ b/internal/testutil/fixture.go @@ -24,16 +24,27 @@ 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 { + return NewFixtureWithConfigFile(t, []byte{}) +} + +// NewFixtureWithConfigFile creates a new Fixture with the given config file. +// 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 := cfg.Read(f); 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: &config.MockConfig{Config: cfg}, } }