pax_global_header00006660000000000000000000000064150307674230014521gustar00rootroot0000000000000052 comment=37de4a8c978665aa16aeb42005009e695a5827de cs-3.4.0/000077500000000000000000000000001503076742300121325ustar00rootroot00000000000000cs-3.4.0/.github/000077500000000000000000000000001503076742300134725ustar00rootroot00000000000000cs-3.4.0/.github/dependabot.yml000066400000000000000000000003721503076742300163240ustar00rootroot00000000000000# Set update schedule for GitHub Actions --- version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" cs-3.4.0/.github/workflows/000077500000000000000000000000001503076742300155275ustar00rootroot00000000000000cs-3.4.0/.github/workflows/acceptance.yml000066400000000000000000000062511503076742300203440ustar00rootroot00000000000000# 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. # See https://github.com/apache/cloudstack-terraform-provider/blob/main/.github/workflows/acceptance.yml name: Acceptance Test on: pull_request: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-acceptance cancel-in-progress: true permissions: contents: read env: CLOUDSTACK_API_URL: http://localhost:8080/client/api CLOUDSTACK_VERSIONS: "['1.7.0']" jobs: prepare-matrix: runs-on: ubuntu-latest outputs: cloudstack-versions: ${{ steps.set-versions.outputs.cloudstack-versions }} steps: - name: Set versions id: set-versions run: | echo "cloudstack-versions=${{ env.CLOUDSTACK_VERSIONS }}" >> $GITHUB_OUTPUT acceptance-cs: name: Python ${{ matrix.python-version }} with CloudStack test container ${{ matrix.cloudstack-version }} needs: [prepare-matrix] runs-on: ubuntu-latest services: cloudstack-simulator: image: quay.io/ansible/cloudstack-test-container:${{ matrix.cloudstack-version }} ports: - 8080:8080 strategy: fail-fast: false max-parallel: 1 matrix: cloudstack-version: ${{ fromJson(needs.prepare-matrix.outputs.cloudstack-versions) }} python-version: - '3.12' steps: - uses: actions/checkout@v4 - name: Configure Cloudstack v${{ matrix.cloudstack-version }} uses: ./.github/workflows/setup-cloudstack id: setup-cloudstack with: cloudstack-version: ${{ matrix.cloudstack-version }} - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Run acceptance test env: CLOUDSTACK_USER_ID: ${{ steps.setup-cloudstack.outputs.CLOUDSTACK_USER_ID }} CLOUDSTACK_KEY: ${{ steps.setup-cloudstack.outputs.CLOUDSTACK_API_KEY }} CLOUDSTACK_SECRET: ${{ steps.setup-cloudstack.outputs.CLOUDSTACK_SECRET_KEY }} CLOUDSTACK_ENDPOINT: ${{ steps.setup-cloudstack.outputs.CLOUDSTACK_API_URL }} run: | python -m venv .venv source .venv/bin/activate which python pip install . cs listZones all-jobs-passed: # Will succeed if it is skipped runs-on: ubuntu-latest needs: [acceptance-cs] # Only run if any of the previous jobs failed if: ${{ failure() }} steps: - name: Previous jobs failed run: exit 1 cs-3.4.0/.github/workflows/main.yml000066400000000000000000000025301503076742300171760ustar00rootroot00000000000000--- name: CI on: pull_request: push: branches: - '**' paths-ignore: - '**.md' - '**.rst' - '**.txt' tags-ignore: - 'v**' # Don't run CI tests on release tags jobs: tests: name: Tests on ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - '3.9' - '3.10' - '3.11' - '3.12' - '3.13' steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: "Install dependencies" run: | python -VV python -m site python -m pip install -U pip wheel setuptools python -m pip install -U tox tox-gh-actions - name: Tests run: tox lint: name: Linting runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.x' - name: "Install dependencies" run: | python -VV python -m site python -m pip install -U pip wheel setuptools python -m pip install -U black flake8 flake8-import-order flake8-bugbear - name: Lint run: | black --check --diff . flake8 . cs-3.4.0/.github/workflows/publish.yml000066400000000000000000000012461503076742300177230ustar00rootroot00000000000000name: Upload Python Package on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install dependencies run: | python -m pip install --upgrade pip pip install -U setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | rm -rf dist/* python setup.py sdist bdist_wheel twine upload dist/* cs-3.4.0/.github/workflows/setup-cloudstack/000077500000000000000000000000001503076742300210215ustar00rootroot00000000000000cs-3.4.0/.github/workflows/setup-cloudstack/action.yml000066400000000000000000000061571503076742300230320ustar00rootroot00000000000000# 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. # See https://raw.githubusercontent.com/apache/cloudstack-terraform-provider/refs/heads/main/.github/workflows/setup-cloudstack/action.yml name: Setup Cloudstack inputs: cloudstack-version: description: 'Cloudstack version' required: true outputs: CLOUDSTACK_USER_ID: description: 'Cloudstack user id' value: ${{ steps.setup-cloudstack.outputs.user_id }} CLOUDSTACK_API_KEY: description: 'Cloudstack api key' value: ${{ steps.setup-cloudstack.outputs.api_key }} CLOUDSTACK_SECRET_KEY: description: 'Cloudstack secret key' value: ${{ steps.setup-cloudstack.outputs.secret_key }} CLOUDSTACK_API_URL: description: 'Cloudstack API URL' value: http://localhost:8080/client/api runs: using: composite steps: - name: Wait Cloudstack to be ready shell: bash run: | echo "Starting Cloudstack health check" T=0 until [ $T -gt 20 ] || curl -sfL http://localhost:8080 --output /dev/null do echo "Waiting for Cloudstack to be ready..." ((T+=1)) sleep 30 done - name: Setting up Cloudstack id: setup-cloudstack shell: bash run: | curl -sf --location "${CLOUDSTACK_API_URL}" \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'command=login' \ --data-urlencode 'username=admin' \ --data-urlencode 'password=password' \ --data-urlencode 'response=json' \ --data-urlencode 'domain=/' -j -c cookies.txt --output /dev/null CLOUDSTACK_USER_ID=$(curl -fs "${CLOUDSTACK_API_URL}?command=listUsers&response=json" -b cookies.txt | jq -r '.listusersresponse.user[0].id') CLOUDSTACK_API_KEY=$(curl -s "${CLOUDSTACK_API_URL}?command=getUserKeys&id=${CLOUDSTACK_USER_ID}&response=json" -b cookies.txt | jq -r '.getuserkeysresponse.userkeys.apikey') CLOUDSTACK_SECRET_KEY=$(curl -fs "${CLOUDSTACK_API_URL}?command=getUserKeys&id=${CLOUDSTACK_USER_ID}&response=json" -b cookies.txt | jq -r '.getuserkeysresponse.userkeys.secretkey') echo "::add-mask::$CLOUDSTACK_API_KEY" echo "::add-mask::$CLOUDSTACK_SECRET_KEY" echo "user_id=$CLOUDSTACK_USER_ID" >> $GITHUB_OUTPUT echo "api_key=$CLOUDSTACK_API_KEY" >> $GITHUB_OUTPUT echo "secret_key=$CLOUDSTACK_SECRET_KEY" >> $GITHUB_OUTPUT cs-3.4.0/.github/workflows/stale.yml000066400000000000000000000003431503076742300173620ustar00rootroot00000000000000name: Close stale issues and PRs on: schedule: - cron: '23 5 * * *' jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v9 with: any-of-labels: 'needs-more-info,needs-demo' cs-3.4.0/.gitignore000066400000000000000000000001001503076742300141110ustar00rootroot00000000000000dist build .venv .coverage .eggs .tox *.egg-info *.pyc .*_cache cs-3.4.0/LICENSE000066400000000000000000000027351503076742300131460ustar00rootroot00000000000000Copyright (c) 2014, Bruno Renié and contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. cs-3.4.0/MANIFEST.in000066400000000000000000000000521503076742300136650ustar00rootroot00000000000000recursive-include cs *.py include LICENSE cs-3.4.0/README.rst000066400000000000000000000172031503076742300136240ustar00rootroot00000000000000.. image:: https://github.com/ngine-io/cs/actions/workflows/main.yml/badge.svg :alt: CI :target: https://github.com/ngine-io/cs/actions/workflows/main.yml .. image:: https://img.shields.io/pypi/pyversions/cs.svg :alt: Python versions :target: https://pypi.org/project/cs/ .. image:: https://img.shields.io/pypi/dw/cs.svg :alt: Downloads / Week :target: https://pypi.org/project/cs/ .. image:: https://img.shields.io/pypi/l/cs.svg :alt: License :target: https://pypi.org/project/cs/ CS - Python CloudStack API client ================================= A simple, yet powerful CloudStack API client for python and the command-line. * Async support. * All present and future CloudStack API calls and parameters are supported. * Syntax highlight in the command-line client if Pygments is installed. * BSD license. Installation ------------ :: pip install cs # with the colored output pip install cs[highlight] # with the async support pip install cs[async] # with both pip install cs[async,highlight] Usage ----- In Python: .. code-block:: python from cs import CloudStack cs = CloudStack(endpoint='https://cloudstack.example.com/client/api', key='cloudstack api key', secret='cloudstack api secret') vms = cs.listVirtualMachines() cs.createSecurityGroup(name='web', description='HTTP traffic') From the command-line, this requires some configuration: .. code-block:: console cat $HOME/.cloudstack.ini .. code-block:: ini [cloudstack] endpoint = https://cloudstack.example.com/client/api key = cloudstack api key secret = cloudstack api secret # Optional ca authority certificate verify = /path/to/certs/ca.crt # Optional client PEM certificate cert = /path/to/client.pem # If you need to pass the certificate and key as separate files cert_key = /path/to/client_key.pem Then: .. code-block:: console $ cs listVirtualMachines .. code-block:: json { "count": 1, "virtualmachine": [ { "account": "...", ... } ] } .. code-block:: console $ cs authorizeSecurityGroupIngress \ cidrlist="0.0.0.0/0" endport=443 startport=443 \ securitygroupname="blah blah" protocol=tcp The command-line client polls when async results are returned. To disable polling, use the ``--async`` flag. To find the list CloudStack API calls go to http://cloudstack.apache.org/api.html Configuration ------------- Configuration is read from several locations, in the following order: * The ``CLOUDSTACK_ENDPOINT``, ``CLOUDSTACK_KEY``, ``CLOUDSTACK_SECRET`` and ``CLOUDSTACK_METHOD`` environment variables, * A ``CLOUDSTACK_CONFIG`` environment variable pointing to an ``.ini`` file, * A ``CLOUDSTACK_VERIFY`` (optional) environment variable pointing to a CA authority cert file, * A ``CLOUDSTACK_CERT`` (optional) environment variable pointing to a client PEM cert file, * A ``CLOUDSTACK_CERT_KEY`` (optional) environment variable pointing to a client PEM certificate key file, * A ``cloudstack.ini`` file in the current working directory, * A ``.cloudstack.ini`` file in the home directory. To use that configuration scheme from your Python code: .. code-block:: python from cs import CloudStack, read_config cs = CloudStack(**read_config()) Note that ``read_config()`` can raise ``SystemExit`` if no configuration is found. ``CLOUDSTACK_METHOD`` or the ``method`` entry in the configuration file can be used to change the HTTP verb used to make CloudStack requests. By default, requests are made with the GET method but CloudStack supports POST requests. POST can be useful to overcome some length limits in the CloudStack API. ``CLOUDSTACK_TIMEOUT`` or the ``timeout`` entry in the configuration file can be used to change the HTTP timeout when making CloudStack requests (in seconds). The default value is 10. ``CLOUDSTACK_RETRY`` or the ``retry`` entry in the configuration file (integer) can be used to retry ``list`` and ``queryAsync`` requests on failure. The default value is 0, meaning no retry. ``CLOUDSTACK_JOB_TIMEOUT`` or the `job_timeout`` entry in the configuration file (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. ``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. ``CLOUDSTACK_EXPIRATION`` or the ``expiration`` entry in the configuration file (integer) can be used to set how long a signature is valid. By default, it picks 10 minutes but may be deactivated using any negative value, e.g. -1. ``CLOUDSTACK_DANGEROUS_NO_TLS_VERIFY`` or the ``dangerous_no_tls_verify`` entry in the configuration file (boolean) can be used to deactivate the TLS verification made when using the HTTPS protocol. Multiple credentials can be set in ``.cloudstack.ini``. This allows selecting the credentials or endpoint to use with a command-line flag. .. code-block:: ini [cloudstack] endpoint = https://some-host/api/v1 key = api key secret = api secret [region-example] endpoint = https://cloudstack.example.com/client/api key = api key secret = api secret Usage:: $ cs listVirtualMachines --region=region-example Optionally ``CLOUDSTACK_REGION`` can be used to overwrite the default region ``cloudstack``. For the power users that don't want to put any secrets on disk, ``CLOUDSTACK_OVERRIDES`` let you pick which key will be set from the environment even if present in the ini file. Pagination ---------- CloudStack paginates requests. ``cs`` is able to abstract away the pagination logic to allow fetching large result sets in one go. This is done with the ``fetch_list`` parameter:: $ cs listVirtualMachines fetch_list=true Or in Python:: cs.listVirtualMachines(fetch_list=True) Tracing HTTP requests --------------------- Once in a while, it could be useful to understand, see what HTTP calls are made under the hood. The ``trace`` flag (or ``CLOUDSTACK_TRACE``) does just that:: $ cs --trace listVirtualMachines $ cs -t listZones Async client ------------ ``cs`` provides the ``AIOCloudStack`` class for async/await calls in Python 3.5+. .. code-block:: python import asyncio from cs import AIOCloudStack, read_config cs = AIOCloudStack(**read_config()) async def main(): vms = await cs.listVirtualMachines(fetch_list=True) print(vms) asyncio.run(main()) Async deployment of multiple VMs ________________________________ .. code-block:: python import asyncio from cs import AIOCloudStack, read_config cs = AIOCloudStack(**read_config()) machine = {"zoneid": ..., "serviceofferingid": ..., "templateid": ...} async def main(): tasks = asyncio.gather(*(cs.deployVirtualMachine(name=f"vm-{i}", **machine, fetch_result=True) for i in range(5))) results = await tasks # Destroy all of them, but skip waiting on the job results await asyncio.gather(*(cs.destroyVirtualMachine(id=result['virtualmachine']['id']) for result in results)) asyncio.run(main()) Release Procedure ----------------- .. code-block:: shell-session mktmpenv -p /usr/bin/python3 pip install -U twine wheel build cd ./cs rm -rf build dist python -m build twine upload dist/* Links ----- * CloudStack API: http://cloudstack.apache.org/api.html cs-3.4.0/cs/000077500000000000000000000000001503076742300125375ustar00rootroot00000000000000cs-3.4.0/cs/__init__.py000066400000000000000000000104561503076742300146560ustar00rootroot00000000000000import argparse import json import os import sys from collections import defaultdict from configparser import NoSectionError try: import pygments from pygments.lexers import JsonLexer from pygments.styles import get_style_by_name from pygments.formatters import Terminal256Formatter except ImportError: pygments = None from .client import ( CloudStack, CloudStackApiException, CloudStackException, read_config, ) from .version import __version__ __all__ = [ "read_config", "CloudStack", "CloudStackException", "CloudStackApiException", ] try: import aiohttp # noqa except ImportError: pass else: from ._async import AIOCloudStack # noqa __all__.append("AIOCloudStack") def _format_json(data, theme): """Pretty print a dict as a JSON, with colors if pygments is present.""" output = json.dumps(data, indent=2, sort_keys=True) if pygments and sys.stdout.isatty(): style = get_style_by_name(theme) formatter = Terminal256Formatter(style=style) return pygments.highlight(output, JsonLexer(), formatter) return output def main(args=None): parser = argparse.ArgumentParser(description="Cloudstack client.") parser.add_argument( "--region", "-r", metavar="REGION", help="Cloudstack region in ~/.cloudstack.ini", default=os.environ.get("CLOUDSTACK_REGION", "cloudstack"), ) parser.add_argument( "--theme", metavar="THEME", help="Pygments style", default=os.environ.get("CLOUDSTACK_THEME", "default"), ) parser.add_argument( "--post", action="store_true", default=False, help="use POST instead of GET", ) parser.add_argument( "--async", action="store_true", default=False, help="do not wait for async result", ) parser.add_argument( "--quiet", "-q", action="store_true", default=False, help="do not display additional status messages", ) parser.add_argument( "--trace", "-t", action="store_true", default=os.environ.get("CLOUDSTACK_TRACE", False), help="trace the HTTP requests done on stderr", ) parser.add_argument( "command", metavar="COMMAND", help="Cloudstack API command to execute", ) parser.add_argument( "--version", action="version", version=__version__, ) def parse_option(x): if "=" not in x: raise ValueError( "{!r} is not a correctly formatted " "option".format(x) ) return x.split("=", 1) parser.add_argument( "arguments", metavar="OPTION=VALUE", nargs="*", type=parse_option, help="Cloudstack API argument", ) options = parser.parse_args(args=args) command = options.command kwargs = defaultdict(set) for arg in options.arguments: key, value = arg kwargs[key].add(value.strip(" \"'")) try: config = read_config(ini_group=options.region) except NoSectionError: raise SystemExit("Error: region '%s' not in config" % options.region) theme = config.pop("theme", "default") fetch_result = "Async" not in command and not getattr(options, "async") if options.post: config["method"] = "post" if options.trace: config["trace"] = True cs = CloudStack(**config) ok = True response = None try: response = getattr(cs, command)(fetch_result=fetch_result, **kwargs) except CloudStackException as e: ok = False if e.response is not None: if not options.quiet: sys.stderr.write("CloudStack error: ") sys.stderr.write("\n".join((str(arg) for arg in e.args))) sys.stderr.write("\n") try: response = json.loads(e.response.text) except ValueError: sys.stderr.write(e.response.text) sys.stderr.write("\n") else: message, data = (e.args[0], e.args[0:]) sys.stderr.write("Error: {0}\n{1}\n".format(message, data)) if response: sys.stdout.write(_format_json(response, theme=theme)) sys.stdout.write("\n") return not ok cs-3.4.0/cs/__main__.py000066400000000000000000000000731503076742300146310ustar00rootroot00000000000000from . import main if __name__ == "__main__": main() cs-3.4.0/cs/_async.py000066400000000000000000000112411503076742300143640ustar00rootroot00000000000000import asyncio import ssl import aiohttp from . import CloudStack, CloudStackApiException, CloudStackException from .client import PENDING, SUCCESS, transform class AIOCloudStack(CloudStack): def __getattr__(self, command): def handler(**kwargs): return self._request(command, **kwargs) return handler async def _request( self, command, json=True, opcode_name="command", fetch_list=False, headers=None, **params ): fetch_result = params.pop("fetch_result", self.fetch_result) kwarg, kwargs = self._prepare_request( command, json, opcode_name, fetch_list, **params ) ssl_context = None if self.cert: ssl_context = ssl.create_default_context(cafile=self.cert) connector = aiohttp.TCPConnector( verify_ssl=self.verify, ssl_context=ssl_context ) async with aiohttp.ClientSession( read_timeout=self.timeout, conn_timeout=self.timeout, connector=connector, ) as session: handler = getattr(session, self.method) done = False final_data = [] page = 1 while not done: if fetch_list: kwargs["page"] = page transform(kwargs) kwargs.pop("signature", None) self._sign(kwargs) response = await handler( self.endpoint, headers=headers, **{kwarg: kwargs} ) ctype = response.headers["content-type"].split(";")[0] try: data = await response.json(content_type=ctype) except ValueError as e: msg = "Make sure endpoint URL {!r} is correct.".format( self.endpoint ) raise CloudStackException( "HTTP {0} response from CloudStack".format( response.status ), "{}. {}".format(e, msg), response=response, ) [key] = data.keys() data = data[key] if response.status != 200: raise CloudStackApiException( "HTTP {0} response from CloudStack".format( response.status ), error=data, response=response, ) if fetch_list: try: [key] = [k for k in data.keys() if k != "count"] except ValueError: done = True else: final_data.extend(data[key]) page += 1 elif fetch_result and "jobid" in data: try: final_data = await asyncio.wait_for( self._jobresult(data["jobid"], response), self.job_timeout, ) except asyncio.TimeoutError: raise CloudStackException( "Timeout waiting for async job result", data["jobid"], response=response, ) done = True else: final_data = data done = True return final_data async def _jobresult(self, jobid, response): failures = 0 while True: try: j = await self.queryAsyncJobResult( jobid=jobid, fetch_result=False ) failures = 0 if j["jobstatus"] != PENDING: if j["jobresultcode"] != 0 or j["jobstatus"] != SUCCESS: raise CloudStackApiException( "Job failure", j, error=j["jobresult"], response=response, ) if "jobresult" not in j: raise CloudStackException( "Unknown job result", j, response=response ) return j["jobresult"] except CloudStackException: raise except Exception: failures += 1 if failures > 10: raise await asyncio.sleep(self.poll_interval) cs-3.4.0/cs/client.py000066400000000000000000000427161503076742300144010ustar00rootroot00000000000000import base64 import hashlib import hmac import os import re import sys import time from configparser import ConfigParser from datetime import datetime, timedelta from fnmatch import fnmatch from urllib.parse import quote import pytz import requests from requests.structures import CaseInsensitiveDict try: from . import AIOCloudStack # noqa except ImportError: pass TIMEOUT = 10 PAGE_SIZE = 500 POLL_INTERVAL = 2.0 EXPIRATION = timedelta(minutes=10) EXPIRES_FORMAT = "%Y-%m-%dT%H:%M:%S%z" REQUIRED_CONFIG_KEYS = {"endpoint", "key", "secret", "method", "timeout"} ALLOWED_CONFIG_KEYS = { "verify", "cert", "cert_key", "retry", "theme", "expiration", "poll_interval", "trace", "dangerous_no_tls_verify", "header_*", } DEFAULT_CONFIG = { "timeout": 10, "method": "get", "retry": 0, "verify": None, "cert": None, "cert_key": None, "name": None, "expiration": 600, "poll_interval": POLL_INTERVAL, "trace": None, "dangerous_no_tls_verify": False, } PENDING = 0 SUCCESS = 1 FAILURE = 2 def strtobool(val): """Convert a string representation of truth to true (1) or false (0). True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if 'val' is anything else. This function has been borrowed from distutils.util module in order to avoid pulling a dependency on deprecated module "imp". """ val = val.lower() if val in ("y", "yes", "t", "true", "on", "1"): return 1 elif val in ("n", "no", "f", "false", "off", "0"): return 0 else: raise ValueError("invalid truth value %r" % (val,)) def check_key(key, allowed): """ Validate that the specified key is allowed according the provided list of patterns. """ if key in allowed: return True for pattern in allowed: if fnmatch(key, pattern): return True return False def cs_encode(s): """Encode URI component like CloudStack would do before signing. java.net.URLEncoder.encode(s).replace('+', '%20') """ return quote(s, safe="*") def transform(params): """ Transforms an heterogeneous map of params into a CloudStack ready mapping of parameter to values. It handles lists and dicts. >>> p = {"a": 1, "b": "foo", "c": ["eggs", "spam"], "d": {"key": "value"}} >>> transform(p) >>> print(p) {'a': '1', 'b': 'foo', 'c': 'eggs,spam', 'd[0].key': 'value'} """ for key, value in list(params.items()): if value is None: params.pop(key) continue if isinstance(value, (str, bytes)): continue if isinstance(value, int): params[key] = str(value) elif isinstance(value, (list, tuple, set, dict)): if not value: params.pop(key) else: if isinstance(value, dict): value = [value] if isinstance(value, set): value = list(value) if not isinstance(value[0], dict): params[key] = ",".join(value) else: params.pop(key) for index, val in enumerate(value): for name, v in val.items(): k = "%s[%d].%s" % (key, index, name) params[k] = str(v) else: raise ValueError(type(value)) class CloudStackException(Exception): """Exception nicely wrapping a request response.""" def __init__(self, *args, **kwargs): self.response = kwargs.pop("response") super(CloudStackException, self).__init__(*args, **kwargs) class CloudStackApiException(CloudStackException): def __init__(self, *args, **kwargs): self.error = kwargs.pop("error") super(CloudStackApiException, self).__init__(*args, **kwargs) def __str__(self): return "{0}, error: {1}".format( super(CloudStackApiException, self).__str__(), self.error ) ten_minutes = timedelta(minutes=10) class CloudStack(object): def __init__( self, endpoint, key, secret, timeout=10, method="get", verify=None, cert=None, cert_key=None, name=None, retry=0, job_timeout=None, poll_interval=POLL_INTERVAL, expiration=ten_minutes, trace=False, dangerous_no_tls_verify=False, headers=None, session=None, fetch_result=False, ): self.endpoint = endpoint self.key = key self.secret = secret self.timeout = int(timeout) self.method = method.lower() if verify: self.verify = verify else: self.verify = not dangerous_no_tls_verify if headers is None: headers = {} self.headers = headers self.session = session if session is not None else requests.Session() if cert and cert_key: cert = (cert, cert_key) self.cert = cert self.name = name self.retry = int(retry) self.job_timeout = int(job_timeout) if job_timeout else None self.poll_interval = float(poll_interval) if not hasattr(expiration, "seconds"): expiration = timedelta(seconds=int(expiration)) self.expiration = expiration self.trace = bool(trace) self.fetch_result = fetch_result def __repr__(self): return "".format(self.name or self.endpoint) def __getattr__(self, command): def handler(**kwargs): return self._request(command, **kwargs) return handler def _prepare_request( self, command, json=True, opcode_name="command", fetch_list=False, **kwargs, ): params = CaseInsensitiveDict(**kwargs) params.update( { "apiKey": self.key, opcode_name: command, } ) if json: params["response"] = "json" if "page" in kwargs or fetch_list: params.setdefault("pagesize", PAGE_SIZE) if "expires" not in params and self.expiration.total_seconds() >= 0: params["signatureVersion"] = "3" tz = pytz.utc expires = tz.localize(datetime.utcnow() + self.expiration) params["expires"] = expires.astimezone(tz).strftime(EXPIRES_FORMAT) kind = "params" if self.method == "get" else "data" return kind, dict(params.items()) def _request( self, command, json=True, opcode_name="command", fetch_list=False, headers=None, **params, ): fetch_result = params.pop("fetch_result", self.fetch_result) kind, params = self._prepare_request( command, json, opcode_name, fetch_list, **params ) if headers is None: headers = {} headers.update(self.headers) done = False max_retry = self.retry final_data = [] page = 1 while not done: if fetch_list: params["page"] = page transform(params) params.pop("signature", None) self._sign(params) req = requests.Request( self.method, self.endpoint, headers=headers, **{kind: params} ) prepped = req.prepare() if self.trace: print(prepped.method, prepped.url, file=sys.stderr) if prepped.headers: print(prepped.headers, "\n", file=sys.stderr) if prepped.body: print(prepped.body, file=sys.stderr) else: print(file=sys.stderr) try: with self.session as session: response = session.send( prepped, timeout=self.timeout, verify=self.verify, cert=self.cert, ) except requests.exceptions.ConnectionError: max_retry -= 1 if max_retry < 0 or not command.startswith( ("list", "queryAsync") ): raise continue max_retry = self.retry if self.trace: print(response.status_code, response.reason, file=sys.stderr) headersTrace = "\n".join( "{}: {}".format(k, v) for k, v in response.headers.items() ) print(headersTrace, "\n", file=sys.stderr) print(response.text, "\n", file=sys.stderr) data = self._response_value(response, json) if fetch_list: try: [key] = [k for k in data.keys() if k != "count"] except ValueError: done = True else: final_data.extend(data[key]) page += 1 if len(final_data) >= data.get("count", PAGE_SIZE): done = True elif fetch_result and "jobid" in data: final_data = self._jobresult( jobid=data["jobid"], headers=headers ) done = True else: final_data = data done = True return final_data def _response_value(self, response, json=True): """Parses the HTTP response as a the cloudstack value. It throws an exception if the server didn't answer with a 200. """ if json: ctype = response.headers.get("Content-Type", "") if not ctype.startswith(("application/json", "text/javascript")): if response.status_code == 200: msg = ( f"JSON (application/json) was expected, got {ctype:!r}" ) raise CloudStackException(msg, response=response) raise CloudStackException( "HTTP {0.status_code} {0.reason}".format(response), "Make sure endpoint URL {!r} is correct.".format( self.endpoint ), response=response, ) try: data = response.json() except ValueError as e: raise CloudStackException( "HTTP {0.status_code} {0.reason}".format(response), "{0!s}. Malformed JSON document".format(e), response=response, ) [key] = data.keys() data = data[key] else: data = response.text if response.status_code != 200: raise CloudStackApiException( "HTTP {0} response from CloudStack".format( response.status_code ), error=data, response=response, ) return data def _jobresult(self, jobid, json=True, headers=None): """Poll the async job result. To be run via in a Thread, the result is put within the result list which is a hack. """ failures = 0 total_time = self.job_timeout or 2**30 remaining = timedelta(seconds=total_time) endtime = datetime.now() + remaining while remaining.total_seconds() > 0: timeout = max(min(self.timeout, remaining.total_seconds()), 1) try: kind, params = self._prepare_request( "queryAsyncJobResult", jobid=jobid ) transform(params) self._sign(params) req = requests.Request( self.method, self.endpoint, headers=headers, **{kind: params}, ) prepped = req.prepare() if self.trace: print(prepped.method, prepped.url, file=sys.stderr) if prepped.headers: print(prepped.headers, "\n", file=sys.stderr) if prepped.body: print(prepped.body, file=sys.stderr) else: print(file=sys.stderr) with self.session as session: response = session.send( prepped, timeout=timeout, verify=self.verify, cert=self.cert, ) j = self._response_value(response, json) if self.trace: print( response.status_code, response.reason, file=sys.stderr ) headersTrace = "\n".join( "{}: {}".format(k, v) for k, v in response.headers.items() ) print(headersTrace, "\n", file=sys.stderr) print(response.text, "\n", file=sys.stderr) failures = 0 if j["jobstatus"] != PENDING: if j["jobresultcode"] or j["jobstatus"] != SUCCESS: raise CloudStackApiException( "Job failure", error=j["jobresult"], response=response, ) if "jobresult" not in j: raise CloudStackException( "Unknown job result", response=response ) return j["jobresult"] except CloudStackException: raise except Exception: failures += 1 if failures > 10: raise time.sleep(self.poll_interval) remaining = endtime - datetime.now() if response: response.status_code = 408 raise CloudStackException( "Timeout waiting for async job result", jobid, response=response ) def _sign(self, data): """ Compute a signature string according to the CloudStack signature method (hmac/sha1). """ # Python2/3 urlencode aren't good enough for this task. params = "&".join( "=".join((key, cs_encode(value))) for key, value in sorted(data.items()) ) digest = hmac.new( self.secret.encode("utf-8"), msg=params.lower().encode("utf-8"), digestmod=hashlib.sha1, ).digest() data["signature"] = base64.b64encode(digest).decode("utf-8").strip() def read_config_from_ini(ini_group=None): # Config file: $PWD/cloudstack.ini or $HOME/.cloudstack.ini # Last read wins in configparser paths = [ os.path.join(os.path.expanduser("~"), ".cloudstack.ini"), os.path.join(os.getcwd(), "cloudstack.ini"), ] # Look at CLOUDSTACK_CONFIG first if present if "CLOUDSTACK_CONFIG" in os.environ: paths.append(os.path.expanduser(os.environ["CLOUDSTACK_CONFIG"])) if not any([os.path.exists(c) for c in paths]): raise SystemExit( "Config file not found. Tried {0}".format(", ".join(paths)) ) conf = ConfigParser() conf.read(paths) if not ini_group: ini_group = os.getenv("CLOUDSTACK_REGION", "cloudstack") if not conf.has_section(ini_group): return dict(name=None) ini_config = { k: v for k, v in conf.items(ini_group) if v and check_key(k, REQUIRED_CONFIG_KEYS.union(ALLOWED_CONFIG_KEYS)) } ini_config["name"] = ini_group # Convert individual header_* settings into a single dict for k in list(ini_config): if k.startswith("header_"): ini_config.setdefault("headers", {}) start = len("header_") ini_config["headers"][k[start:]] = ini_config.pop(k) return ini_config def read_config(ini_group=None): """ Read the configuration from the environment, or config. First it try to go for the environment, then it overrides those with the cloudstack.ini file. """ env_conf = dict(DEFAULT_CONFIG) for key in REQUIRED_CONFIG_KEYS.union(ALLOWED_CONFIG_KEYS): env_key = "CLOUDSTACK_{0}".format(key.upper()) value = os.getenv(env_key) if value: env_conf[key] = value # overrides means we have a .ini to read overrides = os.getenv("CLOUDSTACK_OVERRIDES", "").strip() if not overrides and set(env_conf).issuperset(REQUIRED_CONFIG_KEYS): return env_conf ini_conf = read_config_from_ini(ini_group) overrides = {s.lower() for s in re.split(r"\W+", overrides)} config = dict( dict(env_conf, **ini_conf), **{k: v for k, v in env_conf.items() if k in overrides}, ) missings = REQUIRED_CONFIG_KEYS.difference(config) if missings: raise ValueError( "the configuration is missing the following keys: " + ", ".join(missings) ) # convert booleans values. bool_keys = ("dangerous_no_tls_verify",) for bool_key in bool_keys: if isinstance(config[bool_key], str): try: config[bool_key] = strtobool(config[bool_key]) except ValueError: pass return config cs-3.4.0/cs/version.py000066400000000000000000000000261503076742300145740ustar00rootroot00000000000000__version__ = "3.4.0" cs-3.4.0/pyproject.toml000066400000000000000000000000361503076742300150450ustar00rootroot00000000000000[tool.black] line-length = 79 cs-3.4.0/setup.cfg000066400000000000000000000022211503076742300137500ustar00rootroot00000000000000[metadata] name = cs version = 3.4.0 url = https://github.com/ngine-io/cs author = Bruno Renié description = A simple yet powerful CloudStack API client for Python and the command-line. long_description = file: README.rst license = BSD license_files = LICENSE classifiers = Intended Audience :: Developers Intended Audience :: System Administrators License :: OSI Approved :: BSD License Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 [options] packages = find: include_package_data = true zip_safe = false install_requires = pytz requests [options.packages.find] exclude = tests [options.entry_points] console_scripts = cs = cs:main [options.extras_require] async = aiohttp highlight = pygments [aliases] test = pytest [wheel] universal = 1 [tool:pytest] addopts = --cov=cs --cov-report=term-missing cs tests.py [check-manifest] ignore = tox.ini tests.py [isort] style = pep8 cs-3.4.0/setup.py000066400000000000000000000002141503076742300136410ustar00rootroot00000000000000# coding: utf-8 """ A simple yet powerful CloudStack API client for Python and the command-line. """ from setuptools import setup setup() cs-3.4.0/tests.py000066400000000000000000000405061503076742300136530ustar00rootroot00000000000000# coding: utf-8 import datetime import os from contextlib import contextmanager from functools import partial from unittest import TestCase from unittest.mock import patch from urllib.parse import parse_qs, urlparse from cs import ( CloudStack, CloudStackApiException, CloudStackException, read_config, ) from cs.client import EXPIRES_FORMAT from requests.structures import CaseInsensitiveDict @contextmanager def env(**kwargs): old_env = {} for key in kwargs: if key in os.environ: old_env[key] = os.environ[key] os.environ.update(kwargs) try: yield finally: for key in kwargs: if key in old_env: os.environ[key] = old_env[key] else: del os.environ[key] @contextmanager def cwd(path): initial = os.getcwd() os.chdir(path) try: with patch("os.path.expanduser", new=lambda x: path): yield finally: os.chdir(initial) class ExceptionTest(TestCase): def test_api_exception_str(self): e = CloudStackApiException( "CS failed", error={"test": 42}, response=None ) self.assertEqual("CS failed, error: {'test': 42}", str(e)) class ConfigTest(TestCase): def test_env_vars(self): with env( CLOUDSTACK_KEY="test key from env", CLOUDSTACK_SECRET="test secret from env", CLOUDSTACK_ENDPOINT="https://api.example.com/from-env", ): conf = read_config() self.assertEqual( { "key": "test key from env", "secret": "test secret from env", "endpoint": "https://api.example.com/from-env", "expiration": 600, "method": "get", "trace": None, "timeout": 10, "poll_interval": 2.0, "verify": None, "dangerous_no_tls_verify": False, "cert": None, "cert_key": None, "name": None, "retry": 0, }, conf, ) with env( CLOUDSTACK_KEY="test key from env", CLOUDSTACK_SECRET="test secret from env", CLOUDSTACK_ENDPOINT="https://api.example.com/from-env", CLOUDSTACK_METHOD="post", CLOUDSTACK_TIMEOUT="99", CLOUDSTACK_RETRY="5", CLOUDSTACK_VERIFY="/path/to/ca.pem", CLOUDSTACK_CERT="/path/to/cert.pem", ): conf = read_config() self.assertEqual( { "key": "test key from env", "secret": "test secret from env", "endpoint": "https://api.example.com/from-env", "expiration": 600, "method": "post", "timeout": "99", "trace": None, "poll_interval": 2.0, "verify": "/path/to/ca.pem", "cert": "/path/to/cert.pem", "cert_key": None, "dangerous_no_tls_verify": False, "name": None, "retry": "5", }, conf, ) def test_env_var_combined_with_dir_config(self): with open("/tmp/cloudstack.ini", "w") as f: f.write( "[hanibal]\n" "endpoint = https://api.example.com/from-file\n" "key = test key from file\n" "secret = secret from file\n" "theme = monokai\n" "other = please ignore me\n" "timeout = 50" ) self.addCleanup(partial(os.remove, "/tmp/cloudstack.ini")) # Secret gets read from env var with env( CLOUDSTACK_ENDPOINT="https://api.example.com/from-env", CLOUDSTACK_KEY="test key from env", CLOUDSTACK_SECRET="test secret from env", CLOUDSTACK_REGION="hanibal", CLOUDSTACK_DANGEROUS_NO_TLS_VERIFY="1", CLOUDSTACK_OVERRIDES="endpoint,secret", ), cwd("/tmp"): conf = read_config() self.assertEqual( { "endpoint": "https://api.example.com/from-env", "key": "test key from file", "secret": "test secret from env", "expiration": 600, "theme": "monokai", "timeout": "50", "trace": None, "poll_interval": 2.0, "name": "hanibal", "verify": None, "dangerous_no_tls_verify": True, "retry": 0, "method": "get", "cert": None, "cert_key": None, }, conf, ) def test_current_dir_config(self): with open("/tmp/cloudstack.ini", "w") as f: f.write( "[cloudstack]\n" "endpoint = https://api.example.com/from-file\n" "key = test key from file\n" "secret = test secret from file\n" "dangerous_no_tls_verify = true\n" "theme = monokai\n" "other = please ignore me\n" "header_x-custom-header1 = foo\n" "header_x-custom-header2 = bar\n" "timeout = 50" ) self.addCleanup(partial(os.remove, "/tmp/cloudstack.ini")) with cwd("/tmp"): conf = read_config() self.assertEqual( { "endpoint": "https://api.example.com/from-file", "key": "test key from file", "secret": "test secret from file", "expiration": 600, "theme": "monokai", "timeout": "50", "trace": None, "poll_interval": 2.0, "name": "cloudstack", "verify": None, "dangerous_no_tls_verify": True, "retry": 0, "method": "get", "cert": None, "cert_key": None, "headers": { "x-custom-header1": "foo", "x-custom-header2": "bar", }, }, conf, ) def test_incomplete_config(self): with open("/tmp/cloudstack.ini", "w") as f: f.write( "[hanibal]\n" "endpoint = https://api.example.com/from-file\n" "secret = secret from file\n" "theme = monokai\n" "other = please ignore me\n" "timeout = 50" ) self.addCleanup(partial(os.remove, "/tmp/cloudstack.ini")) # Secret gets read from env var with cwd("/tmp"): self.assertRaises(ValueError, read_config) class RequestTest(TestCase): @patch("requests.Session.send") def test_request_params(self, mock): cs = CloudStack( endpoint="https://localhost", key="foo", secret="bar", timeout=20, expiration=-1, ) mock.return_value.status_code = 200 mock.return_value.json.return_value = { "listvirtualmachinesresponse": {}, } machines = cs.listVirtualMachines( listall="true", headers={"Accept-Encoding": "br"} ) self.assertEqual(machines, {}) self.assertEqual(1, mock.call_count) [request], kwargs = mock.call_args self.assertEqual(dict(cert=None, timeout=20, verify=True), kwargs) self.assertEqual("GET", request.method) self.assertEqual("br", request.headers["Accept-Encoding"]) url = urlparse(request.url) qs = parse_qs(url.query, True) self.assertEqual("listVirtualMachines", qs["command"][0]) self.assertEqual("B0d6hBsZTcFVCiioSxzwKA9Pke8=", qs["signature"][0]) self.assertEqual("true", qs["listall"][0]) @patch("requests.Session.send") def test_request_params_casing(self, mock): cs = CloudStack( endpoint="https://localhost", key="foo", secret="bar", timeout=20, expiration=-1, ) mock.return_value.status_code = 200 mock.return_value.json.return_value = { "listvirtualmachinesresponse": {}, } machines = cs.listVirtualMachines( zoneId=2, templateId="3", temPlateidd="4", pageSize="10", fetch_list=True, ) self.assertEqual(machines, []) self.assertEqual(1, mock.call_count) [request], kwargs = mock.call_args self.assertEqual(dict(cert=None, timeout=20, verify=True), kwargs) self.assertEqual("GET", request.method) self.assertFalse(request.headers) url = urlparse(request.url) qs = parse_qs(url.query, True) self.assertEqual("listVirtualMachines", qs["command"][0]) self.assertEqual("mMS7XALuGkCXk7kj5SywySku0Z0=", qs["signature"][0]) self.assertEqual("3", qs["templateId"][0]) self.assertEqual("4", qs["temPlateidd"][0]) @patch("requests.Session.send") def test_encoding(self, mock): cs = CloudStack( endpoint="https://localhost", key="foo", secret="bar", expiration=-1, ) mock.return_value.status_code = 200 mock.return_value.json.return_value = { "listvirtualmachinesresponse": {}, } cs.listVirtualMachines(listall=1, unicode_param="éèààû") self.assertEqual(1, mock.call_count) [request], _ = mock.call_args url = urlparse(request.url) qs = parse_qs(url.query, True) self.assertEqual("listVirtualMachines", qs["command"][0]) self.assertEqual("gABU/KFJKD3FLAgKDuxQoryu4sA=", qs["signature"][0]) self.assertEqual("éèààû", qs["unicode_param"][0]) @patch("requests.Session.send") def test_transform(self, mock): cs = CloudStack( endpoint="https://localhost", key="foo", secret="bar", expiration=-1, ) mock.return_value.status_code = 200 mock.return_value.json.return_value = { "listvirtualmachinesresponse": {}, } cs.listVirtualMachines( foo=["foo", "bar"], bar=[{"baz": "blah", "foo": 1000}], bytes_param=b"blah", ) self.assertEqual(1, mock.call_count) [request], kwargs = mock.call_args self.assertEqual(dict(cert=None, timeout=10, verify=True), kwargs) self.assertEqual("GET", request.method) self.assertFalse(request.headers) url = urlparse(request.url) qs = parse_qs(url.query, True) self.assertEqual("listVirtualMachines", qs["command"][0]) self.assertEqual("ImJ/5F0P2RDL7yn4LdLnGcEx5WE=", qs["signature"][0]) self.assertEqual("1000", qs["bar[0].foo"][0]) self.assertEqual("blah", qs["bar[0].baz"][0]) self.assertEqual("blah", qs["bytes_param"][0]) self.assertEqual("foo,bar", qs["foo"][0]) @patch("requests.Session.send") def test_transform_dict(self, mock): cs = CloudStack( endpoint="https://localhost", key="foo", secret="bar", expiration=-1, ) mock.return_value.status_code = 200 mock.return_value.json.return_value = { "scalevirtualmachineresponse": {}, } cs.scaleVirtualMachine( id="a", details={"cpunumber": 1000, "memory": "640k"} ) self.assertEqual(1, mock.call_count) [request], kwargs = mock.call_args self.assertEqual(dict(cert=None, timeout=10, verify=True), kwargs) self.assertEqual("GET", request.method) self.assertFalse(request.headers) url = urlparse(request.url) qs = parse_qs(url.query, True) self.assertEqual("scaleVirtualMachine", qs["command"][0]) self.assertEqual("ZNl66z3gFhnsx2Eo3vvCIM0kAgI=", qs["signature"][0]) self.assertEqual("1000", qs["details[0].cpunumber"][0]) self.assertEqual("640k", qs["details[0].memory"][0]) @patch("requests.Session.send") def test_transform_empty(self, mock): cs = CloudStack( endpoint="https://localhost", key="foo", secret="bar", expiration=-1, ) mock.return_value.status_code = 200 mock.return_value.json.return_value = { "createnetworkresponse": {}, } cs.createNetwork(name="", display_text="") self.assertEqual(1, mock.call_count) [request], kwargs = mock.call_args self.assertEqual(dict(cert=None, timeout=10, verify=True), kwargs) self.assertEqual("GET", request.method) self.assertFalse(request.headers) url = urlparse(request.url) qs = parse_qs(url.query, True) self.assertEqual("createNetwork", qs["command"][0]) self.assertEqual("CistTEiPt/4Rv1v4qSyILvPbhmg=", qs["signature"][0]) self.assertEqual("", qs["name"][0]) self.assertEqual("", qs["display_text"][0]) @patch("requests.Session.send") def test_method(self, mock): cs = CloudStack( endpoint="https://localhost", key="foo", secret="bar", method="post", expiration=-1, ) mock.return_value.status_code = 200 mock.return_value.json.return_value = { "listvirtualmachinesresponse": {}, } cs.listVirtualMachines(blah="brah") self.assertEqual(1, mock.call_count) [request], kwargs = mock.call_args self.assertEqual(dict(cert=None, timeout=10, verify=True), kwargs) self.assertEqual("POST", request.method) self.assertEqual( "application/x-www-form-urlencoded", request.headers["Content-Type"], ) qs = parse_qs(request.body, True) self.assertEqual("listVirtualMachines", qs["command"][0]) self.assertEqual("58VvLSaVUqHnG9DhXNOAiDFwBoA=", qs["signature"][0]) self.assertEqual("brah", qs["blah"][0]) @patch("requests.Session.send") def test_error(self, mock): mock.return_value.status_code = 530 mock.return_value.json.return_value = { "listvirtualmachinesresponse": { "errorcode": 530, "uuidList": [], "cserrorcode": 9999, "errortext": "Fail", } } cs = CloudStack(endpoint="https://localhost", key="foo", secret="bar") self.assertRaises(CloudStackException, cs.listVirtualMachines) @patch("requests.Session.send") def test_bad_content_type(self, get): get.return_value.status_code = 502 get.return_value.headers = CaseInsensitiveDict( **{"content-type": "text/html;charset=utf-8"} ) get.return_value.text = ( "502" "

