From a861024bedb00d695dad70eed50071aa890a876e Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Thu, 3 Jan 2013 12:21:53 +0000 Subject: [PATCH] Migrate client code from libra codebase Change-Id: Icd9d758e45c7167c5b8db7aa2c5e15b4bb93c766 --- .gitreview | 11 +- .testr.conf | 4 + MANIFEST.in | 9 + README | 1 + build_pdf.sh | 6 + client/__init__.py | 15 ++ client/client.py | 36 ++++ client/clientoptions.py | 154 ++++++++++++++ client/libraapi.py | 217 +++++++++++++++++++ doc/command.rst | 237 +++++++++++++++++++++ doc/conf.py | 234 +++++++++++++++++++++ doc/index.rst | 7 + openstack-common.conf | 7 + openstack/__init__.py | 0 openstack/common/__init__.py | 0 openstack/common/importutils.py | 59 ++++++ openstack/common/setup.py | 360 ++++++++++++++++++++++++++++++++ setup.py | 68 ++++++ tests/__init__.py | 13 ++ tests/test_lbaas_client.py | 330 +++++++++++++++++++++++++++++ tools/pip-requires | 1 + tools/test-requires | 5 + tox.ini | 21 ++ 23 files changed, 1789 insertions(+), 6 deletions(-) create mode 100644 .testr.conf create mode 100644 MANIFEST.in create mode 100644 README create mode 100755 build_pdf.sh create mode 100644 client/__init__.py create mode 100644 client/client.py create mode 100644 client/clientoptions.py create mode 100644 client/libraapi.py create mode 100644 doc/command.rst create mode 100644 doc/conf.py create mode 100644 doc/index.rst create mode 100644 openstack-common.conf create mode 100644 openstack/__init__.py create mode 100644 openstack/common/__init__.py create mode 100644 openstack/common/importutils.py create mode 100644 openstack/common/setup.py create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/test_lbaas_client.py create mode 100644 tools/pip-requires create mode 100644 tools/test-requires create mode 100644 tox.ini diff --git a/.gitreview b/.gitreview index faaf546..24e9a28 100644 --- a/.gitreview +++ b/.gitreview @@ -1,6 +1,5 @@ - - [gerrit] - host=review.openstack.org - port=29418 - project=stackforge/python-libraclient.git - \ No newline at end of file +[gerrit] +host=review.openstack.org +port=29418 +project=stackforge/python-libraclient.git + diff --git a/.testr.conf b/.testr.conf new file mode 100644 index 0000000..081907d --- /dev/null +++ b/.testr.conf @@ -0,0 +1,4 @@ +[DEFAULT] +test_command=${PYTHON:-python} -m subunit.run discover -t ./ ./tests $LISTOPT $IDOPTION +test_id_option=--load-list $IDFILE +test_list_option=--list diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..3928293 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include README + +exclude .gitignore +exclude .gitreview + +global-exclude *.pyc + +graft doc +graft tools diff --git a/README b/README new file mode 100644 index 0000000..a67ef41 --- /dev/null +++ b/README @@ -0,0 +1 @@ +Libra command line client diff --git a/build_pdf.sh b/build_pdf.sh new file mode 100755 index 0000000..edf0d43 --- /dev/null +++ b/build_pdf.sh @@ -0,0 +1,6 @@ +#!/bin/bash +python setup.py build_sphinx_latex +# Fix option double dashes in latex output +perl -i -pe 's/\\bfcode\{--(.*)\}/\\bfcode\{-\{\}-\1\}/g' build/sphinx/latex/*.tex +perl -i -pe 's/\\index\{(.*?)--(.*?)\}/\\index\{\1-\{\}-\2\}/g' build/sphinx/latex/*.tex +make -C build/sphinx/latex all-pdf diff --git a/client/__init__.py b/client/__init__.py new file mode 100644 index 0000000..f7a43d1 --- /dev/null +++ b/client/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2012 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +__version__ = "1.0" diff --git a/client/client.py b/client/client.py new file mode 100644 index 0000000..737435e --- /dev/null +++ b/client/client.py @@ -0,0 +1,36 @@ +# Copyright 2012 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from libraapi import LibraAPI +from clientoptions import ClientOptions +from novaclient import exceptions + + +def main(): + options = ClientOptions() + args = options.run() + + api = LibraAPI(args.os_username, args.os_password, args.os_tenant_name, + args.os_auth_url, args.os_region_name, args.insecure, + args.debug, args.bypass_url) + + cmd = args.command.replace('-', '_') + method = getattr(api, '{cmd}_lb'.format(cmd=cmd)) + + try: + method(args) + except exceptions.ClientException as exc: + print exc + + return 0 diff --git a/client/clientoptions.py b/client/clientoptions.py new file mode 100644 index 0000000..ac5f16e --- /dev/null +++ b/client/clientoptions.py @@ -0,0 +1,154 @@ +# Copyright 2012 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import argparse + + +class ClientOptions(object): + def __init__(self): + self.options = argparse.ArgumentParser('Libra command line client') + + def _generate(self): + self.options.add_argument( + '--os_auth_url', + metavar='', + required=True, + help='Authentication URL' + ) + self.options.add_argument( + '--os_username', + metavar='', + required=True, + help='Authentication username' + ) + self.options.add_argument( + '--os_password', + metavar='', + required=True, + help='Authentication password' + ) + self.options.add_argument( + '--os_tenant_name', + metavar='', + required=True, + help='Authentication tenant' + ) + self.options.add_argument( + '--os_region_name', + metavar='', + required=True, + help='Authentication region' + ) + self.options.add_argument( + '--debug', + action='store_true', + help='Debug network messages' + ) + self.options.add_argument( + '--insecure', + action='store_true', + help='Don\'t verify SSL cert' + ) + self.options.add_argument( + '--bypass_url', + help='Use this API endpoint instead of the Service Catalog' + ) + subparsers = self.options.add_subparsers( + metavar='', dest='command' + ) + subparsers.add_parser( + 'limits', help='get account API usage limits' + ) + subparsers.add_parser( + 'algorithms', help='get a list of supported algorithms' + ) + subparsers.add_parser( + 'protocols', help='get a list of supported protocols and ports' + ) + sp = subparsers.add_parser( + 'list', help='list load balancers' + ) + sp.add_argument( + '--deleted', help='list deleted load balancers', + action='store_true' + ) + sp = subparsers.add_parser( + 'delete', help='delete a load balancer' + ) + sp.add_argument('--id', help='load balancer ID', required=True) + sp = subparsers.add_parser( + 'create', help='create a load balancer' + ) + sp.add_argument('--name', help='name for the load balancer', + required=True) + sp.add_argument('--port', + help='port for the load balancer, 80 is default') + sp.add_argument('--protocol', + help='protocol for the load balancer, HTTP is default', + choices=['HTTP', 'TCP']) + sp.add_argument('--algorithm', + help='algorithm for the load balancer,' + ' ROUND_ROBIN is default', + choices=['LEAST_CONNECTIONS', 'ROUND_ROBIN']) + sp.add_argument('--node', + help='a node for the load balancer in ip:port format', + action='append', required=True) + sp.add_argument('--vip', + help='the virtual IP to attach the load balancer to') + sp = subparsers.add_parser( + 'modify', help='modify a load balancer' + ) + sp.add_argument('--id', help='load balancer ID', required=True) + sp.add_argument('--name', help='new name for the load balancer') + sp.add_argument('--algorithm', + help='new algorithm for the load balancer', + choices=['LEAST_CONNECTIONS', 'ROUND_ROBIN']) + sp = subparsers.add_parser( + 'status', help='get status of a load balancer' + ) + sp.add_argument('--id', help='load balancer ID', required=True) + sp = subparsers.add_parser( + 'node-list', help='list nodes in a load balancer' + ) + sp.add_argument('--id', help='load balancer ID', required=True) + sp = subparsers.add_parser( + 'node-delete', help='delete node from a load balancer' + ) + sp.add_argument('--id', help='load balancer ID', required=True) + sp.add_argument('--nodeid', + help='node ID to remove from load balancer', + required=True) + sp = subparsers.add_parser( + 'node-add', help='add node to a load balancer' + ) + sp.add_argument('--id', help='load balancer ID', required=True) + sp.add_argument('--node', help='node to add in ip:port form', + required=True, action='append') + sp = subparsers.add_parser( + 'node-modify', help='modify node in a load balancer' + ) + sp.add_argument('--id', help='load balancer ID', required=True) + sp.add_argument('--nodeid', help='node ID to modify', required=True) + sp.add_argument('--condition', help='the new state for the node', + choices=['ENABLED', 'DISABLED'], required=True) + sp = subparsers.add_parser( + 'node-status', help='get status of a node in a load balancer' + ) + sp.add_argument('--id', help='load balancer ID', required=True) + sp.add_argument('--nodeid', help='node ID to get status from', + required=True) + + def run(self): + self._generate() + return self.options.parse_args() diff --git a/client/libraapi.py b/client/libraapi.py new file mode 100644 index 0000000..828d7f1 --- /dev/null +++ b/client/libraapi.py @@ -0,0 +1,217 @@ +# Copyright 2012 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import prettytable +import novaclient + +from novaclient import client + + +# NOTE(LinuxJedi): Override novaclient's error handler as we send messages in +# a slightly different format which causes novaclient's to throw an exception + +def from_response(response, body): + """ + Return an instance of an ClientException or subclass + based on an httplib2 response. + + Usage:: + + resp, body = http.request(...) + if resp.status != 200: + raise exception_from_response(resp, body) + """ + cls = novaclient.exceptions._code_map.get( + response.status, novaclient.exceptions.ClientException + ) + request_id = response.get('x-compute-request-id') + if body: + message = "n/a" + details = "n/a" + if hasattr(body, 'keys'): + message = body.get('message', None) + details = body.get('details', None) + return cls(code=response.status, message=message, details=details, + request_id=request_id) + else: + return cls(code=response.status, request_id=request_id) + +novaclient.exceptions.from_response = from_response + + +class LibraAPI(object): + def __init__(self, username, password, tenant, auth_url, region, + insecure, debug, bypass_url): + self.nova = client.HTTPClient( + username, + password, + tenant, + auth_url, + region_name=region, + service_type='compute', + http_log_debug=debug, + insecure=insecure, + bypass_url=bypass_url + ) + + def limits_lb(self, args): + resp, body = self._get('/limits') + column_names = ['Verb', 'Value', 'Remaining', 'Unit', 'Next Available'] + columns = ['verb', 'value', 'remaining', 'unit', 'next-available'] + self._render_list(column_names, columns, + body['limits']['rate']['values']['limit']) + column_names = ['Values'] + columns = ['values'] + self._render_dict(column_names, columns, body['limits']['absolute']) + + def protocols_lb(self, args): + resp, body = self._get('/protocols') + column_names = ['Name', 'Port'] + columns = ['name', 'port'] + self._render_list(column_names, columns, body['protocols']) + + def algorithms_lb(self, args): + resp, body = self._get('/algorithms') + column_names = ['Name'] + columns = ['name'] + self._render_list(column_names, columns, body['algorithms']) + + def list_lb(self, args): + if args.deleted: + resp, body = self._get('/loadbalancers?status=DELETED') + else: + resp, body = self._get('/loadbalancers') + column_names = ['Name', 'ID', 'Protocol', 'Port', 'Algorithm', + 'Status', 'Created', 'Updated'] + columns = ['name', 'id', 'protocol', 'port', 'algorithm', 'status', + 'created', 'updated'] + self._render_list(column_names, columns, body['loadBalancers']) + + def status_lb(self, args): + resp, body = self._get('/loadbalancers/{0}'.format(args.id)) + column_names = ['ID', 'Name', 'Protocol', 'Port', 'Algorithm', + 'Status', 'Created', 'Updated', 'IPs', 'Nodes', + 'Persistence Type', 'Connection Throttle'] + columns = ['id', 'name', 'protocol', 'port', 'algorithm', 'status', + 'created', 'updated', 'virtualIps', 'nodes', + 'sessionPersistence', 'connectionThrottle'] + if 'sessionPersistence' not in body: + body['sessionPersistence'] = 'None' + if 'connectionThrottle' not in body: + body['connectionThrottle'] = 'None' + self._render_dict(column_names, columns, body) + + def delete_lb(self, args): + self._delete('/loadbalancers/{0}'.format(args.id)) + + def create_lb(self, args): + data = {} + nodes = [] + data['name'] = args.name + if args.port is not None: + data['port'] = args.port + if args.protocol is not None: + data['protocol'] = args.protocol + if args.algorithm is not None: + data['algorithm'] = args.algorithm + for node in args.node: + addr = node.split(':') + nodes.append({'address': addr[0], 'port': addr[1], + 'condition': 'ENABLED'}) + data['nodes'] = nodes + if args.vip is not None: + data['virtualIps'] = [{'id': args.vip}] + + resp, body = self._post('/loadbalancers', body=data) + column_names = ['ID', 'Name', 'Protocol', 'Port', 'Algorithm', + 'Status', 'Created', 'Updated', 'IPs', 'Nodes'] + columns = ['id', 'name', 'protocol', 'port', 'algorithm', 'status', + 'created', 'updated', 'virtualIps', 'nodes'] + self._render_dict(column_names, columns, body) + + def modify_lb(self, args): + data = {} + if args.name is not None: + data['name'] = args.name + if args.algorithm is not None: + data['algorithm'] = args.algorithm + self._put('/loadbalancers/{0}'.format(args.id), body=data) + + def node_list_lb(self, args): + resp, body = self._get('/loadbalancers/{0}/nodes'.format(args.id)) + column_names = ['ID', 'Address', 'Port', 'Condition', 'Status'] + columns = ['id', 'address', 'port', 'condition', 'status'] + self._render_list(column_names, columns, body['nodes']) + + def node_delete_lb(self, args): + self._delete('/loadbalancers/{0}/nodes/{1}' + .format(args.id, args.nodeid)) + + def node_add_lb(self, args): + data = {} + nodes = [] + + for node in args.node: + addr = node.split(':') + nodes.append({'address': addr[0], 'port': addr[1], + 'condition': 'ENABLED'}) + data['nodes'] = nodes + resp, body = self._post('/loadbalancers/{0}/nodes' + .format(args.id), body=data) + column_names = ['ID', 'Address', 'Port', 'Condition', 'Status'] + columns = ['id', 'address', 'port', 'condition', 'status'] + self._render_list(column_names, columns, body['nodes']) + + def node_modify_lb(self, args): + data = {'condition': args.condition} + self._put('/loadbalancers/{0}/nodes/{1}' + .format(args.id, args.nodeid), body=data) + + def node_status_lb(self, args): + resp, body = self._get('/loadbalancers/{0}/nodes/{1}' + .format(args.id, args.nodeid)) + column_names = ['ID', 'Address', 'Port', 'Condition', 'Status'] + columns = ['id', 'address', 'port', 'condition', 'status'] + self._render_dict(column_names, columns, body) + + def _render_list(self, column_names, columns, data): + table = prettytable.PrettyTable(column_names) + for item in data: + row = [] + for column in columns: + rdata = item[column] + row.append(rdata) + table.add_row(row) + print table + + def _render_dict(self, column_names, columns, data): + table = prettytable.PrettyTable(column_names) + row = [] + for column in columns: + rdata = data[column] + row.append(rdata) + table.add_row(row) + print table + + def _get(self, url, **kwargs): + return self.nova.get(url, **kwargs) + + def _post(self, url, **kwargs): + return self.nova.post(url, **kwargs) + + def _put(self, url, **kwargs): + return self.nova.put(url, **kwargs) + + def _delete(self, url, **kwargs): + return self.nova.delete(url, **kwargs) diff --git a/doc/command.rst b/doc/command.rst new file mode 100644 index 0000000..f536706 --- /dev/null +++ b/doc/command.rst @@ -0,0 +1,237 @@ +Libra Client +============ + +Synopsis +-------- + +:program:`libra_client` [:ref:`GENERAL OPTIONS `] [:ref:`COMMAND `] [*COMMAND_OPTIONS*] + +Description +----------- + +:program:`libra_client` is a utility designed to communicate with Atlas API +based Load Balancer as a Service systems. + +.. _libra_client-options: + +Global Options +-------------- + +.. program:: libra_client + +.. option:: --help, -h + + Show help message and exit + +.. option:: --debug + + Turn on HTTP debugging for requests + +.. option:: --insecure + + Don't validate SSL certs + +.. option:: --bypass_url + + URL to use as an endpoint instead of the one specified by the Service + Catalog + +.. option:: --os_auth_url + + The OpenStack authentication URL + +.. option:: --os_username + + The user name to use for authentication + +.. option:: --os_password + + The password to use for authentication + +.. option:: --os_tenant_name + + The tenant to authenticate to + +.. option:: --os_region_name + + The region the load balancer is located + +.. _libra_client-commands: + +Client Commands +--------------- + +.. program:: libra_client create + +create +^^^^^^ + +Create a load balancer + +.. option:: --name + + The name of the node to be created + +.. option:: --port + + The port the load balancer will listen on + +.. option:: --protocol + + The protocol type for the load balancer (HTTP or TCP) + +.. option:: --node + + The IP and port for a load balancer node (can be used multiple times to add multiple nodes) + +.. option:: --vip + + The virtual IP ID of an existing load balancer to attach to + +.. program:: libra_client modify + +modify +^^^^^^ + +Update a load balancer's configuration + +.. option:: --id + + The ID of the load balancer + +.. option:: --name + + A new name for the load balancer + +.. option:: --algorithm + + A new algorithm for the load balancer + +.. program:: libra_client list + +list +^^^^ + +List all load balancers + +.. option:: --deleted + + Show deleted load balancers + +.. program:: libra_client limits + +limits +^^^^^^ + +Show the API limits for the user + +.. program:: libra_client algorithms + +algorithms +^^^^^^^^^^ + +Gets a list of supported algorithms + +.. program:: libra_client protocols + +protocols +^^^^^^^^^ + +Gets a list of supported protocols + +.. program:: libra_client status + +status +^^^^^^ + +Get the status of a single load balancer + +.. option:: --id + + The ID of the load balancer + +.. program:: libra_client delete + +delete +^^^^^^ + +Delete a load balancer + +.. option:: --id + + The ID of the load balancer + +.. program:: libra_client node-list + +node-list +^^^^^^^^^ + +List the nodes in a load balancer + +.. option:: --id + + The ID of the load balancer + +.. program:: libra_client node-delete + +node-delete +^^^^^^^^^^^ + +Delete a node from the load balancer + +.. option:: --id + + The ID of the load balancer + +.. option:: --nodeid + + The ID of the node to be removed + +.. program:: libra_client node-add + +node-add +^^^^^^^^ + +Add a node to a load balancer + +.. option:: --id + + The ID of the load balancer + +.. option:: --node + + The node address in ip:port format (can be used multiple times to add multiple nodes) + +.. program:: libra_client node-modify + +node-modify +^^^^^^^^^^^ + +Modify a node's state in a load balancer + +.. option:: --id + + The ID of the load balancer + +.. option:: --nodeid + + The ID of the node to be modified + +.. option:: --condition + + The new state of the node (either ENABLED or DISABLED) + +.. program:: libra_client node-status + +node-status +^^^^^^^^^^^ + +Get the status of a node in a load balancer + +.. option:: --id + + The ID of the load balancer + +.. option:: --nodeid + + The ID of the node in the load balancer diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 0000000..e0fdf30 --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# +# OpenStack CI documentation build configuration file, created by +# sphinx-quickstart on Mon Jul 18 13:42:23 2011. +# +# This file is execfile()d with the current directory set to its containing +# dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import datetime +import sys + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ---------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +#extensions = ['rst2pdf.pdfbuilder'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'LBaaS Command Line Client' +copyright = u'2012, Andrew Hutchings' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = "%d-%02d-%02d-alpha1" % ( + datetime.datetime.now().year, + datetime.datetime.now().month, + datetime.datetime.now().day +) +# The full version, including alpha/beta/rc tags. +release = "%d-%02d-%02d-alpha1" % ( + datetime.datetime.now().year, + datetime.datetime.now().month, + datetime.datetime.now().day +) + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output -------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'LibraClent' + + +# -- Options for LaTeX output ------------------------------------------------- + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, +# documentclass [howto/manual]). +latex_documents = [ + ('index', 'pthon-libraclient-{0}.tex'.format(version), u'Libra Client Documentation', + u'Andrew Hutchings', 'manual'), +] + +#pdf_documents = [('index', 'Libra-{0}'.format(version), u'Libra Client, Worker and Pool Manager Documentation', u'Andrew Hutchings and David Shrewsbury')] + +#pdf_break_level = 1 + +#pdf_stylesheets = ['sphinx', 'libra'] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Additional stuff for the LaTeX preamble. +#latex_preamble = '' + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output ------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'lbaas', u'LBaaS Client', + [u'Andrew Hutchings'], 1) +] diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..8a3c222 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,7 @@ +Libra Command Line Client +========================= + +.. toctree:: + :maxdepth: 2 + + command diff --git a/openstack-common.conf b/openstack-common.conf new file mode 100644 index 0000000..6f16c8c --- /dev/null +++ b/openstack-common.conf @@ -0,0 +1,7 @@ +[DEFAULT] + +# The list of modules to copy from openstack-common +modules=importutils,setup + +# The base module to hold the copy of openstack.common +base= diff --git a/openstack/__init__.py b/openstack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openstack/common/__init__.py b/openstack/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openstack/common/importutils.py b/openstack/common/importutils.py new file mode 100644 index 0000000..f45372b --- /dev/null +++ b/openstack/common/importutils.py @@ -0,0 +1,59 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Import related utilities and helper functions. +""" + +import sys +import traceback + + +def import_class(import_str): + """Returns a class from a string including module and class""" + mod_str, _sep, class_str = import_str.rpartition('.') + try: + __import__(mod_str) + return getattr(sys.modules[mod_str], class_str) + except (ValueError, AttributeError), exc: + raise ImportError('Class %s cannot be found (%s)' % + (class_str, + traceback.format_exception(*sys.exc_info()))) + + +def import_object(import_str, *args, **kwargs): + """Import a class and return an instance of it.""" + return import_class(import_str)(*args, **kwargs) + + +def import_object_ns(name_space, import_str, *args, **kwargs): + """ + Import a class and return an instance of it, first by trying + to find the class in a default namespace, then failing back to + a full path if not found in the default namespace. + """ + import_value = "%s.%s" % (name_space, import_str) + try: + return import_class(import_value)(*args, **kwargs) + except ImportError: + return import_class(import_str)(*args, **kwargs) + + +def import_module(import_str): + """Import a module.""" + __import__(import_str) + return sys.modules[import_str] diff --git a/openstack/common/setup.py b/openstack/common/setup.py new file mode 100644 index 0000000..4e2a577 --- /dev/null +++ b/openstack/common/setup.py @@ -0,0 +1,360 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Utilities with minimum-depends for use in setup.py +""" + +import datetime +import os +import re +import subprocess +import sys + +from setuptools.command import sdist + + +def parse_mailmap(mailmap='.mailmap'): + mapping = {} + if os.path.exists(mailmap): + with open(mailmap, 'r') as fp: + for l in fp: + l = l.strip() + if not l.startswith('#') and ' ' in l: + canonical_email, alias = [x for x in l.split(' ') + if x.startswith('<')] + mapping[alias] = canonical_email + return mapping + + +def canonicalize_emails(changelog, mapping): + """Takes in a string and an email alias mapping and replaces all + instances of the aliases in the string with their real email. + """ + for alias, email in mapping.iteritems(): + changelog = changelog.replace(alias, email) + return changelog + + +# Get requirements from the first file that exists +def get_reqs_from_files(requirements_files): + for requirements_file in requirements_files: + if os.path.exists(requirements_file): + with open(requirements_file, 'r') as fil: + return fil.read().split('\n') + return [] + + +def parse_requirements(requirements_files=['requirements.txt', + 'tools/pip-requires']): + requirements = [] + for line in get_reqs_from_files(requirements_files): + # For the requirements list, we need to inject only the portion + # after egg= so that distutils knows the package it's looking for + # such as: + # -e git://github.com/openstack/nova/master#egg=nova + if re.match(r'\s*-e\s+', line): + requirements.append(re.sub(r'\s*-e\s+.*#egg=(.*)$', r'\1', + line)) + # such as: + # http://github.com/openstack/nova/zipball/master#egg=nova + elif re.match(r'\s*https?:', line): + requirements.append(re.sub(r'\s*https?:.*#egg=(.*)$', r'\1', + line)) + # -f lines are for index locations, and don't get used here + elif re.match(r'\s*-f\s+', line): + pass + # argparse is part of the standard library starting with 2.7 + # adding it to the requirements list screws distro installs + elif line == 'argparse' and sys.version_info >= (2, 7): + pass + else: + requirements.append(line) + + return requirements + + +def parse_dependency_links(requirements_files=['requirements.txt', + 'tools/pip-requires']): + dependency_links = [] + # dependency_links inject alternate locations to find packages listed + # in requirements + for line in get_reqs_from_files(requirements_files): + # skip comments and blank lines + if re.match(r'(\s*#)|(\s*$)', line): + continue + # lines with -e or -f need the whole line, minus the flag + if re.match(r'\s*-[ef]\s+', line): + dependency_links.append(re.sub(r'\s*-[ef]\s+', '', line)) + # lines that are only urls can go in unmolested + elif re.match(r'\s*https?:', line): + dependency_links.append(line) + return dependency_links + + +def write_requirements(): + venv = os.environ.get('VIRTUAL_ENV', None) + if venv is not None: + with open("requirements.txt", "w") as req_file: + output = subprocess.Popen(["pip", "-E", venv, "freeze", "-l"], + stdout=subprocess.PIPE) + requirements = output.communicate()[0].strip() + req_file.write(requirements) + + +def _run_shell_command(cmd): + output = subprocess.Popen(["/bin/sh", "-c", cmd], + stdout=subprocess.PIPE) + out = output.communicate() + if len(out) == 0: + return None + if len(out[0].strip()) == 0: + return None + return out[0].strip() + + +def _get_git_next_version_suffix(branch_name): + datestamp = datetime.datetime.now().strftime('%Y%m%d') + if branch_name == 'milestone-proposed': + revno_prefix = "r" + else: + revno_prefix = "" + _run_shell_command("git fetch origin +refs/meta/*:refs/remotes/meta/*") + milestone_cmd = "git show meta/openstack/release:%s" % branch_name + milestonever = _run_shell_command(milestone_cmd) + if not milestonever: + milestonever = "" + post_version = _get_git_post_version() + # post version should look like: + # 0.1.1.4.gcc9e28a + # where the bit after the last . is the short sha, and the bit between + # the last and second to last is the revno count + (revno, sha) = post_version.split(".")[-2:] + first_half = "%s~%s" % (milestonever, datestamp) + second_half = "%s%s.%s" % (revno_prefix, revno, sha) + return ".".join((first_half, second_half)) + + +def _get_git_current_tag(): + return _run_shell_command("git tag --contains HEAD") + + +def _get_git_tag_info(): + return _run_shell_command("git describe --tags") + + +def _get_git_post_version(): + current_tag = _get_git_current_tag() + if current_tag is not None: + return current_tag + else: + tag_info = _get_git_tag_info() + if tag_info is None: + base_version = "0.0" + cmd = "git --no-pager log --oneline" + out = _run_shell_command(cmd) + revno = len(out.split("\n")) + sha = _run_shell_command("git describe --always") + else: + tag_infos = tag_info.split("-") + base_version = "-".join(tag_infos[:-2]) + (revno, sha) = tag_infos[-2:] + return "%s.%s.%s" % (base_version, revno, sha) + + +def write_git_changelog(): + """Write a changelog based on the git changelog.""" + new_changelog = 'ChangeLog' + if not os.getenv('SKIP_WRITE_GIT_CHANGELOG'): + if os.path.isdir('.git'): + git_log_cmd = 'git log --stat' + changelog = _run_shell_command(git_log_cmd) + mailmap = parse_mailmap() + with open(new_changelog, "w") as changelog_file: + changelog_file.write(canonicalize_emails(changelog, mailmap)) + else: + open(new_changelog, 'w').close() + + +def generate_authors(): + """Create AUTHORS file using git commits.""" + jenkins_email = 'jenkins@review.(openstack|stackforge).org' + old_authors = 'AUTHORS.in' + new_authors = 'AUTHORS' + if not os.getenv('SKIP_GENERATE_AUTHORS'): + if os.path.isdir('.git'): + # don't include jenkins email address in AUTHORS file + git_log_cmd = ("git log --format='%aN <%aE>' | sort -u | " + "egrep -v '" + jenkins_email + "'") + changelog = _run_shell_command(git_log_cmd) + mailmap = parse_mailmap() + with open(new_authors, 'w') as new_authors_fh: + new_authors_fh.write(canonicalize_emails(changelog, mailmap)) + if os.path.exists(old_authors): + with open(old_authors, "r") as old_authors_fh: + new_authors_fh.write('\n' + old_authors_fh.read()) + else: + open(new_authors, 'w').close() + + +_rst_template = """%(heading)s +%(underline)s + +.. automodule:: %(module)s + :members: + :undoc-members: + :show-inheritance: +""" + + +def read_versioninfo(project): + """Read the versioninfo file. If it doesn't exist, we're in a github + zipball, and there's really no way to know what version we really + are, but that should be ok, because the utility of that should be + just about nil if this code path is in use in the first place.""" + versioninfo_path = os.path.join(project, 'versioninfo') + if os.path.exists(versioninfo_path): + with open(versioninfo_path, 'r') as vinfo: + version = vinfo.read().strip() + else: + version = "0.0.0" + return version + + +def write_versioninfo(project, version): + """Write a simple file containing the version of the package.""" + with open(os.path.join(project, 'versioninfo'), 'w') as fil: + fil.write("%s\n" % version) + + +def get_cmdclass(): + """Return dict of commands to run from setup.py.""" + + cmdclass = dict() + + def _find_modules(arg, dirname, files): + for filename in files: + if filename.endswith('.py') and filename != '__init__.py': + arg["%s.%s" % (dirname.replace('/', '.'), + filename[:-3])] = True + + class LocalSDist(sdist.sdist): + """Builds the ChangeLog and Authors files from VC first.""" + + def run(self): + write_git_changelog() + generate_authors() + # sdist.sdist is an old style class, can't use super() + sdist.sdist.run(self) + + cmdclass['sdist'] = LocalSDist + + # If Sphinx is installed on the box running setup.py, + # enable setup.py to build the documentation, otherwise, + # just ignore it + try: + from sphinx.setup_command import BuildDoc + + class LocalBuildDoc(BuildDoc): + def generate_autoindex(self): + print "**Autodocumenting from %s" % os.path.abspath(os.curdir) + modules = {} + option_dict = self.distribution.get_option_dict('build_sphinx') + source_dir = os.path.join(option_dict['source_dir'][1], 'api') + if not os.path.exists(source_dir): + os.makedirs(source_dir) + for pkg in self.distribution.packages: + if '.' not in pkg: + os.path.walk(pkg, _find_modules, modules) + module_list = modules.keys() + module_list.sort() + autoindex_filename = os.path.join(source_dir, 'autoindex.rst') + with open(autoindex_filename, 'w') as autoindex: + autoindex.write(""".. toctree:: + :maxdepth: 1 + +""") + for module in module_list: + output_filename = os.path.join(source_dir, + "%s.rst" % module) + heading = "The :mod:`%s` Module" % module + underline = "=" * len(heading) + values = dict(module=module, heading=heading, + underline=underline) + + print "Generating %s" % output_filename + with open(output_filename, 'w') as output_file: + output_file.write(_rst_template % values) + autoindex.write(" %s.rst\n" % module) + + def run(self): + if not os.getenv('SPHINX_DEBUG'): + self.generate_autoindex() + + for builder in ['html', 'man']: + self.builder = builder + self.finalize_options() + self.project = self.distribution.get_name() + self.version = self.distribution.get_version() + self.release = self.distribution.get_version() + BuildDoc.run(self) + cmdclass['build_sphinx'] = LocalBuildDoc + except ImportError: + pass + + return cmdclass + + +def get_git_branchname(): + for branch in _run_shell_command("git branch --color=never").split("\n"): + if branch.startswith('*'): + _branch_name = branch.split()[1].strip() + if _branch_name == "(no": + _branch_name = "no-branch" + return _branch_name + + +def get_pre_version(projectname, base_version): + """Return a version which is leading up to a version that will + be released in the future.""" + if os.path.isdir('.git'): + current_tag = _get_git_current_tag() + if current_tag is not None: + version = current_tag + else: + branch_name = os.getenv('BRANCHNAME', + os.getenv('GERRIT_REFNAME', + get_git_branchname())) + version_suffix = _get_git_next_version_suffix(branch_name) + version = "%s~%s" % (base_version, version_suffix) + write_versioninfo(projectname, version) + return version + else: + version = read_versioninfo(projectname) + return version + + +def get_post_version(projectname): + """Return a version which is equal to the tag that's on the current + revision if there is one, or tag plus number of additional revisions + if the current revision has no tag.""" + + if os.path.isdir('.git'): + version = _get_git_post_version() + write_versioninfo(projectname, version) + return version + return read_versioninfo(projectname) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7fdff19 --- /dev/null +++ b/setup.py @@ -0,0 +1,68 @@ +# Copyright 2012 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import sys +import setuptools +from openstack.common import setup + +requires = setup.parse_requirements() +tests_requires = setup.parse_requirements(['tools/test-requires']) + +ci_cmdclass = {} + +try: + from sphinx.setup_command import BuildDoc + + class local_BuildDoc(BuildDoc): + def run(self): + builders = ['html', 'man'] + for builder in builders: + self.builder = builder + self.finalize_options() + BuildDoc.run(self) + + class local_BuildDoc_latex(BuildDoc): + def run(self): + builders = ['latex'] + for builder in builders: + self.builder = builder + self.finalize_options() + BuildDoc.run(self) + + ci_cmdclass['build_sphinx'] = local_BuildDoc + ci_cmdclass['build_sphinx_latex'] = local_BuildDoc_latex +except Exception: + pass + +setup_reqs = ['Sphinx'] + +execfile('client/__init__.py') + + +setuptools.setup( + name="python-libraclient", + version=__version__, + description="Python client for libra LBaaS solution", + author="Andrew Hutchings ", + packages=setuptools.find_packages(exclude=["*.tests"]), + entry_points={ + 'console_scripts': [ + 'libra_client = client.client:main', + ] + }, + cmdclass=ci_cmdclass, + tests_require=tests_requires, + install_requires=requires, + setup_requires=setup_reqs +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..92bd912 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/tests/test_lbaas_client.py b/tests/test_lbaas_client.py new file mode 100644 index 0000000..cdfeb69 --- /dev/null +++ b/tests/test_lbaas_client.py @@ -0,0 +1,330 @@ +import json +import mock +import httplib2 +import sys +import novaclient +import testtools +from StringIO import StringIO +from client.libraapi import LibraAPI + +class DummyArgs(object): + """ Fake argparse response """ + def __init__(self): + self.id = 2000 + self.deleted = False + +class DummyCreateArgs(object): + """ Fake argparse response for Create function """ + def __init__(self): + self.name = 'a-new-loadbalancer' + self.node = ['10.1.1.1:80', '10.1.1.2:81'] + self.port = None + self.protocol = None + self.algorithm = None + self.vip = None + +class DummyModifyArgs(object): + """ Fake argparse response for Modify function """ + def __init__(self): + self.id = 2012 + self.name = 'a-modified-loadbalancer' + self.algorithm = 'LEAST_CONNECTIONS' + +class MockLibraAPI(LibraAPI): + """ Used to capture data that would be sent to the API server """ + def __init__(self, username, password, tenant, auth_url, region): + self.postdata = None + self.putdata = None + return super(MockLibraAPI, self).__init__(username, password, tenant, auth_url, region, False, False, None) + def _post(self, url, **kwargs): + """ Store the post data and execute as normal """ + self.postdata = kwargs['body'] + return super(MockLibraAPI, self)._post(url, **kwargs) + def _put(self, url, **kwargs): + """ Store the put data, no need to execute the httplib """ + self.putdata = kwargs['body'] + +class TestLBaaSClientLibraAPI(testtools.TestCase): + def setUp(self): + """ Fake a login with token """ + super(TestLBaaSClientLibraAPI, self).setUp() + self.api = MockLibraAPI('username', 'password', 'tenant', 'auth_test', 'region') + self.api.nova.management_url = "http://example.com" + self.api.nova.auth_token = "token" + + def testListLb(self): + """ Test the table generated from the LIST function """ + fake_response = httplib2.Response({"status": '200'}) + fake_body = json.dumps({ + "loadBalancers":[ + { + "name":"lb-site1", + "id":"71", + "protocol":"HTTP", + "port":"80", + "algorithm":"LEAST_CONNECTIONS", + "status":"ACTIVE", + "created":"2010-11-30T03:23:42Z", + "updated":"2010-11-30T03:23:44Z" + }, + { + "name":"lb-site2", + "id":"166", + "protocol":"TCP", + "port":"9123", + "algorithm":"ROUND_ROBIN", + "status":"ACTIVE", + "created":"2010-11-30T03:23:42Z", + "updated":"2010-11-30T03:23:44Z" + } + ] + }) + mock_request = mock.Mock(return_value=(fake_response, fake_body)) + + with mock.patch.object(httplib2.Http, "request", mock_request): + with mock.patch('time.time', mock.Mock(return_value=1234)): + orig = sys.stdout + try: + out = StringIO() + sys.stdout = out + args = DummyArgs() + self.api.list_lb(args) + output = out.getvalue().strip() + self.assertRegexpMatches(output, 'lb-site1') + self.assertRegexpMatches(output, '71') + self.assertRegexpMatches(output, 'HTTP') + self.assertRegexpMatches(output, '80') + self.assertRegexpMatches(output, 'LEAST_CONNECTIONS') + self.assertRegexpMatches(output, 'ACTIVE') + self.assertRegexpMatches(output, '2010-11-30T03:23:42Z') + self.assertRegexpMatches(output, '2010-11-30T03:23:44Z') + finally: + sys.stdout = orig + + def testGetLb(self): + """ Test the table generated from the STATUS function """ + fake_response = httplib2.Response({"status": '200'}) + fake_body = json.dumps({ + "id": "2000", + "name":"sample-loadbalancer", + "protocol":"HTTP", + "port": "80", + "algorithm":"ROUND_ROBIN", + "status":"ACTIVE", + "created":"2010-11-30T03:23:42Z", + "updated":"2010-11-30T03:23:44Z", + "virtualIps":[ + { + "id": "1000", + "address":"2001:cdba:0000:0000:0000:0000:3257:9652", + "type":"PUBLIC", + "ipVersion":"IPV6" + }], + "nodes": [ + { + "id": "1041", + "address":"10.1.1.1", + "port": "80", + "condition":"ENABLED", + "status":"ONLINE" + }, + { + "id": "1411", + "address":"10.1.1.2", + "port": "80", + "condition":"ENABLED", + "status":"ONLINE" + }], + "sessionPersistence":{ + "persistenceType":"HTTP_COOKIE" + }, + "connectionThrottle":{ + "maxRequestRate": "50", + "rateInterval": "60" + } + }) + mock_request = mock.Mock(return_value=(fake_response, fake_body)) + with mock.patch.object(httplib2.Http, "request", mock_request): + with mock.patch('time.time', mock.Mock(return_value=1234)): + orig = sys.stdout + try: + out = StringIO() + sys.stdout = out + args = DummyArgs() + self.api.status_lb(args) + output = out.getvalue().strip() + self.assertRegexpMatches(output, 'HTTP_COOKIE') + finally: + sys.stdout = orig + + def testDeleteFailLb(self): + """ + Test a failure of a DELETE function. We don't test a succeed yet + since that has no response so nothing to assert on + """ + fake_response = httplib2.Response({"status": '500'}) + fake_body = '' + mock_request = mock.Mock(return_value=(fake_response, fake_body)) + with mock.patch.object(httplib2.Http, "request", mock_request): + with mock.patch('time.time', mock.Mock(return_value=1234)): + args = DummyArgs() + self.assertRaises(novaclient.exceptions.ClientException, + self.api.delete_lb, args) + + def testCreateLb(self): + """ + Tests the CREATE function, tests that: + 1. We send the correct POST data + 2. We create a table from the response correctly + """ + fake_response = httplib2.Response({"status": '202'}) + fake_body = json.dumps({ + 'name': 'a-new-loadbalancer', + 'id': '144', + 'protocol': 'HTTP', + 'port': '83', + 'algorithm': 'ROUND_ROBIN', + 'status': 'BUILD', + 'created': '2011-04-13T14:18:07Z', + 'updated': '2011-04-13T14:18:07Z', + 'virtualIps': [ + { + 'address': '15.0.0.1', + 'id': '39', + 'type': 'PUBLIC', + 'ipVersion': 'IPV4', + } + ], + 'nodes': [ + { + 'address': '10.1.1.1', + 'id': '653', + 'port': '80', + 'status': 'ONLINE', + 'condition': 'ENABLED' + } + ] + }) + # This is what the POST data should look like based on the args passed + post_compare = { + "name": "a-new-loadbalancer", + "nodes": [ + { + "address": "10.1.1.1", + "condition": "ENABLED", + "port": "80" + }, + { + "address": "10.1.1.2", + "condition": "ENABLED", + "port": "81" + } + ] + } + mock_request = mock.Mock(return_value=(fake_response, fake_body)) + with mock.patch.object(httplib2.Http, "request", mock_request): + with mock.patch('time.time', mock.Mock(return_value=1234)): + orig = sys.stdout + try: + out = StringIO() + sys.stdout = out + args = DummyCreateArgs() + self.api.create_lb(args) + self.assertEquals(post_compare, self.api.postdata) + output = out.getvalue().strip() + # At some point we should possibly compare the complete + # table rendering somehow instead of basic field data + self.assertRegexpMatches(output, 'ROUND_ROBIN') + self.assertRegexpMatches(output, 'BUILD') + self.assertRegexpMatches(output, '144') + finally: + sys.stdout = orig + + def testCreateAddLb(self): + """ + Tests the CREATE function as above but adding a load balancer to a + virtual IP + """ + fake_response = httplib2.Response({"status": '202'}) + fake_body = json.dumps({ + 'name': 'a-new-loadbalancer', + 'id': '144', + 'protocol': 'HTTP', + 'port': '83', + 'algorithm': 'ROUND_ROBIN', + 'status': 'BUILD', + 'created': '2011-04-13T14:18:07Z', + 'updated': '2011-04-13T14:18:07Z', + 'virtualIps': [ + { + 'address': '15.0.0.1', + 'id': '39', + 'type': 'PUBLIC', + 'ipVersion': 'IPV4', + } + ], + 'nodes': [ + { + 'address': '10.1.1.1', + 'id': '653', + 'port': '80', + 'status': 'ONLINE', + 'condition': 'ENABLED' + } + ] + }) + # This is what the POST data should look like based on the args passed + post_compare = { + "name": "a-new-loadbalancer", + "port": "83", + "protocol": "HTTP", + "virtualIps": [ + { + "id": "39" + } + ], + "nodes": [ + { + "address": "10.1.1.1", + "condition": "ENABLED", + "port": "80" + } + ] + } + mock_request = mock.Mock(return_value=(fake_response, fake_body)) + with mock.patch.object(httplib2.Http, "request", mock_request): + with mock.patch('time.time', mock.Mock(return_value=1234)): + orig = sys.stdout + try: + out = StringIO() + sys.stdout = out + # Add args to add a LB to a VIP + args = DummyCreateArgs() + args.port = '83' + args.protocol = 'HTTP' + args.vip = '39' + args.node = ['10.1.1.1:80'] + self.api.create_lb(args) + self.assertEquals(post_compare, self.api.postdata) + output = out.getvalue().strip() + # At some point we should possibly compare the complete + # table rendering somehow instead of basic field data + self.assertRegexpMatches(output, 'ROUND_ROBIN') + self.assertRegexpMatches(output, 'BUILD') + self.assertRegexpMatches(output, '144') + finally: + sys.stdout = orig + + + def testModifyLb(self): + """ + Tests the MODIFY function, no repsonse so we only test the PUT data + """ + # This is what the PUT data should look like based on the args passed + put_compare = { + "name": "a-modified-loadbalancer", + "algorithm": "LEAST_CONNECTIONS" + } + args = DummyModifyArgs() + self.api.modify_lb(args) + self.assertEquals(put_compare, self.api.putdata) diff --git a/tools/pip-requires b/tools/pip-requires new file mode 100644 index 0000000..ac5346b --- /dev/null +++ b/tools/pip-requires @@ -0,0 +1 @@ +python_novaclient diff --git a/tools/test-requires b/tools/test-requires new file mode 100644 index 0000000..473457d --- /dev/null +++ b/tools/test-requires @@ -0,0 +1,5 @@ +pep8 +mock +httplib2 +testrepository>=0.0.8 +testtools>=0.9.22 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..0f19f40 --- /dev/null +++ b/tox.ini @@ -0,0 +1,21 @@ +[tox] +envlist = py27,pep8 + +[testenv] +deps = -r{toxinidir}/tools/pip-requires + -r{toxinidir}/tools/test-requires +commands = bash -c 'if [ ! -d ./.testrepository ] ; then testr init ; fi' + bash -c 'testr run --parallel {posargs} ; RET=$? ; echo "Slowest Tests" ; testr slowest && exit $RET' + +[tox:jenkins] +downloadcache = ~/cache/pip + +[testenv:py27] + +[testenv:pep8] +deps = pep8 +commands = pep8 --repeat --show-source --exclude=.venv,.tox,dist,doc,*openstack/common*,*lib/python*,*egg client setup.py + +[testenv:pyflakes] +deps = pyflakes +commands = pyflakes client