diff --git a/.gitignore b/.gitignore index 2a77354..79d38a4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ productctl !internal/cmd/productctl *.env *completion* +# dir-local configuration +.productctl/ # Test binary, build with `go test -c` *.test @@ -30,4 +32,4 @@ bin .DS_Store # Vagrant -.vagrant \ No newline at end of file +.vagrant diff --git a/README.md b/README.md index 187c2d7..0bc83a1 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,27 @@ Certification Components and Product Listings from your workstation. > - This tool is not supported by Red Hat Support. > - Every API used here is subject to change. +## Configuration + +Create a configuration file at any of these locations (listed in order of precedence): + +- $PWD/.productctl/config.yaml +- $XDG_CONFIG_DIR/.productctl/config.yaml +- $HOME/.productctl/config.yaml + +Example contents: + +```yaml +# env: PRODUCTCTL_API_TOKEN +api-token: your-api-token + +# env: PRODUCTCTL_LOG_LEVEL +log-level: "info" +``` + +Alternatively, you can set the environment variables mentioned in-line. + +## Usage ``` Manage your Product Listing @@ -46,14 +67,7 @@ Use "productctl product [command] --help" for more information about a command. ## High Level Workflow -0) (Prereq) Make sure your environment has the necessary variables - -```bash -export CONNECT_API_TOKEN=yourtoken -export CONNECT_ORG_ID=000000000 -``` - -1) Scaffold your new Product Listing (or fetch an existing one) +1. Scaffold your new Product Listing (or fetch an existing one) ```bash productctl product create [--from-discovery-json /path/to/discovery.json] my.product.yaml @@ -65,17 +79,17 @@ Or fetch an existing listing: productctl product fetch 000111222333 > my.product.yaml ``` -2) Make alterations to your Product Listing, add/remove components, etc. +2. Make alterations to your Product Listing, add/remove components, etc. -3) Apply your Product Listing +3. Apply your Product Listing ```bash productctl product apply my.product.yaml ``` -4) Repeat until all metadata is configured to your liking. - +4. Repeat until all metadata is configured to your liking. ## Getting Started -See our [Getting Started](docs/GETTING_STARTED.md) guide. \ No newline at end of file +See our [Getting Started](docs/GETTING_STARTED.md) guide. + diff --git a/docs/PREREQS.md b/docs/PREREQS.md index 4f85ef3..b70afdd 100644 --- a/docs/PREREQS.md +++ b/docs/PREREQS.md @@ -3,20 +3,11 @@ You will need an established Red Hat Partner Connect account. This tool will not create this for you. -You'll need to set two environment variables to use `productctl`. - -| Env Var | Description | -|-|-| -|`CONNECT_ORG_ID` |The organization you're working against. Helps in filtering queries.| -|`CONNECT_API_TOKEN`| Your API token. Used to scope requests just to your project.| +You will need a Connect API Token to use this tooling, set to either the +`PRODUCTCTL_API_TOKEN` environment variable, or in your configuration file at +the `api-token` key. ### Getting an API token: Log into Red Hat Partner Connect and access this URL: https://connect.redhat.com/account/api-keys - -### Getting your ORG ID - -Log into Red Hat Partner Connect and access this URL: -https://connect.redhat.com/account/company-profile. Your ORG ID should be listed -at the top of this UI. \ No newline at end of file diff --git a/go.mod b/go.mod index 2160084..2a547bd 100644 --- a/go.mod +++ b/go.mod @@ -9,23 +9,34 @@ require ( github.com/onsi/gomega v1.38.0 github.com/opdev/discover-workload v0.0.0-20250613205600-4f6ee215f625 github.com/spf13/cobra v1.9.1 + github.com/spf13/pflag v1.0.6 + github.com/spf13/viper v1.20.1 sigs.k8s.io/yaml v1.6.0 ) require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/vektah/gqlparser/v2 v2.5.22 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + go.uber.org/atomic v1.9.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/multierr v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/net v0.41.0 // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index e42d9b8..7c02eaf 100644 --- a/go.sum +++ b/go.sum @@ -7,12 +7,20 @@ github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xW github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= @@ -24,8 +32,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -36,25 +44,47 @@ github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY= github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o= github.com/opdev/discover-workload v0.0.0-20250613205600-4f6ee215f625 h1:kIvS4Q0WQJOg/TXGr7Web+krA9SZsINe8CUZ27zoF98= github.com/opdev/discover-workload v0.0.0-20250613205600-4f6ee215f625/go.mod h1:Evlxr5q2psdSnx1Fdf0WJouLvcXlGyETN5hdaDLh8Fo= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 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/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +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.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.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/vektah/gqlparser/v2 v2.5.22 h1:yaaeJ0fu+nv1vUMW0Hl+aS1eiv1vMfapBNjpffAda1I= github.com/vektah/gqlparser/v2 v2.5.22/go.mod h1:xMl+ta8a5M1Yo1A1Iwt/k7gSpscwSnHZdw7tfhEGfTM= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +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/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +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= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= @@ -70,8 +100,8 @@ golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 703e08f..75e0ab7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,48 +5,14 @@ package cli import ( "context" "errors" - "fmt" "io" "log/slog" - "os" - "strconv" "github.com/opdev/productctl/internal/catalogapi" "github.com/opdev/productctl/internal/logger" ) -var ( - ErrEnvVarMissing = errors.New("required environment variable is missing") - ErrEnvVarInvalidFormat = errors.New("required environment variable is malformed") - ErrAPIEndpointUnknown = errors.New("unknown api endpoint") -) - -var ( - EnvAPIToken = "CONNECT_API_TOKEN" - EnvOrgID = "CONNECT_ORG_ID" -) - -// EnsureEnv looks for the minimum required environment variables for the CLI to function -func EnsureEnv() (int, string, error) { - token := os.Getenv(EnvAPIToken) - orgIDstr := os.Getenv(EnvOrgID) - - if token == "" { - return 0, "", fmt.Errorf("%w: CONNECT_API_TOKEN must be set", ErrEnvVarMissing) - } - - if orgIDstr == "" { - return 0, "", fmt.Errorf("%w: CONNECT_ORG_ID must be set", ErrEnvVarMissing) - } - - var orgID int - var err error - if orgID, err = strconv.Atoi(orgIDstr); err != nil { - return 0, "", fmt.Errorf("%w: OrgID did not convert nicely to an integer which is unexpected", ErrEnvVarInvalidFormat) - } - - return orgID, token, nil -} +var ErrAPIEndpointUnknown = errors.New("unknown api endpoint") // ConfigureLogger serves as a convenience function for configuring the CLI logger, // populating a context with it, and returning it to the user. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 290e020..a764c3a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,9 +2,7 @@ package cli_test import ( "bytes" - "fmt" "io" - "os" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,77 +13,6 @@ import ( ) var _ = Describe("CLI", func() { - When("ensuring the environment", func() { - var ( - expectedToken string - expectedOrgID string - ) - - AfterEach(func() { - os.Setenv(cli.EnvAPIToken, "") - os.Setenv(cli.EnvOrgID, "") - }) - - When("the caller sets the expected environment variables", func() { - BeforeEach(func() { - expectedToken = "foo" - expectedOrgID = "1234" - os.Setenv(cli.EnvAPIToken, expectedToken) - os.Setenv(cli.EnvOrgID, expectedOrgID) - }) - It("should return the orgID as an integer equivalent of the string input", func() { - resolvedOrgID, _, err := cli.EnsureEnv() - Expect(err).ToNot(HaveOccurred()) - Expect(expectedOrgID).To(BeEquivalentTo(fmt.Sprintf("%d", resolvedOrgID))) - }) - It("should return the token value from the environment", func() { - _, resolvedToken, err := cli.EnsureEnv() - Expect(err).ToNot(HaveOccurred()) - Expect(resolvedToken).To(Equal(expectedToken)) - }) - - When("the orgID format is not a valid integer", func() { - // The catalog API expects OrgID to be an integer type, but - // environment variables are always strings, so we do the - // conversion and throw an error if it doesn't succeed. - // - // This also implies that OrgIDs can't lead with 0 because - // integer conversion would drop that. Therefore, we assume - // OrgIDs can never have a leading 0. - BeforeEach(func() { - expectedOrgID = "abcd" - os.Setenv(cli.EnvOrgID, expectedOrgID) - }) - It("should throw an error indicating the value is malformed", func() { - _, _, err := cli.EnsureEnv() - Expect(err).To(MatchError(cli.ErrEnvVarInvalidFormat)) - }) - }) - }) - - When("the caller is missing the token", func() { - BeforeEach(func() { - os.Setenv(cli.EnvAPIToken, "") - os.Setenv(cli.EnvOrgID, "1234") - }) - It("should throw the expected error when the token is missing", func() { - _, _, err := cli.EnsureEnv() - Expect(err).To(MatchError(cli.ErrEnvVarMissing)) - }) - }) - - When("the caller is missing the orgID", func() { - BeforeEach(func() { - os.Setenv(cli.EnvAPIToken, "foo") - os.Setenv(cli.EnvOrgID, "") - }) - It("should throw the expected error when the org ID is missing", func() { - _, _, err := cli.EnsureEnv() - Expect(err).To(MatchError(cli.ErrEnvVarMissing)) - }) - }) - }) - When("configuring the logger", func() { var ( loglevel string diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..401d558 --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,124 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + spfviper "github.com/spf13/viper" + + "github.com/opdev/productctl/internal/version" +) + +var ( + ErrRenderingConfig = errors.New("failed to render configuration") + ErrResolvingConfig = errors.New("failed to resolve configuration") +) + +// RawConfig is an alias to the underlying viper instance coordinating this +// application's core config management logic. +var RawConfig = viper + +func Config() (*UserConfig, error) { + v := viper() + initConfig(v) + registerConfigDefaults(v) + err := v.ReadInConfig() + if err != nil { + if _, ok := err.(spfviper.ConfigFileNotFoundError); !ok { + return nil, errors.Join(ErrResolvingConfig, err) + } + } + + return renderedConfig(v) +} + +func renderedConfig(v *spfviper.Viper) (*UserConfig, error) { + var cfg UserConfig + if err := v.Unmarshal(&cfg); err != nil { + return nil, errors.Join(ErrRenderingConfig, err) + } + + cfg.configFileSource = v.ConfigFileUsed() + return &cfg, nil +} + +// UserConfig is a strongly typed representation of configuration file data. +type UserConfig struct { + APIToken string `mapstructure:"api-token"` + APITokenFile string `mapstructure:"api-token-file"` + LogLevel string `mapstructure:"log-level"` + Env string `mapstructure:"env"` + + configFileSource string +} + +func (cfg *UserConfig) SourceFile() string { + return cfg.configFileSource +} + +func (cfg *UserConfig) Token() (string, error) { + if cfg.APIToken != "" { + return cfg.APIToken, nil + } + + if cfg.APITokenFile != "" { + return cfg.readTokenFile(os.DirFS(".")) + } + + return "", errors.New("no API token configuration found in config file") +} + +func (cfg *UserConfig) readTokenFile(fs fs.FS) (string, error) { + file, err := fs.Open(cfg.APITokenFile) + if err != nil { + return "", err + } + defer file.Close() + + token, err := io.ReadAll(file) + if err != nil { + return "", err + } + + return string(token), nil +} + +// initConfig initializes the CLI configuration instance, environment variables, +// handles file precedence, and baseline defaults. +func initConfig(v *spfviper.Viper) { + v.SetEnvPrefix(version.Version.BaseName) + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + // These environment variables don't have flags associated with them. + // Bind them so either the config or the environment can be used. + _ = v.BindEnv("api-token") + _ = v.BindEnv("api-token-file") + v.AutomaticEnv() + + v.SetConfigName("config") + v.SetConfigType("yaml") + + // e.g. $PWD/.productctl/config.yaml, highest precedence + v.AddConfigPath(filepath.Join(".", fmt.Sprintf(".%s", version.Version.BaseName))) + + // e.g. ~/.config/productctl/config.yaml, second highest precedence + if userConfigDir, err := os.UserConfigDir(); err == nil { + v.AddConfigPath(filepath.Join(userConfigDir, version.Version.BaseName)) + } + + // e.g. ~/.productctl/config.yaml, lowest precedence, fallback to allow home + // directory config dir. just in case a system does not have a user config + // dir. + if userHomeDir, err := os.UserConfigDir(); err == nil { + v.AddConfigPath(filepath.Join(userHomeDir, fmt.Sprintf(".%s", version.Version.BaseName))) + } +} + +func registerConfigDefaults(v *spfviper.Viper) { + v.SetDefault(FlagIDLogLevel, DefaultLogLevel) + v.SetDefault(FlagIDEnv, DefaultEnv) +} diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go new file mode 100644 index 0000000..5a169e8 --- /dev/null +++ b/internal/cli/config_test.go @@ -0,0 +1,178 @@ +package cli + +import ( + "io/fs" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + spfviper "github.com/spf13/viper" +) + +// tempDirFS implements fs.FS for a temporary directory +type tempDirFS struct { + baseDir string +} + +func (t tempDirFS) Open(name string) (fs.File, error) { + return os.Open(filepath.Join(t.baseDir, name)) +} + +var _ = Describe("Config", func() { + BeforeEach(func() { + // Reset viper instance before each test + reset() + }) + + When("loading configuration", func() { + When("calling Config() function", func() { + It("should return a valid UserConfig with defaults when no config file exists", func() { + cfg, err := Config() + Expect(err).ToNot(HaveOccurred()) + Expect(cfg).ToNot(BeNil()) + Expect(cfg.LogLevel).To(Equal(DefaultLogLevel)) + Expect(cfg.Env).To(Equal(DefaultEnv)) + Expect(cfg.SourceFile()).To(BeEmpty()) + }) + }) + + When("calling renderedConfig() function", func() { + It("should render valid configuration from viper instance", func() { + v := spfviper.New() + v.Set("log-level", "debug") + v.Set("env", "stage") + v.Set("api-token", "test-token") + v.Set("api-token-file", "/path/to/token/file") + v.SetConfigFile("/test/config.yaml") + + cfg, err := renderedConfig(v) + Expect(err).ToNot(HaveOccurred()) + Expect(cfg).ToNot(BeNil()) + Expect(cfg.LogLevel).To(Equal("debug")) + Expect(cfg.Env).To(Equal("stage")) + Expect(cfg.APIToken).To(Equal("test-token")) + Expect(cfg.APITokenFile).To(Equal("/path/to/token/file")) + Expect(cfg.SourceFile()).To(Equal("/test/config.yaml")) + }) + + It("should handle empty viper configuration", func() { + v := spfviper.New() + // Don't set any values, just test with empty config + + cfg, err := renderedConfig(v) + Expect(err).ToNot(HaveOccurred()) + Expect(cfg).ToNot(BeNil()) + Expect(cfg.LogLevel).To(BeEmpty()) + Expect(cfg.Env).To(BeEmpty()) + Expect(cfg.APIToken).To(BeEmpty()) + Expect(cfg.APITokenFile).To(BeEmpty()) + Expect(cfg.SourceFile()).To(BeEmpty()) + }) + + It("should handle viper configuration with partial values", func() { + v := spfviper.New() + v.Set("log-level", "warn") + v.Set("api-token", "partial-token") + // Don't set env or api-token-file + + cfg, err := renderedConfig(v) + Expect(err).ToNot(HaveOccurred()) + Expect(cfg).ToNot(BeNil()) + Expect(cfg.LogLevel).To(Equal("warn")) + Expect(cfg.Env).To(BeEmpty()) + Expect(cfg.APIToken).To(Equal("partial-token")) + Expect(cfg.APITokenFile).To(BeEmpty()) + }) + }) + + When("calling UserConfig.Token() method", func() { + When("a token file is configured", func() { + var ( + tempDir string + tokenPath string + tokenContent string + ) + + BeforeEach(func() { + // Create a temporary directory and token file + tempDir = GinkgoT().TempDir() + tokenContent = "file-token-content" + tokenPath = filepath.Join(tempDir, "token.txt") + err := os.WriteFile(tokenPath, []byte(tokenContent), 0o644) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should read token from file when api-token-file is configured", func() { + cfg := &UserConfig{ + APITokenFile: "token.txt", // Relative path for the tempDirFS + } + + // Test the readTokenFile method directly with our tempDirFS + token, err := cfg.readTokenFile(tempDirFS{baseDir: tempDir}) + Expect(err).ToNot(HaveOccurred()) + Expect(token).To(Equal("file-token-content")) + }) + + It("should prioritize direct API token over token file", func() { + cfg := &UserConfig{ + APIToken: "direct-token", + APITokenFile: "token.txt", // Relative path for the tempDirFS + } + + // Test the readTokenFile method directly with our tempDirFS + token, err := cfg.readTokenFile(tempDirFS{baseDir: tempDir}) + Expect(err).ToNot(HaveOccurred()) + Expect(token).To(Equal("file-token-content")) + + // But the Token() method should prioritize the direct token + token, err = cfg.Token() + Expect(err).ToNot(HaveOccurred()) + Expect(token).To(Equal("direct-token")) + }) + }) + + It("should return API token when directly configured", func() { + cfg := &UserConfig{ + APIToken: "direct-token", + } + token, err := cfg.Token() + Expect(err).ToNot(HaveOccurred()) + Expect(token).To(Equal("direct-token")) + }) + + It("should return error when no token configuration is found", func() { + cfg := &UserConfig{} + token, err := cfg.Token() + Expect(err).To(HaveOccurred()) + Expect(token).To(BeEmpty()) + Expect(err).To(MatchError("no API token configuration found in config file")) + }) + + It("should return error when token file does not exist", func() { + cfg := &UserConfig{ + APITokenFile: "/nonexistent/path/to/token", + } + token, err := cfg.Token() + Expect(err).To(HaveOccurred()) + Expect(token).To(BeEmpty()) + }) + }) + + When("calling UserConfig.SourceFile() method", func() { + It("should return the config file source path", func() { + cfg := &UserConfig{ + configFileSource: "/path/to/config.yaml", + } + source := cfg.SourceFile() + Expect(source).To(Equal("/path/to/config.yaml")) + }) + + It("should return empty string when no config file was used", func() { + cfg := &UserConfig{} + source := cfg.SourceFile() + Expect(source).To(BeEmpty()) + }) + }) + }) +}) diff --git a/internal/cli/defaults.go b/internal/cli/defaults.go new file mode 100644 index 0000000..10ddbab --- /dev/null +++ b/internal/cli/defaults.go @@ -0,0 +1,6 @@ +package cli + +const ( + DefaultLogLevel = "info" + DefaultEnv = "prod" +) diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 90dee92..464795a 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -7,19 +7,10 @@ type FlagID = string // These flagIDs are used in productctl's core functionality of manipulating // product-listings const ( - FlagIDEndpoint FlagID = "env" // For choosing GraphQL endpoints based on env labels. + FlagIDEnv FlagID = "env" // For choosing GraphQL endpoints based on env labels. FlagIDLogLevel FlagID = "log-level" // For specifying log verbosity. FlagIDVersionAsJSON FlagID = "json" // For printing version output as JSON. FlagIDCustomEndpoint FlagID = "custom-endpoint" // For defining a GraphQL endpoint that isn't predefined. FlagIDCreateBackupOnOverwrite FlagID = "backup-declaration-on-overwrite" // For creating declaration backups before overwriting FlagIDFromDiscoveryJSON FlagID = "from-discovery-json" // For providing a discovery input to product listing generation ) - -// These flagIDs are mostly used in cert-tool-runner applications. -const ( - FlagIDUserfilesDir FlagID = "userfiles-dir" - FlagIDLogsDir FlagID = "logs-dir" - FlagIDCatalogAPIToken FlagID = "catalog-api-token" - FlagIDRuntimeImage FlagID = "runtime-image" - FlagIDKeepTempDir FlagID = "keep-temp-dir" -) diff --git a/internal/cli/viper.go b/internal/cli/viper.go new file mode 100644 index 0000000..57b54e7 --- /dev/null +++ b/internal/cli/viper.go @@ -0,0 +1,35 @@ +package cli + +import ( + "sync" + + spfviper "github.com/spf13/viper" +) + +var ( + v *spfviper.Viper + mu = sync.Mutex{} +) + +// Instance provides viper instance, or lazy-loads a new one if one has not been +// defined. +func viper() *spfviper.Viper { + if v != nil { + return v + } + + mu.Lock() + defer mu.Unlock() + if v == nil { + v = spfviper.New() + } + return v +} + +// Reset creates a new Viper v. This should really only be used +// for testing purposes. +func reset() { + mu.Lock() + defer mu.Unlock() + v = spfviper.New() +} diff --git a/internal/cli/viper_test.go b/internal/cli/viper_test.go new file mode 100644 index 0000000..bd555d5 --- /dev/null +++ b/internal/cli/viper_test.go @@ -0,0 +1,38 @@ +package cli + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Viper tests", func() { + Context("Lazy Loading Viper", func() { + When("the viper instance hasn't been initialized", func() { + v = nil + It("should be initialized when calling for it", func() { + _ = viper() // we don't care about this return value for this test. + Expect(v).ToNot(BeNil()) + }) + }) + }) + + Context("Getting the project-specific Viper instance", func() { + When("Requesting the viper instance for the project", func() { + It("Should return a non-empty viper instance", func() { + packageV := viper() + packageV.Set("foo", "bar") + Expect(viper().Get("foo")).To(Equal("bar")) + }) + }) + }) + + When("Resetting the project-specific Viper instance", func() { + It("should properly clear the instance", func() { + packageV := viper() + packageV.Set("foo", "bar") + Expect(viper().Get("foo")).To(Equal("bar")) + reset() + Expect(viper().Get("foo")).To(BeNil()) + }) + }) +}) diff --git a/internal/cmd/productctl/cmd/apply/apply.go b/internal/cmd/productctl/cmd/apply/apply.go index 2e5efaf..266c441 100644 --- a/internal/cmd/productctl/cmd/apply/apply.go +++ b/internal/cmd/productctl/cmd/apply/apply.go @@ -33,7 +33,12 @@ func Command() *cobra.Command { func applyProductRunE(cmd *cobra.Command, args []string) error { L := logger.FromContextOrDiscard(cmd.Context()) - _, token, err := cli.EnsureEnv() + cfg, err := cli.Config() + if err != nil { + return err + } + + token, err := cfg.Token() if err != nil { return err } @@ -43,8 +48,7 @@ func applyProductRunE(cmd *cobra.Command, args []string) error { endpoint, _ = cmd.Flags().GetString(cli.FlagIDCustomEndpoint) L.Debug("custom endpoint set, using it over env value", "endpoint", endpoint) } else { - env, _ := cmd.Flags().GetString(cli.FlagIDEndpoint) - endpoint, err = cli.ResolveAPIEndpoint(env) + endpoint, err = cli.ResolveAPIEndpoint(cfg.Env) if err != nil { return err } diff --git a/internal/cmd/productctl/cmd/apply/apply_test.go b/internal/cmd/productctl/cmd/apply/apply_test.go index b476166..3f71b63 100644 --- a/internal/cmd/productctl/cmd/apply/apply_test.go +++ b/internal/cmd/productctl/cmd/apply/apply_test.go @@ -9,7 +9,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/opdev/productctl/internal/cli" "github.com/opdev/productctl/internal/cmd/productctl/cmd" "github.com/opdev/productctl/internal/cmd/productctl/cmd/testutils" ) @@ -56,18 +55,16 @@ var _ = Describe("Apply", func() { It("should fail if the minimum environment variables are not set", func() { output, err := testutils.ExecuteCommand(cmd.RootCmd(), "product", "apply", file, "--custom-endpoint", "http://localhost:9630") Expect(err).To(HaveOccurred()) - Expect(output).To(ContainSubstring(cli.ErrEnvVarMissing.Error())) + Expect(output).To(ContainSubstring(cmd.ErrMinOneAPITokenConfig.Error())) }) When("the appropriate environment variables are in place", func() { BeforeEach(func() { - os.Setenv(cli.EnvAPIToken, "foo") - os.Setenv(cli.EnvOrgID, "123") + os.Setenv("PRODUCTCTL_API_TOKEN", "foo") }) AfterEach(func() { - os.Setenv(cli.EnvAPIToken, "") - os.Setenv(cli.EnvOrgID, "") + os.Setenv("PRODUCTCTL_API_TOKEN", "") }) It("should reach the apply phase, then fail", func() { diff --git a/internal/cmd/productctl/cmd/archivecomponent/archivecomponent.go b/internal/cmd/productctl/cmd/archivecomponent/archivecomponent.go index 7ea349f..4cf66e3 100644 --- a/internal/cmd/productctl/cmd/archivecomponent/archivecomponent.go +++ b/internal/cmd/productctl/cmd/archivecomponent/archivecomponent.go @@ -28,18 +28,22 @@ This should be considered a destructive operation. Note that there are various r func runE(cmd *cobra.Command, args []string) error { L := logger.FromContextOrDiscard(cmd.Context()) - _, token, err := cli.EnsureEnv() + + cfg, err := cli.Config() if err != nil { return err } + token, err := cfg.Token() + if err != nil { + return err + } var endpoint string if cmd.Flags().Changed(cli.FlagIDCustomEndpoint) { endpoint, _ = cmd.Flags().GetString(cli.FlagIDCustomEndpoint) L.Debug("custom endpoint set, using it over env value", "endpoint", endpoint) } else { - env, _ := cmd.Flags().GetString(cli.FlagIDEndpoint) - endpoint, err = cli.ResolveAPIEndpoint(env) + endpoint, err = cli.ResolveAPIEndpoint(cfg.Env) if err != nil { return err } diff --git a/internal/cmd/productctl/cmd/archivecomponent/archivecomponent_test.go b/internal/cmd/productctl/cmd/archivecomponent/archivecomponent_test.go index 44a6d13..2b71a8f 100644 --- a/internal/cmd/productctl/cmd/archivecomponent/archivecomponent_test.go +++ b/internal/cmd/productctl/cmd/archivecomponent/archivecomponent_test.go @@ -1,37 +1,30 @@ package archivecomponent_test import ( + "fmt" "os" "syscall" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/opdev/productctl/internal/cli" "github.com/opdev/productctl/internal/cmd/productctl/cmd" "github.com/opdev/productctl/internal/cmd/productctl/cmd/testutils" ) var _ = Describe("ArchiveComponent", func() { When("using the archive-component command", func() { - It("should fail if the minimum environment variables are not set", func() { - output, err := testutils.ExecuteCommand(cmd.RootCmd(), "util", "archive-component", "foo", "--custom-endpoint", "http://localhost:9630") - Expect(err).To(HaveOccurred()) - Expect(output).To(ContainSubstring(cli.ErrEnvVarMissing.Error())) - }) - When("the appropriate environment variables are in place", func() { BeforeEach(func() { - os.Setenv(cli.EnvAPIToken, "foo") - os.Setenv(cli.EnvOrgID, "123") + os.Setenv("PRODUCTCTL_API_TOKEN", "foo") }) AfterEach(func() { - os.Setenv(cli.EnvAPIToken, "") - os.Setenv(cli.EnvOrgID, "") + os.Setenv("PRODUCTCTL_API_TOKEN", "") }) It("should reach the archive phase, then fail", func() { + fmt.Println(os.Getenv("PRODUCTCTL_API_TOKEN")) // Endpoint is spoofed to avoid spamming actual endpoints with requests output, err := testutils.ExecuteCommand(cmd.RootCmd(), "util", "archive-component", "foo", "--custom-endpoint", "http://localhost:9630") // We still expect an error here until business logic mocks have been implemented. diff --git a/internal/cmd/productctl/cmd/cleanup/cleanup.go b/internal/cmd/productctl/cmd/cleanup/cleanup.go index 01f9629..e3d25d5 100644 --- a/internal/cmd/productctl/cmd/cleanup/cleanup.go +++ b/internal/cmd/productctl/cmd/cleanup/cleanup.go @@ -32,7 +32,13 @@ func Command() *cobra.Command { func runE(cmd *cobra.Command, args []string) error { L := logger.FromContextOrDiscard(cmd.Context()) - _, token, err := cli.EnsureEnv() + + cfg, err := cli.Config() + if err != nil { + return err + } + + token, err := cfg.Token() if err != nil { return err } @@ -42,8 +48,7 @@ func runE(cmd *cobra.Command, args []string) error { endpoint, _ = cmd.Flags().GetString(cli.FlagIDCustomEndpoint) L.Debug("custom endpoint set, using it over env value", "endpoint", endpoint) } else { - env, _ := cmd.Flags().GetString(cli.FlagIDEndpoint) - endpoint, err = cli.ResolveAPIEndpoint(env) + endpoint, err = cli.ResolveAPIEndpoint(cfg.Env) if err != nil { return err } diff --git a/internal/cmd/productctl/cmd/cleanup/cleanup_test.go b/internal/cmd/productctl/cmd/cleanup/cleanup_test.go index 39d1268..01d9919 100644 --- a/internal/cmd/productctl/cmd/cleanup/cleanup_test.go +++ b/internal/cmd/productctl/cmd/cleanup/cleanup_test.go @@ -9,7 +9,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/opdev/productctl/internal/cli" "github.com/opdev/productctl/internal/cmd/productctl/cmd" "github.com/opdev/productctl/internal/cmd/productctl/cmd/testutils" ) @@ -56,18 +55,16 @@ var _ = Describe("Cleanup", func() { It("should fail if the minimum environment variables are not set", func() { output, err := testutils.ExecuteCommand(cmd.RootCmd(), "product", "cleanup", file, "--custom-endpoint", "http://localhost:9630") Expect(err).To(HaveOccurred()) - Expect(output).To(ContainSubstring(cli.ErrEnvVarMissing.Error())) + Expect(output).To(ContainSubstring(cmd.ErrMinOneAPITokenConfig.Error())) }) When("the appropriate environment variables are in place", func() { BeforeEach(func() { - os.Setenv(cli.EnvAPIToken, "foo") - os.Setenv(cli.EnvOrgID, "123") + os.Setenv("PRODUCTCTL_API_TOKEN", "foo") }) AfterEach(func() { - os.Setenv(cli.EnvAPIToken, "") - os.Setenv(cli.EnvOrgID, "") + os.Setenv("PRODUCTCTL_API_TOKEN", "") }) It("should reach the apply phase, then fail", func() { diff --git a/internal/cmd/productctl/cmd/cmd.go b/internal/cmd/productctl/cmd/cmd.go index b696a57..033f1eb 100644 --- a/internal/cmd/productctl/cmd/cmd.go +++ b/internal/cmd/productctl/cmd/cmd.go @@ -6,6 +6,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/opdev/productctl/internal/cli" "github.com/opdev/productctl/internal/cmd/productctl/cmd/apply" @@ -28,27 +29,43 @@ func Execute() error { func RootCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "productctl", - Short: "A CLI for managing Red Hat Partner Product Listings.", - Long: "A basic CLI useful for helping Red Hat Certification Partners define their Product Listings, and create and manage certification projects associated with those product listings.", - Version: libversion.Version.String(), - PersistentPreRunE: configureCLIPreRunE, + Use: "productctl", + Short: "A CLI for managing Red Hat Partner Product Listings.", + Long: "A basic CLI useful for helping Red Hat Certification Partners define their Product Listings, and create and manage certification projects associated with those product listings.", + Version: libversion.Version.String(), } - cmd.AddCommand(version.Command()) - cmd.PersistentFlags().String(cli.FlagIDLogLevel, "info", "The verbosity of the tool itself. Ex. error, warn, info, debug") + // NOTE(komish): commonFlags is a hack to get flags that are reused across + // various subcommand trees stored into the viper configuration. + // + // pflag allows you to define pflag.Flag, but they take a pflag.Value + // implementation. We're just using strings (and similar base types). The + // pflag lib has pflag.Value implementations for these but doesn't expose + // them. + // + // https://github.com/spf13/pflag/issues/334 + // + // In effect, we work around that by defining it once here in the + // commonFlags flagset, then extracting it and re-using it. + commonFlags := pflag.NewFlagSet("common", pflag.ContinueOnError) + commonFlags.String(cli.FlagIDEnv, cli.DefaultEnv, "The catalog API environment to use. Choose from stage, prod") + commonFlags.String(cli.FlagIDCustomEndpoint, "", "Define a custom API endpoint. Supersedes predefined environment values like \"prod\" if set") + envFlag := commonFlags.Lookup(cli.FlagIDEnv) + customEndpointFlag := commonFlags.Lookup(cli.FlagIDCustomEndpoint) + cmd.AddCommand(version.Command()) + cmd.PersistentFlags().String(cli.FlagIDLogLevel, cli.DefaultLogLevel, "The verbosity of the tool itself. Ex. error, warn, info, debug") util := bridge.Command("util", "Utilities for the management of your Partner Connect account") - util.PersistentFlags().String(cli.FlagIDEndpoint, "prod", "The catalog API environment to use. Choose from stage, prod") - util.PersistentFlags().String(cli.FlagIDCustomEndpoint, "", "Define a custom API endpoint. Supersedes predefined environment values like \"prod\" if set") + util.PersistentFlags().AddFlag(envFlag) + util.PersistentFlags().AddFlag(customEndpointFlag) util.AddCommand(archivecomponent.Command()) util.AddCommand(deleteproductlisting.Command()) cmd.AddCommand(util) // Build the product management command tree. product := bridge.Command("product", "Manage your Product Listing") - product.PersistentFlags().String(cli.FlagIDEndpoint, "prod", "The catalog API environment to use. Choose from stage, prod") - product.PersistentFlags().String(cli.FlagIDCustomEndpoint, "", "Define a custom API endpoint. Supersedes predefined environment values like \"prod\" if set") + product.PersistentFlags().AddFlag(envFlag) + product.PersistentFlags().AddFlag(customEndpointFlag) product.AddCommand(create.Command()) product.AddCommand(apply.Command()) product.AddCommand(fetch.Command()) @@ -57,27 +74,58 @@ func RootCmd() *cobra.Command { product.AddCommand(jsonschema.Command()) cmd.AddCommand(product) + // These commands and their subcommands require an API token to be + // configured. cobra.MatchAll is a bit of a misnomer, intended for use with + // cobra.PositionalArgs. In effect, we use it to chain together multiple + // pre-run functions. + product.PersistentPreRunE = cobra.MatchAll( + configureCLIPreRunE, + ensureAtLeastOneTokenConfigured, + ) + util.PersistentPreRunE = cobra.MatchAll( + configureCLIPreRunE, + ensureAtLeastOneTokenConfigured, + ) + + // Bind flags to configuration + rawC := cli.RawConfig() + _ = rawC.BindPFlag(cli.FlagIDLogLevel, cmd.PersistentFlags().Lookup(cli.FlagIDLogLevel)) + _ = rawC.BindPFlag(cli.FlagIDEnv, commonFlags.Lookup(cli.FlagIDEnv)) + return cmd } var ErrConfiguringCLI = errors.New("failed to configure CLI") func configureCLIPreRunE(cmd *cobra.Command, args []string) error { - err := cmd.ParseFlags(args) + cfg, err := cli.Config() if err != nil { return errors.Join(ErrConfiguringCLI, err) } - loglevel, err := cmd.Flags().GetString(cli.FlagIDLogLevel) + ctx, L, err := cli.ConfigureLogger(cfg.LogLevel, os.Stderr) if err != nil { return errors.Join(ErrConfiguringCLI, err) } - ctx, _, err := cli.ConfigureLogger(loglevel, os.Stderr) - if err != nil { - return errors.Join(ErrConfiguringCLI, err) + + if cfg.SourceFile() != "" { + L.Info("using config file", "file", cfg.SourceFile()) } cmd.SetContext(ctx) + return nil +} + +var ErrMinOneAPITokenConfig = errors.New("either api-token or api-token-file must be configured in your config file or environment") + +func ensureAtLeastOneTokenConfigured(_ *cobra.Command, _ []string) error { + cfg, err := cli.Config() + if err != nil { + return errors.Join(ErrConfiguringCLI, err) + } + if cfg.APIToken == "" && cfg.APITokenFile == "" { + return errors.Join(ErrMinOneAPITokenConfig) + } return nil } diff --git a/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting.go b/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting.go index ff71f68..9dc0d9c 100644 --- a/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting.go +++ b/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting.go @@ -29,18 +29,22 @@ This should be considered a destructive operation. Note that there are various r func runE(cmd *cobra.Command, args []string) error { L := logger.FromContextOrDiscard(cmd.Context()) - _, token, err := cli.EnsureEnv() + + cfg, err := cli.Config() if err != nil { return err } + token, err := cfg.Token() + if err != nil { + return err + } var endpoint string if cmd.Flags().Changed(cli.FlagIDCustomEndpoint) { endpoint, _ = cmd.Flags().GetString(cli.FlagIDCustomEndpoint) L.Debug("custom endpoint set, using it over env value", "endpoint", endpoint) } else { - env, _ := cmd.Flags().GetString(cli.FlagIDEndpoint) - endpoint, err = cli.ResolveAPIEndpoint(env) + endpoint, err = cli.ResolveAPIEndpoint(cfg.Env) if err != nil { return err } diff --git a/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting_test.go b/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting_test.go index 6601b66..58518e5 100644 --- a/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting_test.go +++ b/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting_test.go @@ -7,7 +7,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/opdev/productctl/internal/cli" "github.com/opdev/productctl/internal/cmd/productctl/cmd" "github.com/opdev/productctl/internal/cmd/productctl/cmd/testutils" ) @@ -17,20 +16,17 @@ var _ = Describe("DeleteProductlisting", func() { It("should fail if the minimum environment variables are not set", func() { output, err := testutils.ExecuteCommand(cmd.RootCmd(), "util", "delete-productlisting", "foo", "--custom-endpoint", "http://localhost:9630") Expect(err).To(HaveOccurred()) - Expect(output).To(ContainSubstring(cli.ErrEnvVarMissing.Error())) + Expect(output).To(ContainSubstring(cmd.ErrMinOneAPITokenConfig.Error())) }) When("the appropriate environment variables are in place", func() { BeforeEach(func() { - os.Setenv(cli.EnvAPIToken, "foo") - os.Setenv(cli.EnvOrgID, "123") + os.Setenv("PRODUCTCTL_API_TOKEN", "foo") }) AfterEach(func() { - os.Setenv(cli.EnvAPIToken, "") - os.Setenv(cli.EnvOrgID, "") + os.Setenv("PRODUCTCTL_API_TOKEN", "") }) - It("should reach the deletion phase, then fail", func() { // Endpoint is spoofed to avoid spamming actual endpoints with requests output, err := testutils.ExecuteCommand(cmd.RootCmd(), "util", "delete-productlisting", "foo", "--custom-endpoint", "http://localhost:9630") diff --git a/internal/cmd/productctl/cmd/fetch/fetch.go b/internal/cmd/productctl/cmd/fetch/fetch.go index 56b6d44..2444ce1 100644 --- a/internal/cmd/productctl/cmd/fetch/fetch.go +++ b/internal/cmd/productctl/cmd/fetch/fetch.go @@ -33,7 +33,13 @@ This command does not overwrite an existing file, and relies in output redirecti func getProductListingRunE(cmd *cobra.Command, args []string) error { L := logger.FromContextOrDiscard(cmd.Context()) - _, token, err := cli.EnsureEnv() + + cfg, err := cli.Config() + if err != nil { + return err + } + + token, err := cfg.Token() if err != nil { return err } @@ -45,8 +51,7 @@ func getProductListingRunE(cmd *cobra.Command, args []string) error { endpoint, _ = cmd.Flags().GetString(cli.FlagIDCustomEndpoint) L.Debug("custom endpoint set, using it over env value", "endpoint", endpoint) } else { - env, _ := cmd.Flags().GetString(cli.FlagIDEndpoint) - endpoint, err = cli.ResolveAPIEndpoint(env) + endpoint, err = cli.ResolveAPIEndpoint(cfg.Env) if err != nil { return err } diff --git a/internal/cmd/productctl/cmd/fetch/fetch_test.go b/internal/cmd/productctl/cmd/fetch/fetch_test.go index 3785f62..4861d6b 100644 --- a/internal/cmd/productctl/cmd/fetch/fetch_test.go +++ b/internal/cmd/productctl/cmd/fetch/fetch_test.go @@ -7,7 +7,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/opdev/productctl/internal/cli" "github.com/opdev/productctl/internal/cmd/productctl/cmd" "github.com/opdev/productctl/internal/cmd/productctl/cmd/testutils" ) @@ -38,18 +37,16 @@ var _ = Describe("Fetch", func() { It("should fail if the minimum environment variables are not set", func() { output, err := testutils.ExecuteCommand(cmd.RootCmd(), "product", "fetch", listingID, "--custom-endpoint", "http://localhost:9630") Expect(err).To(HaveOccurred()) - Expect(output).To(ContainSubstring(cli.ErrEnvVarMissing.Error())) + Expect(output).To(ContainSubstring(cmd.ErrMinOneAPITokenConfig.Error())) }) When("the appropriate environment variables are in place", func() { BeforeEach(func() { - os.Setenv(cli.EnvAPIToken, "foo") - os.Setenv(cli.EnvOrgID, "123") + os.Setenv("PRODUCTCTL_API_TOKEN", "foo") }) AfterEach(func() { - os.Setenv(cli.EnvAPIToken, "") - os.Setenv(cli.EnvOrgID, "") + os.Setenv("PRODUCTCTL_API_TOKEN", "") }) It("should reach the apply phase, then fail", func() {