├── .github ├── dependabot.yml └── workflows │ ├── acceptance.yml │ ├── main.yml │ ├── publish.yml │ ├── setup-cloudstack │ └── action.yml │ └── stale.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── cs ├── __init__.py ├── __main__.py ├── _async.py ├── client.py └── version.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | --- 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/acceptance.yml: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | # See https://github.com/apache/cloudstack-terraform-provider/blob/main/.github/workflows/acceptance.yml 19 | name: Acceptance Test 20 | 21 | on: 22 | pull_request: 23 | 24 | concurrency: 25 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-acceptance 26 | cancel-in-progress: true 27 | 28 | permissions: 29 | contents: read 30 | 31 | env: 32 | CLOUDSTACK_API_URL: http://localhost:8080/client/api 33 | CLOUDSTACK_VERSIONS: "['1.7.0']" 34 | jobs: 35 | prepare-matrix: 36 | runs-on: ubuntu-latest 37 | outputs: 38 | cloudstack-versions: ${{ steps.set-versions.outputs.cloudstack-versions }} 39 | steps: 40 | - name: Set versions 41 | id: set-versions 42 | run: | 43 | echo "cloudstack-versions=${{ env.CLOUDSTACK_VERSIONS }}" >> $GITHUB_OUTPUT 44 | 45 | acceptance-cs: 46 | name: Python ${{ matrix.python-version }} with CloudStack test container ${{ matrix.cloudstack-version }} 47 | needs: [prepare-matrix] 48 | runs-on: ubuntu-latest 49 | services: 50 | cloudstack-simulator: 51 | image: quay.io/ansible/cloudstack-test-container:${{ matrix.cloudstack-version }} 52 | ports: 53 | - 8080:8080 54 | strategy: 55 | fail-fast: false 56 | max-parallel: 1 57 | matrix: 58 | cloudstack-version: ${{ fromJson(needs.prepare-matrix.outputs.cloudstack-versions) }} 59 | python-version: 60 | - '3.12' 61 | steps: 62 | - uses: actions/checkout@v4 63 | - name: Configure Cloudstack v${{ matrix.cloudstack-version }} 64 | uses: ./.github/workflows/setup-cloudstack 65 | id: setup-cloudstack 66 | with: 67 | cloudstack-version: ${{ matrix.cloudstack-version }} 68 | - uses: actions/setup-python@v5 69 | with: 70 | python-version: ${{ matrix.python-version }} 71 | - name: Run acceptance test 72 | env: 73 | CLOUDSTACK_USER_ID: ${{ steps.setup-cloudstack.outputs.CLOUDSTACK_USER_ID }} 74 | CLOUDSTACK_KEY: ${{ steps.setup-cloudstack.outputs.CLOUDSTACK_API_KEY }} 75 | CLOUDSTACK_SECRET: ${{ steps.setup-cloudstack.outputs.CLOUDSTACK_SECRET_KEY }} 76 | CLOUDSTACK_ENDPOINT: ${{ steps.setup-cloudstack.outputs.CLOUDSTACK_API_URL }} 77 | run: | 78 | python -m venv .venv 79 | source .venv/bin/activate 80 | which python 81 | pip install . 82 | cs listZones 83 | 84 | all-jobs-passed: # Will succeed if it is skipped 85 | runs-on: ubuntu-latest 86 | needs: [acceptance-cs] 87 | # Only run if any of the previous jobs failed 88 | if: ${{ failure() }} 89 | steps: 90 | - name: Previous jobs failed 91 | run: exit 1 92 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - '**' 9 | paths-ignore: 10 | - '**.md' 11 | - '**.rst' 12 | - '**.txt' 13 | tags-ignore: 14 | - 'v**' # Don't run CI tests on release tags 15 | 16 | jobs: 17 | tests: 18 | name: Tests on ${{ matrix.python-version }} 19 | runs-on: ubuntu-latest 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: 24 | - '3.8' 25 | - '3.9' 26 | - '3.10' 27 | - '3.11' 28 | - '3.12' 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: "Install dependencies" 35 | run: | 36 | python -VV 37 | python -m site 38 | python -m pip install -U pip wheel setuptools 39 | python -m pip install -U tox tox-gh-actions 40 | - name: Tests 41 | run: tox 42 | lint: 43 | name: Linting 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/setup-python@v5 48 | with: 49 | python-version: '3.x' 50 | - name: "Install dependencies" 51 | run: | 52 | python -VV 53 | python -m site 54 | python -m pip install -U pip wheel setuptools 55 | python -m pip install -U black flake8 flake8-import-order flake8-bugbear 56 | - name: Lint 57 | run: | 58 | black --check --diff . 59 | flake8 . 60 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.x" 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install -U setuptools wheel twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | rm -rf dist/* 26 | python setup.py sdist bdist_wheel 27 | twine upload dist/* 28 | -------------------------------------------------------------------------------- /.github/workflows/setup-cloudstack/action.yml: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | # See https://raw.githubusercontent.com/apache/cloudstack-terraform-provider/refs/heads/main/.github/workflows/setup-cloudstack/action.yml 19 | 20 | name: Setup Cloudstack 21 | 22 | inputs: 23 | cloudstack-version: 24 | description: 'Cloudstack version' 25 | required: true 26 | outputs: 27 | CLOUDSTACK_USER_ID: 28 | description: 'Cloudstack user id' 29 | value: ${{ steps.setup-cloudstack.outputs.user_id }} 30 | CLOUDSTACK_API_KEY: 31 | description: 'Cloudstack api key' 32 | value: ${{ steps.setup-cloudstack.outputs.api_key }} 33 | CLOUDSTACK_SECRET_KEY: 34 | description: 'Cloudstack secret key' 35 | value: ${{ steps.setup-cloudstack.outputs.secret_key }} 36 | CLOUDSTACK_API_URL: 37 | description: 'Cloudstack API URL' 38 | value: http://localhost:8080/client/api 39 | 40 | runs: 41 | using: composite 42 | steps: 43 | - name: Wait Cloudstack to be ready 44 | shell: bash 45 | run: | 46 | echo "Starting Cloudstack health check" 47 | T=0 48 | until [ $T -gt 20 ] || curl -sfL http://localhost:8080 --output /dev/null 49 | do 50 | echo "Waiting for Cloudstack to be ready..." 51 | ((T+=1)) 52 | sleep 30 53 | done 54 | - name: Setting up Cloudstack 55 | id: setup-cloudstack 56 | shell: bash 57 | run: | 58 | curl -sf --location "${CLOUDSTACK_API_URL}" \ 59 | --header 'Content-Type: application/x-www-form-urlencoded' \ 60 | --data-urlencode 'command=login' \ 61 | --data-urlencode 'username=admin' \ 62 | --data-urlencode 'password=password' \ 63 | --data-urlencode 'response=json' \ 64 | --data-urlencode 'domain=/' -j -c cookies.txt --output /dev/null 65 | 66 | CLOUDSTACK_USER_ID=$(curl -fs "${CLOUDSTACK_API_URL}?command=listUsers&response=json" -b cookies.txt | jq -r '.listusersresponse.user[0].id') 67 | CLOUDSTACK_API_KEY=$(curl -s "${CLOUDSTACK_API_URL}?command=getUserKeys&id=${CLOUDSTACK_USER_ID}&response=json" -b cookies.txt | jq -r '.getuserkeysresponse.userkeys.apikey') 68 | CLOUDSTACK_SECRET_KEY=$(curl -fs "${CLOUDSTACK_API_URL}?command=getUserKeys&id=${CLOUDSTACK_USER_ID}&response=json" -b cookies.txt | jq -r '.getuserkeysresponse.userkeys.secretkey') 69 | 70 | echo "::add-mask::$CLOUDSTACK_API_KEY" 71 | echo "::add-mask::$CLOUDSTACK_SECRET_KEY" 72 | 73 | echo "user_id=$CLOUDSTACK_USER_ID" >> $GITHUB_OUTPUT 74 | echo "api_key=$CLOUDSTACK_API_KEY" >> $GITHUB_OUTPUT 75 | echo "secret_key=$CLOUDSTACK_SECRET_KEY" >> $GITHUB_OUTPUT 76 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues and PRs 2 | on: 3 | schedule: 4 | - cron: '23 5 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | any-of-labels: 'needs-more-info,needs-demo' 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | build 3 | .venv 4 | .coverage 5 | .eggs 6 | .tox 7 | *.egg-info 8 | *.pyc 9 | .*_cache 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Bruno Renié and contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include cs *.py 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/ngine-io/cs/actions/workflows/main.yml/badge.svg 2 | :alt: CI 3 | :target: https://github.com/ngine-io/cs/actions/workflows/main.yml 4 | .. image:: https://img.shields.io/pypi/pyversions/cs.svg 5 | :alt: Python versions 6 | :target: https://pypi.org/project/cs/ 7 | 8 | .. image:: https://img.shields.io/pypi/dw/cs.svg 9 | :alt: Downloads / Week 10 | :target: https://pypi.org/project/cs/ 11 | 12 | .. image:: https://img.shields.io/pypi/l/cs.svg 13 | :alt: License 14 | :target: https://pypi.org/project/cs/ 15 | 16 | CS - Python CloudStack API client 17 | ================================= 18 | 19 | A simple, yet powerful CloudStack API client for python and the command-line. 20 | 21 | * Async support. 22 | * All present and future CloudStack API calls and parameters are supported. 23 | * Syntax highlight in the command-line client if Pygments is installed. 24 | * BSD license. 25 | 26 | Installation 27 | ------------ 28 | 29 | :: 30 | 31 | pip install cs 32 | 33 | # with the colored output 34 | pip install cs[highlight] 35 | 36 | # with the async support 37 | pip install cs[async] 38 | 39 | # with both 40 | pip install cs[async,highlight] 41 | 42 | Usage 43 | ----- 44 | 45 | In Python: 46 | 47 | .. code-block:: python 48 | 49 | from cs import CloudStack 50 | 51 | cs = CloudStack(endpoint='https://cloudstack.example.com/client/api', 52 | key='cloudstack api key', 53 | secret='cloudstack api secret') 54 | 55 | vms = cs.listVirtualMachines() 56 | 57 | cs.createSecurityGroup(name='web', description='HTTP traffic') 58 | 59 | From the command-line, this requires some configuration: 60 | 61 | .. code-block:: console 62 | 63 | cat $HOME/.cloudstack.ini 64 | 65 | .. code-block:: ini 66 | 67 | [cloudstack] 68 | endpoint = https://cloudstack.example.com/client/api 69 | key = cloudstack api key 70 | secret = cloudstack api secret 71 | # Optional ca authority certificate 72 | verify = /path/to/certs/ca.crt 73 | # Optional client PEM certificate 74 | cert = /path/to/client.pem 75 | # If you need to pass the certificate and key as separate files 76 | cert_key = /path/to/client_key.pem 77 | 78 | Then: 79 | 80 | .. code-block:: console 81 | 82 | $ cs listVirtualMachines 83 | 84 | .. code-block:: json 85 | 86 | { 87 | "count": 1, 88 | "virtualmachine": [ 89 | { 90 | "account": "...", 91 | ... 92 | } 93 | ] 94 | } 95 | 96 | .. code-block:: console 97 | 98 | $ cs authorizeSecurityGroupIngress \ 99 | cidrlist="0.0.0.0/0" endport=443 startport=443 \ 100 | securitygroupname="blah blah" protocol=tcp 101 | 102 | The command-line client polls when async results are returned. To disable 103 | polling, use the ``--async`` flag. 104 | 105 | To find the list CloudStack API calls go to 106 | http://cloudstack.apache.org/api.html 107 | 108 | Configuration 109 | ------------- 110 | 111 | Configuration is read from several locations, in the following order: 112 | 113 | * The ``CLOUDSTACK_ENDPOINT``, ``CLOUDSTACK_KEY``, ``CLOUDSTACK_SECRET`` and 114 | ``CLOUDSTACK_METHOD`` environment variables, 115 | * A ``CLOUDSTACK_CONFIG`` environment variable pointing to an ``.ini`` file, 116 | * A ``CLOUDSTACK_VERIFY`` (optional) environment variable pointing to a CA authority cert file, 117 | * A ``CLOUDSTACK_CERT`` (optional) environment variable pointing to a client PEM cert file, 118 | * A ``CLOUDSTACK_CERT_KEY`` (optional) environment variable pointing to a client PEM certificate key file, 119 | * A ``cloudstack.ini`` file in the current working directory, 120 | * A ``.cloudstack.ini`` file in the home directory. 121 | 122 | To use that configuration scheme from your Python code: 123 | 124 | .. code-block:: python 125 | 126 | from cs import CloudStack, read_config 127 | 128 | cs = CloudStack(**read_config()) 129 | 130 | Note that ``read_config()`` can raise ``SystemExit`` if no configuration is 131 | found. 132 | 133 | ``CLOUDSTACK_METHOD`` or the ``method`` entry in the configuration file can be 134 | used to change the HTTP verb used to make CloudStack requests. By default, 135 | requests are made with the GET method but CloudStack supports POST requests. 136 | POST can be useful to overcome some length limits in the CloudStack API. 137 | 138 | ``CLOUDSTACK_TIMEOUT`` or the ``timeout`` entry in the configuration file can 139 | be used to change the HTTP timeout when making CloudStack requests (in 140 | seconds). The default value is 10. 141 | 142 | ``CLOUDSTACK_RETRY`` or the ``retry`` entry in the configuration file 143 | (integer) can be used to retry ``list`` and ``queryAsync`` requests on 144 | failure. The default value is 0, meaning no retry. 145 | 146 | ``CLOUDSTACK_JOB_TIMEOUT`` or the `job_timeout`` entry in the configuration file 147 | (float) can be used to set how long an async call is retried assuming ``fetch_result`` is set to true). The default value is ``None``, it waits forever. 148 | 149 | ``CLOUDSTACK_POLL_INTERVAL`` or the ``poll_interval`` entry in the configuration file (number of seconds, float) can be used to set how frequently polling an async job result is done. The default value is 2. 150 | 151 | ``CLOUDSTACK_EXPIRATION`` or the ``expiration`` entry in the configuration file 152 | (integer) can be used to set how long a signature is valid. By default, it picks 153 | 10 minutes but may be deactivated using any negative value, e.g. -1. 154 | 155 | ``CLOUDSTACK_DANGEROUS_NO_TLS_VERIFY`` or the ``dangerous_no_tls_verify`` entry 156 | in the configuration file (boolean) can be used to deactivate the TLS verification 157 | made when using the HTTPS protocol. 158 | 159 | Multiple credentials can be set in ``.cloudstack.ini``. This allows selecting 160 | the credentials or endpoint to use with a command-line flag. 161 | 162 | .. code-block:: ini 163 | 164 | [cloudstack] 165 | endpoint = https://some-host/api/v1 166 | key = api key 167 | secret = api secret 168 | 169 | [region-example] 170 | endpoint = https://cloudstack.example.com/client/api 171 | key = api key 172 | secret = api secret 173 | 174 | Usage:: 175 | 176 | $ cs listVirtualMachines --region=region-example 177 | 178 | Optionally ``CLOUDSTACK_REGION`` can be used to overwrite the default region ``cloudstack``. 179 | 180 | For the power users that don't want to put any secrets on disk, 181 | ``CLOUDSTACK_OVERRIDES`` let you pick which key will be set from the 182 | environment even if present in the ini file. 183 | 184 | 185 | Pagination 186 | ---------- 187 | 188 | CloudStack paginates requests. ``cs`` is able to abstract away the pagination 189 | logic to allow fetching large result sets in one go. This is done with the 190 | ``fetch_list`` parameter:: 191 | 192 | $ cs listVirtualMachines fetch_list=true 193 | 194 | Or in Python:: 195 | 196 | cs.listVirtualMachines(fetch_list=True) 197 | 198 | Tracing HTTP requests 199 | --------------------- 200 | 201 | Once in a while, it could be useful to understand, see what HTTP calls are made 202 | under the hood. The ``trace`` flag (or ``CLOUDSTACK_TRACE``) does just that:: 203 | 204 | $ cs --trace listVirtualMachines 205 | 206 | $ cs -t listZones 207 | 208 | Async client 209 | ------------ 210 | 211 | ``cs`` provides the ``AIOCloudStack`` class for async/await calls in Python 212 | 3.5+. 213 | 214 | .. code-block:: python 215 | 216 | import asyncio 217 | from cs import AIOCloudStack, read_config 218 | 219 | cs = AIOCloudStack(**read_config()) 220 | 221 | async def main(): 222 | vms = await cs.listVirtualMachines(fetch_list=True) 223 | print(vms) 224 | 225 | asyncio.run(main()) 226 | 227 | Async deployment of multiple VMs 228 | ________________________________ 229 | 230 | .. code-block:: python 231 | 232 | import asyncio 233 | from cs import AIOCloudStack, read_config 234 | 235 | cs = AIOCloudStack(**read_config()) 236 | 237 | machine = {"zoneid": ..., "serviceofferingid": ..., "templateid": ...} 238 | 239 | async def main(): 240 | tasks = asyncio.gather(*(cs.deployVirtualMachine(name=f"vm-{i}", 241 | **machine, 242 | fetch_result=True) 243 | for i in range(5))) 244 | 245 | results = await tasks 246 | 247 | # Destroy all of them, but skip waiting on the job results 248 | await asyncio.gather(*(cs.destroyVirtualMachine(id=result['virtualmachine']['id']) 249 | for result in results)) 250 | 251 | asyncio.run(main()) 252 | 253 | Release Procedure 254 | ----------------- 255 | 256 | .. code-block:: shell-session 257 | 258 | mktmpenv -p /usr/bin/python3 259 | pip install -U twine wheel build 260 | cd ./cs 261 | rm -rf build dist 262 | python -m build 263 | twine upload dist/* 264 | 265 | Links 266 | ----- 267 | 268 | * CloudStack API: http://cloudstack.apache.org/api.html 269 | -------------------------------------------------------------------------------- /cs/__init__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import os 4 | import sys 5 | from collections import defaultdict 6 | from configparser import NoSectionError 7 | 8 | try: 9 | import pygments 10 | from pygments.lexers import JsonLexer 11 | from pygments.styles import get_style_by_name 12 | from pygments.formatters import Terminal256Formatter 13 | except ImportError: 14 | pygments = None 15 | 16 | from .client import ( 17 | CloudStack, 18 | CloudStackApiException, 19 | CloudStackException, 20 | read_config, 21 | ) 22 | from .version import __version__ 23 | 24 | 25 | __all__ = [ 26 | "read_config", 27 | "CloudStack", 28 | "CloudStackException", 29 | "CloudStackApiException", 30 | ] 31 | 32 | try: 33 | import aiohttp # noqa 34 | except ImportError: 35 | pass 36 | else: 37 | from ._async import AIOCloudStack # noqa 38 | 39 | __all__.append("AIOCloudStack") 40 | 41 | 42 | def _format_json(data, theme): 43 | """Pretty print a dict as a JSON, with colors if pygments is present.""" 44 | output = json.dumps(data, indent=2, sort_keys=True) 45 | 46 | if pygments and sys.stdout.isatty(): 47 | style = get_style_by_name(theme) 48 | formatter = Terminal256Formatter(style=style) 49 | return pygments.highlight(output, JsonLexer(), formatter) 50 | 51 | return output 52 | 53 | 54 | def main(args=None): 55 | parser = argparse.ArgumentParser(description="Cloudstack client.") 56 | parser.add_argument( 57 | "--region", 58 | "-r", 59 | metavar="REGION", 60 | help="Cloudstack region in ~/.cloudstack.ini", 61 | default=os.environ.get("CLOUDSTACK_REGION", "cloudstack"), 62 | ) 63 | parser.add_argument( 64 | "--theme", 65 | metavar="THEME", 66 | help="Pygments style", 67 | default=os.environ.get("CLOUDSTACK_THEME", "default"), 68 | ) 69 | parser.add_argument( 70 | "--post", 71 | action="store_true", 72 | default=False, 73 | help="use POST instead of GET", 74 | ) 75 | parser.add_argument( 76 | "--async", 77 | action="store_true", 78 | default=False, 79 | help="do not wait for async result", 80 | ) 81 | parser.add_argument( 82 | "--quiet", 83 | "-q", 84 | action="store_true", 85 | default=False, 86 | help="do not display additional status messages", 87 | ) 88 | parser.add_argument( 89 | "--trace", 90 | "-t", 91 | action="store_true", 92 | default=os.environ.get("CLOUDSTACK_TRACE", False), 93 | help="trace the HTTP requests done on stderr", 94 | ) 95 | parser.add_argument( 96 | "command", 97 | metavar="COMMAND", 98 | help="Cloudstack API command to execute", 99 | ) 100 | 101 | parser.add_argument( 102 | "--version", 103 | action="version", 104 | version=__version__, 105 | ) 106 | 107 | def parse_option(x): 108 | if "=" not in x: 109 | raise ValueError( 110 | "{!r} is not a correctly formatted " "option".format(x) 111 | ) 112 | return x.split("=", 1) 113 | 114 | parser.add_argument( 115 | "arguments", 116 | metavar="OPTION=VALUE", 117 | nargs="*", 118 | type=parse_option, 119 | help="Cloudstack API argument", 120 | ) 121 | 122 | options = parser.parse_args(args=args) 123 | 124 | command = options.command 125 | kwargs = defaultdict(set) 126 | for arg in options.arguments: 127 | key, value = arg 128 | kwargs[key].add(value.strip(" \"'")) 129 | 130 | try: 131 | config = read_config(ini_group=options.region) 132 | except NoSectionError: 133 | raise SystemExit("Error: region '%s' not in config" % options.region) 134 | 135 | theme = config.pop("theme", "default") 136 | 137 | fetch_result = "Async" not in command and not getattr(options, "async") 138 | 139 | if options.post: 140 | config["method"] = "post" 141 | 142 | if options.trace: 143 | config["trace"] = True 144 | 145 | cs = CloudStack(**config) 146 | ok = True 147 | response = None 148 | 149 | try: 150 | response = getattr(cs, command)(fetch_result=fetch_result, **kwargs) 151 | except CloudStackException as e: 152 | ok = False 153 | if e.response is not None: 154 | if not options.quiet: 155 | sys.stderr.write("CloudStack error: ") 156 | sys.stderr.write("\n".join((str(arg) for arg in e.args))) 157 | sys.stderr.write("\n") 158 | 159 | try: 160 | response = json.loads(e.response.text) 161 | except ValueError: 162 | sys.stderr.write(e.response.text) 163 | sys.stderr.write("\n") 164 | else: 165 | message, data = (e.args[0], e.args[0:]) 166 | sys.stderr.write("Error: {0}\n{1}\n".format(message, data)) 167 | 168 | if response: 169 | sys.stdout.write(_format_json(response, theme=theme)) 170 | sys.stdout.write("\n") 171 | 172 | return not ok 173 | -------------------------------------------------------------------------------- /cs/__main__.py: -------------------------------------------------------------------------------- 1 | from . import main 2 | 3 | 4 | if __name__ == "__main__": 5 | main() 6 | -------------------------------------------------------------------------------- /cs/_async.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ssl 3 | 4 | import aiohttp 5 | 6 | from . import CloudStack, CloudStackApiException, CloudStackException 7 | from .client import PENDING, SUCCESS, transform 8 | 9 | 10 | class AIOCloudStack(CloudStack): 11 | def __getattr__(self, command): 12 | def handler(**kwargs): 13 | return self._request(command, **kwargs) 14 | 15 | return handler 16 | 17 | async def _request( 18 | self, 19 | command, 20 | json=True, 21 | opcode_name="command", 22 | fetch_list=False, 23 | headers=None, 24 | **params 25 | ): 26 | fetch_result = params.pop("fetch_result", self.fetch_result) 27 | kwarg, kwargs = self._prepare_request( 28 | command, json, opcode_name, fetch_list, **params 29 | ) 30 | 31 | ssl_context = None 32 | if self.cert: 33 | ssl_context = ssl.create_default_context(cafile=self.cert) 34 | connector = aiohttp.TCPConnector( 35 | verify_ssl=self.verify, ssl_context=ssl_context 36 | ) 37 | 38 | async with aiohttp.ClientSession( 39 | read_timeout=self.timeout, 40 | conn_timeout=self.timeout, 41 | connector=connector, 42 | ) as session: 43 | handler = getattr(session, self.method) 44 | 45 | done = False 46 | final_data = [] 47 | page = 1 48 | while not done: 49 | if fetch_list: 50 | kwargs["page"] = page 51 | 52 | transform(kwargs) 53 | kwargs.pop("signature", None) 54 | self._sign(kwargs) 55 | response = await handler( 56 | self.endpoint, headers=headers, **{kwarg: kwargs} 57 | ) 58 | 59 | ctype = response.headers["content-type"].split(";")[0] 60 | try: 61 | data = await response.json(content_type=ctype) 62 | except ValueError as e: 63 | msg = "Make sure endpoint URL {!r} is correct.".format( 64 | self.endpoint 65 | ) 66 | raise CloudStackException( 67 | "HTTP {0} response from CloudStack".format( 68 | response.status 69 | ), 70 | "{}. {}".format(e, msg), 71 | response=response, 72 | ) 73 | 74 | [key] = data.keys() 75 | data = data[key] 76 | if response.status != 200: 77 | raise CloudStackApiException( 78 | "HTTP {0} response from CloudStack".format( 79 | response.status 80 | ), 81 | error=data, 82 | response=response, 83 | ) 84 | if fetch_list: 85 | try: 86 | [key] = [k for k in data.keys() if k != "count"] 87 | except ValueError: 88 | done = True 89 | else: 90 | final_data.extend(data[key]) 91 | page += 1 92 | elif fetch_result and "jobid" in data: 93 | try: 94 | final_data = await asyncio.wait_for( 95 | self._jobresult(data["jobid"], response), 96 | self.job_timeout, 97 | ) 98 | except asyncio.TimeoutError: 99 | raise CloudStackException( 100 | "Timeout waiting for async job result", 101 | data["jobid"], 102 | response=response, 103 | ) 104 | done = True 105 | else: 106 | final_data = data 107 | done = True 108 | return final_data 109 | 110 | async def _jobresult(self, jobid, response): 111 | failures = 0 112 | while True: 113 | try: 114 | j = await self.queryAsyncJobResult( 115 | jobid=jobid, fetch_result=False 116 | ) 117 | failures = 0 118 | if j["jobstatus"] != PENDING: 119 | if j["jobresultcode"] != 0 or j["jobstatus"] != SUCCESS: 120 | raise CloudStackApiException( 121 | "Job failure", 122 | j, 123 | error=j["jobresult"], 124 | response=response, 125 | ) 126 | if "jobresult" not in j: 127 | raise CloudStackException( 128 | "Unknown job result", j, response=response 129 | ) 130 | return j["jobresult"] 131 | 132 | except CloudStackException: 133 | raise 134 | 135 | except Exception: 136 | failures += 1 137 | if failures > 10: 138 | raise 139 | 140 | await asyncio.sleep(self.poll_interval) 141 | -------------------------------------------------------------------------------- /cs/client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import hmac 4 | import os 5 | import re 6 | import sys 7 | import time 8 | from configparser import ConfigParser 9 | from datetime import datetime, timedelta 10 | from fnmatch import fnmatch 11 | from urllib.parse import quote 12 | 13 | import pytz 14 | 15 | import requests 16 | from requests.structures import CaseInsensitiveDict 17 | 18 | 19 | try: 20 | from . import AIOCloudStack # noqa 21 | except ImportError: 22 | pass 23 | 24 | 25 | TIMEOUT = 10 26 | PAGE_SIZE = 500 27 | POLL_INTERVAL = 2.0 28 | EXPIRATION = timedelta(minutes=10) 29 | EXPIRES_FORMAT = "%Y-%m-%dT%H:%M:%S%z" 30 | 31 | REQUIRED_CONFIG_KEYS = {"endpoint", "key", "secret", "method", "timeout"} 32 | ALLOWED_CONFIG_KEYS = { 33 | "verify", 34 | "cert", 35 | "cert_key", 36 | "retry", 37 | "theme", 38 | "expiration", 39 | "poll_interval", 40 | "trace", 41 | "dangerous_no_tls_verify", 42 | "header_*", 43 | } 44 | DEFAULT_CONFIG = { 45 | "timeout": 10, 46 | "method": "get", 47 | "retry": 0, 48 | "verify": None, 49 | "cert": None, 50 | "cert_key": None, 51 | "name": None, 52 | "expiration": 600, 53 | "poll_interval": POLL_INTERVAL, 54 | "trace": None, 55 | "dangerous_no_tls_verify": False, 56 | } 57 | 58 | PENDING = 0 59 | SUCCESS = 1 60 | FAILURE = 2 61 | 62 | 63 | def strtobool(val): 64 | """Convert a string representation of truth to true (1) or false (0). 65 | 66 | True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values 67 | are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if 68 | 'val' is anything else. 69 | 70 | This function has been borrowed from distutils.util module in order 71 | to avoid pulling a dependency on deprecated module "imp". 72 | """ 73 | val = val.lower() 74 | if val in ("y", "yes", "t", "true", "on", "1"): 75 | return 1 76 | elif val in ("n", "no", "f", "false", "off", "0"): 77 | return 0 78 | else: 79 | raise ValueError("invalid truth value %r" % (val,)) 80 | 81 | 82 | def check_key(key, allowed): 83 | """ 84 | Validate that the specified key is allowed according the provided 85 | list of patterns. 86 | """ 87 | 88 | if key in allowed: 89 | return True 90 | 91 | for pattern in allowed: 92 | if fnmatch(key, pattern): 93 | return True 94 | 95 | return False 96 | 97 | 98 | def cs_encode(s): 99 | """Encode URI component like CloudStack would do before signing. 100 | 101 | java.net.URLEncoder.encode(s).replace('+', '%20') 102 | """ 103 | return quote(s, safe="*") 104 | 105 | 106 | def transform(params): 107 | """ 108 | Transforms an heterogeneous map of params into a CloudStack 109 | ready mapping of parameter to values. 110 | 111 | It handles lists and dicts. 112 | 113 | >>> p = {"a": 1, "b": "foo", "c": ["eggs", "spam"], "d": {"key": "value"}} 114 | >>> transform(p) 115 | >>> print(p) 116 | {'a': '1', 'b': 'foo', 'c': 'eggs,spam', 'd[0].key': 'value'} 117 | """ 118 | for key, value in list(params.items()): 119 | if value is None: 120 | params.pop(key) 121 | continue 122 | 123 | if isinstance(value, (str, bytes)): 124 | continue 125 | 126 | if isinstance(value, int): 127 | params[key] = str(value) 128 | elif isinstance(value, (list, tuple, set, dict)): 129 | if not value: 130 | params.pop(key) 131 | else: 132 | if isinstance(value, dict): 133 | value = [value] 134 | if isinstance(value, set): 135 | value = list(value) 136 | if not isinstance(value[0], dict): 137 | params[key] = ",".join(value) 138 | else: 139 | params.pop(key) 140 | for index, val in enumerate(value): 141 | for name, v in val.items(): 142 | k = "%s[%d].%s" % (key, index, name) 143 | params[k] = str(v) 144 | else: 145 | raise ValueError(type(value)) 146 | 147 | 148 | class CloudStackException(Exception): 149 | """Exception nicely wrapping a request response.""" 150 | 151 | def __init__(self, *args, **kwargs): 152 | self.response = kwargs.pop("response") 153 | super(CloudStackException, self).__init__(*args, **kwargs) 154 | 155 | 156 | class CloudStackApiException(CloudStackException): 157 | def __init__(self, *args, **kwargs): 158 | self.error = kwargs.pop("error") 159 | super(CloudStackApiException, self).__init__(*args, **kwargs) 160 | 161 | def __str__(self): 162 | return "{0}, error: {1}".format( 163 | super(CloudStackApiException, self).__str__(), self.error 164 | ) 165 | 166 | 167 | ten_minutes = timedelta(minutes=10) 168 | 169 | 170 | class CloudStack(object): 171 | def __init__( 172 | self, 173 | endpoint, 174 | key, 175 | secret, 176 | timeout=10, 177 | method="get", 178 | verify=None, 179 | cert=None, 180 | cert_key=None, 181 | name=None, 182 | retry=0, 183 | job_timeout=None, 184 | poll_interval=POLL_INTERVAL, 185 | expiration=ten_minutes, 186 | trace=False, 187 | dangerous_no_tls_verify=False, 188 | headers=None, 189 | session=None, 190 | fetch_result=False, 191 | ): 192 | self.endpoint = endpoint 193 | self.key = key 194 | self.secret = secret 195 | self.timeout = int(timeout) 196 | self.method = method.lower() 197 | if verify: 198 | self.verify = verify 199 | else: 200 | self.verify = not dangerous_no_tls_verify 201 | if headers is None: 202 | headers = {} 203 | self.headers = headers 204 | self.session = session if session is not None else requests.Session() 205 | if cert and cert_key: 206 | cert = (cert, cert_key) 207 | self.cert = cert 208 | self.name = name 209 | self.retry = int(retry) 210 | self.job_timeout = int(job_timeout) if job_timeout else None 211 | self.poll_interval = float(poll_interval) 212 | if not hasattr(expiration, "seconds"): 213 | expiration = timedelta(seconds=int(expiration)) 214 | self.expiration = expiration 215 | self.trace = bool(trace) 216 | self.fetch_result = fetch_result 217 | 218 | def __repr__(self): 219 | return "".format(self.name or self.endpoint) 220 | 221 | def __getattr__(self, command): 222 | def handler(**kwargs): 223 | return self._request(command, **kwargs) 224 | 225 | return handler 226 | 227 | def _prepare_request( 228 | self, 229 | command, 230 | json=True, 231 | opcode_name="command", 232 | fetch_list=False, 233 | **kwargs, 234 | ): 235 | params = CaseInsensitiveDict(**kwargs) 236 | params.update( 237 | { 238 | "apiKey": self.key, 239 | opcode_name: command, 240 | } 241 | ) 242 | if json: 243 | params["response"] = "json" 244 | if "page" in kwargs or fetch_list: 245 | params.setdefault("pagesize", PAGE_SIZE) 246 | if "expires" not in params and self.expiration.total_seconds() >= 0: 247 | params["signatureVersion"] = "3" 248 | tz = pytz.utc 249 | expires = tz.localize(datetime.utcnow() + self.expiration) 250 | params["expires"] = expires.astimezone(tz).strftime(EXPIRES_FORMAT) 251 | 252 | kind = "params" if self.method == "get" else "data" 253 | return kind, dict(params.items()) 254 | 255 | def _request( 256 | self, 257 | command, 258 | json=True, 259 | opcode_name="command", 260 | fetch_list=False, 261 | headers=None, 262 | **params, 263 | ): 264 | fetch_result = params.pop("fetch_result", self.fetch_result) 265 | kind, params = self._prepare_request( 266 | command, json, opcode_name, fetch_list, **params 267 | ) 268 | if headers is None: 269 | headers = {} 270 | headers.update(self.headers) 271 | 272 | done = False 273 | max_retry = self.retry 274 | final_data = [] 275 | page = 1 276 | while not done: 277 | if fetch_list: 278 | params["page"] = page 279 | 280 | transform(params) 281 | params.pop("signature", None) 282 | self._sign(params) 283 | 284 | req = requests.Request( 285 | self.method, self.endpoint, headers=headers, **{kind: params} 286 | ) 287 | prepped = req.prepare() 288 | if self.trace: 289 | print(prepped.method, prepped.url, file=sys.stderr) 290 | if prepped.headers: 291 | print(prepped.headers, "\n", file=sys.stderr) 292 | if prepped.body: 293 | print(prepped.body, file=sys.stderr) 294 | else: 295 | print(file=sys.stderr) 296 | 297 | try: 298 | with self.session as session: 299 | response = session.send( 300 | prepped, 301 | timeout=self.timeout, 302 | verify=self.verify, 303 | cert=self.cert, 304 | ) 305 | 306 | except requests.exceptions.ConnectionError: 307 | max_retry -= 1 308 | if max_retry < 0 or not command.startswith( 309 | ("list", "queryAsync") 310 | ): 311 | raise 312 | continue 313 | max_retry = self.retry 314 | 315 | if self.trace: 316 | print(response.status_code, response.reason, file=sys.stderr) 317 | headersTrace = "\n".join( 318 | "{}: {}".format(k, v) for k, v in response.headers.items() 319 | ) 320 | print(headersTrace, "\n", file=sys.stderr) 321 | print(response.text, "\n", file=sys.stderr) 322 | 323 | data = self._response_value(response, json) 324 | 325 | if fetch_list: 326 | try: 327 | [key] = [k for k in data.keys() if k != "count"] 328 | except ValueError: 329 | done = True 330 | else: 331 | final_data.extend(data[key]) 332 | page += 1 333 | if len(final_data) >= data.get("count", PAGE_SIZE): 334 | done = True 335 | elif fetch_result and "jobid" in data: 336 | final_data = self._jobresult( 337 | jobid=data["jobid"], headers=headers 338 | ) 339 | done = True 340 | else: 341 | final_data = data 342 | done = True 343 | return final_data 344 | 345 | def _response_value(self, response, json=True): 346 | """Parses the HTTP response as a the cloudstack value. 347 | 348 | It throws an exception if the server didn't answer with a 200. 349 | """ 350 | if json: 351 | ctype = response.headers.get("Content-Type", "") 352 | if not ctype.startswith(("application/json", "text/javascript")): 353 | if response.status_code == 200: 354 | msg = ( 355 | f"JSON (application/json) was expected, got {ctype:!r}" 356 | ) 357 | raise CloudStackException(msg, response=response) 358 | 359 | raise CloudStackException( 360 | "HTTP {0.status_code} {0.reason}".format(response), 361 | "Make sure endpoint URL {!r} is correct.".format( 362 | self.endpoint 363 | ), 364 | response=response, 365 | ) 366 | 367 | try: 368 | data = response.json() 369 | except ValueError as e: 370 | raise CloudStackException( 371 | "HTTP {0.status_code} {0.reason}".format(response), 372 | "{0!s}. Malformed JSON document".format(e), 373 | response=response, 374 | ) 375 | 376 | [key] = data.keys() 377 | data = data[key] 378 | else: 379 | data = response.text 380 | 381 | if response.status_code != 200: 382 | raise CloudStackApiException( 383 | "HTTP {0} response from CloudStack".format( 384 | response.status_code 385 | ), 386 | error=data, 387 | response=response, 388 | ) 389 | 390 | return data 391 | 392 | def _jobresult(self, jobid, json=True, headers=None): 393 | """Poll the async job result. 394 | 395 | To be run via in a Thread, the result is put within 396 | the result list which is a hack. 397 | """ 398 | failures = 0 399 | 400 | total_time = self.job_timeout or 2**30 401 | remaining = timedelta(seconds=total_time) 402 | endtime = datetime.now() + remaining 403 | 404 | while remaining.total_seconds() > 0: 405 | timeout = max(min(self.timeout, remaining.total_seconds()), 1) 406 | try: 407 | kind, params = self._prepare_request( 408 | "queryAsyncJobResult", jobid=jobid 409 | ) 410 | 411 | transform(params) 412 | self._sign(params) 413 | 414 | req = requests.Request( 415 | self.method, 416 | self.endpoint, 417 | headers=headers, 418 | **{kind: params}, 419 | ) 420 | prepped = req.prepare() 421 | if self.trace: 422 | print(prepped.method, prepped.url, file=sys.stderr) 423 | if prepped.headers: 424 | print(prepped.headers, "\n", file=sys.stderr) 425 | if prepped.body: 426 | print(prepped.body, file=sys.stderr) 427 | else: 428 | print(file=sys.stderr) 429 | 430 | with self.session as session: 431 | response = session.send( 432 | prepped, 433 | timeout=timeout, 434 | verify=self.verify, 435 | cert=self.cert, 436 | ) 437 | 438 | j = self._response_value(response, json) 439 | 440 | if self.trace: 441 | print( 442 | response.status_code, response.reason, file=sys.stderr 443 | ) 444 | headersTrace = "\n".join( 445 | "{}: {}".format(k, v) 446 | for k, v in response.headers.items() 447 | ) 448 | print(headersTrace, "\n", file=sys.stderr) 449 | print(response.text, "\n", file=sys.stderr) 450 | 451 | failures = 0 452 | if j["jobstatus"] != PENDING: 453 | if j["jobresultcode"] or j["jobstatus"] != SUCCESS: 454 | raise CloudStackApiException( 455 | "Job failure", 456 | error=j["jobresult"], 457 | response=response, 458 | ) 459 | 460 | if "jobresult" not in j: 461 | raise CloudStackException( 462 | "Unknown job result", response=response 463 | ) 464 | 465 | return j["jobresult"] 466 | 467 | except CloudStackException: 468 | raise 469 | 470 | except Exception: 471 | failures += 1 472 | if failures > 10: 473 | raise 474 | 475 | time.sleep(self.poll_interval) 476 | remaining = endtime - datetime.now() 477 | 478 | if response: 479 | response.status_code = 408 480 | 481 | raise CloudStackException( 482 | "Timeout waiting for async job result", jobid, response=response 483 | ) 484 | 485 | def _sign(self, data): 486 | """ 487 | Compute a signature string according to the CloudStack 488 | signature method (hmac/sha1). 489 | """ 490 | 491 | # Python2/3 urlencode aren't good enough for this task. 492 | params = "&".join( 493 | "=".join((key, cs_encode(value))) 494 | for key, value in sorted(data.items()) 495 | ) 496 | 497 | digest = hmac.new( 498 | self.secret.encode("utf-8"), 499 | msg=params.lower().encode("utf-8"), 500 | digestmod=hashlib.sha1, 501 | ).digest() 502 | 503 | data["signature"] = base64.b64encode(digest).decode("utf-8").strip() 504 | 505 | 506 | def read_config_from_ini(ini_group=None): 507 | # Config file: $PWD/cloudstack.ini or $HOME/.cloudstack.ini 508 | # Last read wins in configparser 509 | paths = [ 510 | os.path.join(os.path.expanduser("~"), ".cloudstack.ini"), 511 | os.path.join(os.getcwd(), "cloudstack.ini"), 512 | ] 513 | # Look at CLOUDSTACK_CONFIG first if present 514 | if "CLOUDSTACK_CONFIG" in os.environ: 515 | paths.append(os.path.expanduser(os.environ["CLOUDSTACK_CONFIG"])) 516 | if not any([os.path.exists(c) for c in paths]): 517 | raise SystemExit( 518 | "Config file not found. Tried {0}".format(", ".join(paths)) 519 | ) 520 | conf = ConfigParser() 521 | conf.read(paths) 522 | 523 | if not ini_group: 524 | ini_group = os.getenv("CLOUDSTACK_REGION", "cloudstack") 525 | 526 | if not conf.has_section(ini_group): 527 | return dict(name=None) 528 | 529 | ini_config = { 530 | k: v 531 | for k, v in conf.items(ini_group) 532 | if v and check_key(k, REQUIRED_CONFIG_KEYS.union(ALLOWED_CONFIG_KEYS)) 533 | } 534 | ini_config["name"] = ini_group 535 | 536 | # Convert individual header_* settings into a single dict 537 | for k in list(ini_config): 538 | if k.startswith("header_"): 539 | ini_config.setdefault("headers", {}) 540 | start = len("header_") 541 | ini_config["headers"][k[start:]] = ini_config.pop(k) 542 | return ini_config 543 | 544 | 545 | def read_config(ini_group=None): 546 | """ 547 | Read the configuration from the environment, or config. 548 | 549 | First it try to go for the environment, then it overrides 550 | those with the cloudstack.ini file. 551 | """ 552 | env_conf = dict(DEFAULT_CONFIG) 553 | for key in REQUIRED_CONFIG_KEYS.union(ALLOWED_CONFIG_KEYS): 554 | env_key = "CLOUDSTACK_{0}".format(key.upper()) 555 | value = os.getenv(env_key) 556 | if value: 557 | env_conf[key] = value 558 | 559 | # overrides means we have a .ini to read 560 | overrides = os.getenv("CLOUDSTACK_OVERRIDES", "").strip() 561 | 562 | if not overrides and set(env_conf).issuperset(REQUIRED_CONFIG_KEYS): 563 | return env_conf 564 | 565 | ini_conf = read_config_from_ini(ini_group) 566 | 567 | overrides = {s.lower() for s in re.split(r"\W+", overrides)} 568 | config = dict( 569 | dict(env_conf, **ini_conf), 570 | **{k: v for k, v in env_conf.items() if k in overrides}, 571 | ) 572 | 573 | missings = REQUIRED_CONFIG_KEYS.difference(config) 574 | if missings: 575 | raise ValueError( 576 | "the configuration is missing the following keys: " 577 | + ", ".join(missings) 578 | ) 579 | 580 | # convert booleans values. 581 | bool_keys = ("dangerous_no_tls_verify",) 582 | for bool_key in bool_keys: 583 | if isinstance(config[bool_key], str): 584 | try: 585 | config[bool_key] = strtobool(config[bool_key]) 586 | except ValueError: 587 | pass 588 | 589 | return config 590 | -------------------------------------------------------------------------------- /cs/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.3.1" 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = cs 3 | version = 3.3.1 4 | url = https://github.com/ngine-io/cs 5 | author = Bruno Renié 6 | description = A simple yet powerful CloudStack API client for Python and the command-line. 7 | long_description = file: README.rst 8 | license = BSD 9 | license_files = LICENSE 10 | classifiers = 11 | Intended Audience :: Developers 12 | Intended Audience :: System Administrators 13 | License :: OSI Approved :: BSD License 14 | Programming Language :: Python 15 | Programming Language :: Python :: 3 16 | Programming Language :: Python :: 3.8 17 | Programming Language :: Python :: 3.9 18 | Programming Language :: Python :: 3.10 19 | Programming Language :: Python :: 3.11 20 | Programming Language :: Python :: 3.12 21 | 22 | [options] 23 | packages = find: 24 | include_package_data = true 25 | zip_safe = false 26 | install_requires = 27 | pytz 28 | requests 29 | 30 | [options.packages.find] 31 | exclude = tests 32 | 33 | [options.entry_points] 34 | console_scripts = 35 | cs = cs:main 36 | 37 | [options.extras_require] 38 | async = 39 | aiohttp 40 | highlight = 41 | pygments 42 | 43 | [aliases] 44 | test = pytest 45 | 46 | [wheel] 47 | universal = 1 48 | 49 | [tool:pytest] 50 | addopts = --cov=cs --cov-report=term-missing cs tests.py 51 | 52 | [check-manifest] 53 | ignore = 54 | tox.ini 55 | tests.py 56 | 57 | [isort] 58 | style = pep8 59 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | A simple yet powerful CloudStack API client for Python and the command-line. 4 | """ 5 | 6 | from setuptools import setup 7 | 8 | setup() 9 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import datetime 3 | import os 4 | from contextlib import contextmanager 5 | from functools import partial 6 | from unittest import TestCase 7 | from unittest.mock import patch 8 | from urllib.parse import parse_qs, urlparse 9 | 10 | from cs import ( 11 | CloudStack, 12 | CloudStackApiException, 13 | CloudStackException, 14 | read_config, 15 | ) 16 | from cs.client import EXPIRES_FORMAT 17 | 18 | from requests.structures import CaseInsensitiveDict 19 | 20 | 21 | @contextmanager 22 | def env(**kwargs): 23 | old_env = {} 24 | for key in kwargs: 25 | if key in os.environ: 26 | old_env[key] = os.environ[key] 27 | os.environ.update(kwargs) 28 | try: 29 | yield 30 | finally: 31 | for key in kwargs: 32 | if key in old_env: 33 | os.environ[key] = old_env[key] 34 | else: 35 | del os.environ[key] 36 | 37 | 38 | @contextmanager 39 | def cwd(path): 40 | initial = os.getcwd() 41 | os.chdir(path) 42 | try: 43 | with patch("os.path.expanduser", new=lambda x: path): 44 | yield 45 | finally: 46 | os.chdir(initial) 47 | 48 | 49 | class ExceptionTest(TestCase): 50 | def test_api_exception_str(self): 51 | e = CloudStackApiException( 52 | "CS failed", error={"test": 42}, response=None 53 | ) 54 | self.assertEqual("CS failed, error: {'test': 42}", str(e)) 55 | 56 | 57 | class ConfigTest(TestCase): 58 | def test_env_vars(self): 59 | with env( 60 | CLOUDSTACK_KEY="test key from env", 61 | CLOUDSTACK_SECRET="test secret from env", 62 | CLOUDSTACK_ENDPOINT="https://api.example.com/from-env", 63 | ): 64 | conf = read_config() 65 | self.assertEqual( 66 | { 67 | "key": "test key from env", 68 | "secret": "test secret from env", 69 | "endpoint": "https://api.example.com/from-env", 70 | "expiration": 600, 71 | "method": "get", 72 | "trace": None, 73 | "timeout": 10, 74 | "poll_interval": 2.0, 75 | "verify": None, 76 | "dangerous_no_tls_verify": False, 77 | "cert": None, 78 | "cert_key": None, 79 | "name": None, 80 | "retry": 0, 81 | }, 82 | conf, 83 | ) 84 | 85 | with env( 86 | CLOUDSTACK_KEY="test key from env", 87 | CLOUDSTACK_SECRET="test secret from env", 88 | CLOUDSTACK_ENDPOINT="https://api.example.com/from-env", 89 | CLOUDSTACK_METHOD="post", 90 | CLOUDSTACK_TIMEOUT="99", 91 | CLOUDSTACK_RETRY="5", 92 | CLOUDSTACK_VERIFY="/path/to/ca.pem", 93 | CLOUDSTACK_CERT="/path/to/cert.pem", 94 | ): 95 | conf = read_config() 96 | self.assertEqual( 97 | { 98 | "key": "test key from env", 99 | "secret": "test secret from env", 100 | "endpoint": "https://api.example.com/from-env", 101 | "expiration": 600, 102 | "method": "post", 103 | "timeout": "99", 104 | "trace": None, 105 | "poll_interval": 2.0, 106 | "verify": "/path/to/ca.pem", 107 | "cert": "/path/to/cert.pem", 108 | "cert_key": None, 109 | "dangerous_no_tls_verify": False, 110 | "name": None, 111 | "retry": "5", 112 | }, 113 | conf, 114 | ) 115 | 116 | def test_env_var_combined_with_dir_config(self): 117 | with open("/tmp/cloudstack.ini", "w") as f: 118 | f.write( 119 | "[hanibal]\n" 120 | "endpoint = https://api.example.com/from-file\n" 121 | "key = test key from file\n" 122 | "secret = secret from file\n" 123 | "theme = monokai\n" 124 | "other = please ignore me\n" 125 | "timeout = 50" 126 | ) 127 | self.addCleanup(partial(os.remove, "/tmp/cloudstack.ini")) 128 | # Secret gets read from env var 129 | with env( 130 | CLOUDSTACK_ENDPOINT="https://api.example.com/from-env", 131 | CLOUDSTACK_KEY="test key from env", 132 | CLOUDSTACK_SECRET="test secret from env", 133 | CLOUDSTACK_REGION="hanibal", 134 | CLOUDSTACK_DANGEROUS_NO_TLS_VERIFY="1", 135 | CLOUDSTACK_OVERRIDES="endpoint,secret", 136 | ), cwd("/tmp"): 137 | conf = read_config() 138 | self.assertEqual( 139 | { 140 | "endpoint": "https://api.example.com/from-env", 141 | "key": "test key from file", 142 | "secret": "test secret from env", 143 | "expiration": 600, 144 | "theme": "monokai", 145 | "timeout": "50", 146 | "trace": None, 147 | "poll_interval": 2.0, 148 | "name": "hanibal", 149 | "verify": None, 150 | "dangerous_no_tls_verify": True, 151 | "retry": 0, 152 | "method": "get", 153 | "cert": None, 154 | "cert_key": None, 155 | }, 156 | conf, 157 | ) 158 | 159 | def test_current_dir_config(self): 160 | with open("/tmp/cloudstack.ini", "w") as f: 161 | f.write( 162 | "[cloudstack]\n" 163 | "endpoint = https://api.example.com/from-file\n" 164 | "key = test key from file\n" 165 | "secret = test secret from file\n" 166 | "dangerous_no_tls_verify = true\n" 167 | "theme = monokai\n" 168 | "other = please ignore me\n" 169 | "header_x-custom-header1 = foo\n" 170 | "header_x-custom-header2 = bar\n" 171 | "timeout = 50" 172 | ) 173 | self.addCleanup(partial(os.remove, "/tmp/cloudstack.ini")) 174 | 175 | with cwd("/tmp"): 176 | conf = read_config() 177 | self.assertEqual( 178 | { 179 | "endpoint": "https://api.example.com/from-file", 180 | "key": "test key from file", 181 | "secret": "test secret from file", 182 | "expiration": 600, 183 | "theme": "monokai", 184 | "timeout": "50", 185 | "trace": None, 186 | "poll_interval": 2.0, 187 | "name": "cloudstack", 188 | "verify": None, 189 | "dangerous_no_tls_verify": True, 190 | "retry": 0, 191 | "method": "get", 192 | "cert": None, 193 | "cert_key": None, 194 | "headers": { 195 | "x-custom-header1": "foo", 196 | "x-custom-header2": "bar", 197 | }, 198 | }, 199 | conf, 200 | ) 201 | 202 | def test_incomplete_config(self): 203 | with open("/tmp/cloudstack.ini", "w") as f: 204 | f.write( 205 | "[hanibal]\n" 206 | "endpoint = https://api.example.com/from-file\n" 207 | "secret = secret from file\n" 208 | "theme = monokai\n" 209 | "other = please ignore me\n" 210 | "timeout = 50" 211 | ) 212 | self.addCleanup(partial(os.remove, "/tmp/cloudstack.ini")) 213 | # Secret gets read from env var 214 | with cwd("/tmp"): 215 | self.assertRaises(ValueError, read_config) 216 | 217 | 218 | class RequestTest(TestCase): 219 | @patch("requests.Session.send") 220 | def test_request_params(self, mock): 221 | cs = CloudStack( 222 | endpoint="https://localhost", 223 | key="foo", 224 | secret="bar", 225 | timeout=20, 226 | expiration=-1, 227 | ) 228 | mock.return_value.status_code = 200 229 | mock.return_value.json.return_value = { 230 | "listvirtualmachinesresponse": {}, 231 | } 232 | machines = cs.listVirtualMachines( 233 | listall="true", headers={"Accept-Encoding": "br"} 234 | ) 235 | self.assertEqual(machines, {}) 236 | 237 | self.assertEqual(1, mock.call_count) 238 | 239 | [request], kwargs = mock.call_args 240 | 241 | self.assertEqual(dict(cert=None, timeout=20, verify=True), kwargs) 242 | self.assertEqual("GET", request.method) 243 | self.assertEqual("br", request.headers["Accept-Encoding"]) 244 | 245 | url = urlparse(request.url) 246 | qs = parse_qs(url.query, True) 247 | 248 | self.assertEqual("listVirtualMachines", qs["command"][0]) 249 | self.assertEqual("B0d6hBsZTcFVCiioSxzwKA9Pke8=", qs["signature"][0]) 250 | self.assertEqual("true", qs["listall"][0]) 251 | 252 | @patch("requests.Session.send") 253 | def test_request_params_casing(self, mock): 254 | cs = CloudStack( 255 | endpoint="https://localhost", 256 | key="foo", 257 | secret="bar", 258 | timeout=20, 259 | expiration=-1, 260 | ) 261 | mock.return_value.status_code = 200 262 | mock.return_value.json.return_value = { 263 | "listvirtualmachinesresponse": {}, 264 | } 265 | machines = cs.listVirtualMachines( 266 | zoneId=2, 267 | templateId="3", 268 | temPlateidd="4", 269 | pageSize="10", 270 | fetch_list=True, 271 | ) 272 | self.assertEqual(machines, []) 273 | 274 | self.assertEqual(1, mock.call_count) 275 | 276 | [request], kwargs = mock.call_args 277 | 278 | self.assertEqual(dict(cert=None, timeout=20, verify=True), kwargs) 279 | self.assertEqual("GET", request.method) 280 | self.assertFalse(request.headers) 281 | 282 | url = urlparse(request.url) 283 | qs = parse_qs(url.query, True) 284 | 285 | self.assertEqual("listVirtualMachines", qs["command"][0]) 286 | self.assertEqual("mMS7XALuGkCXk7kj5SywySku0Z0=", qs["signature"][0]) 287 | self.assertEqual("3", qs["templateId"][0]) 288 | self.assertEqual("4", qs["temPlateidd"][0]) 289 | 290 | @patch("requests.Session.send") 291 | def test_encoding(self, mock): 292 | cs = CloudStack( 293 | endpoint="https://localhost", 294 | key="foo", 295 | secret="bar", 296 | expiration=-1, 297 | ) 298 | mock.return_value.status_code = 200 299 | mock.return_value.json.return_value = { 300 | "listvirtualmachinesresponse": {}, 301 | } 302 | cs.listVirtualMachines(listall=1, unicode_param="éèààû") 303 | self.assertEqual(1, mock.call_count) 304 | 305 | [request], _ = mock.call_args 306 | 307 | url = urlparse(request.url) 308 | qs = parse_qs(url.query, True) 309 | 310 | self.assertEqual("listVirtualMachines", qs["command"][0]) 311 | self.assertEqual("gABU/KFJKD3FLAgKDuxQoryu4sA=", qs["signature"][0]) 312 | self.assertEqual("éèààû", qs["unicode_param"][0]) 313 | 314 | @patch("requests.Session.send") 315 | def test_transform(self, mock): 316 | cs = CloudStack( 317 | endpoint="https://localhost", 318 | key="foo", 319 | secret="bar", 320 | expiration=-1, 321 | ) 322 | mock.return_value.status_code = 200 323 | mock.return_value.json.return_value = { 324 | "listvirtualmachinesresponse": {}, 325 | } 326 | cs.listVirtualMachines( 327 | foo=["foo", "bar"], 328 | bar=[{"baz": "blah", "foo": 1000}], 329 | bytes_param=b"blah", 330 | ) 331 | self.assertEqual(1, mock.call_count) 332 | 333 | [request], kwargs = mock.call_args 334 | 335 | self.assertEqual(dict(cert=None, timeout=10, verify=True), kwargs) 336 | self.assertEqual("GET", request.method) 337 | self.assertFalse(request.headers) 338 | 339 | url = urlparse(request.url) 340 | qs = parse_qs(url.query, True) 341 | 342 | self.assertEqual("listVirtualMachines", qs["command"][0]) 343 | self.assertEqual("ImJ/5F0P2RDL7yn4LdLnGcEx5WE=", qs["signature"][0]) 344 | self.assertEqual("1000", qs["bar[0].foo"][0]) 345 | self.assertEqual("blah", qs["bar[0].baz"][0]) 346 | self.assertEqual("blah", qs["bytes_param"][0]) 347 | self.assertEqual("foo,bar", qs["foo"][0]) 348 | 349 | @patch("requests.Session.send") 350 | def test_transform_dict(self, mock): 351 | cs = CloudStack( 352 | endpoint="https://localhost", 353 | key="foo", 354 | secret="bar", 355 | expiration=-1, 356 | ) 357 | mock.return_value.status_code = 200 358 | mock.return_value.json.return_value = { 359 | "scalevirtualmachineresponse": {}, 360 | } 361 | cs.scaleVirtualMachine( 362 | id="a", details={"cpunumber": 1000, "memory": "640k"} 363 | ) 364 | self.assertEqual(1, mock.call_count) 365 | 366 | [request], kwargs = mock.call_args 367 | 368 | self.assertEqual(dict(cert=None, timeout=10, verify=True), kwargs) 369 | self.assertEqual("GET", request.method) 370 | self.assertFalse(request.headers) 371 | 372 | url = urlparse(request.url) 373 | qs = parse_qs(url.query, True) 374 | 375 | self.assertEqual("scaleVirtualMachine", qs["command"][0]) 376 | self.assertEqual("ZNl66z3gFhnsx2Eo3vvCIM0kAgI=", qs["signature"][0]) 377 | self.assertEqual("1000", qs["details[0].cpunumber"][0]) 378 | self.assertEqual("640k", qs["details[0].memory"][0]) 379 | 380 | @patch("requests.Session.send") 381 | def test_transform_empty(self, mock): 382 | cs = CloudStack( 383 | endpoint="https://localhost", 384 | key="foo", 385 | secret="bar", 386 | expiration=-1, 387 | ) 388 | mock.return_value.status_code = 200 389 | mock.return_value.json.return_value = { 390 | "createnetworkresponse": {}, 391 | } 392 | cs.createNetwork(name="", display_text="") 393 | self.assertEqual(1, mock.call_count) 394 | 395 | [request], kwargs = mock.call_args 396 | 397 | self.assertEqual(dict(cert=None, timeout=10, verify=True), kwargs) 398 | self.assertEqual("GET", request.method) 399 | self.assertFalse(request.headers) 400 | 401 | url = urlparse(request.url) 402 | qs = parse_qs(url.query, True) 403 | 404 | self.assertEqual("createNetwork", qs["command"][0]) 405 | self.assertEqual("CistTEiPt/4Rv1v4qSyILvPbhmg=", qs["signature"][0]) 406 | self.assertEqual("", qs["name"][0]) 407 | self.assertEqual("", qs["display_text"][0]) 408 | 409 | @patch("requests.Session.send") 410 | def test_method(self, mock): 411 | cs = CloudStack( 412 | endpoint="https://localhost", 413 | key="foo", 414 | secret="bar", 415 | method="post", 416 | expiration=-1, 417 | ) 418 | mock.return_value.status_code = 200 419 | mock.return_value.json.return_value = { 420 | "listvirtualmachinesresponse": {}, 421 | } 422 | cs.listVirtualMachines(blah="brah") 423 | self.assertEqual(1, mock.call_count) 424 | 425 | [request], kwargs = mock.call_args 426 | 427 | self.assertEqual(dict(cert=None, timeout=10, verify=True), kwargs) 428 | self.assertEqual("POST", request.method) 429 | self.assertEqual( 430 | "application/x-www-form-urlencoded", 431 | request.headers["Content-Type"], 432 | ) 433 | 434 | qs = parse_qs(request.body, True) 435 | 436 | self.assertEqual("listVirtualMachines", qs["command"][0]) 437 | self.assertEqual("58VvLSaVUqHnG9DhXNOAiDFwBoA=", qs["signature"][0]) 438 | self.assertEqual("brah", qs["blah"][0]) 439 | 440 | @patch("requests.Session.send") 441 | def test_error(self, mock): 442 | mock.return_value.status_code = 530 443 | mock.return_value.json.return_value = { 444 | "listvirtualmachinesresponse": { 445 | "errorcode": 530, 446 | "uuidList": [], 447 | "cserrorcode": 9999, 448 | "errortext": "Fail", 449 | } 450 | } 451 | cs = CloudStack(endpoint="https://localhost", key="foo", secret="bar") 452 | self.assertRaises(CloudStackException, cs.listVirtualMachines) 453 | 454 | @patch("requests.Session.send") 455 | def test_bad_content_type(self, get): 456 | get.return_value.status_code = 502 457 | get.return_value.headers = CaseInsensitiveDict( 458 | **{"content-type": "text/html;charset=utf-8"} 459 | ) 460 | get.return_value.text = ( 461 | "502" "

