From db08934edda438224210c03f7499ee6bd769c028 Mon Sep 17 00:00:00 2001 From: buzzy Date: Wed, 14 Sep 2022 01:28:56 +0800 Subject: [PATCH] Initial commit --- .github/workflows/build.yml | 34 +++ .gitignore | 1 + LICENSE | 201 +++++++++++++ README.md | 359 +++++++++++++++++++++++ build-all.sh | 30 ++ command.go | 570 ++++++++++++++++++++++++++++++++++++ configure.go | 51 ++++ go.mod | 19 ++ go.sum | 45 +++ help.go | 341 +++++++++++++++++++++ main.go | 248 ++++++++++++++++ version.go | 9 + 12 files changed, 1908 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build-all.sh create mode 100644 command.go create mode 100644 configure.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 help.go create mode 100644 main.go create mode 100644 version.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..aad34a2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: Build CLI + +on: + push: + branches: + - main + + workflow_dispatch: + +jobs: + compile: + name: Compile + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-go@v3 + with: + go-version: '>=1.18.0' + + - name: Compile + run: | + VERSION="0.$(git rev-list --count HEAD).0" + echo "version=$VERSION" >> $GITHUB_ENV + sed -i "s/0.0.0/${VERSION}/" main.go + bash build-all.sh $VERSION + + - name: Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create v${{ env.version }} --generate-notes ./bin/*.zip ./bin/*_SHA256SUMS diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dd29b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d54c906 --- /dev/null +++ b/README.md @@ -0,0 +1,359 @@ +"scalr" is a command-line tool that communicates directly with the Scalr API. + +## Beta Software +Please note that this is currently beta software and might not work as expected at all times. No guarantees are given. Use at your own risk. +If you find something that is broken, please run it again with the "-verbose" flag and post the output as an issue. + +## Installation + +Installing the Scalr cli tool is very straightforward as it's distributed as a single static binary. Simply download the zipped binary from the releases section that corresponds to your architecture and (preferably) unpack it somewhere in your PATH. + +## Basic usage +``` +user@server ~$ scalr + +Usage: scalr [OPTION] COMMAND [FLAGS] + + 'scalr' is a command-line interface tool that communicates directly with the Scalr API + +Examples: + $ scalr -help + $ scalr -help get-workspaces + $ scalr get-foo-bar -flag=value + $ scalr -verbose create-foo-bar -flag=value -flag2=value2 + $ scalr create-foo-bar < json-blob.txt + +Environment variables: + SCALR_HOSTNAME Scalr Hostname, i.e example.scalr.io + SCALR_TOKEN Scalr API Token + +Options: + -version Shows current version of this binary + -help Shows documentation for all (or specified) command(s) + -verbose Shows complete request and response communication data + -configure Run configuration wizard +``` + +## Configure: +Before the CLI can be used, you will need to configure what Scalr URL and Token to use. +This can be done using environment variables (SCALR_HOSTNAME and SCALR_TOKEN) or a configuration file. +Run the CLI with the -configure flag to run the configuration wizard. + +``` +user@server ~$ scalr -configure +Scalr Hostname [ex: example.scalr.io]: example.scalr.io +Scalr Token (not echoed!): +Configuration saved in /home/user/.scalr/scalr.conf +``` + +## List available flags for a specific command: +``` +user@server ~$ scalr -help create-environment + +Usage: scalr create-environment [FLAGS] [< json-blob.txt] + + Create a new environment in the account. + + Environment's are collections of related workspaces that correspond to functional areas, SDLC stages, + projects or any grouping that is required. + + An account can have multiple environments. + + Workspaces within an environment are where Terraform configurations are run to deploy infrastructure, + and where state files are stored. + + An Environment can have set of policy groups assigned that are applied to all workspaces in the environment. + The Environment can also have variables, credentials, registry modules, and VCS providers + that are available to every workspace. + +Flags: + -account-id=STRING The account that owns this environment. [*required] + -cloud-credentials-id=STRING + -cost-estimation-enabled=BOOLEAN Indicates if the cost estimation should be performed for `runs` in the environment. + -default-provider-configurations-id=STRING Provider configurations used in the environment workspaces by default. + -name=STRING The name of the environment. [*required] + -policy-groups-id=STRING +``` + +For commands that CREATES or UPDATES something, you can chose to set the values using flags (-flag=value) OR use a raw JSON blob +as you would do when communicating directly with the Scalr API. + +## Example using flags: +``` +user@server ~$ scalr create-environment -name=development -account-id=acc-t2fcrq6h1v3nf0g +``` + +## Examples using JSON blob: +``` +user@server ~$ scalr create-environment < json-blob.txt + +user@server ~$ echo ' +> { +> "data": { +> "attributes": { +> "name": "development" +> }, +> "relationships": { +> "account": { +> "data": { +> "id": "acc-t2fcrq6h1v3nf0g", +> "type": "accounts" +> } +> } +> }, +> "type": "environments" +> } +> } +> ' | scalr create-environment +``` + +## List required flags: + +The -help output will tell you which flags are required. However, it can be hard to find them if the flag list is long. +Simply running the command without flags will tell you which required flags have missing values. + +``` +user@server ~$ scalr lock-workspace +Missing required flag(s): [workspace] +``` + +## List available commands: +``` +user@server ~$ scalr -help + +Access Policy: + create-access-policy Create an Access Policy + delete-access-policy Delete Access Policy + get-access-policies List Access Policies + get-access-policy Get an Access Policy + update-access-policy Update an Access Policy + +Access Token: + create-access-token Create an Access Token + create-agent-pool-token Create an Agent Pool Access Token + delete-access-token Delete an Access Token + get-access-token Get an Access Token + list-agent-pool-access-tokens List Agent Pool Access Tokens + update-access-token Update an Access Token + +Account: + get-account Get an Account + update-account Update Account + +Account Blob Settings: + delete-account-blob-settings Delete Blob Settings + get-account-blob-settings Get Blob Settings + replace-account-blob-settings Replace Blob Settings + update-account-blob-settings Update Blob Settings + +Agent: + delete-agent Delete an Agent + get-agent Get an Agent + get-agents List Agents + +Agent Pool: + create-agent-pool Create an Agent Pool + delete-agent-pool Delete an Agent Pool + get-agent-pool Get an Agent Pool + get-agent-pools List Agent Pools + update-agent-pool Update an Agent Pool + +Apply: + get-apply Get an Apply + get-apply-log Apply Log + +Configuration Version: + create-configuration-version Create a Configuration Version + download-configuration-version Download Configuration Version + get-configuration-version Get a Configuration Version + get-configuration-versions List Configuration Versions + +Cost Estimate: + get-cost-estimate Get a Cost Estimate + get-cost-estimate-breakdown Cost breakdown JSON output + get-cost-estimate-log Cost Estimate log + +Endpoint: + create-endpoint Create an Endpoint + delete-endpoint Delete an Endpoint + get-endpoint Get an Endpoint + list-endpoints List Endpoints + update-endpoint Update Endpoint + +Environment: + create-environment Create an Environment + delete-environment Delete an Environment + get-environment Get an Environment + list-environments List Environments + update-environment Update Environment + +Event Definition: + list-event-definitions List Event Definitions + +Module: + create-module Publish a Module + delete-module Unpublish a Module + get-module Get a Module + list-modules List Modules + resync-module Resync a Module + resync-module-version Resync a Module Version + +Module Version: + get-module-version Get a Module Version + list-module-versions List Module Versions + +Permission: + get-permission Get a Permission + get-permissions List Permissions + +Ping: + ping Ping + +Plan: + get-json-output JSON Output + get-plan Get a Plan + get-plan-log Plan Log + get-sanitized-json-output Sanitized JSON Output + +Policy: + get-policy Get a Policy + +Policy Check: + get-policy-check Get a Policy Check + get-policy-checks-log Policy Check Log + list-policy-checks List Policy Checks + override-policy Override Policy + +Policy Group: + create-policy-group Create a Policy Group + create-policy-group-environments Create policy group environments relationships + delete-policy-group Delete a Policy Group + delete-policy-group-environments Delete policy group's environment relationship + get-policy-group Get a Policy Group + list-policy-groups List Policy Groups + update-policy-group Update a Policy Group + update-policy-group-environments Update policy group environments relationships + +Provider Configuration: + create-provider-configuration Create a Provider configuration + delete-provider-configuration Delete a Provider configuration + get-provider-configuration Get a Provider configuration + list-provider-configurations List Provider configurations + update-provider-configuration Update a Provider configuration + +Provider Configuration Link: + create-provider-configuration-link Attach a Provider configuration to the workspace + delete-provider-configuration-workspace-link Delete a Provider configuration workspace link + get-provider-configuration-link Get a Provider configuration link + list-provider-configuration-links List Provider configuration workspace links + update-provider-configuration-link Update a Provider configuration link + +Provider Configuration Parameter: + create-provider-configuration-parameter Create a Provider configuration parameter + delete-provider-configuration-parameter Delete a Provider configuration parameter + get-provider-configuration-parameter Get a Provider configuration parameter + list-provider-configuration-parameters List Provider configuration parameters for specific provider configurations + update-provider-configuration-parameter Update a Provider configuration parameter + +Role: + create-role Create a Role + delete-role Delete a Role + get-role Get a Role + get-roles List Roles + update-role Update a Role + +Run: + cancel-run Cancel a Run + confirm-run Apply a Run + create-run Create a Run + discard-run Discard a Run + download-policy-input Download a Policy Input + get-run Get a Run + get-runs List Runs + get-runs-queue List Runs Queue + +Run Trigger: + create-run-trigger Create a Run Trigger. + delete-run-trigger Delete a Run Trigger + get-run-trigger Get a Run Trigger + +Service Account: + create-service-account Create a Service Account + delete-service-account Delete a Service Account + get-service-account Get a Service Account + get-service-accounts List Service Accounts + update-service-account Update a Service Account + +State Version: + get-current-state-version Get Workspace's Current State Version + get-state-version Get a State Version + get-state-version-download Download State Version + list-state-versions List Workspace's State Versions + +Tag: + create-tag Create a Tag + delete-tag Delete a Tag + get-tag Get a Tag + list-tags List Tags + update-tag Update a Tag + +Team: + create-team Create a Team + delete-team Delete a Team + get-team Get a Team + get-teams List Teams + update-team Update a Team + +Usage Statistic: + list-usage-statistics List Scalr Usage Statistics + +User: + create-user Create a User + delete-user Delete a User + get-account-users List Account to User relationships + get-user Get a User + get-users List Users + invite-user-to-account Invite a User to the Account + remove-user-from-account Remove a User from the Account + update-user Update a User + +Variable: + create-variable Create a Variable + delete-variable Delete a Variable + get-variable Get a Variable + get-variables List Variables + update-variable Update a Variable + +Vcs Provider: + create-vcs-provider Create a VCS Provider + delete-vcs-provider Delete a VCS Provider + get-vcs-provider Get a VCS Provider + list-vcs-providers List VCS Providers + update-vcs-provider Update a VCS Provider + +Webhook: + create-webhook Create Webhook + delete-webhook Delete a Webhook + get-webhook Get a Webhook + list-webhooks List Webhooks + update-webhook Update Webhook + +Workspace: + add-remote-state-consumers Add remote state consumers + add-workspace-tags Add tags to the workspace + create-workspace Create a Workspace + delete-remote-state-consumers Delete remote state consumers + delete-workspace Delete a Workspace + delete-workspace-tags Delete workspace's tags + get-workspace Get a Workspace + get-workspaces List Workspaces + list-remote-state-consumers List remote state consumers + list-workspace-tags List workspace's tags + lock-workspace Lock a Workspace + replace-remote-state-consumers Replace remote state consumers + replace-workspace-tags Replace workspace's tags + resync-workspace Resync a Workspace + set-schedule Set scheduled runs for the workspace + unlock-workspace Unlock a Workspace + update-workspace Update a Workspace +``` diff --git a/build-all.sh b/build-all.sh new file mode 100644 index 0000000..93fb45e --- /dev/null +++ b/build-all.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +VERSION=${1:-0.0.0} + +declare -a os=("linux" "windows" "darwin") +declare -a arch=("386" "amd64" "arm" "arm64") + +for (( j=0; j<${#os[@]}; j++ )); +do + GOOS="${os[$j]}" + for (( i=0; i<${#arch[@]}; i++ )); + do + GOARCH="${arch[$i]}" + EXT="" + + if [ $GOOS == "windows" ]; then + EXT=".exe" + fi + + BINARY="scalr-cli_${VERSION}_${GOOS}_${GOARCH}${EXT}" + go build -ldflags="-s -w" -o bin/$BINARY . + cd bin + chmod +x $BINARY + PACKAGE="scalr-cli_${VERSION}_${GOOS}_${GOARCH}.zip" + zip -9 $PACKAGE $BINARY + sha256sum $PACKAGE >> "scalr-cli_${VERSION}_SHA256SUMS" + cd .. + done +done diff --git a/command.go b/command.go new file mode 100644 index 0000000..5b78114 --- /dev/null +++ b/command.go @@ -0,0 +1,570 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + "github.com/Jeffail/gabs/v2" + "github.com/getkin/kin-openapi/openapi3" +) + +type Parameter struct { + varType string + description string + required bool + enum []any + location string + value *string +} + +func parseCommand(format string, verbose bool) { + + doc := loadAPI() + + command := flag.Arg(0) + + for uri, path := range doc.Paths { + for method, action := range path.Operations() { + if strings.ReplaceAll(action.OperationID, "_", "-") != command { + continue + } + + //Found command, setup flags + subFlag := flag.NewFlagSet(command, flag.ExitOnError) + + //Disable unwanted built-in flag features + subFlag.Usage = func() {} + + query := url.Values{} + + contentType := "" + + //Will hold all valid flag values + flags := make(map[string]Parameter) + + //Collect all valid URI flags for this command + for _, parameter := range action.Parameters { + + //Ignore paging flags + if parameter.Value.Name == "page[number]" || parameter.Value.Name == "page[size]" { + continue + } + + if parameter.Value.Schema.Value.Type == "string" || + parameter.Value.Schema.Value.Type == "boolean" || + parameter.Value.Schema.Value.Type == "integer" || + parameter.Value.Schema.Value.Type == "array" { + + flags[parameter.Value.Name] = Parameter{ + location: parameter.Value.In, + varType: "string", + required: parameter.Value.Required, + value: new(string), + } + + subFlag.StringVar(flags[parameter.Value.Name].value, parameter.Value.Name, "", parameter.Value.Description) + + continue + } + + //TODO: If code reaches here, means support for new field-type is needed! + fmt.Println("IGNORE UNSUPPORTED FIELD", parameter.Value.Name, parameter.Value.Schema.Value.Type) + + } + + var requiredFlags map[string]bool + + if method == "POST" || method == "PATCH" { + + for contentType = range action.RequestBody.Value.Content { + } + + //Recursively collect all required fields + requiredFlags = collectRequired(action.RequestBody.Value.Content[contentType].Schema.Value) + + //fmt.Printf("%+#v", requiredFlags) + //os.Exit(0) + + var collectAttributes func(*openapi3.Schema, string) + + //Function to support nested objects + collectAttributes = func(nested *openapi3.Schema, prefix string) { + + //Collect all availble attributes for this command + for name, attribute := range nested.Properties { + + //Ignore read-only attributes in body + if attribute.Value.ReadOnly { + continue + } + + flagName := prefix + name + + //Ignore ID-field that is redundant + if flagName == "data-id" { + continue + } + + //Nested object, needs to drill down deeper + if attribute.Value.Type == "object" { + collectAttributes(attribute.Value, flagName+"-") + continue + } + + //Arrays might include objects that needs to be drilled down deeper + if attribute.Value.Type == "array" && attribute.Value.Items.Value.Type == "object" { + collectAttributes(attribute.Value.Items.Value, flagName+"-") + continue + } + + required := false + if requiredFlags[flagName] { + required = true + } + + //If flag is required and only one value is available, no need to offer it to the user + if required && attribute.Value.Enum != nil && len(attribute.Value.Enum) == 1 { + continue + } + + flagName = shortenName(flagName) + + flags[flagName] = Parameter{ + location: "body", + required: required, + enum: attribute.Value.Enum, + value: new(string), + } + + subFlag.StringVar(flags[flagName].value, flagName, "", attribute.Value.Description) + + } + + } + + collectAttributes(action.RequestBody.Value.Content[contentType].Schema.Value, "") + + } + + //Find command possition in args + pos := 1 + for index, arg := range os.Args { + if arg == command { + pos = index + break + } + } + + //Validate all flags + subFlag.Parse(os.Args[pos+1:]) + + var missing []string + var missingBody []string + + //Sort flag values to correct locations + for name, parameter := range flags { + + //Ignore empty flags.. + if *parameter.value == "" { + + //..Unless required + if parameter.required { + if parameter.location == "query" || parameter.location == "path" { + missing = append(missing, name) + } else { + missingBody = append(missingBody, name) + } + + } + + continue + } + + switch parameter.location { + case "query": + //This flag value should be sent as a query parameter + query.Add(name, *parameter.value) + + case "path": + //This flag value goes in the URI + uri = strings.Replace(uri, "{"+name+"}", *parameter.value, 1) + } + + } + + var body string + + if method == "POST" || method == "PATCH" { + + //If stdin contains data, use that as Body + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + + if len(missing) > 0 { + fmt.Printf("Missing required flag(s): %s\n", missing) + os.Exit(1) + } + + var stdin []byte + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + stdin = append(stdin, scanner.Bytes()...) + } + if err := scanner.Err(); err != nil { + log.Fatal(err) + } + body = string(stdin) + + } else { + + if len(missingBody) > 0 || len(missing) > 0 { + fmt.Printf("Missing required flag(s): %s\n", append(missing, missingBody...)) + os.Exit(1) + } + + raw := gabs.New() + + var collectAttributes func(*openapi3.Schema, string) + + //Function to support nested objects + collectAttributes = func(nested *openapi3.Schema, prefix string) { + + //Collect all availble attributes for this command + for name, attribute := range nested.Properties { + + //Ignore read-only attributes in body + if attribute.Value.ReadOnly { + continue + } + + path := prefix + name + + //Nested object, needs to drill down deeper + if attribute.Value.Type == "object" { + collectAttributes(attribute.Value, path+".") + continue + } + + //Special case for arrays of objects used in relationships + if attribute.Value.Type == "array" && attribute.Value.Items.Value.Type == "object" { + path = path + ".id" + attribute.Value.Type = "relationship" + } + + flagName := strings.ReplaceAll(path, ".", "-") + + required := false + if requiredFlags[flagName] { + required = true + } + + //Special case to auto-add type in relationships if ID is set + if strings.HasPrefix(flagName, "data-relationships-") && name == "type" { + id := strings.Replace(shortenName(flagName), "-data-type", "-id", 1) + + if *flags[id].value != "" { + required = true + } + } + + //If required and only one value is available, use it + if required && attribute.Value.Enum != nil && len(attribute.Value.Enum) == 1 { + raw.SetP(attribute.Value.Enum[0], path) + continue + } + + flagName = shortenName(flagName) + + if _, ok := flags[flagName]; !ok { + continue + } + + value := *flags[flagName].value + + //Skip attribute if not set + if value == "" { + continue + } + + switch attribute.Value.Type { + case "relationship": + //Special case for arrays in relationships + for _, item := range strings.Split(value, ",") { + sub := gabs.New() + sub.Set(item, "id") + sub.Set(attribute.Value.Items.Value.Properties["type"].Value.Enum[0], "type") + + raw.ArrayAppendP(sub.Data(), strings.TrimSuffix(path, ".id")) + } + + case "boolean": + val, _ := strconv.ParseBool(value) + raw.SetP(val, path) + + case "string": + raw.SetP(value, path) + + case "integer": + val, _ := strconv.Atoi(value) + raw.SetP(val, path) + + case "array": + raw.SetP(strings.Split(value, ","), path) + + default: + //TODO: If code reaches here, means we need to add support for more field types! + fmt.Println("IGNORE UNSUPPORTED FIELD", name, attribute.Value.Type) + } + + } + + } + + collectAttributes(action.RequestBody.Value.Content[contentType].Schema.Value, "") + + body = raw.StringIndent("", " ") + } + + } else { + if len(missing) > 0 { + fmt.Printf("Missing required flag(s): %s\n", missing) + os.Exit(1) + } + } + + //Make request to the API + callAPI(method, uri, query, body, contentType, verbose, format) + + return + } + } + + //Command not found + fmt.Printf("\nCommand '%s' not found. Use -help to list available commands.\n\n", command) +} + +//Helper function to shorter flag-names for convenience +func shortenName(flagName string) string { + + //If this is an attribute, strip prefix to shorten flag-names + flagName = strings.TrimPrefix(flagName, "data-attributes-") + + //If this is a relationship, strip prefix and -data- to shorten flag-names + if strings.HasPrefix(flagName, "data-relationships-") { + flagName = strings.TrimPrefix(flagName, "data-relationships-") + flagName = strings.Replace(flagName, "-data-id", "-id", 1) + } + + return flagName +} + +//Make a request to the Scalr API +func callAPI(method string, uri string, query url.Values, body string, contentType string, verbose bool, format string) { + + output := gabs.New() + output.Array() + + query.Add("page[size]", "100") + + for page := 1; true; page++ { + + query.Set("page[number]", strconv.Itoa(page)) + + if verbose { + fmt.Println(method, "https://"+ScalrHostname+uri+"?"+query.Encode()) + + if contentType != "" { + fmt.Println("Content-Type = " + contentType) + fmt.Println(body) + } + + } + + req, err := http.NewRequest(method, "https://"+ScalrHostname+uri+"?"+query.Encode(), strings.NewReader(body)) + if err != nil { + fmt.Printf("client: could not create request: %s\n", err) + os.Exit(1) + } + + req.Header.Set("User-Agent", "scalr-cli/"+versionCLI) + req.Header.Add("Authorization", "Bearer "+ScalrToken) + req.Header.Add("Prefer", "profile=preview") + + if contentType != "" { + req.Header.Add("Content-Type", contentType) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("client: error making http request: %s\n", err) + os.Exit(1) + } + + resBody, err := ioutil.ReadAll(res.Body) + if err != nil { + fmt.Printf("client: could not read response body: %s\n", err) + os.Exit(1) + } + + if verbose { + //Show raw server response + fmt.Println(string(resBody)) + } + + switch res.StatusCode { + case 400: + showError(resBody) + + case 404: + showError(resBody) + } + + //Empty response, quit early + if len(resBody) == 0 { + return + } + + //Check if paging is needed + response, err := gabs.ParseJSON(resBody) + if err != nil { + panic(err) + } + + //If not a JSON:API response, just rend it raw + if res.Header.Get("content-type") != "application/vnd.api+json" { + output = response + break + } + + arrayResponse := response.Exists("data", "0") + + newItems := parseData(response) + + //If this is a single item response, return it instead of an array + if !arrayResponse { + output = newItems.Search("0") + break + } + + for _, data := range newItems.Children() { + output.ArrayAppend(data) + } + + if response.Path("meta.pagination.next-page").Data() == nil { + break + } + + } + + //TODO: Add different outputs, such as YAML, CSV and TABLE + //formatJSON(resBody) + fmt.Println(output.StringIndent("", " ")) + +} + +//Parse error response and show it to user +func showError(resBody []byte) { + + jsonParsed, _ := gabs.ParseJSON(resBody) + + fmt.Println(jsonParsed.StringIndent("", " ")) + os.Exit(1) +} + +//Data JSON:API data to make it easier to work with +func parseData(response *gabs.Container) *gabs.Container { + + output := gabs.New() + output.Array() + + //Convert non-array to array if needed + if !response.Exists("data", "0") { + item := response.Path("data").Data() + response.Array("data") + response.ArrayAppend(item, "data") + } + + included := gabs.New() + + for _, include := range response.Path("included").Children() { + included.Set(include.Data(), include.Path("type").Data().(string)+"-"+include.Path("id").Data().(string)) + } + + for _, value := range response.Path("data").Children() { + + sub := gabs.New() + + sub.Set(value.Search("attributes").Data()) + sub.SetP(value.Search("id"), "id") + sub.SetP(value.Search("type"), "type") + + for name, relationship := range value.Search("relationships").ChildrenMap() { + + if relationship.Data() == nil { + continue + } + + //Function to support relationship arrays + //TODO: Should probably move this outside of the loop for performance reason, but will make code less readable + var connectRelationship = func(rel *gabs.Container) *gabs.Container { + + relId := rel.Path("type").Data().(string) + "-" + rel.Path("id").Data().(string) + + if included.Exists(relId) { + + addition := gabs.New() + + //Include attributes + addition.Set(included.Search(relId, "attributes").Data()) + + //Include ID and type + addition.Set(rel.Path("id"), "id") + addition.Set(rel.Path("type"), "type") + + //Include sub-relationship IDs + //TODO: Does this need support for arrays in sub-relationships? Probably... + for subName, subRelationship := range included.Search(relId, "relationships").ChildrenMap() { + + if subRelationship.Data() == nil { + continue + } + + addition.Set(subRelationship.Path("data.id"), subName+"-id") + } + + return addition + } + + return rel + + } + + if !relationship.ExistsP("data.id") { + //Assume this is an array + sub.ArrayP(name) + + for _, value := range relationship.Path("data").Children() { + sub.ArrayAppend(connectRelationship(value), name) + } + continue + } + + sub.SetP(connectRelationship(relationship.Path("data")), name) + + } + + output.ArrayAppend(sub) + + } + + return output + +} diff --git a/configure.go b/configure.go new file mode 100644 index 0000000..42e0fbb --- /dev/null +++ b/configure.go @@ -0,0 +1,51 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "syscall" + + "github.com/Jeffail/gabs/v2" + "golang.org/x/term" +) + +func runConfigure() { + + conf := gabs.New() + + scanner := bufio.NewScanner(os.Stdin) + fmt.Print("Scalr Hostname [ex: example.scalr.io]: ") + scanner.Scan() + conf.Set(scanner.Text(), "hostname") + + fmt.Print("Scalr Token (not echoed!): ") + bytepw, _ := term.ReadPassword(int(syscall.Stdin)) + conf.Set(string(bytepw), "token") + + home, err := os.UserHomeDir() + if err != nil { + log.Fatal(err) + } + + home = home + "/.scalr/" + config := "scalr.conf" + + if _, err := os.Stat(home); os.IsNotExist(err) { + os.MkdirAll(home, 0700) + } + + //Create a empty file + file, err := os.OpenFile(home+config, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + panic(err) + } + defer file.Close() + + fmt.Println("\nConfiguration saved in " + home + config) + + file.WriteString(conf.StringIndent("", " ") + "\n") + file.Sync() + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e19b898 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module scalr.io/cli + +go 1.18 + +require ( + github.com/Jeffail/gabs/v2 v2.6.1 + github.com/getkin/kin-openapi v0.98.0 + golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 +) + +require ( + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/swag v0.22.1 // indirect + github.com/invopop/yaml v0.2.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dca8c88 --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/Jeffail/gabs/v2 v2.6.1 h1:wwbE6nTQTwIMsMxzi6XFQQYRZ6wDc1mSdxoAN+9U4Gk= +github.com/Jeffail/gabs/v2 v2.6.1/go.mod h1:xCn81vdHKxFUuWWAaD5jCTQDNPBMh5pPs9IJ+NcziBI= +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/getkin/kin-openapi v0.98.0 h1:lIACvCG9cxmFsEywz+LCoVhcZHFLUy+Nv5QSkb43eAE= +github.com/getkin/kin-openapi v0.98.0/go.mod h1:w4lRPHiyOdwGbOkLIyk+P0qCwlu7TXPCHD/64nSXzgE= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.22.1 h1:S6xFhsBKAtvfphnJwRzeCh3OEGsTL/crXdEetSxLs0Q= +github.com/go-openapi/swag v0.22.1/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +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/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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc= +golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0/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/help.go b/help.go new file mode 100644 index 0000000..1c248e2 --- /dev/null +++ b/help.go @@ -0,0 +1,341 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "regexp" + "sort" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +func printInfo() { + fmt.Print("\n", "Usage: scalr [OPTION] COMMAND [FLAGS]", "\n\n") + + fmt.Print(" 'scalr' is a command-line interface tool that communicates directly with the Scalr API", "\n\n") + + fmt.Print("Examples:", "\n") + fmt.Print(" $ scalr -help", "\n") + fmt.Print(" $ scalr -help get-workspaces", "\n") + fmt.Print(" $ scalr get-foo-bar -flag=value", "\n") + fmt.Print(" $ scalr -verbose create-foo-bar -flag=value -flag2=value2", "\n") + fmt.Print(" $ scalr create-foo-bar < json-blob.txt", "\n\n") + + fmt.Print("Environment variables:", "\n") + fmt.Print(" SCALR_HOSTNAME", " ", "Scalr Hostname, i.e example.scalr.io", "\n") + fmt.Print(" SCALR_TOKEN", " ", "Scalr API Token", "\n\n") + + fmt.Print("Options:", "\n") + fmt.Print(" -version", " ", "Shows current version of this binary", "\n") + fmt.Print(" -help", " ", "Shows documentation for all (or specified) command(s)", "\n") + fmt.Print(" -verbose", " ", "Shows complete request and response communication data", "\n") + fmt.Print(" -configure", " ", "Run configuration wizard", "\n\n") + //fmt.Print(" -format=STRING", " ", "Specify output format. Options: json (default), table", "\n") + +} + +//Prints CLI help +func printHelp() { + + //Help for specified command + if flag.Arg(0) != "" { + printHelpCommand(flag.Arg(0)) + return + } + + //Load OpenAPI specification + doc := loadAPI() + + groups := make(map[string]map[string]string) + + for _, path := range doc.Paths { + for _, method := range path.Operations() { + + var group string + + json.Unmarshal(method.ExtensionProps.Extensions["x-resource"].(json.RawMessage), &group) + + //Fallback to Tag if x-resource group is missing + if group == "" { + group = strings.Title(method.Tags[0]) + } + + //Add a space before each uppercase letter + group = strings.TrimPrefix(string(regexp.MustCompile(`([A-Z])`).ReplaceAll([]byte(group), []byte(" $1"))), " ") + + //If group does not exist, add to map + if groups[group] == nil { + groups[group] = make(map[string]string) + } + + groups[group][strings.ReplaceAll(method.OperationID, "_", "-")] = method.Summary + + } + } + + //Create a sorted array with group names + sortedGroups := make([]string, 0, len(groups)) + for group := range groups { + sortedGroups = append(sortedGroups, group) + } + sort.Strings(sortedGroups) + + for _, group := range sortedGroups { + fmt.Println("\n" + group + ":") + + //Create a sorted array with commands + sortedCommands := make([]string, 0, len(groups[group])) + maxLength := 0 + for command := range groups[group] { + sortedCommands = append(sortedCommands, command) + + if len(command) <= maxLength { + continue + } + + maxLength = len(command) + } + sort.Strings(sortedCommands) + + for _, command := range sortedCommands { + fmt.Println(" ", command, strings.Repeat(" ", maxLength-len(command)), groups[group][command]) + } + } + +} + +func printHelpCommand(command string) { + + //Load OpenAPI specification + doc := loadAPI() + + for _, path := range doc.Paths { + for _, object := range path.Operations() { + + if command != strings.ReplaceAll(object.OperationID, "_", "-") { + continue + } + + flags := make(map[string]Parameter) + + for _, parameter := range object.Parameters { + + //Ignore paging parameters + if parameter.Value.Name == "page[number]" || parameter.Value.Name == "page[size]" { + continue + } + + //Collect valid flag values + var enum []any + if parameter.Value.Schema.Value.Items != nil { + enum = parameter.Value.Schema.Value.Items.Value.Enum + } + + flags[parameter.Value.Name] = Parameter{ + varType: parameter.Value.Schema.Value.Type, + description: parameter.Value.Description, + required: parameter.Value.Required, + enum: enum, + } + + } + + if object.RequestBody == nil { + fmt.Printf("\nUsage: scalr [OPTION] %s [FLAGS]\n\n", command) + } else { + + //This command requires a body + fmt.Printf("\nUsage: scalr [OPTION] %s [FLAGS] [< json-blob.txt]\n\n", command) + + //Get contentType of this command + var contentType string + for contentType = range object.RequestBody.Value.Content { + } + + //Recursively collect all required fields + requiredFlags := collectRequired(object.RequestBody.Value.Content[contentType].Schema.Value) + + relationshipDesc := make(map[string]string) + + var collectAttributes func(*openapi3.Schema, string, string) + + //Function to support nested objects + //TODO: Should probably move this outside of the loop for performance reason, but will make code less readable + collectAttributes = func(nested *openapi3.Schema, prefix string, inheritType string) { + + //Collect all availble attributes for this command + for name, attribute := range nested.Properties { + + //Special collection of descriptions for relationships + if name == "relationships" { + for rel, desc := range attribute.Value.Properties { + relationshipDesc[rel+"-id"] = desc.Value.Description + } + } + + //Ignore read-only attributes in body + if attribute.Value.ReadOnly { + continue + } + + flagName := prefix + name + + //Ignore ID-field that is redundant + if flagName == "data-id" { + continue + } + + //Nested object, needs to drill down deeper + if attribute.Value.Type == "object" { + collectAttributes(attribute.Value, flagName+"-", "") + continue + } + + //Arrays might include objects that needs to be drilled down deeper + if attribute.Value.Type == "array" && attribute.Value.Items.Value.Type == "object" { + collectAttributes(attribute.Value.Items.Value, flagName+"-", "array") + continue + } + + required := false + if requiredFlags[flagName] { + required = true + } + + //If flag is required and only one value is available, no need to offer it to the user + if required && attribute.Value.Enum != nil && len(attribute.Value.Enum) == 1 { + continue + } + + description := attribute.Value.Description + + //If this is an attribute, strip prefix to shorten flag-names + flagName = strings.TrimPrefix(flagName, "data-attributes-") + + //If this is a relationship, strip prefix and -data- to shorten flag-names + if strings.HasPrefix(flagName, "data-relationships-") { + + //If this is not the relationship ID field, ignore it + if !strings.HasSuffix(flagName, "-id") { + continue + } + + flagName = strings.TrimPrefix(flagName, "data-relationships-") + flagName = strings.Replace(flagName, "-data-id", "-id", 1) + + //Fetch description from parent instead + description = relationshipDesc[flagName] + } + + theType := attribute.Value.Type + if inheritType != "" { + theType = inheritType + } + + flags[flagName] = Parameter{ + varType: theType, + description: description, + required: required, + enum: attribute.Value.Enum, + } + + } + + } + + collectAttributes(object.RequestBody.Value.Content[contentType].Schema.Value, "", "") + + } + + var description string + if object.Description != "" { + description = object.Description + } else if object.Summary != "" { + description = object.Summary + } + + fmt.Print(" ", strings.ReplaceAll(strings.TrimSpace(description), "\n", "\n "), "\n") + + if len(flags) > 0 { + + fmt.Print("\nFlags:", "\n") + + //Create a sorted array with flags + sortedFlags := make([]string, 0, len(flags)) + maxLength := 0 + for flg := range flags { + sortedFlags = append(sortedFlags, flg) + + completeLength := len(flg + "=" + flags[flg].varType) + + if completeLength <= maxLength { + continue + } + + maxLength = completeLength + } + sort.Strings(sortedFlags) + + for _, flg := range sortedFlags { + + varType := strings.ToUpper(flags[flg].varType) + if varType == "ARRAY" { + varType = "LIST" + } + + complete := "-" + flg + "=" + varType + + //TODO: IF DESCRIPTION INCLUDES LINK, CONVERT IT TO A HTTP LINK TO THE DOCS + description := strings.ReplaceAll(flags[flg].description, "\n", " ") + + if flags[flg].required { + description = description + colorRed + " [*required]" + colorReset + } + + fmt.Println(" ", complete, strings.Repeat(" ", maxLength-len(complete)+1), description) + + if flags[flg].enum != nil { + + options := make([]string, len(flags[flg].enum)) + + for index, value := range flags[flg].enum { + options[index] = value.(string) + } + + fmt.Println(colorBlue, strings.Repeat(" ", maxLength+3), "[", strings.Join(options, ", "), "]", colorReset) + } + } + } + + /* + //Probably better to add a flag to show examples? + + if object.RequestBody != nil && object.RequestBody.Value.Content["application/vnd.api+json"].Examples["default"] != nil { + + fmt.Print("\njson-blob.txt example:", "\n") + + var data map[string]any + //TODO: Loop through and show ALL examples, not only default! + err := json.Unmarshal([]byte(object.RequestBody.Value.Content["application/vnd.api+json"].Examples["default"].Value.Value.(string)), &data) + if err != nil { + panic(err) + } + + example, _ := json.MarshalIndent(data, "", " ") + exampleIndented := " " + strings.ReplaceAll(string(example), "\n", "\n ") + + fmt.Println(exampleIndented) + } + */ + + fmt.Println("") + + return + } + } + + fmt.Printf("\nCommand '%s' not found. Use -help to list available commands.\n\n", command) + +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d5f9c44 --- /dev/null +++ b/main.go @@ -0,0 +1,248 @@ +package main + +import ( + "crypto/md5" + "errors" + "flag" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/Jeffail/gabs/v2" + "github.com/getkin/kin-openapi/openapi3" +) + +var ( + ScalrHostname string + ScalrToken string +) + +const ( + versionCLI = "0.0.0" + + colorReset = "\033[0m" + + colorRed = "\033[31m" + //colorGreen = "\033[32m" + //colorYellow = "\033[33m" + colorBlue = "\033[34m" + //colorPurple = "\033[35m" + //colorCyan = "\033[36m" + //colorWhite = "\033[37m" +) + +func main() { + + if len(os.Args[1:]) == 0 { + printInfo() + return + } + + //Disable unwanted built-in flag features + flag.Usage = func() {} + flag.Bool("h", false, "") + + help := flag.Bool("help", false, "") + configure := flag.Bool("configure", false, "") + verbose := flag.Bool("verbose", false, "") + version := flag.Bool("version", false, "") + format := flag.String("format", "json", "") + + flag.Parse() + + if *version { + runVersion() + return + } + + if *configure { + runConfigure() + return + } + + //Load configuration + if os.Getenv("SCALR_HOSTNAME") == "" || os.Getenv("SCALR_TOKEN") == "" { + + home, err := os.UserHomeDir() + if err != nil { + log.Fatal(err) + } + + home = home + "/.scalr/" + config := "scalr.conf" + + content, err := ioutil.ReadFile(home + config) + if err != nil { + fmt.Print("\n", "Not configured! Please run 'scalr -configure' or set environment variables SCALR_HOSTNAME and SCALR_TOKEN", "\n\n") + return + } + + jsonParsed, err := gabs.ParseJSON(content) + if err != nil { + panic(err) + } + + ScalrHostname = jsonParsed.Search("hostname").Data().(string) + ScalrToken = jsonParsed.Search("token").Data().(string) + + } else { + //Read config from Environment + ScalrHostname = os.Getenv("SCALR_HOSTNAME") + ScalrToken = os.Getenv("SCALR_TOKEN") + } + + if *help { + printHelp() + return + } + + parseCommand(*format, *verbose) + +} + +//Loads OpenAPI specification +func loadAPI() *openapi3.T { + + home, err := os.UserHomeDir() + if err != nil { + log.Fatal(err) + } + + home = home + "/.scalr/" + spec := "cache-" + fmt.Sprintf("%x", md5.Sum([]byte(ScalrHostname))) + "-openapi-preview.yml" + + if _, err := os.Stat(home); os.IsNotExist(err) { + os.MkdirAll(home, 0700) + } + + if info, err := os.Stat(home + spec); !os.IsNotExist(err) { + if time.Since(info.ModTime()).Hours() > 24 { + //Cache is more than 24 hours old, re-Download... + downloadFile("https://"+ScalrHostname+"/api/iacp/v3/openapi-preview.yml", home+spec) + } + } else { + //Download spec + downloadFile("https://"+ScalrHostname+"/api/iacp/v3/openapi-preview.yml", home+spec) + } + + openapi3.SchemaFormatValidationDisabled = true + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + + //Prevent loading external example files which makes the CLI too slow + loader.ReadFromURIFunc = disableExternalFiles(openapi3.ReadFromURIs(openapi3.ReadFromHTTP(http.DefaultClient), openapi3.ReadFromFile)) + + doc, err := loader.LoadFromFile(home + spec) + + //api, _ := url.Parse("https://scalr.io/api/iacp/v3/openapi-preview.yml") + //doc, err := loader.LoadFromURI(api) + + if err != nil { + panic(err) + } + + //Validate the specification + if err = doc.Validate(loader.Context); err != nil { + panic(err) + } + + return doc +} + +func disableExternalFiles(reader openapi3.ReadFromURIFunc) openapi3.ReadFromURIFunc { + + return func(loader *openapi3.Loader, location *url.URL) (buf []byte, err error) { + + //Skip examples + if strings.Contains(location.Path, "/examples/") { + return + } + + return reader(loader, location) + } +} + +//Downloads a file +func downloadFile(URL string, fileName string) { + + client := &http.Client{} + + req, err := http.NewRequest("GET", URL, nil) + if err != nil { + panic(err) + } + + req.Header.Set("User-Agent", "scalr-cli/"+versionCLI) + + resp, err := client.Do(req) + if err != nil { + panic(err) + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + panic(err) + } + + if resp.StatusCode != 200 { + panic(errors.New("received non-200 response code from server")) + } + + //Create a empty file + file, err := os.OpenFile(fileName, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + panic(err) + } + defer file.Close() + + file.WriteString(string(body)) + file.Sync() + +} + +//Recursively collect all required fields +func collectRequired(root *openapi3.Schema) map[string]bool { + + requiredFields := make(map[string]bool) + + var recursive func(*openapi3.Schema, string) + + //Function to support nested objects + recursive = func(nested *openapi3.Schema, prefix string) { + + //data should always be considered as required + if prefix == "" && nested.Properties["data"] != nil { + recursive(nested.Properties["data"].Value, prefix+"data-") + } + + //Collect all availble attributes for this command + for _, name := range nested.Required { + + requiredFields[prefix+name] = true + + //Nested object, needs to drill down deeper + if nested.Properties[name].Value.Type == "object" { + recursive(nested.Properties[name].Value, prefix+name+"-") + continue + } + + //Nested array of objects, needs to dril down deeper + if nested.Properties[name].Value.Type == "array" && nested.Properties[name].Value.Items.Value.Type == "object" { + recursive(nested.Properties[name].Value.Items.Value, prefix+name+"-") + continue + } + + } + + } + + recursive(root, "") + + return requiredFields +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..3c0fcc1 --- /dev/null +++ b/version.go @@ -0,0 +1,9 @@ +package main + +import ( + "fmt" +) + +func runVersion() { + fmt.Println(versionCLI) +}