diff --git a/.github/workflows/build-pr-cmk.yml b/.github/workflows/build-pr-cmk.yml new file mode 100644 index 00000000..d6f97d0d --- /dev/null +++ b/.github/workflows/build-pr-cmk.yml @@ -0,0 +1,69 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +name: Build cmk binaries on PR + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + permissions: + contents: read + runs-on: ubuntu-24.04 + env: + GITHUB_TOKEN: "" + outputs: + outcome: ${{ steps.meta.outputs.outcome }} + artifact_url: ${{ steps.meta.outputs.artifact_url }} + steps: + - name: Checkout PR code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Build dist + id: build + run: make dist + continue-on-error: true + + - name: Upload zipped dist artifact + id: upload_artifact + if: ${{ steps.build.outcome == 'success' }} # gate on build outcome + uses: actions/upload-artifact@v4 + with: + name: cmk-binaries.pr${{ github.event.pull_request.number }} + path: dist/ + if-no-files-found: error + retention-days: 10 + + - name: Expose build outcome & artifact link + id: meta + if: always() + run: | + echo "outcome=${{ steps.build.outcome }}" >> $GITHUB_OUTPUT + echo "artifact_url=${{ steps.upload_artifact.outputs.artifact-url }}" >> $GITHUB_OUTPUT diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..e4bdb90f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +name: Build + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.20.x + + - name: Run Script + run: make all + + env: + GO111MODULE: on diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..4fb3e132 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,318 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +name: Simulator CI + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + simulator-latest-ci: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + + env: + GO111MODULE: on + CMK_BIN: bin/cmk + CLOUDSTACK_SIM_API: http://127.0.0.1:8096/client/api + CLOUDSTACK_UI_API: http://127.0.0.1:8080/client/api + MAVEN_OPTS: -Xmx4096m -XX:MaxMetaspaceSize=800m -Djava.security.egd=file:/dev/urandom --add-opens=java.base/java.lang=ALL-UNNAMED --add-exports=java.base/sun.security.x509=ALL-UNNAMED --add-opens=java.base/jdk.internal.reflect=ALL-UNNAMED + + steps: + - name: Check out CloudMonkey + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Build cmk + run: | + make run + + - name: Fetch latest CloudStack release tag + id: csrel + run: | + TAG=$(curl -s https://api.github.com/repos/apache/cloudstack/releases/latest | jq -r .tag_name) + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "Latest CloudStack tag: $TAG" + + - name: Clone CloudStack at latest release + run: | + git clone --depth=1 --branch "${{ steps.csrel.outputs.tag }}" https://github.com/apache/cloudstack.git + + - name: Set up JDK 11 + Maven cache + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'temurin' + cache: maven + cache-dependency-path: cloudstack/pom.xml + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install OS deps + run: | + sudo apt-get update + sudo apt-get install -y \ + mysql-server uuid-runtime genisoimage netcat-openbsd ipmitool \ + build-essential libgcrypt20 libgpg-error-dev libgpg-error0 \ + libopenipmi0 libssl-dev jq curl + + - name: Install Python deps + run: | + python3 -m pip install --upgrade pip + python3 -m pip install --user urllib3 lxml paramiko nose texttable ipmisim pyopenssl pycryptodome mock flask netaddr pylint pycodestyle six astroid mysql-connector-python + + - name: Setup MySQL Server + run: | + # https://github.com/actions/runner-images/blob/main/images/linux/Ubuntu2004-Readme.md#mysql + sudo systemctl start mysql + sudo mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY ''; FLUSH PRIVILEGES;" + sudo systemctl restart mysql + sudo mysql -uroot -e "SELECT VERSION();" + + - name: Build CloudStack (simulator) + working-directory: cloudstack + run: | + mvn -B -P developer,systemvm -Dsimulator clean install -DskipTests=true -T$(nproc) + + - name: Deploy simulator DB + marvin + working-directory: cloudstack + run: | + mvn -q -Pdeveloper -pl developer -Ddeploydb + mvn -q -Pdeveloper -pl developer -Ddeploydb-simulator + python3 -m pip install --user --upgrade tools/marvin/dist/Marvin-*.tar.gz + + - name: Start CloudStack mgmt (Jetty) in background + id: start_ms + working-directory: cloudstack + run: | + set -euo pipefail + LOG=/tmp/jetty-log.txt + mvn -Dsimulator -Dorg.eclipse.jetty.annotations.maxWait=120 -pl :cloud-client-ui jetty:run > "$LOG" 2>&1 & + JETTY_PID=$! + echo "$JETTY_PID" > /tmp/jetty.pid + echo "Waiting for simulator API @ 8096…" + for i in $(seq 1 60); do + if nc -z 127.0.0.1 8096; then + echo "Simulator API is up." + break + fi + sleep 5 + tail -n 50 "$LOG" || true + if ! kill -0 "$JETTY_PID" 2>/dev/null; then + echo "Jetty exited early. Last 200 lines:" + tail -n 200 "$LOG" || true + exit 1 + fi + done + + - name: Deploy Advanced Zone via Marvin + working-directory: cloudstack + run: | + python3 tools/marvin/marvin/deployDataCenter.py -i setup/dev/advdualzone.cfg + + - name: Configure cmk profile + run: | + "${CMK_BIN}" set profile simulator + "${CMK_BIN}" set url "${CLOUDSTACK_UI_API}" + "${CMK_BIN}" set output json + + - name: API discovery parity (curl vs cmk) + run: | + CURL_COUNT=$(curl -s "${CLOUDSTACK_SIM_API}?command=listApis&response=json" | jq '.listapisresponse.count') + echo "curl count: $CURL_COUNT" + CMK_COUNT=$("${CMK_BIN}" listApis | jq '.count') + echo "cmk count: $CMK_COUNT" + test -n "$CURL_COUNT" -a -n "$CMK_COUNT" + [ "$CMK_COUNT" -ge 1 ] + + - name: List API + run: | + "${CMK_BIN}" set output table + OUT=$("${CMK_BIN}" listZones) + echo "$OUT" + if [ -z "$OUT" ]; then + echo "No output from listZones. Failing." + exit 1 + fi + "${CMK_BIN}" set output json + OUT=$("${CMK_BIN}" listZones) + echo "$OUT" + if [ -z "$OUT" ]; then + echo "No output from listZones. Failing." + exit 1 + fi + ZONE_ID=$(echo "$OUT" | jq -r '.zone[0].id') + echo "ZONE_ID=$ZONE_ID" >> $GITHUB_ENV + test -n "$ZONE_ID" + + - name: List API with filter + run: | + "${CMK_BIN}" set output json + OUT=$("${CMK_BIN}" listZones filter=id,name) + echo "$OUT" | jq -e ' + .zone and (.zone|length>=1) + and (all(.zone[]; has("id") and has("name") and ((. | keys - ["id","name"])|length==0))) + ' + + - name: List API with exclude + run: | + "${CMK_BIN}" set output json + OUT=$("${CMK_BIN}" listZones exclude=id,name) + echo "$OUT" | jq -e ' + .zone and (.zone|length>=1) + and (all(.zone[]; (has("id") or has("name"))|not)) + ' + + - name: "Create API" + run: | + "${CMK_BIN}" set output json + OUT=$("${CMK_BIN}" createUser account=admin email=testuser@example.com firstname=Test lastname=User password=password username=test || true) + echo "$OUT" + if [ -n "$OUT" ]; then + echo "$OUT" | jq -e ' + (.user // {}) as $p + | ($p|type=="object") + and ($p|has("id") and has("email")) + and ((($p|keys) - ["id","email"])|length>0) + ' + else + echo "No output. Failing strict check." + exit 1 + fi + + - name: Create API with filter + run: | + "${CMK_BIN}" set output json + OUT=$("${CMK_BIN}" createUser account=admin email=testuser@example.com firstname=Test lastname=User password=password username=test-filter filter=id,email || true) + echo "$OUT" + if [ -n "$OUT" ]; then + echo "$OUT" | jq -e ' + (.user // {}) as $p + | ($p|type=="object") + and ($p|has("id") and has("email")) + and ((($p|keys) - ["id","email"])|length==0) + ' + else + echo "No output. Failing strict check." + exit 1 + fi + + - name: Create API with exclude + run: | + "${CMK_BIN}" set output json + OUT=$("${CMK_BIN}" createUser account=admin email=testuser@example.com firstname=Test lastname=User password=password username=test-exclude exclude=id,email || true) + echo "$OUT" + if [ -n "$OUT" ]; then + echo "$OUT" | jq -e ' + (.user // {}) as $p + | ($p|type=="object") + and ((($p|has("id")) or ($p|has("email")))|not) + ' + else + echo "No output. Failing strict check." + exit 1 + fi + + - name: Get template and service offering IDs for Async API test + run: | + TID=$("${CMK_BIN}" listTemplates listall=true templatefilter=executable | jq -r '.template[0].id') + SOID=$("${CMK_BIN}" listServiceOfferings | jq -r '.serviceoffering[0].id') + echo "TEMPLATE_ID=$TID" >> $GITHUB_ENV + echo "SERVICE_OFFERING_ID=$SOID" >> $GITHUB_ENV + test -n "$TID" -a -n "$SOID" + + - name: Async API + run: | + "${CMK_BIN}" set output json + OUT=$("${CMK_BIN}" deployVirtualMachine zoneid=${ZONE_ID} templateid=${TEMPLATE_ID} serviceofferingid=${SERVICE_OFFERING_ID} || true) + if [ -n "$OUT" ]; then + echo "$OUT" | jq -e ' + (.virtualmachine // {}) as $p + | ($p|type=="object") + and ($p|has("id") and has("name")) + and ((($p|keys) - ["id","name"])|length>0) + ' + else + echo "No output. Failing strict check." + exit 1 + fi + + - name: Async API with filter + run: | + "${CMK_BIN}" set output json + OUT=$("${CMK_BIN}" deployVirtualMachine zoneid=${ZONE_ID} templateid=${TEMPLATE_ID} serviceofferingid=${SERVICE_OFFERING_ID} filter=id,name || true) + if [ -n "$OUT" ]; then + echo "$OUT" | jq -e ' + (.virtualmachine // {}) as $p + | ($p|type=="object") + and ($p|has("id") and has("name")) + and ((($p|keys) - ["id","name"])|length==0) + ' + else + echo "No output. Failing strict check." + exit 1 + fi + + - name: Async API with exclude + run: | + "${CMK_BIN}" set output json + OUT=$("${CMK_BIN}" deployVirtualMachine zoneid=${ZONE_ID} templateid=${TEMPLATE_ID} serviceofferingid=${SERVICE_OFFERING_ID} exclude=id,name || true) + echo "$OUT" + if [ -n "$OUT" ]; then + echo "$OUT" | jq -e ' + (.virtualmachine // {}) as $p + | ($p|type=="object") + and ((($p|has("id")) or ($p|has("name")))|not) + ' + else + echo "No output. Failing strict check." + exit 1 + fi + + - name: Change profile (user) and compare API surface + run: | + ADMIN_COUNT=$("${CMK_BIN}" listApis | jq '.count') + "${CMK_BIN}" createAccount username=user password=p@ssw0rd accounttype=0 domainid=1 firstname=Test lastname=User email=testuser@example.com || true + "${CMK_BIN}" set profile user + "${CMK_BIN}" set url "${CLOUDSTACK_UI_API}" + "${CMK_BIN}" set username user + "${CMK_BIN}" set password p@ssw0rd + USER_COUNT=$("${CMK_BIN}" listApis | jq '.count // 0') + echo "admin=${ADMIN_COUNT} user=${USER_COUNT}" + test $USER_COUNT -le $ADMIN_COUNT + + - name: Stop simulator MS + if: ${{ always() && steps.start_ms.outcome == 'success' }} + working-directory: cloudstack + run: | + echo -e "Stopping Simulator, integration tests run completed\n" + mvn -Dsimulator -pl client jetty:stop 2>&1 + diff --git a/.github/workflows/comment-pr-build.yml b/.github/workflows/comment-pr-build.yml new file mode 100644 index 00000000..661cdef1 --- /dev/null +++ b/.github/workflows/comment-pr-build.yml @@ -0,0 +1,165 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +name: Comment on PR build results + +on: + workflow_run: + workflows: ["Build cmk binaries on PR"] + types: + - completed + +permissions: + contents: read + issues: write + pull-requests: write + actions: read + +jobs: + comment: + runs-on: ubuntu-24.04 + if: > + github.event.workflow_run.event == 'pull_request' + steps: + - name: Download artifact metadata + uses: actions/github-script@v7 + id: artifact-metadata + with: + script: | + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + + const prArtifact = artifacts.data.artifacts.find(a => a.name.startsWith('cmk-binaries.pr')); + + if (prArtifact) { + const prNumber = prArtifact.name.match(/pr(\d+)/)?.[1]; + return { + artifact_url: prArtifact.archive_download_url, + pr_number: prNumber, + conclusion: context.payload.workflow_run.conclusion + }; + } + + return { + pr_number: null, + conclusion: context.payload.workflow_run.conclusion + }; + + - name: Get PR number from workflow run + id: get-pr + uses: actions/github-script@v7 + env: + METADATA: ${{ steps.artifact-metadata.outputs.result }} + with: + script: | + // Primary source: PRs attached to the workflow_run (for pull_request-triggered runs) + const runPRs = context.payload.workflow_run.pull_requests; + if (runPRs && runPRs.length > 0) { + return runPRs[0].number; + } + // Fallback 1: PR number discovered from artifact metadata + let metadata = {}; + if (process.env.METADATA) { + try { + metadata = JSON.parse(process.env.METADATA); + } catch (e) { + core.warning(`Failed to parse artifact metadata: ${e.message}`); + } + } + if (metadata.pr_number) { + return metadata.pr_number; + } + // Fallback 2: look up PRs associated with the workflow run head SHA + const associated = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.payload.workflow_run.head_sha, + }); + if (associated.data.length > 0) { + return associated.data[0].number; + } + return null; + + - name: Comment or update build result on PR + uses: actions/github-script@v7 + with: + script: | + const { execSync } = require('child_process'); + const prNumber = ${{ steps.get-pr.outputs.result }}; + + if (!prNumber) { + core.warning('Could not determine PR number, skipping comment'); + return; + } + + const identifier = "cmk-build-artifact-comment"; + const owner = context.repo.owner; + const repo = context.repo.repo; + const conclusion = '${{ github.event.workflow_run.conclusion }}'; + const runId = '${{ github.event.workflow_run.id }}'; + const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`; + + core.info(`Commenting on PR #${prNumber}`); + core.info(`Build conclusion: ${conclusion}`); + + let body = `\n`; + + if (conclusion === 'success') { + const expiryDate = execSync("date -d '+10 days' '+%B %d, %Y'").toString().trim(); + body += `✅ Build complete for PR #${prNumber}.\n\n`; + body += `📦 Binary artifacts are available in the [workflow run](${runUrl}) (expires on ${expiryDate}).\n\n`; + body += `> **Note:** Download artifacts by clicking on the workflow run link above, then scroll to the "Artifacts" section.\n`; + body += `> _Artifacts from PR builds are for testing only and may contain unreviewed, malicious code._`; + } else if (conclusion === 'failure') { + body += `❌ Build failed for PR #${prNumber}.\n\n`; + body += `See the [workflow run](${runUrl}) for details.`; + } else { + body += `⚠️ Build ${conclusion} for PR #${prNumber}.\n\n`; + body += `See the [workflow run](${runUrl}) for details.`; + } + + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.includes(identifier) + ); + + if (existing) { + core.info(`Updating existing comment id ${existing.id}`); + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body + }); + } else { + core.info(`Creating new comment`); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body + }); + } diff --git a/.travis.yml b/.github/workflows/lint.yml similarity index 69% rename from .travis.yml rename to .github/workflows/lint.yml index 412ca240..db98df71 100644 --- a/.travis.yml +++ b/.github/workflows/lint.yml @@ -15,13 +15,24 @@ # specific language governing permissions and limitations # under the License. -sudo: false -language: go -go: - - 1.8.x - - 1.9.x - - 1.10.x +name: Lint Check -script: - - make all - - make lint +on: [push, pull_request] + +jobs: + lint: + name: Run make lint + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Run lint + run: | + make lint diff --git a/.github/workflows/rat.yaml b/.github/workflows/rat.yaml new file mode 100644 index 00000000..23ed6e94 --- /dev/null +++ b/.github/workflows/rat.yaml @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +name: RAT Check + +on: + push: + branches: + - main + pull_request: + branches: + - '**' + +jobs: + rat: + name: Apache RAT Check + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Download Apache RAT + run: | + curl -L -O https://downloads.apache.org/creadur/apache-rat-0.18/apache-rat-0.18-bin.tar.gz + tar -xzf apache-rat-0.18-bin.tar.gz + + - name: Run RAT + run: | + java -jar apache-rat-0.18/apache-rat-0.18.jar -d . -E .rat-excludes > rat-report.txt + cat rat-report.txt + # Fail if unapproved licenses are found + grep -qe '^\s*Unapproved:\s*0\s*A count of unapproved licenses.$' rat-report.txt && exit 0 || exit 1 diff --git a/.rat-excludes b/.rat-excludes new file mode 100644 index 00000000..3ecb4024 --- /dev/null +++ b/.rat-excludes @@ -0,0 +1,7 @@ +.rat-excludes +CHANGES.md +apache-rat-0.17 +go.sum +rat-report.txt +/snap/snapcraft.yaml +vendor \ No newline at end of file diff --git a/CHANGES.md b/CHANGES.md index 761a7680..9ef21cd0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,7 +1,69 @@ Apache CloudStack CloudMonkey Changelog --------------------------------------- -Version 6.0.0 (alpha, in progress) +Version 6.5.0 +============= +This release includes: +- Adds support for template/ISO file upload +- Adds support for Two-Factor Authentication (2FA) prompts +- Improves autocompletion filtering and listing for different resources, with name/detail-based suggestions +- Enables sending API requests using HTTP POST +- Improves output formatting for multiple formats and asynchronous APIs +- Enhances filtering output, with support for async APIs and reverse filtering via the excludefilter option +- Updates inbuilt API precache to ACS v4.21 +- Improves credential fallback for command-line scenario +- Hardens file permissions for config and history file access +- Improves validation when setting configuration values +- Improves overall linting, tests, and automated workflows for the repository + +Version 6.4.0 +============= +This release includes: +- Improve CLI mode usage and output handling +- Add support for http POST handling for password and user-data +- Optimise async API jobs polling +- Better interrupt handling of Ctrl+C to cancel on-going API request but not + crash `cmk` +- Remove unnecessary call to listApis (sync) when using CLI mode with url, + api key, secret key +- Updates inbuilt API precache to ACS v4.19 + +Version 6.3.0 +============= +This release includes: +- Fixes handling of invalid timeout value +- Fixes csv output +- Add support for ARM64 Darwin/OSX build in Makefile +- Autocompletion on storage pool related APIs +- Enable profile information passing through commandline +- Profile caching improvements and bugfixes +- Fix: reuse cmk's CLI params to call piped cmd +- Maintenance changes - github actions/travis migration, dependencies upgrades, +- Various bug fixes + +Version 6.2.0 +============= +This release includes: +- Validation of arguments while setting cloudmonkey configuration +- Verify user access to cloudmonkey configuration file +- Allow sync command to be used as a verb for cloudstack API calls +- Print response with newlines if output format chosen is "text" +- Map "default" output type to "json" +- Add autocompletion as an optional configuration +- Display output in human readable format +- Show meaningful metadata for id autocompletion for some APIs + +Version 6.1.0 +============= +This release includes +- Fix issue of required parameters for tags and details +- Fix issue of hostid parameter completing to show hosts with type=routing +- Statically building the binary to not depend on gcc compiler #59 +- Allow http/https web-proxy support #49 +- Show keys as per provided filter for table/csv/column output #63 +- Don't sort header when filter keys are provided + +Version 6.0.0 ============= This release includes - Rewrite cloudmonkey in golang @@ -59,7 +121,7 @@ This release includes - Parameter completion uses list api heuristics and related APIs as fallback - Parameter completion options are cached to speed up rendering of options - CloudMonkey returns non-zero exit code when run on shell and a error is return - from managment server, the error message is written to stderr + from management server, the error message is written to stderr - Adds new config parameter 'verifysslcert' to enable/disable SSL cert checking - New command line arg: -d for display (json, table or default) @@ -75,7 +137,7 @@ This release includes - Network requests, json decoding and shell related bugfixes - Based on platform, it will install either pyreadline (Windows) or readline (OSX and Linux) - Config options `protocol`, `host`, `port`, `path` are deprecated now - - Backward compatibilty exists for above options but we use `url` for the mgmt server URL + - Backward compatibility exists for above options but we use `url` for the mgmt server URL - Introduces server profiles so users can use cloudmonkey with different hosts and management server configs - A default profile under the section [local] is added with default values - Everytime `set` is called, cloudmonkey will write the config and reload config file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..0bf4a601 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +FROM debian:sid as builder + +MAINTAINER "Apache CloudStack" +LABEL Description="Apache CloudStack CloudMonkey; Go based CloudStack command line interface" +LABEL Vendor="Apache.org" +LABEL License=ApacheV2 +LABEL Version=6.3.0 + +WORKDIR /work/ +RUN apt -y update && apt -y install git golang-go build-essential && \ + git clone https://github.com/apache/cloudstack-cloudmonkey.git && \ + go version && \ + cd cloudstack-cloudmonkey && \ + make all && \ + pwd && \ + ls -alh ./bin/cmk + +FROM debian:stable +COPY --from=builder /work/cloudstack-cloudmonkey/bin/cmk /usr/bin/ +RUN apt-get -y update && uname -a && mkdir -p /root/.cmk/ &&\ + cmk version && cmk help && ls -alh /root/ diff --git a/Makefile b/Makefile index d19ba76b..ee8b6572 100644 --- a/Makefile +++ b/Makefile @@ -20,13 +20,13 @@ PACKAGE = cmk DATE ?= $(shell date +%FT%T%z) VERSION ?= $(shell git describe --tags --always --dirty --match=v* 2> /dev/null || \ cat $(CURDIR)/.version 2> /dev/null || echo v0) -GOPATH = $(CURDIR)/.gopath -BIN = $(GOPATH)/bin -BASE = $(GOPATH)/src/$(PACKAGE) -PKGS = $(or $(PKG),$(shell cd $(BASE) && env GOPATH=$(GOPATH) $(GO) list ./... | grep -v "^$(PACKAGE)/vendor/")) -TESTPKGS = $(shell env GOPATH=$(GOPATH) $(GO) list -f '{{ if or .TestGoFiles .XTestGoFiles }}{{ .ImportPath }}{{ end }}' $(PKGS)) +BIN = $(CURDIR)/bin +BASE = $(CURDIR) +PKGS = $(or $(PKG),$(shell $(GO) list ./... | grep -v "^$(PACKAGE)/vendor/")) +TESTPKGS = $(shell $(GO) list -f '{{ if or .TestGoFiles .XTestGoFiles }}{{ .ImportPath }}{{ end }}' $(PKGS)) +GIT_SHA = $(shell git rev-parse --short HEAD) -GO = go +GO = CGO_ENABLED=0 go GODOC = godoc GOFMT = gofmt TIMEOUT = 15 @@ -35,37 +35,44 @@ Q = $(if $(filter 1,$V),,@) M = $(shell printf "\033[34;1m▶\033[0m ") .PHONY: all -all: fmt vendor | $(BASE) ; $(info $(M) Building executable…) @ ## Build program binary - $Q cd $(BASE) && GOPATH=$(GOPATH) $(GO) build \ +all: fmt ; $(info $(M) Building executable… $(GIT_SHA)) @ ## Build program binary + $Q $(GO) build -mod=vendor \ -tags release \ - -ldflags '-s -w -X $(PACKAGE)/cmd.Version=$(VERSION) -X $(PACKAGE)/cmd.BuildDate=$(DATE)' \ + -ldflags '-s -w -X main.GitSHA=$(GIT_SHA) -X main.BuildDate=$(DATE)' \ -o bin/$(PACKAGE) cmk.go $(info $(M) Done!) @ -$(BASE): ; $(info $(M) Setting GOPATH…) - @mkdir -p $(dir $@) - @ln -sf $(CURDIR) $@ - run: all ./bin/cmk debug: - $(GO) build -gcflags='-N -l' -o cmk cmk.go && dlv --listen=:2345 --headless=true --api-version=2 exec ./cmk + $(GO) build -mod=vendor -gcflags='-N -l' -o cmk && dlv --listen=:2345 --headless=true --api-version=2 exec ./cmk -dist: all - cd $(BASE) +dist-mkdir: all rm -fr dist mkdir -p dist - GOOS=linux GOARCH=amd64 $(GO) build -ldflags='-s -w' -o dist/cmk.linux.amd64 cmk.go - GOOS=linux GOARCH=arm64 $(GO) build -ldflags='-s -w' -o dist/cmk.linux.arm64 cmk.go - GOOS=windows GOARCH=amd64 $(GO) build -ldflags='-s -w' -o dist/cmk.exe cmk.go - GOOS=darwin GOARCH=amd64 $(GO) build -ldflags='-s -w' -o dist/cmk.darwin.amd64 cmk.go + +dist-linux: dist-mkdir + GOOS=linux GOARCH=amd64 $(GO) build -mod=vendor -ldflags='-s -w -X main.GitSHA=$(GIT_SHA) -X main.BuildDate=$(DATE)' -o dist/cmk.linux.x86-64 cmk.go + GOOS=linux GOARCH=386 $(GO) build -mod=vendor -ldflags='-s -w -X main.GitSHA=$(GIT_SHA) -X main.BuildDate=$(DATE)' -o dist/cmk.linux.x86 cmk.go + GOOS=linux GOARCH=arm $(GO) build -mod=vendor -ldflags='-s -w -X main.GitSHA=$(GIT_SHA) -X main.BuildDate=$(DATE)' -o dist/cmk.linux.arm32 cmk.go + GOOS=linux GOARCH=arm64 $(GO) build -mod=vendor -ldflags='-s -w -X main.GitSHA=$(GIT_SHA) -X main.BuildDate=$(DATE)' -o dist/cmk.linux.arm64 cmk.go + + +dist: dist-linux + GOOS=windows GOARCH=386 $(GO) build -mod=vendor -ldflags='-s -w -X main.GitSHA=$(GIT_SHA) -X main.BuildDate=$(DATE)' -o dist/cmk.windows.x86.exe cmk.go + GOOS=windows GOARCH=amd64 $(GO) build -mod=vendor -ldflags='-s -w -X main.GitSHA=$(GIT_SHA) -X main.BuildDate=$(DATE)' -o dist/cmk.windows.x86-64.exe cmk.go + GOOS=darwin GOARCH=amd64 $(GO) build -mod=vendor -ldflags='-s -w -X main.GitSHA=$(GIT_SHA) -X main.BuildDate=$(DATE)' -o dist/cmk.darwin.x86-64 cmk.go + GOOS=darwin GOARCH=arm64 $(GO) build -mod=vendor -ldflags='-s -w -X main.GitSHA=$(GIT_SHA) -X main.BuildDate=$(DATE)' -o dist/cmk.darwin.arm64 cmk.go # Tools +$(BIN): + @mkdir -p $(BIN) + GOLINT = $(BIN)/golint -$(BIN)/golint: | $(BASE) ; $(info $(M) Building golint…) - $Q go get github.com/golang/lint/golint +$(BIN)/golint: | $(BIN) ; $(info $(M) Building golint…) + $Q GOBIN=$(BIN) go install golang.org/x/lint/golint@latest GOCOVMERGE = $(BIN)/gocovmerge $(BIN)/gocovmerge: | $(BASE) ; $(info $(M) building gocovmerge…) @@ -129,7 +136,7 @@ lint: vendor | $(BASE) $(GOLINT) ; $(info $(M) Running golint…) @ ## Run golin .PHONY: fmt fmt: ; $(info $(M) Running gofmt…) @ ## Run gofmt on all source files - @ret=0 && for d in $$($(GO) list -f '{{.Dir}}' ./... | grep -v /vendor/); do \ + @ret=0 && for d in $$($(GO) list -mod=vendor -f '{{.Dir}}' ./... | grep -v /vendor/); do \ $(GOFMT) -l -w $$d/*.go || ret=$$? ; \ done ; exit $$ret @@ -137,8 +144,7 @@ fmt: ; $(info $(M) Running gofmt…) @ ## Run gofmt on all source files .PHONY: clean clean: ; $(info $(M) Cleaning…) @ - @rm -rf $(GOPATH) - @rm -rf bin dist + @rm -rf bin dist cloudstack-cloudmonkey @rm -rf test/tests.* test/coverage.* .PHONY: help diff --git a/README.md b/README.md index edd98acb..66f4dc6a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ -## CloudMonkey [![Build Status](https://travis-ci.org/apache/cloudstack-cloudmonkey.svg?branch=master)](https://travis-ci.org/apache/cloudstack-cloudmonkey) +## CloudMonkey [![Build Status](https://github.com/apache/cloudstack/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/apache/cloudstack/actions/workflows/build.yml) [![RAT Check](https://github.com/apache/cloudstack-cloudmonkey/actions/workflows/rat.yaml/badge.svg?branch=main)](https://github.com/apache/cloudstack-cloudmonkey/actions/workflows/rat.yamll) [![Lint Check](https://github.com/apache/cloudstack-cloudmonkey/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/apache/cloudstack-cloudmonkey/actions/workflows/lint.yml) [![](https://images.microbadger.com/badges/version/apache/cloudstack-cloudmonkey.svg)](https://hub.docker.com/r/apache/cloudstack-cloudmonkey) [![cloudmonkey](https://snapcraft.io/cloudmonkey/badge.svg)](https://snapcraft.io/cloudmonkey) `cloudmonkey` :cloud::monkey_face: is a command line interface (CLI) for [Apache CloudStack](http://cloudstack.apache.org). -CloudMonkey can be use both as an interactive shell and as a command line tool -which simplifies Apache CloudStack configuration and management. +It can be used both as an interactive shell and as a command-line tool, simplifying Apache CloudStack configuration and management. -The modern cloudmonkey is a re-written and simplified port in Go and can be used +The modern CloudMonkey is a rewritten and simplified port in Go, compatible with Apache CloudStack 4.9 and above. The legacy cloudmonkey written in Python can be used with Apache CloudStack 4.0-incubating and above. @@ -13,7 +12,7 @@ For documentation, kindly see the [wiki](https://github.com/apache/cloudstack-cl ### Development -For development the pre-requisites are Go 10 or latest and a unix-like +To develop CloudMonkey, you need Go 1.11 or later and a unix-like environment. You can use the following targets for building: $ make help @@ -39,6 +38,10 @@ Build and run manually: $ make all $ ./bin/cmk +To build for all distros and platforms, run: + + $ make dist + ### Community You may join the relevant mailing list(s) for cloudmonkey related discussion: @@ -57,19 +60,24 @@ Report issue(s) on the `user` mailing list and/or open a Github [issue](https:// 3. Commit your change 4. Write tests for your change if applicable 5. Run the tests, ensuring they all pass -6. Submit a [Pull Request](https://github.com/apache/cloudstack-cloudmonkey/pull/new/master) using Github +6. Submit a [Pull Request](https://github.com/apache/cloudstack-cloudmonkey/pull/new/main) using Github ### History The original `cloudmonkey` was written in Python and contributed to Apache -CloudStack project by [Rohit Yadav](http://rohityadav.cloud) on 31 Oct 2012 -under the Apache License 2.0. The Python-based original CLI is now referred to -as the legacy cloudmonkey and can be installed using `pip install cloudmonkey`. +CloudStack project by [Rohit Yadav](https://yadv.in) on 31 Oct 2012 +under the Apache License 2.0. Starting version 6.0.0, the modern cloudmonkey `cmk` is a fast and simplified Go-port of the original tool with some backward incompatibilities and reduced -feature set. It ships as a standalone 64-bit executable binary for several -platforms such as Linux, Mac and Windows. +feature set. It ships as a standalone 64-bit [executable binary for several +platforms such as Linux, Mac and Windows](https://github.com/apache/cloudstack-cloudmonkey/releases). + +**NOTE:** + +If cloudmonkey is being upgraded from a version lower than v6.0.0, it must be noted +that the cloudmonkey configuration path is changed from `~/.cloudmonkey/config` to +`~/.cmk/config` and a default `localcloud` profile is created. One must first set up basic configurations such as apikey/secretkey/username/password/url for the required profile(s) as required ### License diff --git a/cli/completer.go b/cli/completer.go index 99f10ed6..755cc7f2 100644 --- a/cli/completer.go +++ b/cli/completer.go @@ -20,17 +20,16 @@ package cli import ( "fmt" "sort" + "strconv" "strings" "unicode" - "cmk/cmd" - "cmk/config" - - "github.com/chzyer/readline/runes" + "github.com/apache/cloudstack-cloudmonkey/cmd" + "github.com/apache/cloudstack-cloudmonkey/config" ) -type autoCompleter struct { - Config *config.Config +var nameSupportingApis = []string{ + "configuration", } func buildAPICacheMap(apiMap map[string][]*config.API) map[string][]*config.API { @@ -77,10 +76,112 @@ func trimSpaceLeft(in []rune) []rune { return in[firstIndex:] } +func equal(a, b []rune) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + if a[i] != b[i] { + return false + } + } + return true +} + +func hasPrefix(r, prefix []rune) bool { + if len(r) < len(prefix) { + return false + } + return equal(r[:len(prefix)], prefix) +} + +func inArray(s string, array []string) bool { + for _, item := range array { + if s == item { + return true + } + } + return false +} + +func lastString(array []string) string { + return array[len(array)-1] +} + +type argOption struct { + Value string + Detail string +} + +func buildArgOptions(response map[string]interface{}, hasID bool) []argOption { + argOptions := []argOption{} + for _, v := range response { + switch obj := v.(type) { + case []interface{}: + if obj == nil { + break + } + for _, item := range obj { + resource, ok := item.(map[string]interface{}) + if !ok { + continue + } + var id, name, detail string + if resource["id"] != nil { + switch rawID := resource["id"].(type) { + case string: + id = rawID + case float64: + id = strconv.FormatFloat(rawID, 'f', -1, 64) + default: + panic(fmt.Errorf("detected an invalid type at path (%v:%T). This should have been caught during validation, indicating a bug in CloudMonkey. Please report this issue", rawID, rawID)) + } + } + if resource["name"] != nil { + name = resource["name"].(string) + } else if resource["username"] != nil { + name = resource["username"].(string) + } else if resource["hypervisor"] != nil && resource["hypervisorversion"] != nil { + name = fmt.Sprintf("%s %s", resource["hypervisor"].(string), resource["hypervisorversion"].(string)) + if resource["osdisplayname"] != nil { + name = fmt.Sprintf("%s; %s", resource["osdisplayname"].(string), name) + } + } + if resource["displaytext"] != nil { + detail = resource["displaytext"].(string) + } + if len(detail) == 0 && resource["description"] != nil { + detail = resource["description"].(string) + } + if len(detail) == 0 && resource["ipaddress"] != nil { + detail = resource["ipaddress"].(string) + } + var opt argOption + if hasID { + opt.Value = id + opt.Detail = name + if len(name) == 0 { + opt.Detail = detail + } + } else { + opt.Value = name + opt.Detail = detail + if len(name) == 0 { + opt.Value = detail + } + } + argOptions = append(argOptions, opt) + } + break + } + } + return argOptions +} + func doInternal(line []rune, pos int, lineLen int, argName []rune) (newLine [][]rune, offset int) { offset = lineLen if lineLen >= len(argName) { - if runes.HasPrefix(line, argName) { + if hasPrefix(line, argName) { if lineLen == len(argName) { newLine = append(newLine, []rune{' '}) } else { @@ -89,13 +190,113 @@ func doInternal(line []rune, pos int, lineLen int, argName []rune) (newLine [][] offset = offset - len(argName) - 1 } } else { - if runes.HasPrefix(argName, line) { + if hasPrefix(argName, line) { newLine = append(newLine, argName[offset:]) } } return } +func findAPI(apiMap map[string][]*config.API, relatedNoun string) *config.API { + var autocompleteAPI *config.API + for _, listAPI := range apiMap["list"] { + if relatedNoun == listAPI.Noun { + autocompleteAPI = listAPI + break + } + } + return autocompleteAPI +} + +func findAutocompleteAPI(arg *config.APIArg, apiFound *config.API, apiMap map[string][]*config.API) *config.API { + if arg.Type == "map" { + return nil + } + + var autocompleteAPI *config.API + argName := strings.Replace(arg.Name, "=", "", -1) + relatedNoun := argName + switch { + case argName == "id" || argName == "ids": + // Heuristic: user is trying to autocomplete for id/ids arg for a list API + relatedNoun = apiFound.Noun + if apiFound.Verb != "list" { + relatedNoun += "s" + } + case argName == "account": + // Heuristic: user is trying to autocomplete for accounts + relatedNoun = "accounts" + case argName == "ipaddressid": + // Heuristic: user is trying to autocomplete for ip addresses + relatedNoun = "publicipaddresses" + case argName == "storageid": + relatedNoun = "storagepools" + case argName == "associatednetworkid": + relatedNoun = "networks" + default: + // Heuristic: autocomplete for the arg for which a lists API exists + // For example, for zoneid arg, listZones API exists + base := argName + if strings.HasSuffix(argName, "id") { + base = strings.TrimSuffix(argName, "id") + } else if strings.HasSuffix(argName, "ids") { + base = strings.TrimSuffix(argName, "ids") + } else if argName == "name" { + for _, noun := range nameSupportingApis { + if strings.HasPrefix(apiFound.Noun, noun) { + base = noun + break + } + } + } + // Handle common cases where base ends with a vowel and needs "es" + if strings.HasSuffix(base, "s") || strings.HasSuffix(base, "x") || strings.HasSuffix(base, "z") || strings.HasSuffix(base, "ch") || strings.HasSuffix(base, "sh") { + relatedNoun = base + "es" + } else { + relatedNoun = base + "s" + } + } + + config.Debug("Possible related noun for the arg: ", relatedNoun, " and type: ", arg.Type) + autocompleteAPI = findAPI(apiMap, relatedNoun) + + if autocompleteAPI == nil { + if strings.Contains(strings.ToLower(relatedNoun), "storage") { + relatedNoun = "storagepools" + autocompleteAPI = findAPI(apiMap, relatedNoun) + } + } + + if autocompleteAPI != nil { + config.Debug("Autocomplete: API found using heuristics: ", autocompleteAPI.Name) + } + + if strings.HasSuffix(relatedNoun, "s") { + relatedNoun = relatedNoun[:len(relatedNoun)-1] + } + + // Heuristic: find any list API that contains the arg name + if autocompleteAPI == nil { + config.Debug("Finding possible API that have: ", argName, " related APIs: ", arg.Related) + possibleAPIs := []*config.API{} + for _, listAPI := range apiMap["list"] { + if strings.Contains(listAPI.Noun, argName) { + config.Debug("Found possible API: ", listAPI.Name) + possibleAPIs = append(possibleAPIs, listAPI) + } + } + if len(possibleAPIs) == 1 { + autocompleteAPI = possibleAPIs[0] + } + } + + return autocompleteAPI +} + +type autoCompleter struct { + Config *config.Config +} + func (t *autoCompleter) Do(line []rune, pos int) (options [][]rune, offset int) { apiMap := buildAPICacheMap(t.Config.GetAPIVerbMap()) @@ -114,7 +315,7 @@ func (t *autoCompleter) Do(line []rune, pos int) (options [][]rune, offset int) var verbFound string for _, verb := range verbs { search := verb + " " - if !runes.HasPrefix(line, []rune(search)) { + if !hasPrefix(line, []rune(search)) { sLine, sOffset := doInternal(line, pos, len(line), []rune(search)) options = append(options, sLine...) offset = sOffset @@ -132,7 +333,7 @@ func (t *autoCompleter) Do(line []rune, pos int) (options [][]rune, offset int) line = trimSpaceLeft(line[len(verbFound):]) for _, api := range apiMap[verbFound] { search := api.Noun + " " - if !runes.HasPrefix(line, []rune(search)) { + if !hasPrefix(line, []rune(search)) { sLine, sOffset := doInternal(line, pos, len(line), []rune(search)) options = append(options, sLine...) offset = sOffset @@ -157,114 +358,135 @@ func (t *autoCompleter) Do(line []rune, pos int) (options [][]rune, offset int) return } - // Auto-complete api args + // Auto-complete API arg splitLine := strings.Split(string(line), " ") line = trimSpaceLeft([]rune(splitLine[len(splitLine)-1])) for _, arg := range apiFound.Args { search := arg.Name - if !runes.HasPrefix(line, []rune(search)) { + if !hasPrefix(line, []rune(search)) { sLine, sOffset := doInternal(line, pos, len(line), []rune(search)) options = append(options, sLine...) offset = sOffset } else { + words := strings.Split(string(line), "=") + argInput := lastString(words) if arg.Type == "boolean" { - options = [][]rune{[]rune("true "), []rune("false ")} - offset = 0 + for _, search := range []string{"true ", "false "} { + offset = 0 + if strings.HasPrefix(search, argInput) { + options = append(options, []rune(search[len(argInput):])) + offset = len(argInput) + } + } return } + if arg.Type == config.FAKE && arg.Name == "filter=" { - options = [][]rune{} offset = 0 + filterInputs := strings.Split(strings.Replace(argInput, ",", ",|", -1), "|") + lastFilterInput := lastString(filterInputs) for _, key := range apiFound.ResponseKeys { - options = append(options, []rune(key)) + if inArray(key, filterInputs) { + continue + } + if strings.HasPrefix(key, lastFilterInput) { + options = append(options, []rune(key[len(lastFilterInput):])) + offset = len(lastFilterInput) + } } return } - argName := strings.Replace(arg.Name, "=", "", -1) - var autocompleteAPI *config.API - var relatedNoun string - if argName == "id" || argName == "ids" { - relatedNoun = apiFound.Noun - if apiFound.Verb != "list" { - relatedNoun += "s" - } - } else if argName == "account" { - relatedNoun = "accounts" - } else { - relatedNoun = strings.Replace(strings.Replace(argName, "ids", "", -1), "id", "", -1) + "s" - } - for _, related := range apiMap["list"] { - if relatedNoun == related.Noun { - autocompleteAPI = related - break + if arg.Type == config.FAKE && arg.Name == "exclude=" { + offset = 0 + excludeFilterInputs := strings.Split(strings.Replace(argInput, ",", ",|", -1), "|") + lastExcludeFilterInput := lastString(excludeFilterInputs) + for _, key := range apiFound.ResponseKeys { + if inArray(key, excludeFilterInputs) { + continue + } + if strings.HasPrefix(key, lastExcludeFilterInput) { + options = append(options, []rune(key[len(lastExcludeFilterInput):])) + offset = len(lastExcludeFilterInput) + } } + return } + autocompleteAPI := findAutocompleteAPI(arg, apiFound, apiMap) if autocompleteAPI == nil { return nil, 0 } - r := cmd.NewRequest(nil, completer.Config, nil) - autocompleteAPIArgs := []string{"listall=true"} - if autocompleteAPI.Noun == "templates" { - autocompleteAPIArgs = append(autocompleteAPIArgs, "templatefilter=executable") - } + completeArgs := t.Config.Core.AutoComplete + autocompleteAPIArgs := []string{} + argOptions := []argOption{} + if completeArgs { + autocompleteAPIArgs = []string{"listall=true"} + if autocompleteAPI.Noun == "templates" { + autocompleteAPIArgs = append(autocompleteAPIArgs, "templatefilter=executable") + } + if autocompleteAPI.Noun == "isos" { + autocompleteAPIArgs = append(autocompleteAPIArgs, "isofilter=executable") + } - fmt.Println("") - spinner := t.Config.StartSpinner("fetching options, please wait...") - response, _ := cmd.NewAPIRequest(r, autocompleteAPI.Name, autocompleteAPIArgs, false) - t.Config.StopSpinner(spinner) - - var autocompleteOptions []selectOption - for _, v := range response { - switch obj := v.(type) { - case []interface{}: - if obj == nil { - break + if apiFound.Name != "provisionCertificate" && autocompleteAPI.Name == "listHosts" { + autocompleteAPIArgs = append(autocompleteAPIArgs, "type=Routing") + } else if apiFound.Name == "migrateSystemVm" && autocompleteAPI.Name == "listVirtualMachines" { + autocompleteAPI.Name = "listSystemVms" + } else if apiFound.Name == "associateIpAddress" { + autocompleteAPIArgs = append(autocompleteAPIArgs, "state=Free") + } else if apiFound.Name == "listServiceOfferings" { + if arg.Name == "id=" && strings.Contains(strings.Join(splitLine, " "), "issystem=true") { + autocompleteAPIArgs = append(autocompleteAPIArgs, "issystem=true") } - for _, item := range obj { - resource, ok := item.(map[string]interface{}) - if !ok { - continue - } - opt := selectOption{} - if resource["id"] != nil { - opt.ID = resource["id"].(string) - } - if resource["name"] != nil { - opt.Name = resource["name"].(string) - } else if resource["username"] != nil { - opt.Name = resource["username"].(string) - } - if resource["displaytext"] != nil { - opt.Detail = resource["displaytext"].(string) - } - - autocompleteOptions = append(autocompleteOptions, opt) - } - break } + + spinner := t.Config.StartSpinner("fetching options, please wait...") + request := cmd.NewRequest(nil, completer.Config, nil, false) + response, _ := cmd.NewAPIRequest(request, autocompleteAPI.Name, autocompleteAPIArgs, false) + t.Config.StopSpinner(spinner) + + hasID := strings.HasSuffix(arg.Name, "id=") || strings.HasSuffix(arg.Name, "ids=") || autocompleteAPI.Name == "listUsageTypes" + argOptions = buildArgOptions(response, hasID) } - var selected string - if len(autocompleteOptions) > 1 { - sort.Slice(autocompleteOptions, func(i, j int) bool { - return autocompleteOptions[i].Name < autocompleteOptions[j].Name - }) - selectedOption := showSelector(autocompleteOptions) - if strings.HasSuffix(arg.Name, "id=") || strings.HasSuffix(arg.Name, "ids=") { - selected = selectedOption.ID + filteredOptions := []argOption{} + if len(argOptions) > 0 { + if autocompleteAPI.Name == "listUsageTypes" { + sort.Slice(argOptions, func(i, j int) bool { + i, _ = strconv.Atoi(argOptions[i].Value) + j, _ = strconv.Atoi(argOptions[j].Value) + return i < j + }) } else { - selected = selectedOption.Name + sort.Slice(argOptions, func(i, j int) bool { + return argOptions[i].Value < argOptions[j].Value + }) } - } else { - if len(autocompleteOptions) == 1 { - selected = autocompleteOptions[0].ID + for _, item := range argOptions { + if strings.HasPrefix(item.Value, argInput) || (len(item.Detail) > 0 && strings.HasPrefix(item.Detail, argInput)) { + filteredOptions = append(filteredOptions, item) + } } } - options = [][]rune{[]rune(selected + " ")} offset = 0 + if len(filteredOptions) == 0 { + options = [][]rune{[]rune("")} + } + for _, item := range filteredOptions { + option := item.Value + " " + if len(filteredOptions) > 0 && len(item.Detail) > 0 { + option += fmt.Sprintf("(%v)", item.Detail) + } + if strings.HasPrefix(option, argInput) { + options = append(options, []rune(option[len(argInput):])) + offset = len(argInput) + } else if len(item.Detail) > 0 && strings.HasPrefix(item.Detail, argInput) { + options = append(options, []rune(option)) + } + } + return } } diff --git a/cli/exec.go b/cli/exec.go index cbf58957..1b4a0d47 100644 --- a/cli/exec.go +++ b/cli/exec.go @@ -18,21 +18,52 @@ package cli import ( - "cmk/cmd" - "cmk/config" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/apache/cloudstack-cloudmonkey/cmd" + "github.com/apache/cloudstack-cloudmonkey/config" + "github.com/google/shlex" ) +// ExecLine executes a line of command +func ExecLine(line string) error { + config.Debug("ExecLine line:", line) + args, err := shlex.Split(line) + if err != nil { + return err + } + + if runtime.GOOS != "windows" { + for _, arg := range args { + if arg == "|" { + fullCommand := fmt.Sprintf("%s %v", strings.Join(os.Args, " "), line) + result, err := exec.Command("bash", "-c", fullCommand).Output() + fmt.Print(string(result)) + return err + } + } + } + + return ExecCmd(args, false) +} + // ExecCmd executes a single provided command -func ExecCmd(cfg *config.Config, args []string) error { +func ExecCmd(args []string, credentialsSupplied bool) error { + config.Debug("ExecCmd args: ", strings.Join(args, ", ")) if len(args) < 1 { return nil } command := cmd.FindCommand(args[0]) - if command != nil { - return command.Handle(cmd.NewRequest(command, cfg, args[1:])) + if command != nil && !(args[0] == "sync" && len(args) > 1) { + r := cmd.NewRequest(command, cfg, args[1:], credentialsSupplied) + return command.Handle(r) } catchAllHandler := cmd.GetAPIHandler() - return catchAllHandler.Handle(cmd.NewRequest(catchAllHandler, cfg, args)) + return catchAllHandler.Handle(cmd.NewRequest(catchAllHandler, cfg, args, credentialsSupplied)) } diff --git a/cli/shell.go b/cli/prompt.go similarity index 73% rename from cli/shell.go rename to cli/prompt.go index 342fe25c..8d3813d2 100644 --- a/cli/shell.go +++ b/cli/prompt.go @@ -22,33 +22,26 @@ import ( "io" "strings" - "cmk/config" - - "github.com/mattn/go-shellwords" - + "github.com/apache/cloudstack-cloudmonkey/config" "github.com/chzyer/readline" - "os" ) +// CLI config instance +var cfg *config.Config + +// SetConfig allows to set a config.Config object to cli +func SetConfig(c *config.Config) { + cfg = c +} + var completer *autoCompleter var shell *readline.Instance -// ExecShell starts a shell -func ExecShell(sysArgs []string) { - cfg := config.NewConfig() +// ExecPrompt starts a CLI prompt +func ExecPrompt() { completer = &autoCompleter{ Config: cfg, } - - if len(sysArgs) > 0 { - err := ExecCmd(cfg, sysArgs) - if err != nil { - fmt.Println("🙈 Error:", err) - os.Exit(1) - } - os.Exit(0) - } - shell, err := readline.NewEx(&readline.Config{ Prompt: cfg.GetPrompt(), HistoryFile: cfg.HistoryFile, @@ -88,22 +81,9 @@ func ExecShell(sysArgs []string) { continue } - shellwords.ParseEnv = true - parser := shellwords.NewParser() - args, err := parser.Parse(line) - if err != nil { - fmt.Println("Failed to parse line:", err) - continue - } - - if parser.Position > 0 { - line = fmt.Sprintf("shell %s %v", cfg.Name(), line) - args = strings.Split(line, " ") - } - - err = ExecCmd(cfg, args) - if err != nil { + if err = ExecLine(line); err != nil { fmt.Println("🙈 Error:", err) } } + } diff --git a/cli/selector.go b/cli/selector.go deleted file mode 100644 index 19933f96..00000000 --- a/cli/selector.go +++ /dev/null @@ -1,105 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you 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. - -package cli - -import ( - "fmt" - "strings" - - "github.com/chzyer/readline" - "github.com/manifoldco/promptui" -) - -type selectOption struct { - ID string - Name string - Detail string -} - -type selector struct { - InUse bool -} - -var optionSelector selector - -func init() { - optionSelector = selector{ - InUse: false, - } -} - -func (s selector) lock() { - s.InUse = true -} - -func (s selector) unlock() { - s.InUse = false -} - -func showSelector(options []selectOption) selectOption { - if optionSelector.InUse { - return selectOption{} - } - optionSelector.lock() - defer optionSelector.unlock() - - templates := &promptui.SelectTemplates{ - Label: "{{ . | magenta }}", - Active: "▶ {{ .Name | cyan }} ({{ .ID | green }})", - Inactive: " {{ .Name }} ({{ .ID }})", - Selected: "Selected option: {{ .Name | cyan }} ({{ .ID | green }})", - Details: ` ---------- Current Selection ---------- -{{ "ID:" | faint }} {{ .ID }} -{{ "Name:" | faint }} {{ .Name }} -{{ "Info:" | faint }} {{ .Detail }}`, - } - - searcher := func(input string, index int) bool { - pepper := options[index] - name := strings.Replace(strings.ToLower(pepper.Name), " ", "", -1) - input = strings.Replace(strings.ToLower(input), " ", "", -1) - - return strings.Contains(name, input) - } - - prompt := promptui.Select{ - Label: "Use the arrow keys to navigate: ↓ ↑ → ← press / to toggle 🔍 search", - Items: options, - Templates: templates, - Size: 5, - Searcher: searcher, - StartInSearchMode: true, - Keys: &promptui.SelectKeys{ - Prev: promptui.Key{Code: readline.CharPrev, Display: "↑"}, - Next: promptui.Key{Code: readline.CharNext, Display: "↓"}, - PageUp: promptui.Key{Code: readline.CharBackward, Display: "←"}, - PageDown: promptui.Key{Code: readline.CharForward, Display: "→"}, - Search: promptui.Key{Code: '/', Display: "/"}, - }, - } - - i, _, err := prompt.Run() - - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return selectOption{} - } - - return options[i] -} diff --git a/cmd/api.go b/cmd/api.go index 0c3b7fd9..f7b91496 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -21,6 +21,8 @@ import ( "errors" "fmt" "strings" + + "github.com/apache/cloudstack-cloudmonkey/config" ) var apiCommand *Command @@ -46,11 +48,23 @@ func init() { apiArgs = r.Args[2:] } - for _, arg := range r.Args { + var uploadFiles []string + + for _, arg := range apiArgs { if arg == "-h" { r.Args[0] = apiName return helpCommand.Handle(r) } + if strings.HasPrefix(arg, config.FilePathArg) && config.IsFileUploadAPI(apiName) { + var err error + uploadFiles, err = ValidateAndGetFileList(arg[len(config.FilePathArg):]) + if err != nil { + return err + } + if len(uploadFiles) == 0 { + return errors.New("no valid files to upload") + } + } } api := r.Config.GetCache()[apiName] @@ -60,9 +74,10 @@ func init() { var missingArgs []string for _, required := range api.RequiredArgs { + required = strings.ReplaceAll(required, "=", "") provided := false for _, arg := range apiArgs { - if strings.HasPrefix(arg, required) { + if strings.Contains(arg, "=") && strings.HasPrefix(arg, required) { provided = true } } @@ -72,14 +87,16 @@ func init() { } if len(missingArgs) > 0 { - fmt.Println("💩 Missing required arguments: ", strings.Join(missingArgs, ", ")) + fmt.Println("💩 Missing required parameters: ", strings.Join(missingArgs, ", ")) return nil } response, err := NewAPIRequest(r, api.Name, apiArgs, api.Async) if err != nil { - if response != nil { - printResult(r.Config.Core.Output, response, nil) + if strings.HasSuffix(err.Error(), "context canceled") { + return nil + } else if response != nil { + printResult(r.Config.Core.Output, response, nil, nil) } return err } @@ -87,12 +104,33 @@ func init() { var filterKeys []string for _, arg := range apiArgs { if strings.HasPrefix(arg, "filter=") { - filterKeys = strings.Split(strings.Split(arg, "=")[1], ",") + for _, filterKey := range strings.Split(strings.Split(arg, "=")[1], ",") { + if len(strings.TrimSpace(filterKey)) > 0 { + filterKeys = append(filterKeys, strings.TrimSpace(filterKey)) + } + } } } - printResult(r.Config.Core.Output, response, filterKeys) + var excludeKeys []string + for _, arg := range apiArgs { + if strings.HasPrefix(arg, "exclude=") { + for _, excludeKey := range strings.Split(strings.Split(arg, "=")[1], ",") { + if len(strings.TrimSpace(excludeKey)) > 0 { + excludeKeys = append(excludeKeys, strings.TrimSpace(excludeKey)) + } + } + } + } + if len(response) > 0 { + printResult(r.Config.Core.Output, response, filterKeys, excludeKeys) + if len(uploadFiles) > 0 { + UploadFiles(r, api.Name, response, uploadFiles) + } else { + PromptAndUploadFilesIfNeeded(r, api.Name, response) + } + } return nil }, } diff --git a/cmd/command.go b/cmd/command.go index 179dd74d..19287553 100644 --- a/cmd/command.go +++ b/cmd/command.go @@ -56,14 +56,24 @@ func AddCommand(cmd *Command) { func PrintUsage() { commandHelp := "" for _, cmd := range commands { - commandHelp += fmt.Sprintf("%s\t\t%s\n", cmd.Name, cmd.Help) + commandHelp += fmt.Sprintf(" %-8s %s\n", cmd.Name, cmd.Help) } - fmt.Printf(`Usage: cmk [options] [commands] + fmt.Printf(`usage: cmk [flags] [commands|apis] [-h] CloudMonkey (cmk) 🐵 is a command line interface for Apache CloudStack. +Allowed flags: + -h Show this help message or API doc when specified after an API + -v Print version + -o API response output format: json, text, table, column, csv + -p Server profile + -d Enable debug mode + -c Different config file path + -u CloudStack's API endpoint URL + -s CloudStack user's secret Key + -k CloudStack user's API Key + Default commands: %s -Try cmk [help] or cmk [action api] -h `, commandHelp) } diff --git a/cmd/exit.go b/cmd/exit.go index 104c5b26..0712c594 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -19,8 +19,6 @@ package cmd import ( "os" - - "github.com/manifoldco/promptui" ) func init() { @@ -28,14 +26,7 @@ func init() { Name: "exit", Help: "Exits", Handle: func(r *Request) error { - prompt := promptui.Prompt{ - Label: "Do you really want to exit ([y]/n)?", - IsConfirm: true, - } - - if result, _ := prompt.Run(); result == "y" { - os.Exit(0) - } + os.Exit(0) return nil }, }) diff --git a/cmd/fileupload.go b/cmd/fileupload.go new file mode 100644 index 00000000..30bf9861 --- /dev/null +++ b/cmd/fileupload.go @@ -0,0 +1,255 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +package cmd + +import ( + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "time" + + "github.com/apache/cloudstack-cloudmonkey/config" + "github.com/briandowns/spinner" +) + +const ( + uploadingMessage = "Uploading files, please wait..." + progressCharCount = 24 +) + +// ValidateAndGetFileList parses a comma-separated string of file paths, trims them, +// checks for existence, and returns a slice of valid file paths or an error if any are missing. +func ValidateAndGetFileList(filePaths string) ([]string, error) { + filePathsList := strings.FieldsFunc(filePaths, func(r rune) bool { return r == ',' }) + + var missingFiles []string + var validFiles []string + for _, filePath := range filePathsList { + filePath = strings.TrimSpace(filePath) + if filePath == "" { + continue + } + if _, err := os.Stat(filePath); os.IsNotExist(err) { + missingFiles = append(missingFiles, filePath) + } else { + validFiles = append(validFiles, filePath) + } + } + if len(missingFiles) > 0 { + return nil, fmt.Errorf("file(s) do not exist or are not accessible: %s", strings.Join(missingFiles, ", ")) + } + return validFiles, nil +} + +// PromptAndUploadFilesIfNeeded prompts the user to provide file paths for upload and the API is getUploadParamsFor* +func PromptAndUploadFilesIfNeeded(r *Request, api string, response map[string]interface{}) { + if !r.Config.HasShell { + return + } + apiName := strings.ToLower(api) + if !config.IsFileUploadAPI(apiName) { + return + } + fmt.Print("Enter path of the file(s) to upload (comma-separated), leave empty to skip: ") + var filePaths string + fmt.Scanln(&filePaths) + if filePaths == "" { + return + } + validFiles, err := ValidateAndGetFileList(filePaths) + if err != nil { + fmt.Println(err) + return + } + if len(validFiles) == 0 { + fmt.Println("No valid files to upload.") + return + } + UploadFiles(r, api, response, validFiles) +} + +// UploadFiles uploads files to a remote server using parameters from the API response. +// Shows progress for each file and reports any failures. +func UploadFiles(r *Request, api string, response map[string]interface{}, validFiles []string) { + paramsRaw, ok := response["getuploadparams"] + if !ok || reflect.TypeOf(paramsRaw).Kind() != reflect.Map { + fmt.Println("Invalid response format for getuploadparams.") + return + } + params := paramsRaw.(map[string]interface{}) + requiredKeys := []string{"postURL", "metadata", "signature", "expires"} + for _, key := range requiredKeys { + if _, ok := params[key]; !ok { + fmt.Printf("Missing required key '%s' in getuploadparams response.\n", key) + return + } + } + postURL, _ := params["postURL"].(string) + signature, _ := params["signature"].(string) + expires, _ := params["expires"].(string) + metadata, _ := params["metadata"].(string) + + fmt.Println("Uploading files for", api, ":", validFiles) + spinner := r.Config.StartSpinner(uploadingMessage) + errored := 0 + for i, filePath := range validFiles { + spinner.Suffix = fmt.Sprintf(" uploading %d/%d %s...", i+1, len(validFiles), filepath.Base(filePath)) + if err := uploadFile(i, len(validFiles), postURL, filePath, signature, expires, metadata, spinner); err != nil { + spinner.Stop() + fmt.Println("Error uploading", filePath, ":", err) + errored++ + spinner.Suffix = fmt.Sprintf(" %s", uploadingMessage) + spinner.Start() + } + } + r.Config.StopSpinner(spinner) + if errored > 0 { + fmt.Printf("🙈 %d out of %d files failed to upload.\n", errored, len(validFiles)) + } else { + fmt.Println("All files uploaded successfully.") + } +} + +// progressReader streams file data and updates progress as bytes are read. +type progressBody struct { + f *os.File + read int64 + total int64 + update func(int) +} + +func (pb *progressBody) Read(p []byte) (int, error) { + n, err := pb.f.Read(p) + if n > 0 { + pb.read += int64(n) + pct := int(float64(pb.read) * 100 / float64(pb.total)) + pb.update(pct) + } + return n, err +} +func (pb *progressBody) Close() error { return pb.f.Close() } + +func barArrow(pct int) string { + width := progressCharCount + if pct < 0 { + pct = 0 + } + if pct > 100 { + pct = 100 + } + pos := (pct * width) / 100 + // 100%: full bar, no head + if pos >= width { + return fmt.Sprintf("[%s]", + strings.Repeat("=", width)) + } + left := strings.Repeat("=", pos) + ">" + right := strings.Repeat(" ", width-pos-1) + + return fmt.Sprintf("[%s%s]", left, right) +} + +// uploadFile streams a large file to the server with progress updates. +func uploadFile(index, count int, postURL, filePath, signature, expires, metadata string, spn *spinner.Spinner) error { + fileName := filepath.Base(filePath) + in, err := os.Open(filePath) + if err != nil { + return err + } + defer in.Close() + _, err = in.Stat() + if err != nil { + return err + } + tmp, err := os.CreateTemp("", "multipart-body-*.tmp") + if err != nil { + return err + } + defer func() { + tmp.Close() + os.Remove(tmp.Name()) + }() + mw := multipart.NewWriter(tmp) + part, err := mw.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return err + } + if _, err := io.Copy(part, in); err != nil { + return err + } + if err := mw.Close(); err != nil { + return err + } + size, err := tmp.Seek(0, io.SeekEnd) + if err != nil { + return err + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + return err + } + req, err := http.NewRequest("POST", postURL, nil) + if err != nil { + return err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("x-signature", signature) + req.Header.Set("x-expires", expires) + req.Header.Set("x-metadata", metadata) + req.ContentLength = size + pb := &progressBody{ + f: tmp, + total: size, + update: func(pct int) { + spn.Suffix = fmt.Sprintf(" [%d/%d] %s\t%s %d%%", index+1, count, fileName, barArrow(pct), pct) + }, + } + req.Body = pb + req.GetBody = func() (io.ReadCloser, error) { + f, err := os.Open(tmp.Name()) + if err != nil { + return nil, err + } + return f, nil + } + client := &http.Client{ + Timeout: 24 * time.Hour, + Transport: &http.Transport{ + ExpectContinueTimeout: 0, + }, + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("[%d/%d] %s\tupload failed: %s", index+1, count, fileName, string(b)) + } + + spn.Stop() + fmt.Printf("[%d/%d] %s\t%s ✅\n", index+1, count, fileName, barArrow(100)) + spn.Suffix = fmt.Sprintf(" %s", uploadingMessage) + spn.Start() + return nil +} diff --git a/cmd/help.go b/cmd/help.go index 98d1f711..9b9ba603 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -22,7 +22,7 @@ import ( "fmt" "strings" - "cmk/config" + "github.com/apache/cloudstack-cloudmonkey/config" ) var helpCommand *Command diff --git a/cmd/login.go b/cmd/login.go deleted file mode 100644 index bb1655d9..00000000 --- a/cmd/login.go +++ /dev/null @@ -1,93 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you 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. - -package cmd - -import ( - "errors" - "fmt" - - "github.com/manifoldco/promptui" -) - -func init() { - AddCommand(&Command{ - Name: "login", - Help: "Log in to your account", - Handle: func(r *Request) error { - if len(r.Args) > 0 { - return errors.New("this does not accept any additional arguments") - } - - validate := func(input string) error { - if len(input) < 1 { - return errors.New("You have not entered anything") - } - return nil - } - - // username - prompt := promptui.Prompt{ - Label: "Username", - Validate: validate, - Default: "", - } - username, err := prompt.Run() - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return nil - } - - //password - prompt = promptui.Prompt{ - Label: "Password", - Validate: validate, - Mask: '*', - } - password, err := prompt.Run() - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return nil - } - - // domain - prompt = promptui.Prompt{ - Label: "Domain", - Validate: validate, - Default: "/", - } - domain, err := prompt.Run() - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return nil - } - - r.Config.ActiveProfile.Username = username - r.Config.ActiveProfile.Password = password - r.Config.ActiveProfile.Domain = domain - - if sessionKey, err := Login(r); err != nil || sessionKey == "" { - fmt.Println("Failed to login, check credentials and try again.") - } else { - fmt.Println("Successfully logged in and saved credentials to the server profile.") - r.Config.UpdateConfig("", "") - } - - return nil - }, - }) -} diff --git a/cmd/network.go b/cmd/network.go index 856ae1a5..286abb5d 100644 --- a/cmd/network.go +++ b/cmd/network.go @@ -29,9 +29,12 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "os" "sort" "strings" "time" + + "github.com/apache/cloudstack-cloudmonkey/config" ) func findSessionCookie(cookies []*http.Cookie) *http.Cookie { @@ -46,6 +49,84 @@ func findSessionCookie(cookies []*http.Cookie) *http.Cookie { return nil } +func getLoginResponse(responseBody []byte) (map[string]interface{}, error) { + var responseMap map[string]interface{} + err := json.Unmarshal(responseBody, &responseMap) + if err != nil { + return nil, errors.New("failed to parse login response: " + err.Error()) + } + loginRespRaw, ok := responseMap["loginresponse"] + if !ok { + return nil, errors.New("failed to parse login response, expected 'loginresponse' key not found") + } + loginResponse, ok := loginRespRaw.(map[string]interface{}) + if !ok { + return nil, errors.New("failed to parse login response, expected 'loginresponse' to be a map") + } + return loginResponse, nil +} + +func getResponseBooleanValue(response map[string]interface{}, key string) (bool, bool) { + v, found := response[key] + if !found { + return false, false + } + switch value := v.(type) { + case bool: + return true, value + case string: + return true, strings.ToLower(value) == "true" + case float64: + return true, value != 0 + default: + return true, false + } +} + +func checkLogin2FAPromptAndValidate(r *Request, response map[string]interface{}, sessionKey string) error { + if !r.Config.HasShell { + return nil + } + config.Debug("Checking if 2FA is enabled and verified for the user ", response) + found, is2faEnabled := getResponseBooleanValue(response, "is2faenabled") + if !found || !is2faEnabled { + config.Debug("2FA is not enabled for the user, skipping 2FA validation") + return nil + } + found, is2faVerified := getResponseBooleanValue(response, "is2faverified") + if !found || is2faVerified { + config.Debug("2FA is already verified for the user, skipping 2FA validation") + return nil + } + activeSpinners := r.Config.PauseActiveSpinners() + fmt.Print("Enter 2FA code: ") + var code string + fmt.Scanln(&code) + if activeSpinners > 0 { + r.Config.ResumePausedSpinners() + } + params := make(url.Values) + params.Add("command", "validateUserTwoFactorAuthenticationCode") + params.Add("codefor2fa", code) + params.Add("sessionkey", sessionKey) + + msURL, _ := url.Parse(r.Config.ActiveProfile.URL) + + config.Debug("Validating 2FA with POST URL:", msURL, params) + spinner := r.Config.StartSpinner("trying to validate 2FA...") + resp, err := r.Client().PostForm(msURL.String(), params) + r.Config.StopSpinner(spinner) + if err != nil { + return errors.New("failed to failed to validate 2FA code: " + err.Error()) + } + config.Debug("ValidateUserTwoFactorAuthenticationCode POST response status code:", resp.StatusCode) + if resp.StatusCode != http.StatusOK { + r.Client().Jar, _ = cookiejar.New(nil) + return errors.New("failed to validate 2FA code, please check the code. Invalidating session") + } + return nil +} + // Login logs in a user based on provided request and returns http client and session key func Login(r *Request) (string, error) { params := make(url.Values) @@ -55,19 +136,21 @@ func Login(r *Request) (string, error) { params.Add("domain", r.Config.ActiveProfile.Domain) params.Add("response", "json") - msUrl, _ := url.Parse(r.Config.ActiveProfile.URL) - if sessionCookie := findSessionCookie(r.Client().Jar.Cookies(msUrl)); sessionCookie != nil { + msURL, _ := url.Parse(r.Config.ActiveProfile.URL) + if sessionCookie := findSessionCookie(r.Client().Jar.Cookies(msURL)); sessionCookie != nil { return sessionCookie.Value, nil } + config.Debug("Login POST URL:", msURL, params) spinner := r.Config.StartSpinner("trying to log in...") - resp, err := r.Client().PostForm(msUrl.String(), params) + resp, err := r.Client().PostForm(msURL.String(), params) r.Config.StopSpinner(spinner) if err != nil { return "", errors.New("failed to authenticate with the CloudStack server, please check the settings: " + err.Error()) } + config.Debug("Login POST response status code:", resp.StatusCode) if resp.StatusCode != http.StatusOK { e := errors.New("failed to authenticate, please check the credentials") if err != nil { @@ -76,6 +159,13 @@ func Login(r *Request) (string, error) { return "", e } + body, _ := ioutil.ReadAll(resp.Body) + config.Debug("Login response body:", string(body)) + loginResponse, err := getLoginResponse(body) + if err != nil { + return "", err + } + var sessionKey string curTime := time.Now() expiryDuration := 15 * time.Minute @@ -92,6 +182,10 @@ func Login(r *Request) (string, error) { r.Client().Jar, _ = cookiejar.New(nil) }() + config.Debug("Login sessionkey:", sessionKey) + if err := checkLogin2FAPromptAndValidate(r, loginResponse, sessionKey); err != nil { + return "", err + } return sessionKey, nil } @@ -114,7 +208,12 @@ func encodeRequestParams(params url.Values) string { } buf.WriteString(key) buf.WriteString("=") - buf.WriteString(url.QueryEscape(value)) + escaped := url.QueryEscape(value) + // we need to ensure + (representing a space) is encoded as %20 + escaped = strings.Replace(escaped, "+", "%20", -1) + // we need to ensure * is not escaped + escaped = strings.Replace(escaped, "%2A", "*", -1) + buf.WriteString(escaped) } return buf.String() } @@ -129,45 +228,97 @@ func getResponseData(data map[string]interface{}) map[string]interface{} { } func pollAsyncJob(r *Request, jobID string) (map[string]interface{}, error) { - for timeout := float64(r.Config.Core.Timeout); timeout > 0.0; { - startTime := time.Now() - spinner := r.Config.StartSpinner("polling for async API result") - queryResult, queryError := NewAPIRequest(r, "queryAsyncJobResult", []string{"jobid=" + jobID}, false) - diff := time.Duration(1*time.Second).Nanoseconds() - time.Now().Sub(startTime).Nanoseconds() - if diff > 0 { - time.Sleep(time.Duration(diff) * time.Nanosecond) - } - timeout = timeout - time.Now().Sub(startTime).Seconds() - r.Config.StopSpinner(spinner) - if queryError != nil { - return queryResult, queryError - } - jobStatus := queryResult["jobstatus"].(float64) - if jobStatus == 0 { - continue - } - if jobStatus == 1 { - return queryResult["jobresult"].(map[string]interface{}), nil + timeout := time.NewTimer(time.Duration(float64(r.Config.Core.Timeout)) * time.Second) + ticker := time.NewTicker(time.Duration(2 * time.Second)) + defer ticker.Stop() + defer timeout.Stop() - } - if jobStatus == 2 { - return queryResult, errors.New("async API failed for job " + jobID) + spinner := r.Config.StartSpinner("polling for async API result") + defer r.Config.StopSpinner(spinner) + + for { + select { + case <-r.Config.C: + return nil, errors.New("async API job polling interrupted") + + case <-timeout.C: + return nil, errors.New("async API job query timed out") + + case <-ticker.C: + args := []string{"jobid=" + jobID} + if r.Args != nil { + for _, arg := range r.Args { + if strings.HasPrefix(strings.ToLower(arg), "filter=") { + args = append(args, arg) + break + } + } + } + queryResult, queryError := NewAPIRequest(r, "queryAsyncJobResult", args, false) + if queryError != nil { + return queryResult, queryError + } + jobStatus := queryResult["jobstatus"].(float64) + + switch jobStatus { + case 0: + continue + + case 1: + return queryResult["jobresult"].(map[string]interface{}), nil + + case 2: + return queryResult, errors.New("async API failed for job " + jobID) + } } } - return nil, errors.New("async API job query timed out") } // NewAPIRequest makes an API request to configured management server func NewAPIRequest(r *Request, api string, args []string, isAsync bool) (map[string]interface{}, error) { params := make(url.Values) params.Add("command", api) + apiData := r.Config.GetCache()[api] for _, arg := range args { + if apiData != nil { + skip := false + for _, fakeArg := range apiData.FakeArgs { + if strings.HasPrefix(arg, fakeArg) { + skip = true + break + } + } + if skip { + continue + } + + } parts := strings.SplitN(arg, "=", 2) if len(parts) == 2 { - params.Add(parts[0], parts[1]) + key := parts[0] + value := parts[1] + if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") { + value = value[1 : len(value)-1] + } + if strings.HasPrefix(value, "@") { + possibleFileName := value[1:] + if fileInfo, err := os.Stat(possibleFileName); err == nil && !fileInfo.IsDir() { + bytes, err := ioutil.ReadFile(possibleFileName) + config.Debug() + if err == nil { + value = string(bytes) + config.Debug("Content for argument ", key, " read from file: ", possibleFileName, " is: ", value) + } + } + } + params.Add(key, value) } } + signatureversion := "3" + expiresKey := "expires" params.Add("response", "json") + params.Add("signatureversion", signatureversion) + params.Add(expiresKey, time.Now().UTC().Add(15*time.Minute).Format(time.RFC3339)) var encodedParams string var err error @@ -182,9 +333,14 @@ func NewAPIRequest(r *Request, api string, args []string, isAsync bool) (map[str encodedParams = encodeRequestParams(params) mac := hmac.New(sha1.New, []byte(secretKey)) - mac.Write([]byte(strings.Replace(strings.ToLower(encodedParams), "+", "%20", -1))) + mac.Write([]byte(strings.ToLower(encodedParams))) signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - encodedParams = encodedParams + fmt.Sprintf("&signature=%s", url.QueryEscape(signature)) + if r.Config.Core.PostRequest { + params.Add("signature", signature) + } else { + encodedParams = encodedParams + fmt.Sprintf("&signature=%s", url.QueryEscape(signature)) + params = nil + } } else if len(r.Config.ActiveProfile.Username) > 0 && len(r.Config.ActiveProfile.Password) > 0 { sessionKey, err := Login(r) if err != nil { @@ -197,12 +353,21 @@ func NewAPIRequest(r *Request, api string, args []string, isAsync bool) (map[str return nil, errors.New("failed to authenticate to make API call") } - response, err := r.Client().Get(fmt.Sprintf("%s?%s", r.Config.ActiveProfile.URL, encodedParams)) + requestURL := fmt.Sprintf("%s?%s", r.Config.ActiveProfile.URL, encodedParams) + config.Debug("NewAPIRequest API request URL:", requestURL) + + var response *http.Response + response, err = executeRequest(r, requestURL, params) if err != nil { return nil, err } + config.Debug("NewAPIRequest response status code:", response.StatusCode) - if response != nil && response.StatusCode == http.StatusUnauthorized { + if r.CredentialsSupplied { + config.Debug("Credentials supplied on command-line, not falling back to login") + } + + if response.StatusCode == http.StatusUnauthorized && !r.CredentialsSupplied { r.Client().Jar, _ = cookiejar.New(nil) sessionKey, err := Login(r) if err != nil { @@ -210,13 +375,17 @@ func NewAPIRequest(r *Request, api string, args []string, isAsync bool) (map[str } params.Del("sessionkey") params.Add("sessionkey", sessionKey) - response, err = r.Client().Get(fmt.Sprintf("%s?%s", r.Config.ActiveProfile.URL, encodeRequestParams(params))) + requestURL = fmt.Sprintf("%s?%s", r.Config.ActiveProfile.URL, encodeRequestParams(params)) + config.Debug("NewAPIRequest API request URL:", requestURL) + + response, err = executeRequest(r, requestURL, params) if err != nil { return nil, err } } body, _ := ioutil.ReadAll(response.Body) + config.Debug("NewAPIRequest response body:", string(body)) var data map[string]interface{} _ = json.Unmarshal([]byte(body), &data) @@ -237,3 +406,16 @@ func NewAPIRequest(r *Request, api string, args []string, isAsync bool) (map[str return nil, errors.New("failed to decode response") } + +// we can implement further conditions to do POST or GET (or other http commands) here +func executeRequest(r *Request, requestURL string, params url.Values) (*http.Response, error) { + config.SetupContext(r.Config) + if params.Has("password") || params.Has("userdata") || r.Config.Core.PostRequest { + requestURL = r.Config.ActiveProfile.URL + config.Debug("Using HTTP POST for the request: ", requestURL) + return r.Client().PostForm(requestURL, params) + } + config.Debug("Using HTTP GET for the request: ", requestURL) + req, _ := http.NewRequestWithContext(*r.Config.Context, "GET", requestURL, nil) + return r.Client().Do(req) +} diff --git a/cmd/output.go b/cmd/output.go index c24d51dc..79f39009 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -18,90 +18,183 @@ package cmd import ( - "cmk/config" + "encoding/csv" "encoding/json" "fmt" - "github.com/olekukonko/tablewriter" "os" "reflect" "sort" "strings" + "text/tabwriter" + + "github.com/apache/cloudstack-cloudmonkey/config" + "github.com/olekukonko/tablewriter" ) +func jsonify(value interface{}, format string) string { + if value == nil { + return "" + } + if reflect.TypeOf(value).Kind() == reflect.Map || reflect.TypeOf(value).Kind() == reflect.Slice { + var jsonStr []byte + var err error + if format == "text" { + jsonStr, err = json.MarshalIndent(value, "", "") + } else { + jsonStr, err = json.Marshal(value) + } + if err == nil { + value = string(jsonStr) + } + } + switch value.(type) { + case float64, float32: + return fmt.Sprintf("%.f", value) + default: + return fmt.Sprintf("%v", value) + } +} + func printJSON(response map[string]interface{}) { - jsonOutput, err := json.MarshalIndent(response, "", " ") - if err != nil { - fmt.Println("Error during json marshalling:", err.Error()) + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + enc.Encode(response) +} + +func getItemsFromValue(v interface{}) ([]interface{}, reflect.Kind, bool) { + valueKind := reflect.TypeOf(v).Kind() + if valueKind == reflect.Slice { + sliceItems, ok := v.([]interface{}) + if !ok { + return nil, valueKind, false + } + return sliceItems, valueKind, true + } else if valueKind == reflect.Map { + mapItem, ok := v.(map[string]interface{}) + if !ok { + return nil, valueKind, false + } + return []interface{}{mapItem}, valueKind, true + } + return nil, valueKind, false +} + +func printText(response map[string]interface{}) { + format := "text" + for k, v := range response { + valueType := reflect.TypeOf(v) + if valueType.Kind() == reflect.Slice || valueType.Kind() == reflect.Map { + items, _, ok := getItemsFromValue(v) + if ok { + fmt.Printf("%v:\n", k) + for idx, item := range items { + if idx > 0 { + fmt.Println("================================================================================") + } + row, isMap := item.(map[string]interface{}) + if isMap { + for field, value := range row { + fmt.Printf("%s = %v\n", field, jsonify(value, format)) + } + } else { + fmt.Printf("%v\n", item) + } + } + return + } + } + fmt.Printf("%v = %v\n", k, jsonify(v, format)) } - fmt.Println(string(jsonOutput)) } -func printTable(response map[string]interface{}) { +func printTable(response map[string]interface{}, filter []string) { + format := "table" table := tablewriter.NewWriter(os.Stdout) for k, v := range response { valueType := reflect.TypeOf(v) - if valueType.Kind() == reflect.Slice { - items, ok := v.([]interface{}) + if valueType.Kind() == reflect.Slice || valueType.Kind() == reflect.Map { + items, _, ok := getItemsFromValue(v) if !ok { continue } - fmt.Printf("%s:\n", k) + fmt.Printf("%v:\n", k) var header []string for _, item := range items { row, ok := item.(map[string]interface{}) if !ok || len(row) < 1 { continue } - if len(header) == 0 { - for field := range row { - header = append(header, field) + if len(filter) > 0 { + header = filter + } else { + for field := range row { + header = append(header, field) + } + sort.Strings(header) } - sort.Strings(header) table.SetHeader(header) } var rowArray []string for _, field := range header { - rowArray = append(rowArray, fmt.Sprintf("%v", row[field])) + rowArray = append(rowArray, jsonify(row[field], format)) } table.Append(rowArray) } } else { - fmt.Printf("%s = %v\n", k, v) + fmt.Printf("%v = %v\n", k, v) } } table.Render() } -func printText(response map[string]interface{}) { - for k, v := range response { +func printColumn(response map[string]interface{}, filter []string) { + format := "column" + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.DiscardEmptyColumns) + for _, v := range response { valueType := reflect.TypeOf(v) - if valueType.Kind() == reflect.Slice { - fmt.Printf("%s:\n", k) - for idx, item := range v.([]interface{}) { - if idx > 0 { - fmt.Println("================================================================================") + if valueType.Kind() == reflect.Slice || valueType.Kind() == reflect.Map { + items, _, ok := getItemsFromValue(v) + if !ok { + continue + } + var header []string + for idx, item := range items { + row, ok := item.(map[string]interface{}) + if !ok || len(row) < 1 { + continue } - row, isMap := item.(map[string]interface{}) - if isMap { - for field, value := range row { - fmt.Printf("%s = %v\n", field, value) + + if idx == 0 { + if len(filter) > 0 { + header = filter + } else { + for rk := range row { + header = append(header, strings.ToUpper(rk)) + } + sort.Strings(header) } - } else { - fmt.Printf("%v\n", item) + fmt.Fprintln(w, strings.Join(header, "\t")) } + var values []string + for _, key := range header { + values = append(values, jsonify(row[strings.ToLower(key)], format)) + } + fmt.Fprintln(w, strings.Join(values, "\t")) } - } else { - fmt.Printf("%s = %v\n", k, v) } } + w.Flush() } -func printCsv(response map[string]interface{}) { +func printCsv(response map[string]interface{}, filter []string) { + format := "csv" + enc := csv.NewWriter(os.Stdout) for _, v := range response { valueType := reflect.TypeOf(v) if valueType.Kind() == reflect.Slice || valueType.Kind() == reflect.Map { - items, ok := v.([]interface{}) + items, _, ok := getItemsFromValue(v) if !ok { continue } @@ -113,74 +206,108 @@ func printCsv(response map[string]interface{}) { } if idx == 0 { - for rk := range row { - header = append(header, rk) + if len(filter) > 0 { + header = filter + } else { + for rk := range row { + header = append(header, rk) + } + sort.Strings(header) } - sort.Strings(header) - fmt.Println(strings.Join(header, ",")) + enc.Write(header) } var values []string for _, key := range header { - values = append(values, fmt.Sprintf("%v", row[key])) + values = append(values, jsonify(row[key], format)) } - fmt.Println(strings.Join(values, ",")) + enc.Write(values) } - } } + enc.Flush() } -func filterResponse(response map[string]interface{}, filter []string) map[string]interface{} { - if filter == nil || len(filter) == 0 { +func filterResponse(response map[string]interface{}, filter []string, excludeFilter []string, outputType string) map[string]interface{} { + if len(filter) == 0 && len(excludeFilter) == 0 { return response } + + excludeSet := make(map[string]struct{}, len(excludeFilter)) + for _, key := range excludeFilter { + excludeSet[key] = struct{}{} + } + + filterSet := make(map[string]struct{}, len(filter)) + for _, key := range filter { + filterSet[key] = struct{}{} + } + filteredResponse := make(map[string]interface{}) - for k, v := range response { - valueType := reflect.TypeOf(v) + + for key, value := range response { + valueType := reflect.TypeOf(value) if valueType.Kind() == reflect.Slice || valueType.Kind() == reflect.Map { - items, ok := v.([]interface{}) + items, originalKind, ok := getItemsFromValue(value) if !ok { continue } var filteredRows []interface{} for _, item := range items { row, ok := item.(map[string]interface{}) - if !ok || len(row) < 1 { + if !ok || len(row) == 0 { continue } + filteredRow := make(map[string]interface{}) - for _, filterKey := range filter { - for field := range row { - if filterKey == field { - filteredRow[field] = row[field] + + if len(filter) > 0 { + // Include only keys that exist in filterSet + for filterKey := range filterSet { + if val, exists := row[filterKey]; exists { + filteredRow[filterKey] = val + } else if outputType == config.COLUMN || outputType == config.CSV || outputType == config.TABLE { + filteredRow[filterKey] = "" // Ensure all filter keys exist in row + } + } + } else { + // Exclude keys from excludeFilter + for field, val := range row { + if _, excluded := excludeSet[field]; !excluded { + filteredRow[field] = val } } } + filteredRows = append(filteredRows, filteredRow) } - filteredResponse[k] = filteredRows + if originalKind == reflect.Map && len(filteredRows) > 0 { + filteredResponse[key] = filteredRows[0] + } else { + filteredResponse[key] = filteredRows + } } else { - filteredResponse[k] = v - continue + filteredResponse[key] = value } - } + return filteredResponse } -func printResult(outputType string, response map[string]interface{}, filter []string) { - response = filterResponse(response, filter) +func printResult(outputType string, response map[string]interface{}, filter []string, excludeFilter []string) { + response = filterResponse(response, filter, excludeFilter, outputType) switch outputType { case config.JSON: printJSON(response) - case config.TABLE: - printTable(response) case config.TEXT: printText(response) + case config.COLUMN: + printColumn(response, filter) case config.CSV: - printCsv(response) - case config.XML: - fmt.Println("Unfinished output format: xml, use something else") + printCsv(response, filter) + case config.TABLE: + printTable(response, filter) + case config.DEFAULT: + printJSON(response) default: fmt.Println("Invalid output type configured, please fix that!") } diff --git a/cmd/request.go b/cmd/request.go index 125ebb7e..fc916800 100644 --- a/cmd/request.go +++ b/cmd/request.go @@ -18,15 +18,17 @@ package cmd import ( - "cmk/config" "net/http" + + "github.com/apache/cloudstack-cloudmonkey/config" ) // Request describes a command request type Request struct { - Command *Command - Config *config.Config - Args []string + Command *Command + Config *config.Config + Args []string + CredentialsSupplied bool } // Client method returns the http Client for the current server profile @@ -35,10 +37,11 @@ func (r *Request) Client() *http.Client { } // NewRequest creates a new request from a command -func NewRequest(cmd *Command, cfg *config.Config, args []string) *Request { +func NewRequest(cmd *Command, cfg *config.Config, args []string, credentialsSupplied bool) *Request { return &Request{ - Command: cmd, - Config: cfg, - Args: args, + Command: cmd, + Config: cfg, + Args: args, + CredentialsSupplied: credentialsSupplied, } } diff --git a/cmd/set.go b/cmd/set.go index a2eeb9a6..c8bba8af 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -18,8 +18,12 @@ package cmd import ( + "errors" "fmt" + "reflect" "strings" + + "github.com/apache/cloudstack-cloudmonkey/config" ) func init() { @@ -27,27 +31,44 @@ func init() { Name: "set", Help: "Configures options for cmk", SubCommands: map[string][]string{ - "prompt": {"🐵", "🐱", "random"}, - "asyncblock": {"true", "false"}, - "timeout": {"600", "1800", "3600"}, - "output": {"json", "text", "table", "csv", "xml"}, - "profile": {}, - "url": {}, - "username": {}, - "password": {}, - "domain": {}, - "apikey": {}, - "secretkey": {}, - "verifycert": {"true", "false"}, + "prompt": {"🐵", "🐱", "random"}, + "asyncblock": {"true", "false"}, + "timeout": {"600", "1800", "3600"}, + "output": config.GetOutputFormats(), + "profile": {}, + "url": {}, + "username": {}, + "password": {}, + "domain": {}, + "apikey": {}, + "secretkey": {}, + "verifycert": {"true", "false"}, + "debug": {"true", "false"}, + "autocomplete": {"true", "false"}, + "postrequest": {"true", "false"}, }, Handle: func(r *Request) error { if len(r.Args) < 1 { - fmt.Println("Please provide one of the sub-commands: ", r.Command.SubCommands) + fmt.Println("Please provide one of the sub-commands: ", reflect.ValueOf(r.Command.SubCommands).MapKeys()) return nil } subCommand := r.Args[0] - value := strings.Join(r.Args[1:], " ") - r.Config.UpdateConfig(subCommand, value) + value := strings.Trim(strings.Join(r.Args[1:], " "), " ") + config.Debug("Set command received:", subCommand, " values:", value) + if r.Args[len(r.Args)-1] == "-h" { + fmt.Println("Usage: set