Gateway timeout

" 462 | ) 463 | 464 | cs = CloudStack(endpoint="https://localhost", key="foo", secret="bar") 465 | self.assertRaises(CloudStackException, cs.listVirtualMachines) 466 | 467 | @patch("requests.Session.send") 468 | def test_signature_v3(self, mock): 469 | cs = CloudStack( 470 | endpoint="https://localhost", 471 | key="foo", 472 | secret="bar", 473 | expiration=600, 474 | ) 475 | mock.return_value.status_code = 200 476 | mock.return_value.json.return_value = { 477 | "createnetworkresponse": {}, 478 | } 479 | cs.createNetwork(name="", display_text="") 480 | self.assertEqual(1, mock.call_count) 481 | 482 | [request], _ = mock.call_args 483 | 484 | url = urlparse(request.url) 485 | qs = parse_qs(url.query, True) 486 | 487 | self.assertEqual("createNetwork", qs["command"][0]) 488 | self.assertEqual("3", qs["signatureVersion"][0]) 489 | 490 | expires = qs["expires"][0] 491 | # we ignore the timezone for Python2's lack of %z 492 | expires = datetime.datetime.strptime(expires[:19], EXPIRES_FORMAT[:-2]) 493 | 494 | self.assertTrue(expires > datetime.datetime.utcnow(), expires) 495 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310,311,312} 4 | lint 5 | skip_missing_interpreters = True 6 | 7 | [gh-actions] 8 | python = 9 | 3.8: py38 10 | 3.9: py39 11 | 3.10: py310 12 | 3.11: py311 13 | 3.12: py312 14 | 15 | [testenv] 16 | deps= 17 | aiohttp 18 | check-manifest 19 | flake8 20 | flake8-import-order 21 | pytest 22 | pytest-cache 23 | pytest-cov 24 | commands = 25 | pip wheel --no-deps -w dist . 26 | test: pytest -v 27 | check-manifest 28 | lint: flake8 cs tests.py 29 | --------------------------------------------------------------------------------