pax_global_header00006660000000000000000000000064125670140560014520gustar00rootroot0000000000000052 comment=26c5723beb993c9e6cade90485d7ceb166fcae8d cs-0.6.10/000077500000000000000000000000001256701405600122115ustar00rootroot00000000000000cs-0.6.10/.gitignore000066400000000000000000000000341256701405600141760ustar00rootroot00000000000000cs.egg-info dist .tox build cs-0.6.10/.travis.yml000066400000000000000000000002711256701405600143220ustar00rootroot00000000000000language: python python: 3.4 sudo: false env: - TOXENV=py26 - TOXENV=py27 - TOXENV=py33 - TOXENV=py34 - TOXENV=lint install: - pip install tox script: - tox -e $TOXENV cs-0.6.10/LICENSE000066400000000000000000000027351256701405600132250ustar00rootroot00000000000000Copyright (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-0.6.10/README.rst000066400000000000000000000056311256701405600137050ustar00rootroot00000000000000CS == .. image:: https://travis-ci.org/exoscale/cs.svg?branch=master :alt: Build Status :target: https://travis-ci.org/exoscale/cs A simple, yet powerful CloudStack API client for python and the command-line. * Python 2.6+ and 3.3+ 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 Usage ----- In Python:: from cs import CloudStack cs = CloudStack(endpoint='https://api.exoscale.ch/compute', 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:: cat $HOME/.cloudstack.ini [cloudstack] endpoint = https://api.exoscale.ch/compute key = cloudstack api key secret = cloudstack api secret Then:: $ cs listVirtualMachines { "count": 1, "virtualmachine": [ { "account": "...", ... } ] } $ 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. 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.ini`` file in the current working directory, * A ``.cloudstack.ini`` file in the home directory. To use that configuration scheme from your Python code:: 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. Multiple credentials can be set in ``.cloudstack.ini``. This allows selecting the credentials or endpoint to use with a command-line flag:: cat $HOME/.cloudstack.ini [cloudstack] endpoint = https://some-host/api/compute key = api key secret = api secret [exoscale] endpoint = https://api.exoscale.ch/compute key = api key secret = api secret Usage:: $ cs listVirtualMachines --region=exoscale cs-0.6.10/cs.py000066400000000000000000000167611256701405600132030ustar00rootroot00000000000000#! /usr/bin/env python import base64 import hashlib import hmac import json import os import requests import sys import time from collections import defaultdict try: from configparser import ConfigParser, NoSectionError except ImportError: # python 2 from ConfigParser import ConfigParser, NoSectionError try: from urllib.parse import quote except ImportError: # python 2 from urllib import quote try: import pygments from pygments.lexers import JsonLexer from pygments.formatters import TerminalFormatter except ImportError: pygments = None PY2 = sys.version_info < (3, 0) if PY2: text_type = unicode # noqa string_type = basestring # noqa integer_types = int, long # noqa else: text_type = str string_type = str integer_types = int def cs_encode(value): """ Try to behave like cloudstack, which uses java.net.URLEncoder.encode(stuff).replace('+', '%20'). """ if isinstance(value, int): value = str(value) elif PY2 and isinstance(value, text_type): value = value.encode('utf-8') return quote(value, safe=".-*_") def transform(params): for key, value in list(params.items()): if value is None or value == "": params.pop(key) continue if isinstance(value, string_type): continue elif isinstance(value, integer_types): params[key] = text_type(value) elif isinstance(value, (list, tuple, set)): if not value: params.pop(key) else: 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 k, v in val.items(): params["%s[%d].%s" % (key, index, k)] = v else: raise ValueError(type(value)) return params class CloudStackException(Exception): pass class Unauthorized(CloudStackException): pass class CloudStack(object): def __init__(self, endpoint, key, secret, timeout=10, method='get'): self.endpoint = endpoint self.key = key self.secret = secret self.timeout = int(timeout) self.method = method.lower() def __repr__(self): return ''.format(self.endpoint) def __getattr__(self, command): def handler(**kwargs): return self._request(command, **kwargs) return handler def _request(self, command, json=True, opcode_name='command', **kwargs): kwargs.update({ 'apiKey': self.key, opcode_name: command, }) if json: kwargs['response'] = 'json' if 'page' in kwargs: kwargs.setdefault('pagesize', 500) kwargs = transform(kwargs) kwargs['signature'] = self._sign(kwargs) kw = {'timeout': self.timeout} if self.method == 'get': kw['params'] = kwargs else: kw['data'] = kwargs response = getattr(requests, self.method)(self.endpoint, **kw) try: data = response.json() except ValueError as e: msg = "Make sure endpoint URL '%s' is correct." % self.endpoint raise CloudStackException( "HTTP {0} response from CloudStack".format( response.status_code), response, "%s. " % str(e) + msg ) [key] = data.keys() data = data[key] if response.status_code != 200: raise CloudStackException( "HTTP {0} response from CloudStack".format( response.status_code), response, data) return data def _sign(self, data): """ Computes a signature string according to the CloudStack signature method (hmac/sha1). """ params = "&".join(sorted([ "=".join((key, cs_encode(value))).lower() for key, value in data.items() ])) digest = hmac.new( self.secret.encode('utf-8'), msg=params.encode('utf-8'), digestmod=hashlib.sha1).digest() return base64.b64encode(digest).decode('utf-8').strip() def read_config(ini_group='cloudstack'): # Try env vars first os.environ.setdefault('CLOUDSTACK_METHOD', 'get') os.environ.setdefault('CLOUDSTACK_TIMEOUT', '10') keys = ['endpoint', 'key', 'secret', 'method', 'timeout'] env_conf = {} for key in keys: if 'CLOUDSTACK_{0}'.format(key.upper()) not in os.environ: break else: env_conf[key] = os.environ['CLOUDSTACK_{0}'.format(key.upper())] else: return env_conf # 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 += (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) try: return conf[ini_group] except AttributeError: # python 2 return dict(conf.items(ini_group)) def main(): usage = "Usage: {0} [option1=value1 " \ "[option2=value2] ...] [--async] [--post] " \ "[--region=]".format(sys.argv[0]) if len(sys.argv) == 1: raise SystemExit(usage) command = sys.argv[1] kwargs = defaultdict(set) flags = set() args = dict() for option in sys.argv[2:]: if option.startswith('--'): option = option.strip('-') if '=' in option: key, value = option.split('=', 1) if not value: raise SystemExit(usage) args[key] = value else: flags.add(option) continue if '=' not in option: raise SystemExit(usage) key, value = option.split('=', 1) kwargs[key].add(value.strip(" \"'")) region = args.get('region', 'cloudstack') try: config = read_config(ini_group=region) except NoSectionError: raise SystemExit("Error: region '%s' not in config" % region) if 'post' in flags: config['method'] = 'post' cs = CloudStack(**config) try: response = getattr(cs, command)(**kwargs) except CloudStackException as e: response = e.args[2] sys.stderr.write("Cloudstack error:\n") if 'Async' not in command and 'jobid' in response and 'async' not in flags: sys.stderr.write("Polling result... ^C to abort\n") while True: try: res = cs.queryAsyncJobResult(**response) if res['jobprocstatus'] == 0: response = res break time.sleep(3) except KeyboardInterrupt: sys.stderr.write("Result not ready yet.\n") break data = json.dumps(response, indent=2, sort_keys=True) if pygments and sys.stdout.isatty(): data = pygments.highlight(data, JsonLexer(), TerminalFormatter()) sys.stdout.write(data) if __name__ == '__main__': main() cs-0.6.10/setup.cfg000066400000000000000000000000261256701405600140300ustar00rootroot00000000000000[wheel] universal = 1 cs-0.6.10/setup.py000066400000000000000000000020771256701405600137310ustar00rootroot00000000000000# coding: utf-8 from setuptools import setup with open('README.rst', 'r') as f: long_description = f.read() setup( name='cs', version='0.6.10', url='https://github.com/exoscale/cs', license='BSD', author=u'Bruno Renié', description=('A simple yet powerful CloudStack API client for ' 'Python and the command-line.'), long_description=long_description, py_modules=('cs',), zip_safe=False, include_package_data=True, platforms='any', classifiers=( 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', ), install_requires=( 'requests', ), extras_require={ 'highlight': ['pygments'], }, test_suite='tests', entry_points={ 'console_scripts': [ 'cs = cs:main', ], }, ) cs-0.6.10/tests.py000066400000000000000000000145661256701405600137410ustar00rootroot00000000000000# coding: utf-8 import os import sys from contextlib import contextmanager from functools import partial from unittest import TestCase try: from unittest.mock import patch, call except ImportError: from mock import patch, call from cs import read_config, CloudStack, CloudStackException @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: yield finally: os.chdir(initial) class ConfigTest(TestCase): if sys.version_info < (2, 7): def setUp(self): super(ConfigTest, self).setUp() self._cleanups = [] def addCleanup(self, fn, *args, **kwargs): self._cleanups.append((fn, args, kwargs)) def tearDown(self): super(ConfigTest, self).tearDown() for fn, args, kwargs in self._cleanups: fn(*args, **kwargs) 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(conf, { 'key': 'test key from env', 'secret': 'test secret from env', 'endpoint': 'https://api.example.com/from-env', 'method': 'get', 'timeout': '10', }) 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'): conf = read_config() self.assertEqual(conf, { 'key': 'test key from env', 'secret': 'test secret from env', 'endpoint': 'https://api.example.com/from-env', 'method': 'post', 'timeout': '99', }) 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' 'timeout = 50') self.addCleanup(partial(os.remove, '/tmp/cloudstack.ini')) with cwd('/tmp'): conf = read_config() self.assertEqual(dict(conf), { 'endpoint': 'https://api.example.com/from-file', 'key': 'test key from file', 'secret': 'test secret from file', 'timeout': '50', }) class RequestTest(TestCase): @patch('requests.get') def test_request_params(self, get): cs = CloudStack(endpoint='localhost', key='foo', secret='bar', timeout=20) get.return_value.status_code = 200 get.return_value.json.return_value = { 'listvirtualmachinesresponse': {}, } machines = cs.listVirtualMachines(listall='true') self.assertEqual(machines, {}) get.assert_called_once_with('localhost', timeout=20, params={ 'apiKey': 'foo', 'response': 'json', 'command': 'listVirtualMachines', 'listall': 'true', 'signature': 'B0d6hBsZTcFVCiioSxzwKA9Pke8='}) @patch('requests.get') def test_encoding(self, get): cs = CloudStack(endpoint='localhost', key='foo', secret='bar') get.return_value.status_code = 200 get.return_value.json.return_value = { 'listvirtualmachinesresponse': {}, } cs.listVirtualMachines(listall=1, unicode_param=u'éèààû') get.assert_called_once_with('localhost', timeout=10, params={ 'apiKey': 'foo', 'response': 'json', 'command': 'listVirtualMachines', 'listall': '1', 'unicode_param': u'éèààû', 'signature': 'gABU/KFJKD3FLAgKDuxQoryu4sA='}) @patch("requests.get") def test_transformt(self, get): cs = CloudStack(endpoint='localhost', key='foo', secret='bar') get.return_value.status_code = 200 get.return_value.json.return_value = { 'listvirtualmachinesresponse': {}, } cs.listVirtualMachines(foo=["foo", "bar"], bar=[{'baz': 'blah', 'foo': 'meh'}]) get.assert_called_once_with('localhost', timeout=10, params={ 'command': 'listVirtualMachines', 'response': 'json', 'bar[0].foo': 'meh', 'bar[0].baz': 'blah', 'foo': 'foo,bar', 'apiKey': 'foo', 'signature': 'UGUVEfCOfGfOlqoTj1D2m5adr2g=', }) @patch("requests.post") @patch("requests.get") def test_method(self, get, post): cs = CloudStack(endpoint='localhost', key='foo', secret='bar', method='post') post.return_value.status_code = 200 post.return_value.json.return_value = { 'listvirtualmachinesresponse': {}, } cs.listVirtualMachines(blah='brah') self.assertEqual(get.call_args_list, []) self.assertEqual(post.call_args_list, [ call('localhost', timeout=10, data={ 'command': 'listVirtualMachines', 'blah': 'brah', 'apiKey': 'foo', 'response': 'json', 'signature': '58VvLSaVUqHnG9DhXNOAiDFwBoA=', })] ) @patch("requests.get") def test_error(self, get): get.return_value.status_code = 530 get.return_value.json.return_value = { 'listvirtualmachinesresponse': {'errorcode': 530, 'uuidList': [], 'cserrorcode': 9999, 'errortext': 'Fail'}} cs = CloudStack(endpoint='localhost', key='foo', secret='bar') self.assertRaises(CloudStackException, cs.listVirtualMachines) cs-0.6.10/tox.ini000066400000000000000000000004241256701405600135240ustar00rootroot00000000000000[tox] envlist = py26, py27, py33, py34, lint [testenv] commands = python setup.py test deps = requests [testenv:py26] deps = {[testenv]deps} mock [testenv:py27] deps = {[testenv]deps} mock [testenv:lint] deps = flake8 commands = flake8 cs.py flake8 tests.py