diff --git a/cmd/hcloud/main.go b/cmd/hcloud/main.go index 1d98ebeb..6417a195 100644 --- a/cmd/hcloud/main.go +++ b/cmd/hcloud/main.go @@ -8,6 +8,7 @@ import ( "github.com/hetznercloud/cli/internal/cmd/all" "github.com/hetznercloud/cli/internal/cmd/certificate" "github.com/hetznercloud/cli/internal/cmd/completion" + configCmd "github.com/hetznercloud/cli/internal/cmd/config" "github.com/hetznercloud/cli/internal/cmd/context" "github.com/hetznercloud/cli/internal/cmd/datacenter" "github.com/hetznercloud/cli/internal/cmd/firewall" @@ -37,14 +38,10 @@ func init() { } func main() { - configPath := os.Getenv("HCLOUD_CONFIG") - if configPath == "" { - configPath = config.DefaultConfigPath() - } - cfg, err := config.ReadConfig(configPath) - if err != nil { - log.Fatalf("unable to read config file %q: %s\n", configPath, err) + cfg := config.NewConfig() + if err := config.ReadConfig(cfg); err != nil { + log.Fatalf("unable to read config file %s\n", err) } s, err := state.New(cfg) @@ -78,6 +75,7 @@ func main() { version.NewCommand(s), completion.NewCommand(s), context.NewCommand(s), + configCmd.NewCommand(s), ) if err := rootCommand.Execute(); err != nil { diff --git a/go.mod b/go.mod index e4fbe686..58977193 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.1 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.22.0 golang.org/x/term v0.19.0 @@ -24,18 +25,31 @@ 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/fatih/color v1.16.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.19.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.19.0 // indirect @@ -43,5 +57,6 @@ require ( golang.org/x/tools v0.17.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 420d10e8..b997c11f 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.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 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,8 +37,8 @@ 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/hetznercloud/hcloud-go/v2 v2.7.0 h1:I+KpXC+Q2MIlOQYeQt3f02vhePPzboqmfAGhJRaF9c4= -github.com/hetznercloud/hcloud-go/v2 v2.7.0/go.mod h1:wkQHjFTzGBesBWaTSQ3BZCHd/st9J4X4JDuPokpjJlM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hetznercloud/hcloud-go/v2 v2.7.1 h1:D4domwRSLOyBL/bwzd1O7hunBbKmeEHZTa7GmCYrniY= github.com/hetznercloud/hcloud-go/v2 v2.7.1/go.mod h1:49tIV+pXRJTUC7fbFZ03s45LKqSQdOPP5y91eOnJo/k= github.com/hetznercloud/hcloud-go/v2 v2.7.2 h1:UlE7n1GQZacCfyjv9tDVUN7HZfOXErPIfM/M039u9A0= @@ -46,6 +51,8 @@ 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= @@ -53,12 +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/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= -github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= @@ -72,28 +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.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +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= @@ -102,8 +129,6 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -118,13 +143,9 @@ 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.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.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.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -148,6 +169,8 @@ google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/root.go b/internal/cli/root.go index 34731808..7387bbfa 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,12 +2,11 @@ package cli import ( "os" - "time" "github.com/spf13/cobra" "github.com/hetznercloud/cli/internal/state" - "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/cli/internal/state/config" ) func NewRootCommand(s state.State) *cobra.Command { @@ -20,18 +19,14 @@ func NewRootCommand(s state.State) *cobra.Command { SilenceErrors: true, DisableFlagsInUseLine: true, } - cmd.PersistentFlags().Duration("poll-interval", 500*time.Millisecond, "Interval at which to poll information, for example action progress") - cmd.PersistentFlags().Bool("quiet", false, "Only print error messages") - cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - pollInterval, err := cmd.Flags().GetDuration("poll-interval") - if err != nil { - return err - } - s.Client().WithOpts(hcloud.WithPollBackoffFunc(hcloud.ConstantBackoff(pollInterval))) + cmd.PersistentFlags().AddFlagSet(config.FlagSet) + cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + var err error out := os.Stdout - if quiet, _ := cmd.Flags().GetBool("quiet"); quiet { + if quiet := config.OptionQuiet.Value(); quiet { + //if quiet := viper.GetBool("quiet"); quiet { out, err = os.Open(os.DevNull) if err != nil { return err diff --git a/internal/cmd/base/create.go b/internal/cmd/base/create.go index 934f5928..36e6795b 100644 --- a/internal/cmd/base/create.go +++ b/internal/cmd/base/create.go @@ -9,6 +9,7 @@ import ( "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" ) // CreateCmd allows defining commands for resource creation @@ -42,7 +43,7 @@ func (cc *CreateCmd) CobraCommand(s state.State) *cobra.Command { cmd.RunE = func(cmd *cobra.Command, args []string) error { outputFlags := output.FlagsForCommand(cmd) - quiet, _ := cmd.Flags().GetBool("quiet") + quiet := config.OptionQuiet.Value() isSchema := outputFlags.IsSet("json") || outputFlags.IsSet("yaml") if isSchema && !quiet { diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go new file mode 100644 index 00000000..76f52433 --- /dev/null +++ b/internal/cmd/config/config.go @@ -0,0 +1,22 @@ +package config + +import ( + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/state" +) + +func NewCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage configuration", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + } + cmd.AddCommand( + newSetCommand(s), + ) + return cmd +} diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go new file mode 100644 index 00000000..fc72d90f --- /dev/null +++ b/internal/cmd/config/set.go @@ -0,0 +1,51 @@ +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" +) + +func newSetCommand(s state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "set ", + Short: "Set a configuration value", + Args: util.Validate, + TraverseChildren: true, + DisableFlagsInUseLine: true, + RunE: state.Wrap(s, runSet), + } + cmd.Flags().Bool("global", false, "Set the value globally (for all contexts)") + return cmd +} + +func runSet(s state.State, cmd *cobra.Command, args []string) error { + global, _ := cmd.Flags().GetBool("global") + + var prefs config.Preferences + + if global { + prefs = s.Config().Preferences() + } else { + ctx := s.Config().ActiveContext() + if ctx == nil { + if ctxName := config.OptionContext.Value(); ctxName != "" { + return fmt.Errorf("active context \"%s\" not found", ctxName) + } else { + return fmt.Errorf("no active context (use --global flag to set a global option)") + } + } + prefs = ctx.Preferences() + } + + key, value := args[0], args[1] + if err := prefs.Set(key, value); err != nil { + return err + } + + return s.Config().Write(nil) +} diff --git a/internal/cmd/context/active.go b/internal/cmd/context/active.go index ea3e4065..80b9b286 100644 --- a/internal/cmd/context/active.go +++ b/internal/cmd/context/active.go @@ -27,7 +27,7 @@ func runActive(s state.State, cmd *cobra.Command, _ []string) error { _, _ = fmt.Fprintln(os.Stderr, "Warning: HCLOUD_TOKEN is set. The active context will have no effect.") } if ctx := s.Config().ActiveContext(); ctx != nil { - cmd.Println(ctx.Name) + cmd.Println(ctx.Name()) } return nil } diff --git a/internal/cmd/context/create.go b/internal/cmd/context/create.go index f013797f..96293fb4 100644 --- a/internal/cmd/context/create.go +++ b/internal/cmd/context/create.go @@ -42,8 +42,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") @@ -82,12 +80,12 @@ func runCreate(s state.State, cmd *cobra.Command, args []string) error { } } - context.Token = token + context := config.NewContext(name, token) cfg.SetContexts(append(cfg.Contexts(), context)) cfg.SetActiveContext(context) - if err := cfg.Write(); err != nil { + if err := cfg.Write(nil); err != nil { return err } diff --git a/internal/cmd/context/delete.go b/internal/cmd/context/delete.go index 56a77530..0ee96a2a 100644 --- a/internal/cmd/context/delete.go +++ b/internal/cmd/context/delete.go @@ -37,5 +37,5 @@ func runDelete(s state.State, _ *cobra.Command, args []string) error { cfg.SetActiveContext(nil) } config.RemoveContext(cfg, context) - return cfg.Write() + return cfg.Write(nil) } diff --git a/internal/cmd/context/list.go b/internal/cmd/context/list.go index a1b191b5..7b325a5d 100644 --- a/internal/cmd/context/list.go +++ b/internal/cmd/context/list.go @@ -58,11 +58,11 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { cfg := s.Config() for _, context := range cfg.Contexts() { presentation := ContextPresentation{ - Name: context.Name, - Token: context.Token, + Name: context.Name(), + Token: context.Token(), Active: " ", } - if ctx := cfg.ActiveContext(); ctx != nil && ctx.Name == context.Name { + if context == cfg.ActiveContext() { presentation.Active = "*" } diff --git a/internal/cmd/context/use.go b/internal/cmd/context/use.go index 6dbf0d64..f0a26e41 100644 --- a/internal/cmd/context/use.go +++ b/internal/cmd/context/use.go @@ -36,5 +36,5 @@ func runUse(s state.State, _ *cobra.Command, args []string) error { return fmt.Errorf("context not found: %v", name) } cfg.SetActiveContext(context) - return cfg.Write() + return cfg.Write(nil) } diff --git a/internal/cmd/server/ssh.go b/internal/cmd/server/ssh.go index 99ff0636..9f9e41af 100644 --- a/internal/cmd/server/ssh.go +++ b/internal/cmd/server/ssh.go @@ -14,6 +14,7 @@ import ( "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" ) var SSHCmd = base.Cmd{ @@ -57,7 +58,7 @@ var SSHCmd = base.Cmd{ } sshArgs := []string{"-l", user, "-p", strconv.Itoa(port), ipAddress.String()} - sshCommand := exec.Command(s.Config().SSHPath(), append(sshArgs, args[1:]...)...) + sshCommand := exec.Command(config.OptionSSHPath.Value(), append(sshArgs, args[1:]...)...) sshCommand.Stdin = os.Stdin sshCommand.Stdout = os.Stdout sshCommand.Stderr = os.Stderr diff --git a/internal/cmd/server/ssh_test.go b/internal/cmd/server/ssh_test.go index 73dddd23..7a38911a 100644 --- a/internal/cmd/server/ssh_test.go +++ b/internal/cmd/server/ssh_test.go @@ -7,6 +7,7 @@ import ( "github.com/golang/mock/gomock" "github.com/hetznercloud/cli/internal/cmd/server" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" ) @@ -28,7 +29,7 @@ func TestSSH(t *testing.T) { Get(gomock.Any(), srv.Name). Return(&srv, nil, nil) - fx.Config.EXPECT().SSHPath().Return("echo") + config.OptionSSHPath.SetValue("echo") } testutil.TestCommand(t, &server.SSHCmd, map[string]testutil.TestCase{ diff --git a/internal/hcapi2/mock/client.go b/internal/hcapi2/mock/client.go index 25053cff..31638321 100644 --- a/internal/hcapi2/mock/client.go +++ b/internal/hcapi2/mock/client.go @@ -4,6 +4,7 @@ import ( "github.com/golang/mock/gomock" "github.com/hetznercloud/cli/internal/hcapi2" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/hcloud-go/v2/hcloud" ) @@ -122,6 +123,10 @@ func (c *MockClient) PlacementGroup() hcapi2.PlacementGroupClient { return c.PlacementGroupClient } -func (*MockClient) WithOpts(...hcloud.ClientOption) { +func (*MockClient) WithOpts(_ ...hcloud.ClientOption) { + // no-op +} + +func (*MockClient) FromConfig(_ config.Config) { // no-op } diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 4187e65c..0ae07ba8 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -1,178 +1,245 @@ package config import ( + "bytes" + "errors" "fmt" + "io" "os" - "path/filepath" - toml "github.com/pelletier/go-toml/v2" -) + "github.com/pelletier/go-toml/v2" + "github.com/spf13/pflag" + "github.com/spf13/viper" -//go:generate go run github.com/golang/mock/mockgen -package config -destination zz_config_mock.go github.com/hetznercloud/cli/internal/state/config Config + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) type Config interface { - Write() error + // Write writes the config to the given writer. If w is nil, the config is written to the config file. + Write(w io.Writer) error + + ParseConfig() error - ActiveContext() *Context - SetActiveContext(*Context) - Contexts() []*Context - SetContexts([]*Context) - Endpoint() string - SetEndpoint(string) + ActiveContext() Context + SetActiveContext(Context) + Contexts() []Context + SetContexts([]Context) - SSHPath() string + Preferences() Preferences } -type Context struct { - Name string - Token string +type schema struct { + ActiveContext string `toml:"active_context"` + Preferences preferences `toml:"preferences"` + Contexts []*context `toml:"contexts"` } type config struct { path string - endpoint string - activeContext *Context `toml:"active_context,omitempty"` - contexts []*Context `toml:"contexts"` + activeContext *context + contexts []*context + preferences preferences } -func ReadConfig(path string) (Config, error) { - cfg := &config{path: path} +var FlagSet *pflag.FlagSet - _, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return cfg, nil - } - return cfg, err - } +func init() { + ResetFlags() +} - data, err := os.ReadFile(path) - if err != nil { - return nil, err +func ResetFlags() { + FlagSet = pflag.NewFlagSet("hcloud", pflag.ContinueOnError) + for _, o := range opts { + o.AddToFlagSet(FlagSet) + } + if err := viper.BindPFlags(FlagSet); err != nil { + panic(err) } +} + +func NewConfig() Config { + return &config{} +} + +func ReadConfig(cfg Config) error { - if err = cfg.unmarshal(data); err != nil { - return nil, err + viper.SetConfigType("toml") + viper.SetEnvPrefix("HCLOUD") + + // error is ignored since invalid flags are already handled by cobra + _ = FlagSet.Parse(os.Args[1:]) + + // load env already so we can determine the active context + viper.AutomaticEnv() + + // load active context + if err := cfg.ParseConfig(); err != nil { + return err } - return cfg, nil + return nil } -func (cfg *config) Write() error { - data, err := cfg.marshal() +func (cfg *config) ParseConfig() error { + var s schema + + cfg.path = OptionConfig.Value() + + // read config file + cfgBytes, err := os.ReadFile(cfg.path) if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(cfg.path), 0777); err != nil { + if err := toml.Unmarshal(cfgBytes, &s); err != nil { + return err + } + + // read config file into viper (particularly active_context) + if err := viper.ReadConfig(bytes.NewReader(cfgBytes)); err != nil { return err } - if err := os.WriteFile(cfg.path, data, 0600); err != nil { + + // read active context from viper + if ctx := OptionContext.Value(); ctx != "" { + s.ActiveContext = ctx + } + + cfg.contexts = s.Contexts + for i, ctx := range s.Contexts { + if ctx.ContextName == s.ActiveContext { + cfg.activeContext = cfg.contexts[i] + } + } + + if s.ActiveContext != "" && cfg.activeContext == nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: active context %q not found\n", s.ActiveContext) + } + + // load global preferences first so that contexts can override them + if err = cfg.loadPreferences(cfg.preferences); err != nil { return err } + + // load context preferences + if cfg.activeContext != nil { + if err = cfg.loadPreferences(cfg.activeContext.ContextPreferences); err != nil { + return err + } + // read context into viper (particularly the token) + ctxBytes, err := toml.Marshal(cfg.activeContext) + if err != nil { + return err + } + if err = viper.ReadConfig(bytes.NewReader(ctxBytes)); err != nil { + return err + } + } return nil } -func (cfg *config) ActiveContext() *Context { - return cfg.activeContext +func (cfg *config) loadPreferences(prefs preferences) error { + if err := prefs.validate(); err != nil { + return err + } + ctxBytes, err := toml.Marshal(prefs) + if err != nil { + return err + } + return viper.MergeConfig(bytes.NewReader(ctxBytes)) } -func (cfg *config) SetActiveContext(context *Context) { - cfg.activeContext = context +func addOption[T any](flagFunc func(string, T, string) *T, key string, defaultVal T, usage string) { + if flagFunc != nil { + flagFunc(key, defaultVal, usage) + } + viper.SetDefault(key, defaultVal) } -func (cfg *config) Contexts() []*Context { - return cfg.contexts -} +func (cfg *config) Write(w io.Writer) (err error) { + if w == nil { + f, err := os.OpenFile(cfg.path, os.O_WRONLY|os.O_APPEND|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, f.Close()) + }() + w = f + } -func (cfg *config) SetContexts(contexts []*Context) { - cfg.contexts = contexts -} + var activeContext string + if cfg.activeContext != nil { + activeContext = cfg.activeContext.ContextName + } -func (cfg *config) Endpoint() string { - return cfg.endpoint -} + s := schema{ + ActiveContext: activeContext, + Preferences: cfg.preferences, + Contexts: cfg.contexts, + } -func (cfg *config) SetEndpoint(endpoint string) { - cfg.endpoint = endpoint + return toml.NewEncoder(w).Encode(s) } -func (cfg *config) SSHPath() string { - return "ssh" +func (cfg *config) ActiveContext() Context { + return cfg.activeContext } -func ContextNames(cfg Config) []string { - ctxs := cfg.Contexts() - names := make([]string, len(ctxs)) - for i, ctx := range ctxs { - names[i] = ctx.Name +func (cfg *config) SetActiveContext(ctx Context) { + if ctx, ok := ctx.(*context); !ok { + panic("invalid context type") + } else { + cfg.activeContext = ctx } - return names } -func ContextByName(cfg Config, name string) *Context { - for _, c := range cfg.Contexts() { - if c.Name == name { - return c - } +func (cfg *config) Contexts() []Context { + ctxs := make([]Context, 0, len(cfg.contexts)) + for _, c := range cfg.contexts { + ctxs = append(ctxs, c) } - return nil + return ctxs } -func RemoveContext(cfg Config, context *Context) { - var filtered []*Context - for _, c := range cfg.Contexts() { - if c != context { - filtered = append(filtered, c) +func (cfg *config) SetContexts(contexts []Context) { + cfg.contexts = make([]*context, 0, len(cfg.contexts)) + for _, c := range contexts { + if c, ok := c.(*context); !ok { + panic("invalid context type") + } else { + cfg.contexts = append(cfg.contexts, c) } } - cfg.SetContexts(filtered) } -type rawConfig struct { - ActiveContext string `toml:"active_context,omitempty"` - Contexts []rawConfigContext `toml:"contexts"` +func (cfg *config) Preferences() Preferences { + if cfg.preferences == nil { + cfg.preferences = make(preferences) + } + return cfg.preferences } -type rawConfigContext struct { - Name string `toml:"name"` - Token string `toml:"token"` -} +func GetHcloudOpts(cfg Config) []hcloud.ClientOption { + var opts []hcloud.ClientOption -func (cfg *config) marshal() ([]byte, error) { - var raw rawConfig - if cfg.activeContext != nil { - raw.ActiveContext = cfg.activeContext.Name - } - for _, context := range cfg.contexts { - raw.Contexts = append(raw.Contexts, rawConfigContext{ - Name: context.Name, - Token: context.Token, - }) - } - return toml.Marshal(raw) -} + token := OptionToken.Value() -func (cfg *config) unmarshal(data []byte) error { - var raw rawConfig - if err := toml.Unmarshal(data, &raw); err != nil { - return err + opts = append(opts, hcloud.WithToken(token)) + if ep := OptionEndpoint.Value(); ep != "" { + opts = append(opts, hcloud.WithEndpoint(ep)) } - for _, rawContext := range raw.Contexts { - cfg.contexts = append(cfg.contexts, &Context{ - Name: rawContext.Name, - Token: rawContext.Token, - }) - } - if raw.ActiveContext != "" { - for _, c := range cfg.contexts { - if c.Name == raw.ActiveContext { - cfg.activeContext = c - break - } - } - if cfg.activeContext == nil { - return fmt.Errorf("active context %s not found", raw.ActiveContext) + if OptionDebug.Value() { + if filePath := OptionDebugFile.Value(); filePath == "" { + opts = append(opts, hcloud.WithDebugWriter(os.Stderr)) + } else { + writer, _ := os.Create(filePath) + opts = append(opts, hcloud.WithDebugWriter(writer)) } } - return nil + pollInterval := OptionPollInterval.Value() + if pollInterval > 0 { + opts = append(opts, hcloud.WithBackoffFunc(hcloud.ConstantBackoff(pollInterval))) + } + + return opts } diff --git a/internal/state/config/config_mock.go b/internal/state/config/config_mock.go new file mode 100644 index 00000000..b973038c --- /dev/null +++ b/internal/state/config/config_mock.go @@ -0,0 +1,41 @@ +package config + +import "io" + +// We do not need to generate a gomock for the Config, since you can set config +// values during tests with viper.Set() + +type MockConfig struct { + activeContext Context + contexts []Context +} + +func (*MockConfig) Write(io.Writer) error { + return nil +} + +func (*MockConfig) ParseConfig() error { + return nil +} + +func (m *MockConfig) ActiveContext() Context { + return m.activeContext +} + +func (m *MockConfig) SetActiveContext(ctx Context) { + m.activeContext = ctx +} + +func (m *MockConfig) Contexts() []Context { + return m.contexts +} + +func (m *MockConfig) SetContexts(ctxs []Context) { + m.contexts = ctxs +} + +func (*MockConfig) Preferences() Preferences { + return preferences{} +} + +var _ Config = &MockConfig{} diff --git a/internal/state/config/context.go b/internal/state/config/context.go new file mode 100644 index 00000000..a2f4b829 --- /dev/null +++ b/internal/state/config/context.go @@ -0,0 +1,65 @@ +package config + +type Context interface { + Name() string + Token() string + Preferences() Preferences +} + +func NewContext(name, token string) Context { + return &context{ + ContextName: name, + ContextToken: token, + } +} + +type context struct { + ContextName string `toml:"name"` + ContextToken string `toml:"token"` + ContextPreferences preferences `toml:"preferences"` +} + +func (ctx *context) Name() string { + return ctx.ContextName +} + +// Token returns the token for the context. +// If you just need the token regardless of the context, please use [OptionToken] instead. +func (ctx *context) Token() string { + return ctx.ContextToken +} + +func (ctx *context) Preferences() Preferences { + if ctx.ContextPreferences == nil { + ctx.ContextPreferences = make(preferences) + } + return ctx.ContextPreferences +} + +func ContextNames(cfg Config) []string { + ctxs := cfg.Contexts() + names := make([]string, len(ctxs)) + for i, ctx := range ctxs { + names[i] = ctx.Name() + } + return names +} + +func ContextByName(cfg Config, name string) Context { + for _, c := range cfg.Contexts() { + if c.Name() == name { + return c + } + } + return nil +} + +func RemoveContext(cfg Config, context Context) { + var filtered []Context + for _, c := range cfg.Contexts() { + if c != context { + filtered = append(filtered, c) + } + } + cfg.SetContexts(filtered) +} diff --git a/internal/state/config/options.go b/internal/state/config/options.go new file mode 100644 index 00000000..5b45bd54 --- /dev/null +++ b/internal/state/config/options.go @@ -0,0 +1,92 @@ +package config + +import ( + "fmt" + "time" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +type OptionSource int + +const ( + // OptionSourcePreference indicates that the option can be set in the config file, globally or per context (in the preferences section) + OptionSourcePreference OptionSource = 1 << iota + // OptionSourceConfig indicates that the option can be set in the config file, but only globally or per context (not in the preferences section) + OptionSourceConfig + // OptionSourceFlag indicates that the option can be set via a command line flag + OptionSourceFlag + // OptionSourceEnv indicates that the option can be set via an environment variable + OptionSourceEnv +) + +type opt interface { + AddToFlagSet(fs *pflag.FlagSet) + HasSource(src OptionSource) bool + T() any +} + +var opts = make(map[string]opt) + +var ( + OptionConfig = newOpt("config", "Config file path", DefaultConfigPath(), OptionSourceFlag|OptionSourceEnv) + OptionToken = newOpt("token", "Hetzner Cloud API token", "", OptionSourceConfig|OptionSourceEnv) + OptionEndpoint = newOpt("endpoint", "Hetzner Cloud API endpoint", "", OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) + OptionDebug = newOpt("debug", "Enable debug output", false, OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) + OptionDebugFile = newOpt("debug-file", "Write debug output to file", "", OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) + OptionContext = newOpt("context", "Active context", "", OptionSourceConfig|OptionSourceFlag|OptionSourceEnv) + OptionPollInterval = newOpt("poll-interval", "Interval at which to poll information, for example action progress", 500*time.Millisecond, OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) + OptionQuiet = newOpt("quiet", "Only print error messages", false, OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) + OptionDefaultSSHKeys = newOpt("default-ssh-keys", "Default SSH keys for new servers", []string{}, OptionSourcePreference|OptionSourceEnv) + OptionSSHPath = newOpt("ssh-path", "Path to the ssh binary", "ssh", OptionSourcePreference|OptionSourceFlag|OptionSourceEnv) +) + +type Option[T any] struct { + Name string + Usage string + Default T + Source OptionSource +} + +func (o *Option[T]) Value() T { + return viper.Get(o.Name).(T) +} + +func (o *Option[T]) SetValue(v T) { + viper.Set(o.Name, v) +} + +func (o *Option[T]) HasSource(src OptionSource) bool { + return o.Source&src != 0 +} + +func (o *Option[T]) T() any { + var t T + return t +} + +func (o *Option[T]) AddToFlagSet(fs *pflag.FlagSet) { + if !o.HasSource(OptionSourceFlag) { + return + } + switch v := any(o.Default).(type) { + case bool: + fs.Bool(o.Name, v, o.Usage) + case string: + fs.String(o.Name, v, o.Usage) + case time.Duration: + fs.Duration(o.Name, v, o.Usage) + case []string: + fs.StringSlice(o.Name, v, o.Usage) + default: + panic(fmt.Sprintf("unsupported type %T", v)) + } +} + +func newOpt[T any](name, usage string, def T, source OptionSource) *Option[T] { + o := &Option[T]{Name: name, Usage: usage, Default: def, Source: source} + opts[name] = o + viper.SetDefault(name, def) + return o +} diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go new file mode 100644 index 00000000..0f7c1a12 --- /dev/null +++ b/internal/state/config/preferences.go @@ -0,0 +1,63 @@ +package config + +import ( + "fmt" + "strings" + "time" +) + +type Preferences interface { + Set(key string, value string) error +} + +// preferences are options that can be set in the config file, globally or per context +type preferences map[string]any + +func (p preferences) validate() error { + for key := range p { + opt, ok := opts[key] + if !ok || !opt.HasSource(OptionSourcePreference) { + return fmt.Errorf("unknown preference: %s", key) + } + } + return nil +} + +func (p preferences) Set(key string, value string) error { + opt, ok := opts[key] + if !ok || !opt.HasSource(OptionSourcePreference) { + return fmt.Errorf("unknown preference: %s", key) + } + + var val any + switch t := opt.T().(type) { + case bool: + switch strings.ToLower(value) { + case "true", "t", "yes", "y", "1": + val = true + case "false", "f", "no", "n", "0": + val = false + default: + return fmt.Errorf("invalid boolean value: %s", value) + } + case string: + val = value + case time.Duration: + var err error + val, err = time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid duration value: %s", value) + } + case []string: + val = strings.Split(value, ",") + default: + return fmt.Errorf("unsupported type %T", t) + } + + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + + p[configKey] = val + return nil +} + +var _ Preferences = preferences{} diff --git a/internal/state/config/preferences_test.go b/internal/state/config/preferences_test.go new file mode 100644 index 00000000..f2451b03 --- /dev/null +++ b/internal/state/config/preferences_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUnknownPreference(t *testing.T) { + t.Run("existing", func(t *testing.T) { + clear(opts) + newOpt("foo", "", "", OptionSourcePreference) + + p := preferences{"foo": ""} + assert.NoError(t, p.validate()) + }) + + t.Run("existing but no preference", func(t *testing.T) { + clear(opts) + newOpt("foo", "", "", 0) + + p := preferences{"foo": ""} + assert.EqualError(t, p.validate(), "unknown preference: foo") + }) + + t.Run("not existing", func(t *testing.T) { + clear(opts) + p := preferences{"foo": ""} + assert.EqualError(t, p.validate(), "unknown preference: foo") + }) +} diff --git a/internal/state/config/zz_config_mock.go b/internal/state/config/zz_config_mock.go deleted file mode 100644 index 63416270..00000000 --- a/internal/state/config/zz_config_mock.go +++ /dev/null @@ -1,140 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/hetznercloud/cli/internal/state/config (interfaces: Config) - -// Package config is a generated GoMock package. -package config - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockConfig is a mock of Config interface. -type MockConfig struct { - ctrl *gomock.Controller - recorder *MockConfigMockRecorder -} - -// MockConfigMockRecorder is the mock recorder for MockConfig. -type MockConfigMockRecorder struct { - mock *MockConfig -} - -// NewMockConfig creates a new mock instance. -func NewMockConfig(ctrl *gomock.Controller) *MockConfig { - mock := &MockConfig{ctrl: ctrl} - mock.recorder = &MockConfigMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockConfig) EXPECT() *MockConfigMockRecorder { - return m.recorder -} - -// ActiveContext mocks base method. -func (m *MockConfig) ActiveContext() *Context { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActiveContext") - ret0, _ := ret[0].(*Context) - return ret0 -} - -// ActiveContext indicates an expected call of ActiveContext. -func (mr *MockConfigMockRecorder) ActiveContext() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveContext", reflect.TypeOf((*MockConfig)(nil).ActiveContext)) -} - -// Contexts mocks base method. -func (m *MockConfig) Contexts() []*Context { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Contexts") - ret0, _ := ret[0].([]*Context) - return ret0 -} - -// Contexts indicates an expected call of Contexts. -func (mr *MockConfigMockRecorder) Contexts() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Contexts", reflect.TypeOf((*MockConfig)(nil).Contexts)) -} - -// Endpoint mocks base method. -func (m *MockConfig) Endpoint() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Endpoint") - ret0, _ := ret[0].(string) - return ret0 -} - -// Endpoint indicates an expected call of Endpoint. -func (mr *MockConfigMockRecorder) Endpoint() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Endpoint", reflect.TypeOf((*MockConfig)(nil).Endpoint)) -} - -// SSHPath mocks base method. -func (m *MockConfig) SSHPath() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SSHPath") - ret0, _ := ret[0].(string) - return ret0 -} - -// SSHPath indicates an expected call of SSHPath. -func (mr *MockConfigMockRecorder) SSHPath() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHPath", reflect.TypeOf((*MockConfig)(nil).SSHPath)) -} - -// SetActiveContext mocks base method. -func (m *MockConfig) SetActiveContext(arg0 *Context) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetActiveContext", arg0) -} - -// SetActiveContext indicates an expected call of SetActiveContext. -func (mr *MockConfigMockRecorder) SetActiveContext(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetActiveContext", reflect.TypeOf((*MockConfig)(nil).SetActiveContext), arg0) -} - -// SetContexts mocks base method. -func (m *MockConfig) SetContexts(arg0 []*Context) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetContexts", arg0) -} - -// SetContexts indicates an expected call of SetContexts. -func (mr *MockConfigMockRecorder) SetContexts(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetContexts", reflect.TypeOf((*MockConfig)(nil).SetContexts), arg0) -} - -// SetEndpoint mocks base method. -func (m *MockConfig) SetEndpoint(arg0 string) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetEndpoint", arg0) -} - -// SetEndpoint indicates an expected call of SetEndpoint. -func (mr *MockConfigMockRecorder) SetEndpoint(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEndpoint", reflect.TypeOf((*MockConfig)(nil).SetEndpoint), arg0) -} - -// Write mocks base method. -func (m *MockConfig) Write() error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Write") - ret0, _ := ret[0].(error) - return ret0 -} - -// Write indicates an expected call of Write. -func (mr *MockConfigMockRecorder) Write() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockConfig)(nil).Write)) -} diff --git a/internal/state/helpers.go b/internal/state/helpers.go index b3f8f7cf..38ca40b5 100644 --- a/internal/state/helpers.go +++ b/internal/state/helpers.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/crypto/ssh/terminal" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/hcloud-go/v2/hcloud" ) @@ -64,7 +65,8 @@ func (c *state) ActionsProgresses(cmd *cobra.Command, ctx context.Context, actio } func (c *state) EnsureToken(_ *cobra.Command, _ []string) error { - if c.token == "" { + token := config.OptionToken.Value() + if token == "" { return errors.New("no active context or token (see `hcloud context --help`)") } return nil diff --git a/internal/state/state.go b/internal/state/state.go index b1314a7c..5e72f925 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -2,8 +2,6 @@ package state import ( "context" - "log" - "os" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state/config" @@ -24,34 +22,16 @@ type State interface { type state struct { context.Context - token string - endpoint string - debug bool - debugFilePath string - client hcapi2.Client - config config.Config + client hcapi2.Client + config config.Config } func New(cfg config.Config) (State, error) { - var ( - token string - endpoint string - ) - if ctx := cfg.ActiveContext(); ctx != nil { - token = ctx.Token - } - if ep := cfg.Endpoint(); ep != "" { - endpoint = ep - } - s := &state{ - Context: context.Background(), - config: cfg, - token: token, - endpoint: endpoint, + Context: context.Background(), + config: cfg, } - s.readEnv() s.client = s.newClient() return s, nil } @@ -64,44 +44,8 @@ func (c *state) Config() config.Config { return c.config } -func (c *state) readEnv() { - if s := os.Getenv("HCLOUD_TOKEN"); s != "" { - c.token = s - } - if s := os.Getenv("HCLOUD_ENDPOINT"); s != "" { - c.endpoint = s - } - if s := os.Getenv("HCLOUD_DEBUG"); s != "" { - c.debug = true - } - if s := os.Getenv("HCLOUD_DEBUG_FILE"); s != "" { - c.debugFilePath = s - } - if s := os.Getenv("HCLOUD_CONTEXT"); s != "" && c.config != nil { - if cfgCtx := config.ContextByName(c.config, s); cfgCtx != nil { - c.config.SetActiveContext(cfgCtx) - c.token = cfgCtx.Token - } else { - log.Printf("warning: context %q specified in HCLOUD_CONTEXT does not exist\n", s) - } - } -} - func (c *state) newClient() hcapi2.Client { - opts := []hcloud.ClientOption{ - hcloud.WithToken(c.token), - hcloud.WithApplication("hcloud-cli", version.Version), - } - if c.endpoint != "" { - opts = append(opts, hcloud.WithEndpoint(c.endpoint)) - } - if c.debug { - if c.debugFilePath == "" { - opts = append(opts, hcloud.WithDebugWriter(os.Stderr)) - } else { - writer, _ := os.Create(c.debugFilePath) - opts = append(opts, hcloud.WithDebugWriter(writer)) - } - } + opts := config.GetHcloudOpts(c.Config()) + opts = append(opts, hcloud.WithApplication("hcloud-cli", version.Version)) return hcapi2.NewClient(opts...) } diff --git a/internal/testutil/fixture.go b/internal/testutil/fixture.go index fb5d5310..5def1ed5 100644 --- a/internal/testutil/fixture.go +++ b/internal/testutil/fixture.go @@ -8,6 +8,7 @@ import ( "github.com/golang/mock/gomock" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/hetznercloud/cli/internal/hcapi2" hcapi2_mock "github.com/hetznercloud/cli/internal/hcapi2/mock" @@ -28,12 +29,20 @@ type Fixture struct { func NewFixture(t *testing.T) *Fixture { ctrl := gomock.NewController(t) + viper.Reset() + config.ResetFlags() + cfg := &config.MockConfig{} + + if err := config.ReadConfig(cfg); err != nil { + t.Fatal(err) + } + return &Fixture{ MockController: ctrl, Client: hcapi2_mock.NewMockClient(ctrl), ActionWaiter: state.NewMockActionWaiter(ctrl), TokenEnsurer: state.NewMockTokenEnsurer(ctrl), - Config: config.NewMockConfig(ctrl), + Config: cfg, } }