Gateway timeout

" ) cs = CloudStack(endpoint="https://localhost", key="foo", secret="bar") self.assertRaises(CloudStackException, cs.listVirtualMachines) @patch("requests.Session.send") def test_signature_v3(self, mock): cs = CloudStack( endpoint="https://localhost", key="foo", secret="bar", expiration=600, ) mock.return_value.status_code = 200 mock.return_value.json.return_value = { "createnetworkresponse": {}, } cs.createNetwork(name="", display_text="") self.assertEqual(1, mock.call_count) [request], _ = mock.call_args url = urlparse(request.url) qs = parse_qs(url.query, True) self.assertEqual("createNetwork", qs["command"][0]) self.assertEqual("3", qs["signatureVersion"][0]) expires = qs["expires"][0] # we ignore the timezone for Python2's lack of %z expires = datetime.datetime.strptime(expires[:19], EXPIRES_FORMAT[:-2]) self.assertTrue(expires > datetime.datetime.utcnow(), expires) cs-3.4.0/tox.ini000066400000000000000000000005521503076742300134470ustar00rootroot00000000000000[tox] envlist = py{39,310,311,312,313} lint skip_missing_interpreters = True [gh-actions] 3.9: py39 3.10: py310 3.11: py311 3.12: py312 3.13: py313 [testenv] deps= aiohttp check-manifest flake8 flake8-import-order pytest pytest-cache pytest-cov commands = pip wheel --no-deps -w dist . test: pytest -v check-manifest lint: flake8 cs tests.py