From 21839c4c3be70ff74d753b297e3abb195cec306c Mon Sep 17 00:00:00 2001 From: Tung Doan Date: Mon, 7 May 2018 14:07:29 +0200 Subject: [PATCH] Update APMEC client code --- HACKING.rst | 26 + LICENSE | 201 +++++ MANIFEST.in | 6 + README.md | 1 + README.rst | 10 + apmec_test.sh | 20 + apmecclient/__init__.py | 0 apmecclient/apmec/__init__.py | 0 apmecclient/apmec/client.py | 72 ++ apmecclient/apmec/v1_0/__init__.py | 717 +++++++++++++++ apmecclient/apmec/v1_0/events/__init__.py | 0 apmecclient/apmec/v1_0/events/events.py | 95 ++ apmecclient/apmec/v1_0/extension.py | 34 + apmecclient/apmec/v1_0/mem/__init__.py | 0 apmecclient/apmec/v1_0/mem/mea.py | 304 +++++++ apmecclient/apmec/v1_0/mem/mead.py | 115 +++ apmecclient/apmec/v1_0/meo/__init__.py | 0 apmecclient/apmec/v1_0/meo/mes.py | 124 +++ apmecclient/apmec/v1_0/meo/mesd.py | 102 +++ apmecclient/apmec/v1_0/meo/vim.py | 135 +++ apmecclient/apmec/v1_0/meo/vim_utils.py | 44 + apmecclient/client.py | 390 ++++++++ apmecclient/common/__init__.py | 0 apmecclient/common/_i18n.py | 40 + apmecclient/common/clientmanager.py | 108 +++ apmecclient/common/command.py | 35 + apmecclient/common/constants.py | 38 + apmecclient/common/exceptions.py | 232 +++++ apmecclient/common/extension.py | 86 ++ apmecclient/common/serializer.py | 405 +++++++++ apmecclient/common/utils.py | 186 ++++ apmecclient/common/validators.py | 69 ++ apmecclient/i18n.py | 18 + apmecclient/shell.py | 837 ++++++++++++++++++ apmecclient/tests/__init__.py | 0 apmecclient/tests/unit/__init__.py | 0 apmecclient/tests/unit/test_auth.py | 370 ++++++++ apmecclient/tests/unit/test_casual_args.py | 119 +++ apmecclient/tests/unit/test_cli10.py | 791 +++++++++++++++++ .../tests/unit/test_cli10_extensions.py | 47 + apmecclient/tests/unit/test_command_meta.py | 39 + apmecclient/tests/unit/test_http.py | 71 ++ apmecclient/tests/unit/test_shell.py | 190 ++++ apmecclient/tests/unit/test_ssl.py | 82 ++ apmecclient/tests/unit/test_utils.py | 149 ++++ apmecclient/tests/unit/test_validators.py | 101 +++ apmecclient/tests/unit/vm/__init__.py | 0 .../tests/unit/vm/samples/vim_config.yaml | 6 + .../samples/vim_config_without_auth_url.yaml | 5 + apmecclient/tests/unit/vm/test_cli10_mea.py | 213 +++++ apmecclient/tests/unit/vm/test_cli10_mead.py | 146 +++ .../tests/unit/vm/test_cli10_v10_event.py | 69 ++ apmecclient/tests/unit/vm/test_cli10_vim.py | 170 ++++ apmecclient/tests/unit/vm/test_vim_utils.py | 69 ++ apmecclient/v1_0/__init__.py | 0 apmecclient/v1_0/client.py | 544 ++++++++++++ apmecclient/version.py | 19 + doc/source/conf.py | 66 ++ doc/source/index.rst | 25 + requirements.txt | 18 + setup.cfg | 45 + setup.py | 29 + test-requirements.txt | 19 + tools/apmec.bash_completion | 27 + tools/tox_install.sh | 30 + tox.ini | 36 + 66 files changed, 7945 insertions(+) create mode 100644 HACKING.rst create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 README.rst create mode 100755 apmec_test.sh create mode 100644 apmecclient/__init__.py create mode 100644 apmecclient/apmec/__init__.py create mode 100644 apmecclient/apmec/client.py create mode 100644 apmecclient/apmec/v1_0/__init__.py create mode 100644 apmecclient/apmec/v1_0/events/__init__.py create mode 100644 apmecclient/apmec/v1_0/events/events.py create mode 100644 apmecclient/apmec/v1_0/extension.py create mode 100644 apmecclient/apmec/v1_0/mem/__init__.py create mode 100644 apmecclient/apmec/v1_0/mem/mea.py create mode 100644 apmecclient/apmec/v1_0/mem/mead.py create mode 100644 apmecclient/apmec/v1_0/meo/__init__.py create mode 100644 apmecclient/apmec/v1_0/meo/mes.py create mode 100644 apmecclient/apmec/v1_0/meo/mesd.py create mode 100644 apmecclient/apmec/v1_0/meo/vim.py create mode 100644 apmecclient/apmec/v1_0/meo/vim_utils.py create mode 100644 apmecclient/client.py create mode 100644 apmecclient/common/__init__.py create mode 100644 apmecclient/common/_i18n.py create mode 100644 apmecclient/common/clientmanager.py create mode 100644 apmecclient/common/command.py create mode 100644 apmecclient/common/constants.py create mode 100644 apmecclient/common/exceptions.py create mode 100644 apmecclient/common/extension.py create mode 100644 apmecclient/common/serializer.py create mode 100644 apmecclient/common/utils.py create mode 100644 apmecclient/common/validators.py create mode 100644 apmecclient/i18n.py create mode 100644 apmecclient/shell.py create mode 100644 apmecclient/tests/__init__.py create mode 100644 apmecclient/tests/unit/__init__.py create mode 100644 apmecclient/tests/unit/test_auth.py create mode 100644 apmecclient/tests/unit/test_casual_args.py create mode 100644 apmecclient/tests/unit/test_cli10.py create mode 100644 apmecclient/tests/unit/test_cli10_extensions.py create mode 100644 apmecclient/tests/unit/test_command_meta.py create mode 100644 apmecclient/tests/unit/test_http.py create mode 100644 apmecclient/tests/unit/test_shell.py create mode 100644 apmecclient/tests/unit/test_ssl.py create mode 100644 apmecclient/tests/unit/test_utils.py create mode 100644 apmecclient/tests/unit/test_validators.py create mode 100644 apmecclient/tests/unit/vm/__init__.py create mode 100644 apmecclient/tests/unit/vm/samples/vim_config.yaml create mode 100644 apmecclient/tests/unit/vm/samples/vim_config_without_auth_url.yaml create mode 100644 apmecclient/tests/unit/vm/test_cli10_mea.py create mode 100644 apmecclient/tests/unit/vm/test_cli10_mead.py create mode 100644 apmecclient/tests/unit/vm/test_cli10_v10_event.py create mode 100644 apmecclient/tests/unit/vm/test_cli10_vim.py create mode 100644 apmecclient/tests/unit/vm/test_vim_utils.py create mode 100644 apmecclient/v1_0/__init__.py create mode 100644 apmecclient/v1_0/client.py create mode 100644 apmecclient/version.py create mode 100644 doc/source/conf.py create mode 100644 doc/source/index.rst create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 test-requirements.txt create mode 100644 tools/apmec.bash_completion create mode 100755 tools/tox_install.sh create mode 100644 tox.ini diff --git a/HACKING.rst b/HACKING.rst new file mode 100644 index 0000000..970e0ea --- /dev/null +++ b/HACKING.rst @@ -0,0 +1,26 @@ +Apmec Style Commandments +================================ + +- Step 1: Read the OpenStack Style Commandments + http://docs.openstack.org/developer/hacking/ +- Step 2: Read on + + +Running Tests +------------- +The testing system is based on a combination of tox and testr. The canonical +approach to running tests is to simply run the command `tox`. This will +create virtual environments, populate them with depenedencies and run all of +the tests that OpenStack CI systems run. Behind the scenes, tox is running +`testr run --parallel`, but is set up such that you can supply any additional +testr arguments that are needed to tox. For example, you can run: +`tox -- --analyze-isolation` to cause tox to tell testr to add +--analyze-isolation to its argument list. + +It is also possible to run the tests inside of a virtual environment +you have created, or it is possible that you have all of the dependencies +installed locally already. In this case, you can interact with the testr +command directly. Running `testr run` will run the entire test suite. `testr +run --parallel` will run it in parallel (this is the default incantation tox +uses.) More information about testr can be found at: +http://wiki.openstack.org/testr diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c0f014e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include tox.ini +include LICENSE README.rst HACKING.rst +include AUTHORS +include ChangeLog +include tools/* +recursive-include tests * diff --git a/README.md b/README.md new file mode 100644 index 0000000..7587789 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# python-apmecclient \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..8c423ed --- /dev/null +++ b/README.rst @@ -0,0 +1,10 @@ +======================== +Team and repository tags +======================== + +.. image:: http://governance.openstack.org/badges/python-apmecclient.svg + :target: http://governance.openstack.org/reference/tags/index.html + +.. Change things from this point on + +This is the client API library for Apmec. diff --git a/apmec_test.sh b/apmec_test.sh new file mode 100755 index 0000000..4260983 --- /dev/null +++ b/apmec_test.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -x +function die() { + local exitcode=$? + set +o xtrace + echo $@ + exit $exitcode +} + +noauth_tenant_id=me +if [ $1 == 'noauth' ]; then + NOAUTH="--tenant_id $noauth_tenant_id" +else + NOAUTH= +fi + +FORMAT=" --request-format xml" + +# test the CRUD of xxx +# TODO(yamahata) diff --git a/apmecclient/__init__.py b/apmecclient/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/apmec/__init__.py b/apmecclient/apmec/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/apmec/client.py b/apmecclient/apmec/client.py new file mode 100644 index 0000000..3d39a6e --- /dev/null +++ b/apmecclient/apmec/client.py @@ -0,0 +1,72 @@ +# Copyright 2012 OpenStack Foundation. +# 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. +# + +from apmecclient.common._i18n import _ +from apmecclient.common import exceptions +from apmecclient.common import utils + + +API_NAME = 'mec-orchestration' +API_VERSIONS = { + '1.0': 'apmecclient.v1_0.client.Client', +} + + +def make_client(instance): + """Returns an apmec client.""" + + apmec_client = utils.get_client_class( + API_NAME, + instance._api_version[API_NAME], + API_VERSIONS, + ) + instance.initialize() + url = instance._url + url = url.rstrip("/") + if '1.0' == instance._api_version[API_NAME]: + client = apmec_client(username=instance._username, + tenant_name=instance._tenant_name, + password=instance._password, + region_name=instance._region_name, + auth_url=instance._auth_url, + endpoint_url=url, + endpoint_type=instance._endpoint_type, + token=instance._token, + auth_strategy=instance._auth_strategy, + insecure=instance._insecure, + ca_cert=instance._ca_cert, + retries=instance._retries, + raise_errors=instance._raise_errors, + session=instance._session, + auth=instance._auth) + return client + else: + raise exceptions.UnsupportedVersion(_("API version %s is not " + "supported") % + instance._api_version[API_NAME]) + + +def Client(api_version, *args, **kwargs): + """Return an apmec client. + + :param api_version: only 1.0 is supported now + """ + apmec_client = utils.get_client_class( + API_NAME, + api_version, + API_VERSIONS, + ) + return apmec_client(*args, **kwargs) diff --git a/apmecclient/apmec/v1_0/__init__.py b/apmecclient/apmec/v1_0/__init__.py new file mode 100644 index 0000000..7ed0e38 --- /dev/null +++ b/apmecclient/apmec/v1_0/__init__.py @@ -0,0 +1,717 @@ +# Copyright 2012 OpenStack Foundation. +# 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. +# + +from __future__ import print_function + +import abc +import argparse +import logging +import re + +from cliff.formatters import table +from cliff import lister +from cliff import show +from oslo_serialization import jsonutils +import six + +from apmecclient.common._i18n import _ +from apmecclient.common import command +from apmecclient.common import exceptions +from apmecclient.common import utils + +HEX_ELEM = '[0-9A-Fa-f]' +UUID_PATTERN = '-'.join([HEX_ELEM + '{8}', HEX_ELEM + '{4}', + HEX_ELEM + '{4}', HEX_ELEM + '{4}', + HEX_ELEM + '{12}']) + + +def _get_resource_plural(resource, client): + plurals = getattr(client, 'EXTED_PLURALS', []) + for k in plurals: + if plurals[k] == resource: + return k + return resource + 's' + + +def find_resourceid_by_id(client, resource, resource_id): + resource_plural = _get_resource_plural(resource, client) + obj_lister = getattr(client, "list_%s" % resource_plural) + if resource == 'event': + match = resource_id.isdigit() and resource_id != 0 + else: + match = re.match(UUID_PATTERN, resource_id) + collection = resource_plural + if match: + data = obj_lister(id=resource_id, fields='id') + if data and data[collection]: + return data[collection][0]['id'] + not_found_message = (_("Unable to find %(resource)s with id " + "'%(id)s'") % + {'resource': resource, 'id': resource_id}) + # 404 is used to simulate server side behavior + raise exceptions.ApmecClientException( + message=not_found_message, status_code=404) + + +def _find_resourceid_by_name(client, resource, name): + resource_plural = _get_resource_plural(resource, client) + obj_lister = getattr(client, "list_%s" % resource_plural) + data = obj_lister(name=name, fields='id') + collection = resource_plural + info = data[collection] + if len(info) > 1: + raise exceptions.ApmecClientNoUniqueMatch(resource=resource, + name=name) + elif len(info) == 0: + not_found_message = (_("Unable to find %(resource)s with name " + "'%(name)s'") % + {'resource': resource, 'name': name}) + # 404 is used to simulate server side behavior + raise exceptions.ApmecClientException( + message=not_found_message, status_code=404) + else: + return info[0]['id'] + + +def find_resourceid_by_name_or_id(client, resource, name_or_id): + try: + return find_resourceid_by_id(client, resource, name_or_id) + except exceptions.ApmecClientException: + return _find_resourceid_by_name(client, resource, name_or_id) + + +def add_show_list_common_argument(parser): + parser.add_argument( + '-D', '--show-details', + help=_('Show detailed info'), + action='store_true', + default=False,) + parser.add_argument( + '--show_details', + action='store_true', + help=argparse.SUPPRESS) + parser.add_argument( + '--fields', + help=argparse.SUPPRESS, + action='append', + default=[]) + parser.add_argument( + '-F', '--field', + dest='fields', metavar='FIELD', + help=_('Specify the field(s) to be returned by server. You can ' + 'repeat this option.'), + action='append', + default=[]) + + +def add_pagination_argument(parser): + parser.add_argument( + '-P', '--page-size', + dest='page_size', metavar='SIZE', type=int, + help=_("Specify retrieve unit of each request, then split one request " + "to several requests"), + default=None) + + +def add_sorting_argument(parser): + parser.add_argument( + '--sort-key', + dest='sort_key', metavar='FIELD', + action='append', + help=_("Sorts the list by the specified fields in the specified " + "directions. You can repeat this option, but you must " + "specify an equal number of sort_dir and sort_key values. " + "Extra sort_dir options are ignored. Missing sort_dir options " + "use the default asc value."), + default=[]) + parser.add_argument( + '--sort-dir', + dest='sort_dir', metavar='{asc,desc}', + help=_("Sorts the list in the specified direction. You can repeat " + "this option."), + action='append', + default=[], + choices=['asc', 'desc']) + + +def is_number(s): + try: + float(s) # for int, long and float + except ValueError: + try: + complex(s) # for complex + except ValueError: + return False + + return True + + +def _process_previous_argument(current_arg, _value_number, current_type_str, + _list_flag, _values_specs, _clear_flag, + values_specs): + if current_arg is not None: + if _value_number == 0 and (current_type_str or _list_flag): + # This kind of argument should have value + raise exceptions.CommandError( + _("Invalid values_specs %s") % ' '.join(values_specs)) + if _value_number > 1 or _list_flag or current_type_str == 'list': + current_arg.update({'nargs': '+'}) + elif _value_number == 0: + if _clear_flag: + # if we have action=clear, we use argument's default + # value None for argument + _values_specs.pop() + else: + # We assume non value argument as bool one + current_arg.update({'action': 'store_true'}) + + +def parse_args_to_dict(values_specs): + '''It is used to analyze the extra command options to command. + + Besides known options and arguments, our commands also support user to + put more options to the end of command line. For example, + list_nets -- --tag x y --key1 value1, where '-- --tag x y --key1 value1' + is extra options to our list_nets. This feature can support V1.0 API's + fields selection and filters. For example, to list networks which has name + 'test4', we can have list_nets -- --name=test4. + + value spec is: --key type=int|bool|... value. Type is one of Python + built-in types. By default, type is string. The key without value is + a bool option. Key with two values will be a list option. + + ''' + + # values_specs for example: '-- --tag x y --key1 type=int value1' + # -- is a pseudo argument + values_specs_copy = values_specs[:] + if values_specs_copy and values_specs_copy[0] == '--': + del values_specs_copy[0] + # converted ArgumentParser arguments for each of the options + _options = {} + # the argument part for current option in _options + current_arg = None + # the string after remove meta info in values_specs + # for example, '--tag x y --key1 value1' + _values_specs = [] + # record the count of values for an option + # for example: for '--tag x y', it is 2, while for '--key1 value1', it is 1 + _value_number = 0 + # list=true + _list_flag = False + # action=clear + _clear_flag = False + # the current item in values_specs + current_item = None + # the str after 'type=' + current_type_str = None + for _item in values_specs_copy: + if _item.startswith('--'): + # Deal with previous argument if any + _process_previous_argument( + current_arg, _value_number, current_type_str, + _list_flag, _values_specs, _clear_flag, values_specs) + + # Init variables for current argument + current_item = _item + _list_flag = False + _clear_flag = False + current_type_str = None + if "=" in _item: + _value_number = 1 + _item = _item.split('=')[0] + else: + _value_number = 0 + if _item in _options: + raise exceptions.CommandError( + _("Duplicated options %s") % ' '.join(values_specs)) + else: + _options.update({_item: {}}) + current_arg = _options[_item] + _item = current_item + elif _item.startswith('type='): + if current_arg is None: + raise exceptions.CommandError( + _("Invalid values_specs %s") % ' '.join(values_specs)) + if 'type' not in current_arg: + current_type_str = _item.split('=', 2)[1] + current_arg.update({'type': eval(current_type_str)}) + if current_type_str == 'bool': + current_arg.update({'type': utils.str2bool}) + elif current_type_str == 'dict': + current_arg.update({'type': utils.str2dict}) + continue + elif _item == 'list=true': + _list_flag = True + continue + elif _item == 'action=clear': + _clear_flag = True + continue + + if not _item.startswith('--'): + # All others are value items + # Make sure '--' occurs first and allow minus value + if (not current_item or '=' in current_item or + _item.startswith('-') and not is_number(_item)): + raise exceptions.CommandError( + _("Invalid values_specs %s") % ' '.join(values_specs)) + _value_number += 1 + + _values_specs.append(_item) + + # Deal with last one argument + _process_previous_argument( + current_arg, _value_number, current_type_str, + _list_flag, _values_specs, _clear_flag, values_specs) + + # populate the parser with arguments + _parser = argparse.ArgumentParser(add_help=False) + for opt, optspec in _options.items(): + _parser.add_argument(opt, **optspec) + _args = _parser.parse_args(_values_specs) + + result_dict = {} + for opt in _options.keys(): + _opt = opt.split('--', 2)[1] + _opt = _opt.replace('-', '_') + _value = getattr(_args, _opt) + result_dict.update({_opt: _value}) + return result_dict + + +def _merge_args(qCmd, parsed_args, _extra_values, value_specs): + """Merge arguments from _extra_values into parsed_args. + + If an argument value are provided in both and it is a list, + the values in _extra_values will be merged into parsed_args. + + @param parsed_args: the parsed args from known options + @param _extra_values: the other parsed arguments in unknown parts + @param values_specs: the unparsed unknown parts + """ + temp_values = _extra_values.copy() + for key, value in temp_values.items(): + if hasattr(parsed_args, key): + arg_value = getattr(parsed_args, key) + if arg_value is not None and value is not None: + if isinstance(arg_value, list): + if value and isinstance(value, list): + if (not arg_value or + isinstance(arg_value[0], type(value[0]))): + arg_value.extend(value) + _extra_values.pop(key) + + +def update_dict(obj, dict, attributes): + """Update dict with fields from obj.attributes + + :param obj: the object updated into dict + :param dict: the result dictionary + :param attributes: a list of attributes belonging to obj + """ + for attribute in attributes: + if hasattr(obj, attribute) and getattr(obj, attribute) is not None: + dict[attribute] = getattr(obj, attribute) + + +class TableFormater(table.TableFormatter): + """This class is used to keep consistency with prettytable 0.6. + + https://bugs.launchpad.net/python-apmecclient/+bug/1165962 + """ + def emit_list(self, column_names, data, stdout, parsed_args): + if column_names: + super(TableFormater, self).emit_list(column_names, data, stdout, + parsed_args) + else: + stdout.write('\n') + + +# command.OpenStackCommand is abstract class so that metaclass of +# subclass must be subclass of metaclass of all its base. +# otherwise metaclass conflict exception is raised. +class ApmecCommandMeta(abc.ABCMeta): + def __new__(cls, name, bases, cls_dict): + if 'log' not in cls_dict: + cls_dict['log'] = logging.getLogger( + cls_dict['__module__'] + '.' + name) + return super(ApmecCommandMeta, cls).__new__(cls, + name, bases, cls_dict) + + +@six.add_metaclass(ApmecCommandMeta) +class ApmecCommand(command.OpenStackCommand): + + api = 'mec-orchestration' + values_specs = [] + json_indent = None + + def __init__(self, app, app_args): + super(ApmecCommand, self).__init__(app, app_args) + # NOTE(markmcclain): This is no longer supported in cliff version 1.5.2 + # see https://bugs.launchpad.net/python-apmecclient/+bug/1265926 + + # if hasattr(self, 'formatters'): + # self.formatters['table'] = TableFormater() + + def get_client(self): + return self.app.client_manager.apmec + + def get_parser(self, prog_name): + parser = super(ApmecCommand, self).get_parser(prog_name) + parser.add_argument( + '--request-format', + help=_('The xml or json request format'), + default='json', + choices=['json', 'xml', ], ) + parser.add_argument( + '--request_format', + choices=['json', 'xml', ], + help=argparse.SUPPRESS) + + return parser + + def format_output_data(self, data): + # Modify data to make it more readable + if self.resource in data: + for k, v in data[self.resource].items(): + if isinstance(v, list): + value = '\n'.join(jsonutils.dumps( + i, indent=self.json_indent) if isinstance(i, dict) + else str(i) for i in v) + data[self.resource][k] = value + elif isinstance(v, dict): + value = jsonutils.dumps(v, indent=self.json_indent) + data[self.resource][k] = value + elif v is None: + data[self.resource][k] = '' + + def add_known_arguments(self, parser): + pass + + def args2body(self, parsed_args): + return {} + + +class CreateCommand(ApmecCommand, show.ShowOne): + """Create a resource for a given tenant + + """ + + api = 'mec-orchestration' + resource = None + log = None + remove_output_fields = [] + + def get_parser(self, prog_name): + parser = super(CreateCommand, self).get_parser(prog_name) + parser.add_argument( + '--tenant-id', metavar='TENANT_ID', + help=_('The owner tenant ID'), ) + parser.add_argument( + '--tenant_id', + help=argparse.SUPPRESS) + self.add_known_arguments(parser) + return parser + + def get_data(self, parsed_args): + self.log.debug('get_data(%s)', parsed_args) + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + _extra_values = parse_args_to_dict(self.values_specs) + _merge_args(self, parsed_args, _extra_values, + self.values_specs) + body = self.args2body(parsed_args) + body[self.resource].update(_extra_values) + obj_creator = getattr(apmec_client, + "create_%s" % self.resource) + data = obj_creator(body) + self.format_output_data(data) + # {u'network': {u'id': u'e9424a76-6db4-4c93-97b6-ec311cd51f19'}} + info = self.resource in data and data[self.resource] or None + if info: + print(_('Created a new %s:') % self.resource, + file=self.app.stdout) + for f in self.remove_output_fields: + if f in info: + info.pop(f) + else: + info = {'': ''} + return zip(*sorted(info.items())) + + +class UpdateCommand(ApmecCommand): + """Update resource's information.""" + + api = 'mec-orchestration' + resource = None + log = None + allow_names = True + + def get_parser(self, prog_name): + parser = super(UpdateCommand, self).get_parser(prog_name) + parser.add_argument( + 'id', metavar=self.resource.upper(), + help=_('ID or name of %s to update') % self.resource) + self.add_known_arguments(parser) + return parser + + def run(self, parsed_args): + self.log.debug('run(%s)', parsed_args) + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + _extra_values = parse_args_to_dict(self.values_specs) + _merge_args(self, parsed_args, _extra_values, + self.values_specs) + body = self.args2body(parsed_args) + if self.resource in body: + body[self.resource].update(_extra_values) + else: + body[self.resource] = _extra_values + if not body[self.resource]: + raise exceptions.CommandError( + _("Must specify new values to update %s") % self.resource) + if self.allow_names: + _id = find_resourceid_by_name_or_id( + apmec_client, self.resource, parsed_args.id) + else: + _id = find_resourceid_by_id( + apmec_client, self.resource, parsed_args.id) + obj_updator = getattr(apmec_client, + "update_%s" % self.resource) + obj_updator(_id, body) + print((_('Updated %(resource)s: %(id)s') % + {'id': parsed_args.id, 'resource': self.resource}), + file=self.app.stdout) + return + + +class DeleteCommand(ApmecCommand): + """Delete given resource(s) + + """ + + api = 'mec-orchestration' + resource = None + log = None + allow_names = True + deleted_msg = {} + + def get_parser(self, prog_name): + parser = super(DeleteCommand, self).get_parser(prog_name) + if self.allow_names: + help_str = _('IDs or names of %s to delete') + else: + help_str = _('IDs of %s to delete') + parser.add_argument( + 'ids', nargs='+', + metavar=self.resource.upper(), + help=help_str % self.resource) + return parser + + def run(self, parsed_args): + failure = False + deleted_ids = [] + failed_items = {} + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + obj_deleter = getattr(apmec_client, + "delete_%s" % self.resource) + for resource_id in parsed_args.ids: + try: + if self.allow_names: + _id = find_resourceid_by_name_or_id( + apmec_client, self.resource, resource_id) + else: + _id = resource_id + obj_deleter(_id) + deleted_ids.append(resource_id) + except Exception as e: + failure = True + failed_items[resource_id] = e + if failure: + msg = '' + if deleted_ids: + status_msg = self.deleted_msg.get(self.resource, 'deleted') + msg = (_('Successfully %(status_msg)s %(resource)s(s):' + ' %(deleted_list)s') % {'status_msg': status_msg, + 'deleted_list': + ', '.join(deleted_ids), + 'resource': self.resource}) + err_msg = _("\n\nUnable to delete the below" + " %s(s):") % self.resource + for failed_id, error in failed_items.iteritems(): + err_msg += (_('\n Cannot delete %(failed_id)s: %(error)s') + % {'failed_id': failed_id, + 'error': error}) + msg += err_msg + raise exceptions.CommandError(msg) + else: + print((_('All specified %(resource)s(s) %(msg)s successfully') + % {'msg': self.deleted_msg.get(self.resource, 'deleted'), + 'resource': self.resource})) + return + + +class ListCommand(ApmecCommand, lister.Lister): + """List resources that belong to a given tenant + + """ + + api = 'mec-orchestration' + resource = None + log = None + _formatters = {} + list_columns = [] + unknown_parts_flag = True + pagination_support = False + sorting_support = False + + def get_parser(self, prog_name): + parser = super(ListCommand, self).get_parser(prog_name) + add_show_list_common_argument(parser) + if self.pagination_support: + add_pagination_argument(parser) + if self.sorting_support: + add_sorting_argument(parser) + return parser + + def args2search_opts(self, parsed_args): + search_opts = {} + fields = parsed_args.fields + if parsed_args.fields: + search_opts.update({'fields': fields}) + if parsed_args.show_details: + search_opts.update({'verbose': 'True'}) + return search_opts + + def call_server(self, apmec_client, search_opts, parsed_args): + resource_plural = _get_resource_plural(self.resource, apmec_client) + obj_lister = getattr(apmec_client, "list_%s" % resource_plural) + data = obj_lister(**search_opts) + return data + + def retrieve_list(self, parsed_args): + """Retrieve a list of resources from Apmec server""" + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + _extra_values = parse_args_to_dict(self.values_specs) + _merge_args(self, parsed_args, _extra_values, + self.values_specs) + search_opts = self.args2search_opts(parsed_args) + search_opts.update(_extra_values) + if self.pagination_support: + page_size = parsed_args.page_size + if page_size: + search_opts.update({'limit': page_size}) + if self.sorting_support: + keys = parsed_args.sort_key + if keys: + search_opts.update({'sort_key': keys}) + dirs = parsed_args.sort_dir + len_diff = len(keys) - len(dirs) + if len_diff > 0: + dirs += ['asc'] * len_diff + elif len_diff < 0: + dirs = dirs[:len(keys)] + if dirs: + search_opts.update({'sort_dir': dirs}) + data = self.call_server(apmec_client, search_opts, parsed_args) + collection = _get_resource_plural(self.resource, apmec_client) + return data.get(collection, []) + + def extend_list(self, data, parsed_args): + """Update a retrieved list. + + This method provides a way to modify a original list returned from + the apmec server. For example, you can add subnet cidr information + to a list network. + """ + pass + + def setup_columns(self, info, parsed_args): + _columns = len(info) > 0 and sorted(info[0].keys()) or [] + if not _columns: + # clean the parsed_args.columns so that cliff will not break + parsed_args.columns = [] + elif parsed_args.columns: + _columns = [x for x in parsed_args.columns if x in _columns] + elif self.list_columns: + # if no -c(s) by user and list_columns, we use columns in + # both list_columns and returned resource. + # Also Keep their order the same as in list_columns + _columns = [x for x in self.list_columns if x in _columns] + return (_columns, (utils.get_item_properties( + s, _columns, formatters=self._formatters, ) + for s in info), ) + + def get_data(self, parsed_args): + self.log.debug('get_data(%s)', parsed_args) + data = self.retrieve_list(parsed_args) + self.extend_list(data, parsed_args) + return self.setup_columns(data, parsed_args) + + +class ShowCommand(ApmecCommand, show.ShowOne): + """Show information of a given resource + + """ + + api = 'mec-orchestration' + resource = None + log = None + allow_names = True + + def get_id(self): + if self.resource: + return self.resource.upper() + + def get_parser(self, prog_name): + parser = super(ShowCommand, self).get_parser(prog_name) + add_show_list_common_argument(parser) + if self.allow_names: + help_str = _('ID or name of %s to look up') + else: + help_str = _('ID of %s to look up') + parser.add_argument( + 'id', metavar=self.get_id(), + help=help_str % self.resource) + return parser + + def get_data(self, parsed_args): + self.log.debug('get_data(%s)', parsed_args) + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + + params = {} + if parsed_args.show_details: + params = {'verbose': 'True'} + if parsed_args.fields: + params = {'fields': parsed_args.fields} + if self.allow_names: + _id = find_resourceid_by_name_or_id(apmec_client, self.resource, + parsed_args.id) + else: + _id = parsed_args.id + + obj_shower = getattr(apmec_client, "show_%s" % self.resource) + data = obj_shower(_id, **params) + self.format_output_data(data) + resource = data[self.resource] + if self.resource in data: + return zip(*sorted(resource.items())) + else: + return None diff --git a/apmecclient/apmec/v1_0/events/__init__.py b/apmecclient/apmec/v1_0/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/apmec/v1_0/events/events.py b/apmecclient/apmec/v1_0/events/events.py new file mode 100644 index 0000000..28e8b3f --- /dev/null +++ b/apmecclient/apmec/v1_0/events/events.py @@ -0,0 +1,95 @@ +# Copyright 2016 Brocade Communications Systems Inc +# 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. + +from apmecclient.apmec import v1_0 as apmecV10 + +_EVENT = "event" + + +class ListEventsBase(apmecV10.ListCommand): + """Base class for list command.""" + + list_columns = ['id', 'resource_type', 'resource_id', + 'resource_state', 'event_type', + 'timestamp', 'event_details'] + + def get_parser(self, prog_name): + parser = super(ListEventsBase, self).get_parser(prog_name) + parser.add_argument('--id', + help='id of the event to look up.') + parser.add_argument('--resource-id', + help='resource id of the events to look up.') + parser.add_argument('--resource-state', + help='resource state of the events to look up.') + parser.add_argument('--event-type', + help='event type of the events to look up.') + return parser + + def args2search_opts(self, parsed_args): + search_opts = super(ListEventsBase, self).args2search_opts( + parsed_args) + if parsed_args.id: + search_opts.update({'id': parsed_args.id}) + if parsed_args.resource_id: + search_opts.update({'resource_id': parsed_args.resource_id}) + if parsed_args.resource_state: + search_opts.update({'resource_state': parsed_args.resource_state}) + if parsed_args.event_type: + search_opts.update({'event_type': parsed_args.event_type}) + return search_opts + + +class ListResourceEvents(ListEventsBase): + """List events of resources.""" + + resource = _EVENT + + def get_parser(self, prog_name): + parser = super(ListResourceEvents, self).get_parser(prog_name) + parser.add_argument('--resource-type', + help='resource type of the events to look up.') + return parser + + def args2search_opts(self, parsed_args): + search_opts = super(ListResourceEvents, self).args2search_opts( + parsed_args) + if parsed_args.resource_type: + search_opts.update({'resource_type': parsed_args.resource_type}) + return search_opts + + +class ListMEAEvents(ListEventsBase): + """List events of MEAs.""" + + resource = "mea_event" + + +class ListMEADEvents(ListEventsBase): + """List events of MEADs.""" + + resource = "mead_event" + + +class ListVIMEvents(ListEventsBase): + """List events of VIMs.""" + + resource = "vim_event" + + +class ShowEvent(apmecV10.ShowCommand): + """Show event given the event id.""" + + resource = _EVENT diff --git a/apmecclient/apmec/v1_0/extension.py b/apmecclient/apmec/v1_0/extension.py new file mode 100644 index 0000000..0a52e48 --- /dev/null +++ b/apmecclient/apmec/v1_0/extension.py @@ -0,0 +1,34 @@ +# Copyright 2012 OpenStack Foundation. +# 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. +# + +from apmecclient.apmec import v1_0 as cmd_base + + +class ListExt(cmd_base.ListCommand): + """List all extensions.""" + + resource = 'extension' + list_columns = ['alias', 'name'] + + +class ShowExt(cmd_base.ShowCommand): + """Show information of a given resource.""" + + resource = "extension" + allow_names = False + + def get_id(self): + return 'EXT-ALIAS' diff --git a/apmecclient/apmec/v1_0/mem/__init__.py b/apmecclient/apmec/v1_0/mem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/apmec/v1_0/mem/mea.py b/apmecclient/apmec/v1_0/mem/mea.py new file mode 100644 index 0000000..610187f --- /dev/null +++ b/apmecclient/apmec/v1_0/mem/mea.py @@ -0,0 +1,304 @@ +# +# Copyright 2013 Intel Corporation +# 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 yaml + +from apmecclient.common import exceptions +from apmecclient.i18n import _ +from apmecclient.apmec import v1_0 as apmecV10 + + +_MEA = 'mea' +_RESOURCE = 'resource' + + +class ListMEA(apmecV10.ListCommand): + """List MEA that belong to a given tenant.""" + + resource = _MEA + list_columns = ['id', 'name', 'mgmt_url', 'status', + 'vim_id', 'mead_id'] + + +class ShowMEA(apmecV10.ShowCommand): + """Show information of a given MEA.""" + + resource = _MEA + + +class CreateMEA(apmecV10.CreateCommand): + """Create a MEA.""" + + resource = _MEA + remove_output_fields = ["attributes"] + + def add_known_arguments(self, parser): + parser.add_argument( + 'name', metavar='NAME', + help=_('Set a name for the MEA')) + parser.add_argument( + '--description', + help=_('Set description for the MEA')) + mead_group = parser.add_mutually_exclusive_group(required=True) + mead_group.add_argument( + '--mead-id', + help=_('MEAD ID to use as template to create MEA')) + mead_group.add_argument( + '--mead-name', + help=_('MEAD Name to use as template to create MEA')) + mead_group.add_argument( + '--mead-template', + help=_("MEAD file to create MEA")) + vim_group = parser.add_mutually_exclusive_group() + vim_group.add_argument( + '--vim-id', + help=_('VIM ID to use to create MEA on the specified VIM')) + vim_group.add_argument( + '--vim-name', + help=_('VIM name to use to create MEA on the specified VIM')) + parser.add_argument( + '--vim-region-name', + help=_('VIM Region to use to create MEA on the specified VIM')) + parser.add_argument( + '--config-file', + help=_('YAML file with MEA configuration')) + parser.add_argument( + '--param-file', + help=_('Specify parameter yaml file')) + + def args2body(self, parsed_args): + args = {'attributes': {}} + body = {self.resource: args} + # config arg passed as data overrides config yaml when both args passed + config = None + if parsed_args.config_file: + with open(parsed_args.config_file) as f: + config_yaml = f.read() + try: + config = yaml.load( + config_yaml, Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + + if config: + args['attributes']['config'] = config + if parsed_args.vim_region_name: + args.setdefault('placement_attr', {})['region_name'] = \ + parsed_args.vim_region_name + + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + if parsed_args.vim_name: + _id = apmecV10.find_resourceid_by_name_or_id(apmec_client, + 'vim', + parsed_args. + vim_name) + parsed_args.vim_id = _id + if parsed_args.mead_name: + _id = apmecV10.find_resourceid_by_name_or_id(apmec_client, + 'mead', + parsed_args. + mead_name) + parsed_args.mead_id = _id + elif parsed_args.mead_template: + with open(parsed_args.mead_template) as f: + template = f.read() + try: + args['mead_template'] = yaml.load( + template, Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + + if parsed_args.param_file: + with open(parsed_args.param_file) as f: + param_yaml = f.read() + try: + args['attributes']['param_values'] = yaml.load( + param_yaml, Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + apmecV10.update_dict(parsed_args, body[self.resource], + ['tenant_id', 'name', 'description', + 'mead_id', 'vim_id']) + return body + + +class UpdateMEA(apmecV10.UpdateCommand): + """Update a given MEA.""" + + resource = _MEA + + def add_known_arguments(self, parser): + parser.add_argument( + '--config-file', + help=_('YAML file with MEA configuration')) + parser.add_argument( + '--config', + help=_('Specify config yaml data')) + + def args2body(self, parsed_args): + body = {self.resource: {}} + # config arg passed as data overrides config yaml when both args passed + config = None + if parsed_args.config_file: + with open(parsed_args.config_file) as f: + config_yaml = f.read() + try: + config = yaml.load(config_yaml, Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + if parsed_args.config: + config = parsed_args.config + if isinstance(config, str) or isinstance(config, unicode): + config_str = parsed_args.config.decode('unicode_escape') + try: + config = yaml.load(config_str, Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + if config: + body[self.resource]['attributes'] = {'config': config} + apmecV10.update_dict(parsed_args, body[self.resource], ['tenant_id']) + return body + + +class DeleteMEA(apmecV10.DeleteCommand): + """Delete given MEA(s).""" + + resource = _MEA + deleted_msg = {'mea': 'delete initiated'} + + +class ListMEAResources(apmecV10.ListCommand): + """List resources of a MEA like VDU, CP, etc.""" + + list_columns = ['name', 'id', 'type'] + allow_names = True + resource = _MEA + + def get_id(self): + if self.resource: + return self.resource.upper() + + def get_parser(self, prog_name): + parser = super(ListMEAResources, self).get_parser(prog_name) + if self.allow_names: + help_str = _('ID or name of %s to look up') + else: + help_str = _('ID of %s to look up') + parser.add_argument( + 'id', metavar=self.get_id(), + help=help_str % self.resource) + return parser + + def get_data(self, parsed_args): + self.log.debug('get_data(%s)', parsed_args) + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + if self.allow_names: + _id = apmecV10.find_resourceid_by_name_or_id(apmec_client, + self.resource, + parsed_args.id) + else: + _id = parsed_args.id + + data = self.retrieve_list_by_id(_id, parsed_args) + self.extend_list(data, parsed_args) + return self.setup_columns(data, parsed_args) + + def retrieve_list_by_id(self, id, parsed_args): + """Retrieve a list of sub resources from Apmec server""" + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + _extra_values = apmecV10.parse_args_to_dict(self.values_specs) + apmecV10._merge_args(self, parsed_args, _extra_values, + self.values_specs) + search_opts = self.args2search_opts(parsed_args) + search_opts.update(_extra_values) + if self.pagination_support: + page_size = parsed_args.page_size + if page_size: + search_opts.update({'limit': page_size}) + if self.sorting_support: + keys = parsed_args.sort_key + if keys: + search_opts.update({'sort_key': keys}) + dirs = parsed_args.sort_dir + len_diff = len(keys) - len(dirs) + if len_diff > 0: + dirs += ['asc'] * len_diff + elif len_diff < 0: + dirs = dirs[:len(keys)] + if dirs: + search_opts.update({'sort_dir': dirs}) + obj_lister = getattr(apmec_client, "list_mea_resources") + data = obj_lister(id, **search_opts) + return data.get('resources', []) + + +class ScaleMEA(apmecV10.ApmecCommand): + """Scale a MEA.""" + + api = 'mec-orchestration' + resource = None + log = None + + def get_parser(self, prog_name): + parser = super(ScaleMEA, self).get_parser(prog_name) + self.add_known_arguments(parser) + return parser + + def run(self, parsed_args): + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + body = self.args2body(parsed_args) + obj_creator = getattr(apmec_client, + "scale_mea") + obj_creator(body["scale"].pop('mea_id'), body) + + def add_known_arguments(self, parser): + mea_group = parser.add_mutually_exclusive_group(required=True) + mea_group.add_argument( + '--mea-id', + help=_('MEA ID')) + mea_group.add_argument( + '--mea-name', + help=_('MEA name')) + parser.add_argument( + '--scaling-policy-name', + help=_('MEA policy name used to scale')) + parser.add_argument( + '--scaling-type', + help=_('MEA scaling type, it could be either "out" or "in"')) + + def args2body(self, parsed_args): + args = {} + body = {"scale": args} + + if parsed_args.mea_name: + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + _id = apmecV10.find_resourceid_by_name_or_id(apmec_client, + 'mea', + parsed_args. + mea_name) + parsed_args.mea_id = _id + + args['mea_id'] = parsed_args.mea_id + args['type'] = parsed_args.scaling_type + args['policy'] = parsed_args.scaling_policy_name + + return body diff --git a/apmecclient/apmec/v1_0/mem/mead.py b/apmecclient/apmec/v1_0/mem/mead.py new file mode 100644 index 0000000..b4f68cc --- /dev/null +++ b/apmecclient/apmec/v1_0/mem/mead.py @@ -0,0 +1,115 @@ +# +# Copyright 2013 Intel Corporation +# 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. + +from __future__ import print_function + +from oslo_serialization import jsonutils +import yaml + +from apmecclient.common import exceptions +from apmecclient.i18n import _ +from apmecclient.apmec import v1_0 as apmecV10 + + +_MEAD = "mead" + + +class ListMEAD(apmecV10.ListCommand): + """List MEAD that belong to a given tenant.""" + + resource = _MEAD + list_columns = ['id', 'name', 'template_source', 'description'] + + def get_parser(self, prog_name): + parser = super(ListMEAD, self).get_parser(prog_name) + parser.add_argument( + '--template-source', + help=_("List MEAD with specified template source. Available \ + options are 'onboarded' (default), 'inline' or 'all'"), + action='store', + default='onboarded') + return parser + + def args2search_opts(self, parsed_args): + search_opts = super(ListMEAD, self).args2search_opts(parsed_args) + template_source = parsed_args.template_source + if parsed_args.template_source: + search_opts.update({'template_source': template_source}) + return search_opts + + +class ShowMEAD(apmecV10.ShowCommand): + """Show information of a given MEAD.""" + + resource = _MEAD + + +class CreateMEAD(apmecV10.CreateCommand): + """Create a MEAD.""" + + resource = _MEAD + remove_output_fields = ["attributes"] + + def add_known_arguments(self, parser): + parser.add_argument('--mead-file', help=_('Specify MEAD file')) + parser.add_argument( + 'name', metavar='NAME', + help=_('Set a name for the MEAD')) + parser.add_argument( + '--description', + help=_('Set a description for the MEAD')) + + def args2body(self, parsed_args): + body = {self.resource: {}} + mead = None + if not parsed_args.mead_file: + raise exceptions.InvalidInput("Invalid input for mead file") + with open(parsed_args.mead_file) as f: + mead = f.read() + try: + mead = yaml.load(mead, Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + if not mead: + raise exceptions.InvalidInput("mead file is empty") + body[self.resource]['attributes'] = {'mead': mead} + apmecV10.update_dict(parsed_args, body[self.resource], + ['tenant_id', 'name', 'description']) + return body + + +class DeleteMEAD(apmecV10.DeleteCommand): + """Delete given MEAD(s).""" + resource = _MEAD + + +class ShowTemplateMEAD(apmecV10.ShowCommand): + """Show template of a given MEAD.""" + + resource = _MEAD + + def run(self, parsed_args): + self.log.debug('run(%s)', parsed_args) + template = None + data = self.get_data(parsed_args) + try: + attributes_index = data[0].index('attributes') + attributes_json = data[1][attributes_index] + template = jsonutils.loads(attributes_json).get('mead', None) + except (IndexError, TypeError, ValueError) as e: + self.log.debug('Data handling error: %s', str(e)) + print(template or _('Unable to display MEAD template!')) diff --git a/apmecclient/apmec/v1_0/meo/__init__.py b/apmecclient/apmec/v1_0/meo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/apmec/v1_0/meo/mes.py b/apmecclient/apmec/v1_0/meo/mes.py new file mode 100644 index 0000000..af68bac --- /dev/null +++ b/apmecclient/apmec/v1_0/meo/mes.py @@ -0,0 +1,124 @@ +# 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/LICEMESE-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 CONDITIOMES OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import yaml + +from apmecclient.common import exceptions +from apmecclient.i18n import _ +from apmecclient.apmec import v1_0 as apmecV10 + + +_MES = 'mes' +_RESOURCE = 'resource' + + +class ListMES(apmecV10.ListCommand): + """List MES that belong to a given tenant.""" + + resource = _MES + list_columns = ['id', 'name', 'mesd_id', 'mgmt_urls', 'status'] + + +class ShowMES(apmecV10.ShowCommand): + """Show information of a given MES.""" + + resource = _MES + + +class CreateMES(apmecV10.CreateCommand): + """Create a MES.""" + + resource = _MES + remove_output_fields = ["attributes"] + + def add_known_arguments(self, parser): + parser.add_argument( + 'name', metavar='NAME', + help=_('Set a name for the MES')) + parser.add_argument( + '--description', + help=_('Set description for the MES')) + mesd_group = parser.add_mutually_exclusive_group(required=True) + mesd_group.add_argument( + '--mesd-id', + help=_('MESD ID to use as template to create MES')) + mesd_group.add_argument( + '--mesd-template', + help=_('MESD file to create MES')) + mesd_group.add_argument( + '--mesd-name', + help=_('MESD name to use as template to create MES')) + vim_group = parser.add_mutually_exclusive_group() + vim_group.add_argument( + '--vim-id', + help=_('VIM ID to use to create MES on the specified VIM')) + vim_group.add_argument( + '--vim-name', + help=_('VIM name to use to create MES on the specified VIM')) + parser.add_argument( + '--vim-region-name', + help=_('VIM Region to use to create MES on the specified VIM')) + parser.add_argument( + '--param-file', + help=_('Specify parameter yaml file')) + + def args2body(self, parsed_args): + args = {'attributes': {}} + body = {self.resource: args} + if parsed_args.vim_region_name: + args.setdefault('placement_attr', {})['region_name'] = \ + parsed_args.vim_region_name + + apmec_client = self.get_client() + apmec_client.format = parsed_args.request_format + if parsed_args.vim_name: + _id = apmecV10.find_resourceid_by_name_or_id(apmec_client, + 'vim', + parsed_args. + vim_name) + parsed_args.vim_id = _id + if parsed_args.mesd_name: + _id = apmecV10.find_resourceid_by_name_or_id(apmec_client, + 'mesd', + parsed_args. + mesd_name) + parsed_args.mesd_id = _id + elif parsed_args.mesd_template: + with open(parsed_args.mesd_template) as f: + template = f.read() + try: + args['mesd_template'] = yaml.load( + template, Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + if not args['mesd_template']: + raise exceptions.InvalidInput('The mesd file is empty') + + if parsed_args.param_file: + with open(parsed_args.param_file) as f: + param_yaml = f.read() + try: + args['attributes']['param_values'] = yaml.load( + param_yaml, Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + apmecV10.update_dict(parsed_args, body[self.resource], + ['tenant_id', 'name', 'description', + 'mesd_id', 'vim_id']) + return body + + +class DeleteMES(apmecV10.DeleteCommand): + """Delete given MES(s).""" + + resource = _MES + deleted_msg = {'mes': 'delete initiated'} diff --git a/apmecclient/apmec/v1_0/meo/mesd.py b/apmecclient/apmec/v1_0/meo/mesd.py new file mode 100644 index 0000000..b7bb447 --- /dev/null +++ b/apmecclient/apmec/v1_0/meo/mesd.py @@ -0,0 +1,102 @@ +# 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 __future__ import print_function + +import yaml + +from oslo_serialization import jsonutils + +from apmecclient.i18n import _ +from apmecclient.apmec import v1_0 as apmecV10 + +_MESD = "mesd" + + +class ListMESD(apmecV10.ListCommand): + """List MESDs that belong to a given tenant.""" + + resource = _MESD + list_columns = ['id', 'name', 'template_source', 'description'] + + def get_parser(self, prog_name): + parser = super(ListMESD, self).get_parser(prog_name) + parser.add_argument( + '--template-source', + help=_("List MESD with specified template source. Available \ + options are 'onboared' (default), 'inline' or 'all'"), + action='store', + default='onboarded') + return parser + + def args2search_opts(self, parsed_args): + search_opts = super(ListMESD, self).args2search_opts(parsed_args) + template_source = parsed_args.template_source + if parsed_args.template_source: + search_opts.update({'template_source': template_source}) + return search_opts + + +class ShowMESD(apmecV10.ShowCommand): + """Show information of a given MESD.""" + + resource = _MESD + + +class CreateMESD(apmecV10.CreateCommand): + """Create a MESD.""" + resource = _MESD + remove_output_fields = ["attributes"] + + def add_known_arguments(self, parser): + parser.add_argument('--mesd-file', help='Specify MESD file', + required=True) + parser.add_argument( + 'name', metavar='NAME', + help='Set a name for the MESD') + parser.add_argument( + '--description', + help='Set a description for the MESD') + + def args2body(self, parsed_args): + body = {self.resource: {}} + mesd = None + with open(parsed_args.mesd_file) as f: + mesd = yaml.safe_load(f.read()) + apmecV10.update_dict(parsed_args, body[self.resource], + ['tenant_id', 'name', 'description']) + if mesd: + body[self.resource]['attributes'] = {'mesd': mesd} + + return body + + +class DeleteMESD(apmecV10.DeleteCommand): + """Delete a given MESD.""" + resource = _MESD + + +class ShowTemplateMESD(apmecV10.ShowCommand): + """Show template of a given MESD.""" + resource = _MESD + + def run(self, parsed_args): + self.log.debug('run(%s)', parsed_args) + template = None + data = self.get_data(parsed_args) + try: + attributes_index = data[0].index('attributes') + attributes_json = data[1][attributes_index] + template = jsonutils.loads(attributes_json).get('mesd', None) + except (IndexError, TypeError, ValueError) as e: + self.log.debug('Data handling error: %s', str(e)) + print(template or _('Unable to display MESD template!')) diff --git a/apmecclient/apmec/v1_0/meo/vim.py b/apmecclient/apmec/v1_0/meo/vim.py new file mode 100644 index 0000000..238894f --- /dev/null +++ b/apmecclient/apmec/v1_0/meo/vim.py @@ -0,0 +1,135 @@ +# Copyright 2016 Brocade Communications Systems Inc +# 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 yaml + +from oslo_utils import strutils + +from apmecclient.common import exceptions +from apmecclient.i18n import _ +from apmecclient.apmec import v1_0 as apmecV10 +from apmecclient.apmec.v1_0.meo import vim_utils + +_VIM = "vim" + + +class ListVIM(apmecV10.ListCommand): + """List VIMs that belong to a given tenant.""" + + resource = _VIM + list_columns = ['id', 'tenant_id', 'name', 'type', 'is_default', + 'placement_attr', 'status'] + + +class ShowVIM(apmecV10.ShowCommand): + """Show information of a given VIM.""" + + resource = _VIM + + +class CreateVIM(apmecV10.CreateCommand): + """Create a VIM.""" + + resource = _VIM + + def add_known_arguments(self, parser): + parser.add_argument( + '--config-file', + required=True, + help=_('YAML file with VIM configuration parameters')) + parser.add_argument( + 'name', metavar='NAME', + help=_('Set a name for the VIM')) + parser.add_argument( + '--description', + help=_('Set a description for the VIM')) + parser.add_argument( + '--is-default', + action='store_true', + default=False, + help=_('Set as default VIM')) + + def args2body(self, parsed_args): + body = {self.resource: {}} + if parsed_args.config_file: + with open(parsed_args.config_file) as f: + vim_config = f.read() + try: + config_param = yaml.load(vim_config, + Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + vim_obj = body[self.resource] + try: + auth_url = config_param.pop('auth_url') + except KeyError: + raise exceptions.ApmecClientException(message='Auth URL must be ' + 'specified', + status_code=404) + vim_obj['auth_url'] = vim_utils.validate_auth_url(auth_url).geturl() + vim_obj['type'] = config_param.pop('type', 'openstack') + vim_utils.args2body_vim(config_param, vim_obj) + apmecV10.update_dict(parsed_args, body[self.resource], + ['tenant_id', 'name', 'description', + 'is_default']) + return body + + +class UpdateVIM(apmecV10.UpdateCommand): + """Update a given VIM.""" + + resource = _VIM + + def add_known_arguments(self, parser): + parser.add_argument( + '--config-file', + required=False, + help=_('YAML file with VIM configuration parameters')) + parser.add_argument( + '--name', + help=_('New name for the VIM')) + parser.add_argument( + '--description', + help=_('New description for the VIM')) + parser.add_argument( + '--is-default', + type=strutils.bool_from_string, + metavar='{True,False}', + help=_('Indicate whether the VIM is used as default')) + + def args2body(self, parsed_args): + body = {self.resource: {}} + config_param = None + # config arg passed as data overrides config yaml when both args passed + if parsed_args.config_file: + with open(parsed_args.config_file) as f: + config_yaml = f.read() + try: + config_param = yaml.load(config_yaml) + except yaml.YAMLError as e: + raise exceptions.InvalidInput(e) + vim_obj = body[self.resource] + if config_param is not None: + vim_utils.args2body_vim(config_param, vim_obj) + apmecV10.update_dict(parsed_args, body[self.resource], + ['tenant_id', 'name', 'description', + 'is_default']) + return body + + +class DeleteVIM(apmecV10.DeleteCommand): + """Delete given VIM(s).""" + resource = _VIM diff --git a/apmecclient/apmec/v1_0/meo/vim_utils.py b/apmecclient/apmec/v1_0/meo/vim_utils.py new file mode 100644 index 0000000..2e1a2ad --- /dev/null +++ b/apmecclient/apmec/v1_0/meo/vim_utils.py @@ -0,0 +1,44 @@ +# Copyright 2016 Brocade Communications Systems Inc +# 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 six.moves.urllib.parse as urlparse + +from apmecclient.common import exceptions + + +def args2body_vim(config_param, vim): + """Create additional args to vim body + + :param vim: vim request object + :return: vim body with args populated + """ + vim['vim_project'] = {'name': config_param.pop('project_name', ''), + 'project_domain_name': + config_param.pop('project_domain_name', '')} + if not vim['vim_project']['name']: + raise exceptions.ApmecClientException(message='Project name ' + 'must be specified', + status_code=404) + vim['auth_cred'] = {'username': config_param.pop('username', ''), + 'password': config_param.pop('password', ''), + 'user_domain_name': + config_param.pop('user_domain_name', '')} + + +def validate_auth_url(url): + url_parts = urlparse.urlparse(url) + if not url_parts.scheme or not url_parts.netloc: + raise exceptions.ApmecClientException(message='Invalid auth URL') + return url_parts diff --git a/apmecclient/client.py b/apmecclient/client.py new file mode 100644 index 0000000..84cdb90 --- /dev/null +++ b/apmecclient/client.py @@ -0,0 +1,390 @@ +# Copyright 2012 OpenStack Foundation. +# 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. +# + +try: + import json +except ImportError: + import simplejson as json +import logging +import os + +from keystoneclient import access +from keystoneclient import adapter +import requests + +from apmecclient.common import exceptions +from apmecclient.common import utils +from apmecclient.i18n import _ + +_logger = logging.getLogger(__name__) + +if os.environ.get('APMECCLIENT_DEBUG'): + ch = logging.StreamHandler() + _logger.setLevel(logging.DEBUG) + _logger.addHandler(ch) + _requests_log_level = logging.DEBUG +else: + _requests_log_level = logging.WARNING + +logging.getLogger("requests").setLevel(_requests_log_level) +MAX_URI_LEN = 8192 + + +class HTTPClient(object): + """Handles the REST calls and responses, include authn.""" + + USER_AGENT = 'python-apmecclient' + CONTENT_TYPE = 'application/json' + + def __init__(self, username=None, user_id=None, + tenant_name=None, tenant_id=None, + password=None, auth_url=None, + token=None, region_name=None, timeout=None, + endpoint_url=None, insecure=False, + endpoint_type='publicURL', + auth_strategy='keystone', ca_cert=None, log_credentials=False, + service_type='mec-orchestration', + **kwargs): + + self.username = username + self.user_id = user_id + self.tenant_name = tenant_name + self.tenant_id = tenant_id + self.password = password + self.auth_url = auth_url.rstrip('/') if auth_url else None + self.service_type = service_type + self.endpoint_type = endpoint_type + self.region_name = region_name + self.timeout = timeout + self.auth_token = token + self.auth_tenant_id = None + self.auth_user_id = None + self.endpoint_url = endpoint_url + self.auth_strategy = auth_strategy + self.log_credentials = log_credentials + if insecure: + self.verify_cert = False + else: + self.verify_cert = ca_cert if ca_cert else True + + def _cs_request(self, *args, **kwargs): + kargs = {} + kargs.setdefault('headers', kwargs.get('headers', {})) + kargs['headers']['User-Agent'] = self.USER_AGENT + + if 'body' in kwargs: + kargs['body'] = kwargs['body'] + + if self.log_credentials: + log_kargs = kargs + else: + log_kargs = self._strip_credentials(kargs) + + utils.http_log_req(_logger, args, log_kargs) + try: + resp, body = self.request(*args, **kargs) + except requests.exceptions.SSLError as e: + raise exceptions.SslCertificateValidationError(reason=e) + except Exception as e: + # Wrap the low-level connection error (socket timeout, redirect + # limit, decompression error, etc) into our custom high-level + # connection exception (it is excepted in the upper layers of code) + _logger.debug("throwing ConnectionFailed : %s", e) + raise exceptions.ConnectionFailed(reason=e) + utils.http_log_resp(_logger, resp, body) + if resp.status_code == 401: + raise exceptions.Unauthorized(message=body) + return resp, body + + def _strip_credentials(self, kwargs): + if kwargs.get('body') and self.password: + log_kwargs = kwargs.copy() + log_kwargs['body'] = kwargs['body'].replace(self.password, + 'REDACTED') + return log_kwargs + else: + return kwargs + + def authenticate_and_fetch_endpoint_url(self): + if not self.auth_token: + self.authenticate() + elif not self.endpoint_url: + self.endpoint_url = self._get_endpoint_url() + + def request(self, url, method, body=None, headers=None, **kwargs): + """Request without authentication.""" + + content_type = kwargs.pop('content_type', None) or 'application/json' + headers = headers or {} + headers.setdefault('Accept', content_type) + + if body: + headers.setdefault('Content-Type', content_type) + + headers['User-Agent'] = self.USER_AGENT + + resp = requests.request( + method, + url, + data=body, + headers=headers, + verify=self.verify_cert, + timeout=self.timeout, + **kwargs) + + return resp, resp.text + + def _check_uri_length(self, action): + uri_len = len(self.endpoint_url) + len(action) + if uri_len > MAX_URI_LEN: + raise exceptions.RequestURITooLong( + excess=uri_len - MAX_URI_LEN) + + def do_request(self, url, method, **kwargs): + # Ensure client always has correct uri - do not guesstimate anything + self.authenticate_and_fetch_endpoint_url() + self._check_uri_length(url) + + # Perform the request once. If we get a 401 back then it + # might be because the auth token expired, so try to + # re-authenticate and try again. If it still fails, bail. + try: + kwargs.setdefault('headers', {}) + if self.auth_token is None: + self.auth_token = "" + kwargs['headers']['X-Auth-Token'] = self.auth_token + resp, body = self._cs_request(self.endpoint_url + url, method, + **kwargs) + return resp, body + except exceptions.Unauthorized: + self.authenticate() + resp, body = self._cs_request( + self.endpoint_url + url, method, **kwargs) + return resp, body + + def _extract_service_catalog(self, body): + """Set the client's service catalog from the response data.""" + self.auth_ref = access.AccessInfo.factory(body=body) + self.service_catalog = self.auth_ref.service_catalog + self.auth_token = self.auth_ref.auth_token + self.auth_tenant_id = self.auth_ref.tenant_id + self.auth_user_id = self.auth_ref.user_id + + if not self.endpoint_url: + self.endpoint_url = self.service_catalog.url_for( + region_name=self.region_name, + service_type=self.service_type, + endpoint_type=self.endpoint_type) + + def _authenticate_keystone(self): + if self.user_id: + creds = {'userId': self.user_id, + 'password': self.password} + else: + creds = {'username': self.username, + 'password': self.password} + + if self.tenant_id: + body = {'auth': {'passwordCredentials': creds, + 'tenantId': self.tenant_id, }, } + else: + body = {'auth': {'passwordCredentials': creds, + 'tenantName': self.tenant_name, }, } + + if self.auth_url is None: + raise exceptions.NoAuthURLProvided() + + token_url = self.auth_url + "/tokens" + resp, resp_body = self._cs_request(token_url, "POST", + body=json.dumps(body), + content_type="application/json", + allow_redirects=True) + if resp.status_code != 200: + raise exceptions.Unauthorized(message=resp_body) + if resp_body: + try: + resp_body = json.loads(resp_body) + except ValueError: + pass + else: + resp_body = None + self._extract_service_catalog(resp_body) + + def _authenticate_noauth(self): + if not self.endpoint_url: + message = _('For "noauth" authentication strategy, the endpoint ' + 'must be specified either in the constructor or ' + 'using --os-url') + raise exceptions.Unauthorized(message=message) + + def authenticate(self): + if self.auth_strategy == 'keystone': + self._authenticate_keystone() + elif self.auth_strategy == 'noauth': + self._authenticate_noauth() + else: + err_msg = _('Unknown auth strategy: %s') % self.auth_strategy + raise exceptions.Unauthorized(message=err_msg) + + def _get_endpoint_url(self): + if self.auth_url is None: + raise exceptions.NoAuthURLProvided() + + url = self.auth_url + '/tokens/%s/endpoints' % self.auth_token + try: + resp, body = self._cs_request(url, "GET") + except exceptions.Unauthorized: + # rollback to authenticate() to handle case when apmec client + # is initialized just before the token is expired + self.authenticate() + return self.endpoint_url + + body = json.loads(body) + for endpoint in body.get('endpoints', []): + if (endpoint['type'] == 'mec-orchestration' and + endpoint.get('region') == self.region_name): + if self.endpoint_type not in endpoint: + raise exceptions.EndpointTypeNotFound( + type_=self.endpoint_type) + return endpoint[self.endpoint_type] + + raise exceptions.EndpointNotFound() + + def get_auth_info(self): + return {'auth_token': self.auth_token, + 'auth_tenant_id': self.auth_tenant_id, + 'auth_user_id': self.auth_user_id, + 'endpoint_url': self.endpoint_url} + + +class SessionClient(adapter.Adapter): + + def request(self, *args, **kwargs): + kwargs.setdefault('authenticated', False) + kwargs.setdefault('raise_exc', False) + + content_type = kwargs.pop('content_type', None) or 'application/json' + + headers = kwargs.setdefault('headers', {}) + headers.setdefault('Accept', content_type) + + try: + kwargs.setdefault('data', kwargs.pop('body')) + except KeyError: + pass + + if kwargs.get('data'): + headers.setdefault('Content-Type', content_type) + + resp = super(SessionClient, self).request(*args, **kwargs) + return resp, resp.text + + def _check_uri_length(self, url): + uri_len = len(self.endpoint_url) + len(url) + if uri_len > MAX_URI_LEN: + raise exceptions.RequestURITooLong( + excess=uri_len - MAX_URI_LEN) + + def do_request(self, url, method, **kwargs): + kwargs.setdefault('authenticated', True) + self._check_uri_length(url) + return self.request(url, method, **kwargs) + + @property + def endpoint_url(self): + # NOTE(jamielennox): This is used purely by the CLI and should be + # removed when the CLI gets smarter. + return self.get_endpoint() + + @property + def auth_token(self): + # NOTE(jamielennox): This is used purely by the CLI and should be + # removed when the CLI gets smarter. + return self.get_token() + + def authenticate(self): + # NOTE(jamielennox): This is used purely by the CLI and should be + # removed when the CLI gets smarter. + self.get_token() + + def get_auth_info(self): + auth_info = {'auth_token': self.auth_token, + 'endpoint_url': self.endpoint_url} + + # NOTE(jamielennox): This is the best we can do here. It will work + # with identity plugins which is the primary case but we should + # deprecate it's usage as much as possible. + try: + get_access = (self.auth or self.session.auth).get_access + except AttributeError: + pass + else: + auth_ref = get_access(self.session) + + auth_info['auth_tenant_id'] = auth_ref.project_id + auth_info['auth_user_id'] = auth_ref.user_id + + return auth_info + + +# FIXME(bklei): Should refactor this to use kwargs and only +# explicitly list arguments that are not None. +def construct_http_client(username=None, + user_id=None, + tenant_name=None, + tenant_id=None, + password=None, + auth_url=None, + token=None, + region_name=None, + timeout=None, + endpoint_url=None, + insecure=False, + endpoint_type='publicURL', + log_credentials=None, + auth_strategy='keystone', + ca_cert=None, + service_type='mec-orchestration', + session=None, + **kwargs): + + if session: + kwargs.setdefault('user_agent', 'python-apmecclient') + kwargs.setdefault('interface', endpoint_type) + return SessionClient(session=session, + service_type=service_type, + region_name=region_name, + **kwargs) + else: + # FIXME(bklei): username and password are now optional. Need + # to test that they were provided in this mode. Should also + # refactor to use kwargs. + return HTTPClient(username=username, + password=password, + tenant_id=tenant_id, + tenant_name=tenant_name, + user_id=user_id, + auth_url=auth_url, + token=token, + endpoint_url=endpoint_url, + insecure=insecure, + timeout=timeout, + region_name=region_name, + endpoint_type=endpoint_type, + service_type=service_type, + ca_cert=ca_cert, + log_credentials=log_credentials, + auth_strategy=auth_strategy) diff --git a/apmecclient/common/__init__.py b/apmecclient/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/common/_i18n.py b/apmecclient/common/_i18n.py new file mode 100644 index 0000000..fcfecce --- /dev/null +++ b/apmecclient/common/_i18n.py @@ -0,0 +1,40 @@ +# Copyright 2016 OpenStack Foundation +# +# 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. + +"""oslo.i18n integration module. + +See http://docs.openstack.org/developer/oslo.i18n/usage.html . + +""" + +import oslo_i18n + +DOMAIN = "apmecclient" + +_translators = oslo_i18n.TranslatorFactory(domain=DOMAIN) + +# The primary translation function using the well-known name "_" +_ = _translators.primary + +# The contextual translation function using the name "_C" +# requires oslo.i18n >=2.1.0 +_C = _translators.contextual_form + +# The plural translation function using the name "_P" +# requires oslo.i18n >=2.1.0 +_P = _translators.plural_form + + +def get_available_languages(): + return oslo_i18n.get_available_languages(DOMAIN) diff --git a/apmecclient/common/clientmanager.py b/apmecclient/common/clientmanager.py new file mode 100644 index 0000000..59dfc55 --- /dev/null +++ b/apmecclient/common/clientmanager.py @@ -0,0 +1,108 @@ +# Copyright 2012 OpenStack Foundation. +# 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. +# + +"""Manage access to the clients, including authenticating when needed. +""" + +from apmecclient import client +from apmecclient.apmec import client as apmec_client + + +class ClientCache(object): + """Descriptor class for caching created client handles.""" + + def __init__(self, factory): + self.factory = factory + self._handle = None + + def __get__(self, instance, owner): + # Tell the ClientManager to login to keystone + if self._handle is None: + self._handle = self.factory(instance) + return self._handle + + +class ClientManager(object): + """Manages access to API clients, including authentication.""" + apmec = ClientCache(apmec_client.make_client) + + def __init__(self, token=None, url=None, + auth_url=None, + endpoint_type=None, + tenant_name=None, + tenant_id=None, + username=None, + user_id=None, + password=None, + region_name=None, + api_version=None, + auth_strategy=None, + insecure=False, + ca_cert=None, + log_credentials=False, + service_type=None, + timeout=None, + retries=0, + raise_errors=True, + session=None, + auth=None, + ): + self._token = token + self._url = url + self._auth_url = auth_url + self._service_type = service_type + self._endpoint_type = endpoint_type + self._tenant_name = tenant_name + self._tenant_id = tenant_id + self._username = username + self._user_id = user_id + self._password = password + self._region_name = region_name + self._api_version = api_version + self._service_catalog = None + self._auth_strategy = auth_strategy + self._insecure = insecure + self._ca_cert = ca_cert + self._log_credentials = log_credentials + self._timeout = timeout + self._retries = retries + self._raise_errors = raise_errors + self._session = session + self._auth = auth + return + + def initialize(self): + if not self._url: + httpclient = client.construct_http_client( + username=self._username, + user_id=self._user_id, + tenant_name=self._tenant_name, + tenant_id=self._tenant_id, + password=self._password, + region_name=self._region_name, + auth_url=self._auth_url, + service_type=self._service_type, + endpoint_type=self._endpoint_type, + insecure=self._insecure, + ca_cert=self._ca_cert, + timeout=self._timeout, + session=self._session, + auth=self._auth, + log_credentials=self._log_credentials) + httpclient.authenticate() + # Populate other password flow attributes + self._token = httpclient.auth_token + self._url = httpclient.endpoint_url diff --git a/apmecclient/common/command.py b/apmecclient/common/command.py new file mode 100644 index 0000000..3d05407 --- /dev/null +++ b/apmecclient/common/command.py @@ -0,0 +1,35 @@ +# Copyright 2012 OpenStack Foundation. +# 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. +# + +from cliff import command + + +class OpenStackCommand(command.Command): + """Base class for OpenStack commands.""" + + api = None + + def run(self, parsed_args): + if not self.api: + return + else: + return super(OpenStackCommand, self).run(parsed_args) + + def get_data(self, parsed_args): + pass + + def take_action(self, parsed_args): + return self.get_data(parsed_args) diff --git a/apmecclient/common/constants.py b/apmecclient/common/constants.py new file mode 100644 index 0000000..e63822e --- /dev/null +++ b/apmecclient/common/constants.py @@ -0,0 +1,38 @@ +# Copyright (c) 2012 OpenStack Foundation. +# +# 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. + + +EXT_NS = '_extension_ns' +XML_NS_V10 = 'http://openstack.org/apmec/api/v1.0' +XSI_NAMESPACE = "http://www.w3.org/2001/XMLSchema-instance" +XSI_ATTR = "xsi:nil" +XSI_NIL_ATTR = "xmlns:xsi" +TYPE_XMLNS = "xmlns:apmec" +TYPE_ATTR = "apmec:type" +VIRTUAL_ROOT_KEY = "_v_root" +ATOM_NAMESPACE = "http://www.w3.org/2005/Atom" +ATOM_XMLNS = "xmlns:atom" +ATOM_LINK_NOTATION = "{%s}link" % ATOM_NAMESPACE + +TYPE_BOOL = "bool" +TYPE_INT = "int" +TYPE_LONG = "long" +TYPE_FLOAT = "float" +TYPE_LIST = "list" +TYPE_DICT = "dict" + + +PLURALS = {'templates': 'template', + 'devices': 'device'} diff --git a/apmecclient/common/exceptions.py b/apmecclient/common/exceptions.py new file mode 100644 index 0000000..a003814 --- /dev/null +++ b/apmecclient/common/exceptions.py @@ -0,0 +1,232 @@ +# Copyright 2011 VMware, Inc +# 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. + +from apmecclient.i18n import _ + +""" +Apmec base exception handling. + +Exceptions are classified into three categories: +* Exceptions corresponding to exceptions from apmec server: + This type of exceptions should inherit one of exceptions + in HTTP_EXCEPTION_MAP. +* Exceptions from client library: + This type of exceptions should inherit ApmecClientException. +* Exceptions from CLI code: + This type of exceptions should inherit ApmecCLIError. +""" + + +class ApmecException(Exception): + """Base Apmec Exception. + + To correctly use this class, inherit from it and define + a 'message' property. That message will get printf'd + with the keyword arguments provided to the constructor. + """ + message = _("An unknown exception occurred.") + + def __init__(self, message=None, **kwargs): + if message: + self.message = message + try: + self._error_string = self.message % kwargs + except Exception: + # at least get the core message out if something happened + self._error_string = self.message + + def __str__(self): + return self._error_string + + +class ApmecClientException(ApmecException): + """Base exception which exceptions from Apmec are mapped into. + + NOTE: on the client side, we use different exception types in order + to allow client library users to handle server exceptions in try...except + blocks. The actual error message is the one generated on the server side. + """ + + status_code = 0 + + def __init__(self, message=None, **kwargs): + if 'status_code' in kwargs: + self.status_code = kwargs['status_code'] + super(ApmecClientException, self).__init__(message, **kwargs) + + +# Base exceptions from Apmec + +class BadRequest(ApmecClientException): + status_code = 400 + + +class Unauthorized(ApmecClientException): + status_code = 401 + message = _("Unauthorized: bad credentials.") + + +class Forbidden(ApmecClientException): + status_code = 403 + message = _("Forbidden: your credentials don't give you access to this " + "resource.") + + +class NotFound(ApmecClientException): + status_code = 404 + + +class Conflict(ApmecClientException): + status_code = 409 + + +class InternalServerError(ApmecClientException): + status_code = 500 + + +class ServiceUnavailable(ApmecClientException): + status_code = 503 + + +HTTP_EXCEPTION_MAP = { + 400: BadRequest, + 401: Unauthorized, + 403: Forbidden, + 404: NotFound, + 409: Conflict, + 500: InternalServerError, + 503: ServiceUnavailable, +} + + +# Exceptions mapped to Apmec server exceptions +# These are defined if a user of client library needs specific exception. +# Exception name should be + 'Client' +# e.g., NetworkNotFound -> NetworkNotFoundClient + +class NetworkNotFoundClient(NotFound): + pass + + +class PortNotFoundClient(NotFound): + pass + + +class StateInvalidClient(BadRequest): + pass + + +class NetworkInUseClient(Conflict): + pass + + +class PortInUseClient(Conflict): + pass + + +class IpAddressInUseClient(Conflict): + pass + + +class InvalidIpForNetworkClient(BadRequest): + pass + + +class OverQuotaClient(Conflict): + pass + + +class IpAddressGenerationFailureClient(Conflict): + pass + + +class MacAddressInUseClient(Conflict): + pass + + +class ExternalIpAddressExhaustedClient(BadRequest): + pass + + +# Exceptions from client library + +class NoAuthURLProvided(Unauthorized): + message = _("auth_url was not provided to the Apmec client") + + +class EndpointNotFound(ApmecClientException): + message = _("Could not find Service or Region in Service Catalog.") + + +class EndpointTypeNotFound(ApmecClientException): + message = _("Could not find endpoint type %(type_)s in Service Catalog.") + + +class AmbiguousEndpoints(ApmecClientException): + message = _("Found more than one matching endpoint in Service Catalog: " + "%(matching_endpoints)") + + +class RequestURITooLong(ApmecClientException): + """Raised when a request fails with HTTP error 414.""" + + def __init__(self, **kwargs): + self.excess = kwargs.get('excess', 0) + super(RequestURITooLong, self).__init__(**kwargs) + + +class ConnectionFailed(ApmecClientException): + message = _("Connection to apmec failed: %(reason)s") + + +class SslCertificateValidationError(ApmecClientException): + message = _("SSL certificate validation has failed: %(reason)s") + + +class MalformedResponseBody(ApmecClientException): + message = _("Malformed response body: %(reason)s") + + +class InvalidContentType(ApmecClientException): + message = _("Invalid content type %(content_type)s.") + + +class InvalidInput(ApmecClientException): + message = _("Invalid input: %(reason)s") + + +# Command line exceptions + +class ApmecCLIError(ApmecException): + """Exception raised when command line parsing fails.""" + pass + + +class CommandError(ApmecCLIError): + pass + + +class UnsupportedVersion(ApmecCLIError): + """Unsupported Version. + + Indicates that the user is trying to use an unsupported version of + the API. + """ + pass + + +class ApmecClientNoUniqueMatch(ApmecCLIError): + message = _("Multiple %(resource)s matches found for name '%(name)s'," + " use an ID to be more specific.") diff --git a/apmecclient/common/extension.py b/apmecclient/common/extension.py new file mode 100644 index 0000000..d7e5916 --- /dev/null +++ b/apmecclient/common/extension.py @@ -0,0 +1,86 @@ +# Copyright 2015 Rackspace Hosting Inc. +# 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. +# +from stevedore import extension + +from apmecclient.apmec import v1_0 as apmecV10 + + +def _discover_via_entry_points(): + emgr = extension.ExtensionManager('apmecclient.extension', + invoke_on_load=False) + return ((ext.name, ext.plugin) for ext in emgr) + + +class ApmecClientExtension(apmecV10.ApmecCommand): + pagination_support = False + _formatters = {} + sorting_support = False + + +class ClientExtensionShow(ApmecClientExtension, apmecV10.ShowCommand): + def get_data(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionShow, self).get_data(parsed_args) + + +class ClientExtensionList(ApmecClientExtension, apmecV10.ListCommand): + + def get_data(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionList, self).get_data(parsed_args) + + +class ClientExtensionDelete(ApmecClientExtension, apmecV10.DeleteCommand): + def run(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionDelete, self).run(parsed_args) + + +class ClientExtensionCreate(ApmecClientExtension, apmecV10.CreateCommand): + def get_data(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionCreate, self).get_data(parsed_args) + + +class ClientExtensionUpdate(ApmecClientExtension, apmecV10.UpdateCommand): + def run(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionUpdate, self).run(parsed_args) diff --git a/apmecclient/common/serializer.py b/apmecclient/common/serializer.py new file mode 100644 index 0000000..3c512e8 --- /dev/null +++ b/apmecclient/common/serializer.py @@ -0,0 +1,405 @@ +# Copyright 2013 OpenStack Foundation. +# 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 logging +from xml.etree import ElementTree as etree +from xml.parsers import expat + +from oslo_serialization import jsonutils +import six + +from apmecclient.common import constants +from apmecclient.common import exceptions as exception +from apmecclient.i18n import _ + +LOG = logging.getLogger(__name__) + +if six.PY3: + long = int + + +class ActionDispatcher(object): + """Maps method name to local methods through action name.""" + + def dispatch(self, *args, **kwargs): + """Find and call local method.""" + action = kwargs.pop('action', 'default') + action_method = getattr(self, str(action), self.default) + return action_method(*args, **kwargs) + + def default(self, data): + raise NotImplementedError() + + +class DictSerializer(ActionDispatcher): + """Default request body serialization.""" + + def serialize(self, data, action='default'): + return self.dispatch(data, action=action) + + def default(self, data): + return "" + + +class JSONDictSerializer(DictSerializer): + """Default JSON request body serialization.""" + + def default(self, data): + def sanitizer(obj): + return six.text_type(obj) + return jsonutils.dumps(data, default=sanitizer) + + +class XMLDictSerializer(DictSerializer): + + def __init__(self, metadata=None, xmlns=None): + """XMLDictSerializer constructor. + + :param metadata: information needed to deserialize XML into + a dictionary. + :param xmlns: XML namespace to include with serialized XML + """ + super(XMLDictSerializer, self).__init__() + self.metadata = metadata or {} + if not xmlns: + xmlns = self.metadata.get('xmlns') + if not xmlns: + xmlns = constants.XML_NS_V10 + self.xmlns = xmlns + + def default(self, data): + """Default serializer of XMLDictSerializer. + + :param data: expect data to contain a single key as XML root, or + contain another '*_links' key as atom links. Other + case will use 'VIRTUAL_ROOT_KEY' as XML root. + """ + try: + links = None + has_atom = False + if data is None: + root_key = constants.VIRTUAL_ROOT_KEY + root_value = None + else: + link_keys = [k for k in six.iterkeys(data) or [] + if k.endswith('_links')] + if link_keys: + links = data.pop(link_keys[0], None) + has_atom = True + root_key = (len(data) == 1 and + list(data.keys())[0] or constants.VIRTUAL_ROOT_KEY) + root_value = data.get(root_key, data) + doc = etree.Element("_temp_root") + used_prefixes = [] + self._to_xml_node(doc, self.metadata, root_key, + root_value, used_prefixes) + if links: + self._create_link_nodes(list(doc)[0], links) + return self.to_xml_string(list(doc)[0], used_prefixes, has_atom) + except AttributeError as e: + LOG.exception(str(e)) + return '' + + def __call__(self, data): + # Provides a migration path to a cleaner WSGI layer, this + # "default" stuff and extreme extensibility isn't being used + # like originally intended + return self.default(data) + + def to_xml_string(self, node, used_prefixes, has_atom=False): + self._add_xmlns(node, used_prefixes, has_atom) + return etree.tostring(node, encoding='UTF-8') + + # NOTE(ameade): the has_atom should be removed after all of the + # XML serializers and view builders have been updated to the current + # spec that required all responses include the xmlns:atom, the has_atom + # flag is to prevent current tests from breaking + def _add_xmlns(self, node, used_prefixes, has_atom=False): + node.set('xmlns', self.xmlns) + node.set(constants.TYPE_XMLNS, self.xmlns) + if has_atom: + node.set(constants.ATOM_XMLNS, constants.ATOM_NAMESPACE) + node.set(constants.XSI_NIL_ATTR, constants.XSI_NAMESPACE) + ext_ns = self.metadata.get(constants.EXT_NS, {}) + for prefix in used_prefixes: + if prefix in ext_ns: + node.set('xmlns:' + prefix, ext_ns[prefix]) + + def _to_xml_node(self, parent, metadata, nodename, data, used_prefixes): + """Recursive method to convert data members to XML nodes.""" + result = etree.SubElement(parent, nodename) + if ":" in nodename: + used_prefixes.append(nodename.split(":", 1)[0]) + # TODO(bcwaldon): accomplish this without a type-check + if isinstance(data, list): + if not data: + result.set( + constants.TYPE_ATTR, + constants.TYPE_LIST) + return result + singular = metadata.get('plurals', {}).get(nodename, None) + if singular is None: + if nodename.endswith('s'): + singular = nodename[:-1] + else: + singular = 'item' + for item in data: + self._to_xml_node(result, metadata, singular, item, + used_prefixes) + # TODO(bcwaldon): accomplish this without a type-check + elif isinstance(data, dict): + if not data: + result.set( + constants.TYPE_ATTR, + constants.TYPE_DICT) + return result + attrs = metadata.get('attributes', {}).get(nodename, {}) + for k, v in sorted(data.items()): + if k in attrs: + result.set(k, str(v)) + else: + self._to_xml_node(result, metadata, k, v, + used_prefixes) + elif data is None: + result.set(constants.XSI_ATTR, 'true') + else: + if isinstance(data, bool): + result.set( + constants.TYPE_ATTR, + constants.TYPE_BOOL) + elif isinstance(data, int): + result.set( + constants.TYPE_ATTR, + constants.TYPE_INT) + elif isinstance(data, long): + result.set( + constants.TYPE_ATTR, + constants.TYPE_LONG) + elif isinstance(data, float): + result.set( + constants.TYPE_ATTR, + constants.TYPE_FLOAT) + LOG.debug("Data %(data)s type is %(type)s", + {'data': data, + 'type': type(data)}) + result.text = six.text_type(data) + return result + + def _create_link_nodes(self, xml_doc, links): + for link in links: + link_node = etree.SubElement(xml_doc, 'atom:link') + link_node.set('rel', link['rel']) + link_node.set('href', link['href']) + + +class TextDeserializer(ActionDispatcher): + """Default request body deserialization.""" + + def deserialize(self, datastring, action='default'): + return self.dispatch(datastring, action=action) + + def default(self, datastring): + return {} + + +class JSONDeserializer(TextDeserializer): + + def _from_json(self, datastring): + try: + return jsonutils.loads(datastring) + except ValueError: + msg = _("Cannot understand JSON") + raise exception.MalformedResponseBody(reason=msg) + + def default(self, datastring): + return {'body': self._from_json(datastring)} + + +class XMLDeserializer(TextDeserializer): + + def __init__(self, metadata=None): + """XMLDeserializer constructor. + + :param metadata: information needed to deserialize XML into + a dictionary. + """ + super(XMLDeserializer, self).__init__() + self.metadata = metadata or {} + xmlns = self.metadata.get('xmlns') + if not xmlns: + xmlns = constants.XML_NS_V10 + self.xmlns = xmlns + + def _get_key(self, tag): + tags = tag.split("}", 1) + if len(tags) == 2: + ns = tags[0][1:] + bare_tag = tags[1] + ext_ns = self.metadata.get(constants.EXT_NS, {}) + if ns == self.xmlns: + return bare_tag + for prefix, _ns in ext_ns.items(): + if ns == _ns: + return prefix + ":" + bare_tag + else: + return tag + + def _get_links(self, root_tag, node): + link_nodes = node.findall(constants.ATOM_LINK_NOTATION) + root_tag = self._get_key(node.tag) + link_key = "%s_links" % root_tag + link_list = [] + for link in link_nodes: + link_list.append({'rel': link.get('rel'), + 'href': link.get('href')}) + # Remove link node in order to avoid link node being + # processed as an item in _from_xml_node + node.remove(link) + return link_list and {link_key: link_list} or {} + + def _from_xml(self, datastring): + if datastring is None: + return None + plurals = set(self.metadata.get('plurals', {})) + try: + node = etree.fromstring(datastring) + root_tag = self._get_key(node.tag) + links = self._get_links(root_tag, node) + result = self._from_xml_node(node, plurals) + # There is no case where root_tag = constants.VIRTUAL_ROOT_KEY + # and links is not None because of the way data are serialized + if root_tag == constants.VIRTUAL_ROOT_KEY: + return result + return dict({root_tag: result}, **links) + except Exception as e: + parseError = False + # Python2.7 + if (hasattr(etree, 'ParseError') and + isinstance(e, getattr(etree, 'ParseError'))): + parseError = True + # Python2.6 + elif isinstance(e, expat.ExpatError): + parseError = True + if parseError: + msg = _("Cannot understand XML") + raise exception.MalformedResponseBody(reason=msg) + else: + raise + + def _from_xml_node(self, node, listnames): + """Convert a minidom node to a simple Python type. + + :param node: minidom node name + :param listnames: list of XML node names whose subnodes should + be considered list items. + + """ + attrNil = node.get(str(etree.QName(constants.XSI_NAMESPACE, "nil"))) + attrType = node.get(str(etree.QName( + self.metadata.get('xmlns'), "type"))) + if (attrNil and attrNil.lower() == 'true'): + return None + elif not len(node) and not node.text: + if (attrType and attrType == constants.TYPE_DICT): + return {} + elif (attrType and attrType == constants.TYPE_LIST): + return [] + else: + return '' + elif (len(node) == 0 and node.text): + converters = {constants.TYPE_BOOL: + lambda x: x.lower() == 'true', + constants.TYPE_INT: + lambda x: int(x), + constants.TYPE_LONG: + lambda x: long(x), + constants.TYPE_FLOAT: + lambda x: float(x)} + if attrType and attrType in converters: + return converters[attrType](node.text) + else: + return node.text + elif self._get_key(node.tag) in listnames: + return [self._from_xml_node(n, listnames) for n in node] + else: + result = dict() + for attr in node.keys(): + if (attr == 'xmlns' or + attr.startswith('xmlns:') or + attr == constants.XSI_ATTR or + attr == constants.TYPE_ATTR): + continue + result[self._get_key(attr)] = node.get(attr) + children = list(node) + for child in children: + result[self._get_key(child.tag)] = self._from_xml_node( + child, listnames) + return result + + def default(self, datastring): + return {'body': self._from_xml(datastring)} + + def __call__(self, datastring): + # Adding a migration path to allow us to remove unncessary classes + return self.default(datastring) + + +# NOTE(maru): this class is duplicated from apmec.wsgi +class Serializer(object): + """Serializes and deserializes dictionaries to certain MIME types.""" + + def __init__(self, metadata=None, default_xmlns=None): + """Create a serializer based on the given WSGI environment. + + 'metadata' is an optional dict mapping MIME types to information + needed to serialize a dictionary to that type. + + """ + self.metadata = metadata or {} + self.default_xmlns = default_xmlns + + def _get_serialize_handler(self, content_type): + handlers = { + 'application/json': JSONDictSerializer(), + 'application/xml': XMLDictSerializer(self.metadata), + } + + try: + return handlers[content_type] + except Exception: + raise exception.InvalidContentType(content_type=content_type) + + def serialize(self, data, content_type): + """Serialize a dictionary into the specified content type.""" + return self._get_serialize_handler(content_type).serialize(data) + + def deserialize(self, datastring, content_type): + """Deserialize a string to a dictionary. + + The string must be in the format of a supported MIME type. + """ + return self.get_deserialize_handler(content_type).deserialize( + datastring) + + def get_deserialize_handler(self, content_type): + handlers = { + 'application/json': JSONDeserializer(), + 'application/xml': XMLDeserializer(self.metadata), + } + + try: + return handlers[content_type] + except Exception: + raise exception.InvalidContentType(content_type=content_type) diff --git a/apmecclient/common/utils.py b/apmecclient/common/utils.py new file mode 100644 index 0000000..bedf6d5 --- /dev/null +++ b/apmecclient/common/utils.py @@ -0,0 +1,186 @@ +# Copyright 2011, VMware, Inc. +# +# 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. +# +# Borrowed from nova code base, more utilities will be added/borrowed as and +# when needed. + +"""Utilities and helper functions.""" + +import argparse +import logging +import os + +from oslo_log import versionutils +from oslo_utils import encodeutils +from oslo_utils import importutils +import six + +from apmecclient.common import exceptions +from apmecclient.i18n import _ + + +def env(*vars, **kwargs): + """Returns the first environment variable set. + + If none are non-empty, defaults to '' or keyword arg default. + """ + for v in vars: + value = os.environ.get(v) + if value: + return value + return kwargs.get('default', '') + + +def get_client_class(api_name, version, version_map): + """Returns the client class for the requested API version. + + :param api_name: the name of the API, e.g. 'compute', 'image', etc + :param version: the requested API version + :param version_map: a dict of client classes keyed by version + :rtype: a client class for the requested API version + """ + try: + client_path = version_map[str(version)] + except (KeyError, ValueError): + msg = _("Invalid %(api_name)s client version '%(version)s'. must be " + "one of: %(map_keys)s") + msg = msg % {'api_name': api_name, 'version': version, + 'map_keys': ', '.join(version_map.keys())} + raise exceptions.UnsupportedVersion(msg) + + return importutils.import_class(client_path) + + +def get_item_properties(item, fields, mixed_case_fields=(), formatters=None): + """Return a tuple containing the item properties. + + :param item: a single item resource (e.g. Server, Tenant, etc) + :param fields: tuple of strings with the desired field names + :param mixed_case_fields: tuple of field names to preserve case + :param formatters: dictionary mapping field names to callables + to format the values + """ + if formatters is None: + formatters = {} + + row = [] + + for field in fields: + if field in formatters: + row.append(formatters[field](item)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + if not hasattr(item, field_name) and isinstance(item, dict): + data = item[field_name] + else: + data = getattr(item, field_name, '') + if data is None: + data = '' + row.append(data) + return tuple(row) + + +def str2bool(strbool): + if strbool is None: + return None + return strbool.lower() == 'true' + + +def str2dict(strdict): + """Convert key1=value1,key2=value2,... string into dictionary. + + :param strdict: key1=value1,key2=value2 + """ + if not strdict: + return {} + return dict([kv.split('=', 1) for kv in strdict.split(',')]) + + +def http_log_req(_logger, args, kwargs): + if not _logger.isEnabledFor(logging.DEBUG): + return + + string_parts = ['curl -i'] + for element in args: + if element in ('GET', 'POST', 'DELETE', 'PUT'): + string_parts.append(' -X %s' % element) + else: + string_parts.append(' %s' % element) + + for element in kwargs['headers']: + header = ' -H "%s: %s"' % (element, kwargs['headers'][element]) + string_parts.append(header) + + if 'body' in kwargs and kwargs['body']: + string_parts.append(" -d '%s'" % (kwargs['body'])) + req = encodeutils.safe_encode("".join(string_parts)) + _logger.debug("\nREQ: %s\n", req) + + +def http_log_resp(_logger, resp, body): + if not _logger.isEnabledFor(logging.DEBUG): + return + _logger.debug("RESP:%(code)s %(headers)s %(body)s\n", + {'code': resp.status_code, + 'headers': resp.headers, + 'body': body}) + + +def _safe_encode_without_obj(data): + if isinstance(data, six.string_types): + return encodeutils.safe_encode(data) + return data + + +def safe_encode_list(data): + return list(map(_safe_encode_without_obj, data)) + + +def safe_encode_dict(data): + def _encode_item(item): + k, v = item + if isinstance(v, list): + return (k, safe_encode_list(v)) + elif isinstance(v, dict): + return (k, safe_encode_dict(v)) + return (k, _safe_encode_without_obj(v)) + + return dict(list(map(_encode_item, data.items()))) + + +def add_boolean_argument(parser, name, **kwargs): + for keyword in ('metavar', 'choices'): + kwargs.pop(keyword, None) + default = kwargs.pop('default', argparse.SUPPRESS) + parser.add_argument( + name, + metavar='{True,False}', + choices=['True', 'true', 'False', 'false'], + default=default, + **kwargs) + + +def get_file_path(filename): + file_path = os.path.abspath(os.path.join(os.path.dirname(__file__), + '../%s' % filename)) + return file_path + + +def deprecate_warning(what, as_of, in_favor_of=None, remove_in=1): + versionutils.deprecation_warning(as_of=as_of, what=what, + in_favor_of=in_favor_of, + remove_in=remove_in) diff --git a/apmecclient/common/validators.py b/apmecclient/common/validators.py new file mode 100644 index 0000000..6ae1b3b --- /dev/null +++ b/apmecclient/common/validators.py @@ -0,0 +1,69 @@ +# Copyright 2014 NEC Corporation +# 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 netaddr + +from apmecclient.common import exceptions +from apmecclient.i18n import _ + + +def validate_int_range(parsed_args, attr_name, min_value=None, max_value=None): + val = getattr(parsed_args, attr_name, None) + if val is None: + return + try: + if not isinstance(val, int): + int_val = int(val, 0) + else: + int_val = val + if ((min_value is None or min_value <= int_val) and + (max_value is None or int_val <= max_value)): + return + except (ValueError, TypeError): + pass + + if min_value is not None and max_value is not None: + msg = (_('%(attr_name)s "%(val)s" should be an integer ' + '[%(min)i:%(max)i].') % + {'attr_name': attr_name.replace('_', '-'), + 'val': val, 'min': min_value, 'max': max_value}) + elif min_value is not None: + msg = (_('%(attr_name)s "%(val)s" should be an integer ' + 'greater than or equal to %(min)i.') % + {'attr_name': attr_name.replace('_', '-'), + 'val': val, 'min': min_value}) + elif max_value is not None: + msg = (_('%(attr_name)s "%(val)s" should be an integer ' + 'smaller than or equal to %(max)i.') % + {'attr_name': attr_name.replace('_', '-'), + 'val': val, 'max': max_value}) + else: + msg = (_('%(attr_name)s "%(val)s" should be an integer.') % + {'attr_name': attr_name.replace('_', '-'), + 'val': val}) + + raise exceptions.CommandError(msg) + + +def validate_ip_subnet(parsed_args, attr_name): + val = getattr(parsed_args, attr_name) + if not val: + return + try: + netaddr.IPNetwork(val) + except (netaddr.AddrFormatError, ValueError): + raise exceptions.CommandError( + (_('%(attr_name)s "%(val)s" is not a valid CIDR.') % + {'attr_name': attr_name.replace('_', '-'), 'val': val})) diff --git a/apmecclient/i18n.py b/apmecclient/i18n.py new file mode 100644 index 0000000..36da983 --- /dev/null +++ b/apmecclient/i18n.py @@ -0,0 +1,18 @@ +# 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 oslo_i18n as i18n + +_translators = i18n.TranslatorFactory(domain='apmecclient') + +# The primary translation function using the well-known name "_" +_ = _translators.primary diff --git a/apmecclient/shell.py b/apmecclient/shell.py new file mode 100644 index 0000000..db78905 --- /dev/null +++ b/apmecclient/shell.py @@ -0,0 +1,837 @@ +# Copyright 2012 OpenStack Foundation. +# 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. +# + +""" +Command-line interface to the Apmec APIs +""" + +from __future__ import print_function + +import argparse +import getpass +import inspect +import itertools +import logging +import os +import sys + +from keystoneclient.auth.identity import v2 as v2_auth +from keystoneclient.auth.identity import v3 as v3_auth +from keystoneclient import discover +from keystoneclient import exceptions as ks_exc +from keystoneclient import session +from oslo_utils import encodeutils +import six.moves.urllib.parse as urlparse + +from cliff import app +from cliff import commandmanager + +from apmecclient.common import clientmanager +from apmecclient.common import command as openstack_command +from apmecclient.common import exceptions as exc +from apmecclient.common import extension as client_extension +from apmecclient.common import utils +from apmecclient.i18n import _ +from apmecclient.apmec.v1_0.events import events +from apmecclient.apmec.v1_0 import extension +from apmecclient.apmec.v1_0.meo import mes +from apmecclient.apmec.v1_0.meo import mesd +from apmecclient.apmec.v1_0.meo import vim +from apmecclient.apmec.v1_0.mem import mea +from apmecclient.apmec.v1_0.mem import mead +from apmecclient.version import __version__ + + +VERSION = '1.0' +APMEC_API_VERSION = '1.0' + + +def run_command(cmd, cmd_parser, sub_argv): + _argv = sub_argv + index = -1 + values_specs = [] + if '--' in sub_argv: + index = sub_argv.index('--') + _argv = sub_argv[:index] + values_specs = sub_argv[index:] + known_args, _values_specs = cmd_parser.parse_known_args(_argv) + cmd.values_specs = (index == -1 and _values_specs or values_specs) + return cmd.run(known_args) + + +def env(*_vars, **kwargs): + """Search for the first defined of possibly many env vars. + + Returns the first environment variable defined in vars, or + returns the default defined in kwargs. + + """ + for v in _vars: + value = os.environ.get(v, None) + if value: + return value + return kwargs.get('default', '') + + +def check_non_negative_int(value): + try: + value = int(value) + except ValueError: + raise argparse.ArgumentTypeError(_("invalid int value: %r") % value) + if value < 0: + raise argparse.ArgumentTypeError(_("input value %d is negative") % + value) + return value + + +class BashCompletionCommand(openstack_command.OpenStackCommand): + """Prints all of the commands and options for bash-completion.""" + resource = "bash_completion" + +COMMAND_V1 = { + 'bash-completion': BashCompletionCommand, + 'ext-list': extension.ListExt, + 'ext-show': extension.ShowExt, + + # MANO lingo + 'mead-create': mead.CreateMEAD, + 'mead-delete': mead.DeleteMEAD, + 'mead-list': mead.ListMEAD, + 'mead-show': mead.ShowMEAD, + 'mead-template-show': mead.ShowTemplateMEAD, + + 'mea-create': mea.CreateMEA, + 'mea-update': mea.UpdateMEA, + 'mea-delete': mea.DeleteMEA, + 'mea-list': mea.ListMEA, + 'mea-show': mea.ShowMEA, + 'mea-scale': mea.ScaleMEA, + 'mea-resource-list': mea.ListMEAResources, + # 'mea-config-create' + # 'mea-config-push' + + 'vim-register': vim.CreateVIM, + 'vim-update': vim.UpdateVIM, + 'vim-delete': vim.DeleteVIM, + 'vim-list': vim.ListVIM, + 'vim-show': vim.ShowVIM, + + 'events-list': events.ListResourceEvents, + 'event-show': events.ShowEvent, + 'mea-events-list': events.ListMEAEvents, + 'vim-events-list': events.ListVIMEvents, + 'mead-events-list': events.ListMEADEvents, + + 'mesd-create': mesd.CreateMESD, + 'mesd-list': mesd.ListMESD, + 'mesd-delete': mesd.DeleteMESD, + 'mesd-show': mesd.ShowMESD, + 'mesd-template-show': mesd.ShowTemplateMESD, + + 'mes-create': mes.CreateMES, + 'mes-list': mes.ListMES, + 'mes-delete': mes.DeleteMES, + 'mes-show': mes.ShowMES, + +} + +COMMANDS = {'1.0': COMMAND_V1} + + +class HelpAction(argparse.Action): + """Provides a custom action for the -h and --help options. + + The commands are determined by checking the CommandManager + instance, passed in as the "default" value for the action. + + :returns: a list of the commands + """ + def __call__(self, parser, namespace, values, option_string=None): + outputs = [] + max_len = 0 + app = self.default + parser.print_help(app.stdout) + app.stdout.write(_('\nCommands for API v%s:\n') % app.api_version) + command_manager = app.command_manager + for name, ep in sorted(command_manager): + factory = ep.load() + cmd = factory(self, None) + one_liner = cmd.get_description().split('\n')[0] + outputs.append((name, one_liner)) + max_len = max(len(name), max_len) + for (name, one_liner) in outputs: + app.stdout.write(' %s %s\n' % (name.ljust(max_len), one_liner)) + sys.exit(0) + + +class ApmecShell(app.App): + + # verbose logging levels + WARNING_LEVEL = 0 + INFO_LEVEL = 1 + DEBUG_LEVEL = 2 + CONSOLE_MESSAGE_FORMAT = '%(message)s' + DEBUG_MESSAGE_FORMAT = '%(levelname)s: %(name)s %(message)s' + log = logging.getLogger(__name__) + + def __init__(self, apiversion): + super(ApmecShell, self).__init__( + description=__doc__.strip(), + version=VERSION, + command_manager=commandmanager.CommandManager('apmec.cli'), ) + self.commands = COMMANDS + for k, v in self.commands[apiversion].items(): + self.command_manager.add_command(k, v) + + self._register_extensions(VERSION) + + # Pop the 'complete' to correct the outputs of 'apmec help'. + self.command_manager.commands.pop('complete') + + # This is instantiated in initialize_app() only when using + # password flow auth + self.auth_client = None + self.api_version = apiversion + + def build_option_parser(self, description, version): + """Return an argparse option parser for this application. + + Subclasses may override this method to extend + the parser with more global options. + + :param description: full description of the application + :paramtype description: str + :param version: version number for the application + :paramtype version: str + """ + parser = argparse.ArgumentParser( + description=description, + add_help=False, ) + parser.add_argument( + '--version', + action='version', + version=__version__, ) + parser.add_argument( + '-v', '--verbose', '--debug', + action='count', + dest='verbose_level', + default=self.DEFAULT_VERBOSE_LEVEL, + help=_('Increase verbosity of output and show tracebacks on' + ' errors. You can repeat this option.')) + parser.add_argument( + '-q', '--quiet', + action='store_const', + dest='verbose_level', + const=0, + help=_('Suppress output except warnings and errors.')) + parser.add_argument( + '-h', '--help', + action=HelpAction, + nargs=0, + default=self, # tricky + help=_("Show this help message and exit.")) + parser.add_argument( + '-r', '--retries', + metavar="NUM", + type=check_non_negative_int, + default=0, + help=_("How many times the request to the Apmec server should " + "be retried if it fails.")) + # FIXME(bklei): this method should come from python-keystoneclient + self._append_global_identity_args(parser) + + return parser + + def _append_global_identity_args(self, parser): + # FIXME(bklei): these are global identity (Keystone) arguments which + # should be consistent and shared by all service clients. Therefore, + # they should be provided by python-keystoneclient. We will need to + # refactor this code once this functionality is available in + # python-keystoneclient. + # + # Note: At that time we'll need to decide if we can just abandon + # the deprecated args (--service-type and --endpoint-type). + + parser.add_argument( + '--os-service-type', metavar='', + default=env('OS_APMEC_SERVICE_TYPE', + default='mec-orchestration'), + help=_('Defaults to env[OS_APMEC_SERVICE_TYPE] or \ + mec-orchestration.')) + + parser.add_argument( + '--os-endpoint-type', metavar='', + default=env('OS_ENDPOINT_TYPE', default='publicURL'), + help=_('Defaults to env[OS_ENDPOINT_TYPE] or publicURL.')) + + # FIXME(bklei): --service-type is deprecated but kept in for + # backward compatibility. + parser.add_argument( + '--service-type', metavar='', + default=env('OS_APMEC_SERVICE_TYPE', + default='mec-orchestration'), + help=_('DEPRECATED! Use --os-service-type.')) + + # FIXME(bklei): --endpoint-type is deprecated but kept in for + # backward compatibility. + parser.add_argument( + '--endpoint-type', metavar='', + default=env('OS_ENDPOINT_TYPE', default='publicURL'), + help=_('DEPRECATED! Use --os-endpoint-type.')) + + parser.add_argument( + '--os-auth-strategy', metavar='', + default=env('OS_AUTH_STRATEGY', default='keystone'), + help=_('DEPRECATED! Only keystone is supported.')) + + parser.add_argument( + '--os_auth_strategy', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-auth-url', metavar='', + default=env('OS_AUTH_URL'), + help=_('Authentication URL, defaults to env[OS_AUTH_URL].')) + parser.add_argument( + '--os_auth_url', + help=argparse.SUPPRESS) + + project_name_group = parser.add_mutually_exclusive_group() + project_name_group.add_argument( + '--os-tenant-name', metavar='', + default=env('OS_TENANT_NAME'), + help=_('Authentication tenant name, defaults to ' + 'env[OS_TENANT_NAME].')) + project_name_group.add_argument( + '--os-project-name', + metavar='', + default=utils.env('OS_PROJECT_NAME'), + help=_('Another way to specify tenant name. ' + 'This option is mutually exclusive with ' + ' --os-tenant-name. ' + 'Defaults to env[OS_PROJECT_NAME].')) + + parser.add_argument( + '--os_tenant_name', + help=argparse.SUPPRESS) + + project_id_group = parser.add_mutually_exclusive_group() + project_id_group.add_argument( + '--os-tenant-id', metavar='', + default=env('OS_TENANT_ID'), + help=_('Authentication tenant ID, defaults to ' + 'env[OS_TENANT_ID].')) + project_id_group.add_argument( + '--os-project-id', + metavar='', + default=utils.env('OS_PROJECT_ID'), + help=_('Another way to specify tenant ID. ' + 'This option is mutually exclusive with ' + ' --os-tenant-id. ' + 'Defaults to env[OS_PROJECT_ID].')) + + parser.add_argument( + '--os-username', metavar='', + default=utils.env('OS_USERNAME'), + help=_('Authentication username, defaults to env[OS_USERNAME].')) + parser.add_argument( + '--os_username', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-user-id', metavar='', + default=env('OS_USER_ID'), + help=_('Authentication user ID (Env: OS_USER_ID)')) + + parser.add_argument( + '--os_user_id', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-user-domain-id', + metavar='', + default=utils.env('OS_USER_DOMAIN_ID'), + help=_('OpenStack user domain ID. ' + 'Defaults to env[OS_USER_DOMAIN_ID].')) + + parser.add_argument( + '--os_user_domain_id', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-user-domain-name', + metavar='', + default=utils.env('OS_USER_DOMAIN_NAME'), + help=_('OpenStack user domain name. ' + 'Defaults to env[OS_USER_DOMAIN_NAME].')) + + parser.add_argument( + '--os_user_domain_name', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os_project_id', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os_project_name', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-project-domain-id', + metavar='', + default=utils.env('OS_PROJECT_DOMAIN_ID'), + help=_('Defaults to env[OS_PROJECT_DOMAIN_ID].')) + + parser.add_argument( + '--os-project-domain-name', + metavar='', + default=utils.env('OS_PROJECT_DOMAIN_NAME'), + help=_('Defaults to env[OS_PROJECT_DOMAIN_NAME].')) + + parser.add_argument( + '--os-cert', + metavar='', + default=utils.env('OS_CERT'), + help=_("Path of certificate file to use in SSL " + "connection. This file can optionally be " + "prepended with the private key. Defaults " + "to env[OS_CERT].")) + + parser.add_argument( + '--os-cacert', + metavar='', + default=env('OS_CACERT', default=None), + help=_("Specify a CA bundle file to use in " + "verifying a TLS (https) server certificate. " + "Defaults to env[OS_CACERT].")) + + parser.add_argument( + '--os-key', + metavar='', + default=utils.env('OS_KEY'), + help=_("Path of client key to use in SSL " + "connection. This option is not necessary " + "if your key is prepended to your certificate " + "file. Defaults to env[OS_KEY].")) + + parser.add_argument( + '--os-password', metavar='', + default=utils.env('OS_PASSWORD'), + help=_('Authentication password, defaults to env[OS_PASSWORD].')) + parser.add_argument( + '--os_password', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-region-name', metavar='', + default=env('OS_REGION_NAME'), + help=_('Authentication region name, defaults to ' + 'env[OS_REGION_NAME].')) + parser.add_argument( + '--os_region_name', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-token', metavar='', + default=env('OS_TOKEN'), + help=_('Authentication token, defaults to env[OS_TOKEN].')) + parser.add_argument( + '--os_token', + help=argparse.SUPPRESS) + + parser.add_argument( + '--http-timeout', metavar='', + default=env('OS_NETWORK_TIMEOUT', default=None), type=float, + help=_('Timeout in seconds to wait for an HTTP response. Defaults ' + 'to env[OS_NETWORK_TIMEOUT] or None if not specified.')) + + parser.add_argument( + '--os-url', metavar='', + default=env('OS_URL'), + help=_('Defaults to env[OS_URL].')) + parser.add_argument( + '--os_url', + help=argparse.SUPPRESS) + + parser.add_argument( + '--insecure', + action='store_true', + default=env('APMECCLIENT_INSECURE', default=False), + help=_("Explicitly allow apmecclient to perform \"insecure\" " + "SSL (https) requests. The server's certificate will " + "not be verified against any certificate authorities. " + "This option should be used with caution.")) + + def _bash_completion(self): + """Prints all of the commands and options for bash-completion.""" + commands = set() + options = set() + for option, _action in self.parser._option_string_actions.items(): + options.add(option) + for command_name, command in self.command_manager: + commands.add(command_name) + cmd_factory = command.load() + cmd = cmd_factory(self, None) + cmd_parser = cmd.get_parser('') + for option, _action in cmd_parser._option_string_actions.items(): + options.add(option) + print(' '.join(commands | options)) + + def _register_extensions(self, version): + for name, module in itertools.chain( + client_extension._discover_via_entry_points()): + self._extend_shell_commands(module, version) + + def _extend_shell_commands(self, module, version): + classes = inspect.getmembers(module, inspect.isclass) + for cls_name, cls in classes: + if (issubclass(cls, client_extension.ApmecClientExtension) and + hasattr(cls, 'shell_command')): + cmd = cls.shell_command + if hasattr(cls, 'versions'): + if version not in cls.versions: + continue + try: + self.command_manager.add_command(cmd, cls) + self.commands[version][cmd] = cls + except TypeError: + pass + + def run(self, argv): + """Equivalent to the main program for the application. + + :param argv: input arguments and options + :paramtype argv: list of str + """ + try: + index = 0 + command_pos = -1 + help_pos = -1 + help_command_pos = -1 + for arg in argv: + if arg == 'bash-completion' and help_command_pos == -1: + self._bash_completion() + return 0 + if arg in self.commands[self.api_version]: + if command_pos == -1: + command_pos = index + elif arg in ('-h', '--help'): + if help_pos == -1: + help_pos = index + elif arg == 'help': + if help_command_pos == -1: + help_command_pos = index + index = index + 1 + if command_pos > -1 and help_pos > command_pos: + argv = ['help', argv[command_pos]] + if help_command_pos > -1 and command_pos == -1: + argv[help_command_pos] = '--help' + self.options, remainder = self.parser.parse_known_args(argv) + self.configure_logging() + self.interactive_mode = not remainder + self.initialize_app(remainder) + except Exception as err: + if self.options.verbose_level >= self.DEBUG_LEVEL: + self.log.exception(err) + raise + else: + self.log.error(err) + return 1 + if self.interactive_mode: + _argv = [sys.argv[0]] + sys.argv = _argv + return self.interact() + return self.run_subcommand(remainder) + + def run_subcommand(self, argv): + subcommand = self.command_manager.find_command(argv) + cmd_factory, cmd_name, sub_argv = subcommand + cmd = cmd_factory(self, self.options) + try: + self.prepare_to_run_command(cmd) + full_name = (cmd_name + if self.interactive_mode + else ' '.join([self.NAME, cmd_name]) + ) + cmd_parser = cmd.get_parser(full_name) + return run_command(cmd, cmd_parser, sub_argv) + except Exception as e: + if self.options.verbose_level >= self.DEBUG_LEVEL: + self.log.exception("%s", e) + raise + self.log.error("%s", e) + return 1 + + def authenticate_user(self): + """Authentication validation. + + Make sure the user has provided all of the authentication + info we need. + """ + if self.options.os_auth_strategy == 'keystone': + if self.options.os_token or self.options.os_url: + # Token flow auth takes priority + if not self.options.os_token: + raise exc.CommandError( + _("You must provide a token via" + " either --os-token or env[OS_TOKEN]" + " when providing a service URL")) + + if not self.options.os_url: + raise exc.CommandError( + _("You must provide a service URL via" + " either --os-url or env[OS_URL]" + " when providing a token")) + + else: + # Validate password flow auth + project_info = (self.options.os_tenant_name or + self.options.os_tenant_id or + (self.options.os_project_name and + (self.options.os_project_domain_name or + self.options.os_project_domain_id)) or + self.options.os_project_id) + + if (not self.options.os_username + and not self.options.os_user_id): + raise exc.CommandError( + _("You must provide a username or user ID via" + " --os-username, env[OS_USERNAME] or" + " --os-user-id, env[OS_USER_ID]")) + + if not self.options.os_password: + # No password, If we've got a tty, try prompting for it + if hasattr(sys.stdin, 'isatty') and sys.stdin.isatty(): + # Check for Ctl-D + try: + self.options.os_password = getpass.getpass( + 'OS Password: ') + except EOFError: + pass + # No password because we didn't have a tty or the + # user Ctl-D when prompted. + if not self.options.os_password: + raise exc.CommandError( + _("You must provide a password via" + " either --os-password or env[OS_PASSWORD]")) + + if (not project_info): + # tenent is deprecated in Keystone v3. Use the latest + # terminology instead. + raise exc.CommandError( + _("You must provide a project_id or project_name (" + "with project_domain_name or project_domain_id) " + "via " + " --os-project-id (env[OS_PROJECT_ID])" + " --os-project-name (env[OS_PROJECT_NAME])," + " --os-project-domain-id " + "(env[OS_PROJECT_DOMAIN_ID])" + " --os-project-domain-name " + "(env[OS_PROJECT_DOMAIN_NAME])")) + + if not self.options.os_auth_url: + raise exc.CommandError( + _("You must provide an auth url via" + " either --os-auth-url or via env[OS_AUTH_URL]")) + auth_session = self._get_keystone_session() + auth = auth_session.auth + else: # not keystone + if not self.options.os_url: + raise exc.CommandError( + _("You must provide a service URL via" + " either --os-url or env[OS_URL]")) + auth_session = None + auth = None + + self.client_manager = clientmanager.ClientManager( + token=self.options.os_token, + url=self.options.os_url, + auth_url=self.options.os_auth_url, + tenant_name=self.options.os_tenant_name, + tenant_id=self.options.os_tenant_id, + username=self.options.os_username, + user_id=self.options.os_user_id, + password=self.options.os_password, + region_name=self.options.os_region_name, + api_version=self.api_version, + auth_strategy=self.options.os_auth_strategy, + # FIXME (bklei) honor deprecated service_type and + # endpoint type until they are removed + service_type=self.options.os_service_type or + self.options.service_type, + endpoint_type=self.options.os_endpoint_type or self.endpoint_type, + insecure=self.options.insecure, + ca_cert=self.options.os_cacert, + timeout=self.options.http_timeout, + retries=self.options.retries, + raise_errors=False, + session=auth_session, + auth=auth, + log_credentials=True) + return + + def initialize_app(self, argv): + """Global app init bits: + + * set up API versions + * validate authentication info + """ + + super(ApmecShell, self).initialize_app(argv) + + self.api_version = {'mec-orchestration': + self.api_version} + + # If the user is not asking for help, make sure they + # have given us auth. + cmd_name = None + if argv: + cmd_info = self.command_manager.find_command(argv) + cmd_factory, cmd_name, sub_argv = cmd_info + if self.interactive_mode or cmd_name != 'help': + self.authenticate_user() + + def configure_logging(self): + """Create logging handlers for any log output.""" + root_logger = logging.getLogger('') + + # Set up logging to a file + root_logger.setLevel(logging.DEBUG) + + # Send higher-level messages to the console via stderr + console = logging.StreamHandler(self.stderr) + console_level = {self.WARNING_LEVEL: logging.WARNING, + self.INFO_LEVEL: logging.INFO, + self.DEBUG_LEVEL: logging.DEBUG, + }.get(self.options.verbose_level, logging.DEBUG) + # The default log level is INFO, in this situation, set the + # log level of the console to WARNING, to avoid displaying + # useless messages. This equals using "--quiet" + if console_level == logging.INFO: + console.setLevel(logging.WARNING) + else: + console.setLevel(console_level) + if logging.DEBUG == console_level: + formatter = logging.Formatter(self.DEBUG_MESSAGE_FORMAT) + else: + formatter = logging.Formatter(self.CONSOLE_MESSAGE_FORMAT) + logging.getLogger('iso8601.iso8601').setLevel(logging.WARNING) + logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING) + console.setFormatter(formatter) + root_logger.addHandler(console) + return + + def get_v2_auth(self, v2_auth_url): + return v2_auth.Password( + v2_auth_url, + username=self.options.os_username, + password=self.options.os_password, + tenant_id=self.options.os_tenant_id, + tenant_name=self.options.os_tenant_name) + + def get_v3_auth(self, v3_auth_url): + project_id = self.options.os_project_id or self.options.os_tenant_id + project_name = (self.options.os_project_name or + self.options.os_tenant_name) + + return v3_auth.Password( + v3_auth_url, + username=self.options.os_username, + password=self.options.os_password, + user_id=self.options.os_user_id, + user_domain_name=self.options.os_user_domain_name, + user_domain_id=self.options.os_user_domain_id, + project_id=project_id, + project_name=project_name, + project_domain_name=self.options.os_project_domain_name, + project_domain_id=self.options.os_project_domain_id + ) + + def _discover_auth_versions(self, session, auth_url): + # discover the API versions the server is supporting base on the + # given URL + try: + ks_discover = discover.Discover(session=session, auth_url=auth_url) + return (ks_discover.url_for('2.0'), ks_discover.url_for('3.0')) + except ks_exc.ClientException: + # Identity service may not support discover API version. + # Lets try to figure out the API version from the original URL. + url_parts = urlparse.urlparse(auth_url) + (scheme, netloc, path, params, query, fragment) = url_parts + path = path.lower() + if path.startswith('/v3'): + return (None, auth_url) + elif path.startswith('/v2'): + return (auth_url, None) + else: + # not enough information to determine the auth version + msg = _('Unable to determine the Keystone version ' + 'to authenticate with using the given ' + 'auth_url. Identity service may not support API ' + 'version discovery. Please provide a versioned ' + 'auth_url instead.') + raise exc.CommandError(msg) + + def _get_keystone_session(self): + # first create a Keystone session + cacert = self.options.os_cacert or None + cert = self.options.os_cert or None + key = self.options.os_key or None + insecure = self.options.insecure or False + ks_session = session.Session.construct(dict(cacert=cacert, + cert=cert, + key=key, + insecure=insecure)) + # discover the supported keystone versions using the given url + (v2_auth_url, v3_auth_url) = self._discover_auth_versions( + session=ks_session, + auth_url=self.options.os_auth_url) + + # Determine which authentication plugin to use. First inspect the + # auth_url to see the supported version. If both v3 and v2 are + # supported, then use the highest version if possible. + user_domain_name = self.options.os_user_domain_name or None + user_domain_id = self.options.os_user_domain_id or None + project_domain_name = self.options.os_project_domain_name or None + project_domain_id = self.options.os_project_domain_id or None + domain_info = (user_domain_name or user_domain_id or + project_domain_name or project_domain_id) + + if (v2_auth_url and not domain_info) or not v3_auth_url: + ks_session.auth = self.get_v2_auth(v2_auth_url) + else: + ks_session.auth = self.get_v3_auth(v3_auth_url) + + return ks_session + + +def main(argv=sys.argv[1:]): + try: + return ApmecShell(APMEC_API_VERSION).run( + list(map(encodeutils.safe_decode, argv))) + except KeyboardInterrupt: + print("... terminating apmec client", file=sys.stderr) + return 130 + except exc.ApmecClientException: + return 1 + except Exception as e: + print(e) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/apmecclient/tests/__init__.py b/apmecclient/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/tests/unit/__init__.py b/apmecclient/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/tests/unit/test_auth.py b/apmecclient/tests/unit/test_auth.py new file mode 100644 index 0000000..005f3bf --- /dev/null +++ b/apmecclient/tests/unit/test_auth.py @@ -0,0 +1,370 @@ +# Copyright 2012 NEC Corporation +# 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 copy +import json +import uuid + +from keystoneclient import exceptions as k_exceptions +import mock +import requests +import testtools + +from apmecclient import client +from apmecclient.common import exceptions + + +USERNAME = 'testuser' +USER_ID = 'testuser_id' +TENANT_NAME = 'testtenant' +TENANT_ID = 'testtenant_id' +PASSWORD = 'password' +AUTH_URL = 'authurl' +ENDPOINT_URL = 'localurl' +ENDPOINT_OVERRIDE = 'otherurl' +TOKEN = 'tokentoken' +REGION = 'RegionTest' +NOAUTH = 'noauth' + +KS_TOKEN_RESULT = { + 'access': { + 'token': {'id': TOKEN, + 'expires': '2012-08-11T07:49:01Z', + 'tenant': {'id': str(uuid.uuid1())}}, + 'user': {'id': str(uuid.uuid1())}, + 'serviceCatalog': [ + {'endpoints_links': [], + 'endpoints': [{'adminURL': ENDPOINT_URL, + 'internalURL': ENDPOINT_URL, + 'publicURL': ENDPOINT_URL, + 'region': REGION}], + 'type': 'mec-orchestration', + 'name': 'Apmec Service'} + ] + } +} + +ENDPOINTS_RESULT = { + 'endpoints': [{ + 'type': 'mec-orchestration', + 'name': 'Apmec Service', + 'region': REGION, + 'adminURL': ENDPOINT_URL, + 'internalURL': ENDPOINT_URL, + 'publicURL': ENDPOINT_URL + }] +} + + +def get_response(status_code, headers=None): + response = mock.Mock().CreateMock(requests.Response) + response.headers = headers or {} + response.status_code = status_code + return response + + +resp_200 = get_response(200) +resp_401 = get_response(401) +headers = {'X-Auth-Token': '', + 'User-Agent': 'python-apmecclient'} +expected_headers = {'X-Auth-Token': TOKEN, + 'User-Agent': 'python-apmecclient'} +agent_header = {'User-Agent': 'python-apmecclient'} + + +class CLITestAuthNoAuth(testtools.TestCase): + + def setUp(self): + """Prepare the test environment.""" + super(CLITestAuthNoAuth, self).setUp() + self.client = client.HTTPClient(username=USERNAME, + tenant_name=TENANT_NAME, + password=PASSWORD, + endpoint_url=ENDPOINT_URL, + auth_strategy=NOAUTH, + region_name=REGION) + self.addCleanup(mock.patch.stopall) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_get_noauth(self, mock_request): + + mock_request.return_value = (resp_200, '') + self.client.do_request('/resource', 'GET', + headers=headers) + mock_request.assert_called_once_with( + ENDPOINT_URL + '/resource', + 'GET', + headers=headers) + self.assertEqual(self.client.endpoint_url, ENDPOINT_URL) + + +class CLITestAuthKeystone(testtools.TestCase): + + # Auth Body expected + auth_body = ('{"auth": {"tenantName": "testtenant", ' + '"passwordCredentials": ' + '{"username": "testuser", "password": "password"}}}') + + def setUp(self): + """Prepare the test environment.""" + super(CLITestAuthKeystone, self).setUp() + self.client = client.HTTPClient(username=USERNAME, + tenant_name=TENANT_NAME, + password=PASSWORD, + auth_url=AUTH_URL, + region_name=REGION) + self.addCleanup(mock.patch.stopall) + + def test_reused_token_get_auth_info(self): + """Test Client.get_auth_info(). + + Test that Client.get_auth_info() works even if client was + instantiated with predefined token. + """ + client_ = client.HTTPClient(username=USERNAME, + tenant_name=TENANT_NAME, + token=TOKEN, + password=PASSWORD, + auth_url=AUTH_URL, + region_name=REGION) + expected = {'auth_token': TOKEN, + 'auth_tenant_id': None, + 'auth_user_id': None, + 'endpoint_url': self.client.endpoint_url} + self.assertEqual(client_.get_auth_info(), expected) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_get_token(self, mock_request): + + mock_request.return_value = (resp_200, json.dumps(KS_TOKEN_RESULT)) + self.client.do_request('/resource', 'GET') + mock_request.assert_called_with( + ENDPOINT_URL + '/resource', 'GET', + headers=expected_headers) + self.assertEqual(self.client.endpoint_url, ENDPOINT_URL) + self.assertEqual(self.client.auth_token, TOKEN) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_refresh_token(self, mock_request): + + self.client.auth_token = TOKEN + self.client.endpoint_url = ENDPOINT_URL + + # If a token is expired, apmec server retruns 401 + mock_request.return_value = (resp_401, '') + self.assertRaises(exceptions.Unauthorized, + self.client.do_request, + '/resource', + 'GET') + + mock_request.return_value = (resp_200, json.dumps(KS_TOKEN_RESULT)) + self.client.do_request('/resource', 'GET') + mock_request.assert_called_with( + ENDPOINT_URL + '/resource', 'GET', + headers=expected_headers) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_refresh_token_no_auth_url(self, mock_request): + + self.client.auth_url = None + + self.client.auth_token = TOKEN + self.client.endpoint_url = ENDPOINT_URL + + # If a token is expired, apmec server retruns 401 + mock_request.return_value = (resp_401, '') + self.assertRaises(exceptions.NoAuthURLProvided, + self.client.do_request, + '/resource', + 'GET') + expected_url = ENDPOINT_URL + '/resource' + mock_request.assert_called_with(expected_url, 'GET', + headers=expected_headers) + + def test_get_endpoint_url_with_invalid_auth_url(self): + # Handle the case when auth_url is not provided + self.client.auth_url = None + self.assertRaises(exceptions.NoAuthURLProvided, + self.client._get_endpoint_url) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_get_endpoint_url(self, mock_request): + + self.client.auth_token = TOKEN + + mock_request.return_value = (resp_200, json.dumps(ENDPOINTS_RESULT)) + self.client.do_request('/resource', 'GET') + mock_request.assert_called_with( + ENDPOINT_URL + '/resource', 'GET', + headers=expected_headers) + + mock_request.return_value = (resp_200, '') + self.client.do_request('/resource', 'GET', + headers=headers) + mock_request.assert_called_with( + ENDPOINT_URL + '/resource', 'GET', + headers=headers) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_use_given_endpoint_url(self, mock_request): + self.client = client.HTTPClient( + username=USERNAME, tenant_name=TENANT_NAME, password=PASSWORD, + auth_url=AUTH_URL, region_name=REGION, + endpoint_url=ENDPOINT_OVERRIDE) + self.assertEqual(self.client.endpoint_url, ENDPOINT_OVERRIDE) + + self.client.auth_token = TOKEN + mock_request.return_value = (resp_200, '') + + self.client.do_request('/resource', 'GET', + headers=headers) + mock_request.assert_called_with( + ENDPOINT_OVERRIDE + '/resource', 'GET', + headers=headers) + self.assertEqual(self.client.endpoint_url, ENDPOINT_OVERRIDE) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_get_endpoint_url_other(self, mock_request): + self.client = client.HTTPClient( + username=USERNAME, tenant_name=TENANT_NAME, password=PASSWORD, + auth_url=AUTH_URL, region_name=REGION, endpoint_type='otherURL') + + self.client.auth_token = TOKEN + mock_request.return_value = (resp_200, json.dumps(ENDPOINTS_RESULT)) + self.assertRaises(exceptions.EndpointTypeNotFound, + self.client.do_request, + '/resource', + 'GET') + expected_url = AUTH_URL + '/tokens/%s/endpoints' % TOKEN + headers = {'User-Agent': 'python-apmecclient'} + mock_request.assert_called_with(expected_url, 'GET', + headers=headers) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_get_endpoint_url_failed(self, mock_request): + + self.client.auth_token = TOKEN + self.client.auth_url = AUTH_URL + '/tokens/%s/endpoints' % TOKEN + + mock_request.return_value = (resp_401, '') + self.assertRaises(exceptions.Unauthorized, + self.client.do_request, + '/resource', + 'GET') + + def test_endpoint_type(self): + resources = copy.deepcopy(KS_TOKEN_RESULT) + endpoints = resources['access']['serviceCatalog'][0]['endpoints'][0] + endpoints['internalURL'] = 'internal' + endpoints['adminURL'] = 'admin' + endpoints['publicURL'] = 'public' + + # Test default behavior is to choose public. + self.client = client.HTTPClient( + username=USERNAME, tenant_name=TENANT_NAME, password=PASSWORD, + auth_url=AUTH_URL, region_name=REGION) + + self.client._extract_service_catalog(resources) + self.assertEqual(self.client.endpoint_url, 'public') + + # Test admin url + self.client = client.HTTPClient( + username=USERNAME, tenant_name=TENANT_NAME, password=PASSWORD, + auth_url=AUTH_URL, region_name=REGION, endpoint_type='adminURL') + + self.client._extract_service_catalog(resources) + self.assertEqual(self.client.endpoint_url, 'admin') + + # Test public url + self.client = client.HTTPClient( + username=USERNAME, tenant_name=TENANT_NAME, password=PASSWORD, + auth_url=AUTH_URL, region_name=REGION, endpoint_type='publicURL') + + self.client._extract_service_catalog(resources) + self.assertEqual(self.client.endpoint_url, 'public') + + # Test internal url + self.client = client.HTTPClient( + username=USERNAME, tenant_name=TENANT_NAME, password=PASSWORD, + auth_url=AUTH_URL, region_name=REGION, endpoint_type='internalURL') + + self.client._extract_service_catalog(resources) + self.assertEqual(self.client.endpoint_url, 'internal') + + # Test url that isn't found in the service catalog + self.client = client.HTTPClient( + username=USERNAME, tenant_name=TENANT_NAME, password=PASSWORD, + auth_url=AUTH_URL, region_name=REGION, endpoint_type='privateURL') + + self.assertRaises(k_exceptions.EndpointNotFound, + self.client._extract_service_catalog, + resources) + + @mock.patch('apmecclient.client.HTTPClient.request') + @mock.patch('apmecclient.common.utils.http_log_req') + def test_strip_credentials_from_log(self, mock_http_log_req, + mock_request,): + + body = ('{"auth": {"tenantId": "testtenant_id",' + '"passwordCredentials": {"password": "password",' + '"userId": "testuser_id"}}}') + expected_body = ('{"auth": {"tenantId": "testtenant_id",' + '"REDACTEDCredentials": {"REDACTED": "REDACTED",' + '"userId": "testuser_id"}}}') + _headers = {'headers': expected_headers, 'body': expected_body} + + mock_request.return_value = (resp_200, json.dumps(KS_TOKEN_RESULT)) + self.client.do_request('/resource', 'GET', body=body) + + args, kwargs = mock_http_log_req.call_args + # Check that credentials are stripped while logging. + self.assertEqual(_headers, args[2]) + + +class CLITestAuthKeystoneWithId(CLITestAuthKeystone): + + # Auth Body expected + auth_body = ('{"auth": {"passwordCredentials": ' + '{"password": "password", "userId": "testuser_id"}, ' + '"tenantId": "testtenant_id"}}') + + def setUp(self): + """Prepare the test environment.""" + super(CLITestAuthKeystoneWithId, self).setUp() + self.client = client.HTTPClient(user_id=USER_ID, + tenant_id=TENANT_ID, + password=PASSWORD, + auth_url=AUTH_URL, + region_name=REGION) + + +class CLITestAuthKeystoneWithIdandName(CLITestAuthKeystone): + + # Auth Body expected + auth_body = ('{"auth": {"passwordCredentials": ' + '{"password": "password", "userId": "testuser_id"}, ' + '"tenantId": "testtenant_id"}}') + + def setUp(self): + """Prepare the test environment.""" + super(CLITestAuthKeystoneWithIdandName, self).setUp() + self.client = client.HTTPClient(username=USERNAME, + user_id=USER_ID, + tenant_id=TENANT_ID, + tenant_name=TENANT_NAME, + password=PASSWORD, + auth_url=AUTH_URL, + region_name=REGION) diff --git a/apmecclient/tests/unit/test_casual_args.py b/apmecclient/tests/unit/test_casual_args.py new file mode 100644 index 0000000..0ef4747 --- /dev/null +++ b/apmecclient/tests/unit/test_casual_args.py @@ -0,0 +1,119 @@ +# Copyright 2012 OpenStack Foundation. +# 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 testtools + +from apmecclient.common import exceptions +from apmecclient.apmec import v1_0 as apmecV10 + + +class CLITestArgs(testtools.TestCase): + + def test_empty(self): + _mydict = apmecV10.parse_args_to_dict([]) + self.assertEqual({}, _mydict) + + def test_default_bool(self): + _specs = ['--my_bool', '--arg1', 'value1'] + _mydict = apmecV10.parse_args_to_dict(_specs) + self.assertTrue(_mydict['my_bool']) + + def test_bool_true(self): + _specs = ['--my-bool', 'type=bool', 'true', '--arg1', 'value1'] + _mydict = apmecV10.parse_args_to_dict(_specs) + self.assertTrue(_mydict['my_bool']) + + def test_bool_false(self): + _specs = ['--my_bool', 'type=bool', 'false', '--arg1', 'value1'] + _mydict = apmecV10.parse_args_to_dict(_specs) + self.assertFalse(_mydict['my_bool']) + + def test_nargs(self): + _specs = ['--tag', 'x', 'y', '--arg1', 'value1'] + _mydict = apmecV10.parse_args_to_dict(_specs) + self.assertIn('x', _mydict['tag']) + self.assertIn('y', _mydict['tag']) + + def test_badarg(self): + _specs = ['--tag=t', 'x', 'y', '--arg1', 'value1'] + self.assertRaises(exceptions.CommandError, + apmecV10.parse_args_to_dict, _specs) + + def test_badarg_with_minus(self): + _specs = ['--arg1', 'value1', '-D'] + self.assertRaises(exceptions.CommandError, + apmecV10.parse_args_to_dict, _specs) + + def test_goodarg_with_minus_number(self): + _specs = ['--arg1', 'value1', '-1', '-1.0'] + _mydict = apmecV10.parse_args_to_dict(_specs) + self.assertEqual(['value1', '-1', '-1.0'], + _mydict['arg1']) + + def test_badarg_duplicate(self): + _specs = ['--tag=t', '--arg1', 'value1', '--arg1', 'value1'] + self.assertRaises(exceptions.CommandError, + apmecV10.parse_args_to_dict, _specs) + + def test_badarg_early_type_specification(self): + _specs = ['type=dict', 'key=value'] + self.assertRaises(exceptions.CommandError, + apmecV10.parse_args_to_dict, _specs) + + def test_arg(self): + _specs = ['--tag=t', '--arg1', 'value1'] + self.assertEqual('value1', + apmecV10.parse_args_to_dict(_specs)['arg1']) + + def test_dict_arg(self): + _specs = ['--tag=t', '--arg1', 'type=dict', 'key1=value1,key2=value2'] + arg1 = apmecV10.parse_args_to_dict(_specs)['arg1'] + self.assertEqual('value1', arg1['key1']) + self.assertEqual('value2', arg1['key2']) + + def test_dict_arg_with_attribute_named_type(self): + _specs = ['--tag=t', '--arg1', 'type=dict', 'type=value1,key2=value2'] + arg1 = apmecV10.parse_args_to_dict(_specs)['arg1'] + self.assertEqual('value1', arg1['type']) + self.assertEqual('value2', arg1['key2']) + + def test_list_of_dict_arg(self): + _specs = ['--tag=t', '--arg1', 'type=dict', + 'list=true', 'key1=value1,key2=value2'] + arg1 = apmecV10.parse_args_to_dict(_specs)['arg1'] + self.assertEqual('value1', arg1[0]['key1']) + self.assertEqual('value2', arg1[0]['key2']) + + def test_clear_action(self): + _specs = ['--anyarg', 'action=clear'] + args = apmecV10.parse_args_to_dict(_specs) + self.assertIsNone(args['anyarg']) + + def test_bad_values_str(self): + _specs = ['--strarg', 'type=str'] + self.assertRaises(exceptions.CommandError, + apmecV10.parse_args_to_dict, _specs) + + def test_bad_values_list(self): + _specs = ['--listarg', 'list=true', 'type=str'] + self.assertRaises(exceptions.CommandError, + apmecV10.parse_args_to_dict, _specs) + _specs = ['--listarg', 'type=list'] + self.assertRaises(exceptions.CommandError, + apmecV10.parse_args_to_dict, _specs) + _specs = ['--listarg', 'type=list', 'action=clear'] + self.assertRaises(exceptions.CommandError, + apmecV10.parse_args_to_dict, _specs) diff --git a/apmecclient/tests/unit/test_cli10.py b/apmecclient/tests/unit/test_cli10.py new file mode 100644 index 0000000..b5626ff --- /dev/null +++ b/apmecclient/tests/unit/test_cli10.py @@ -0,0 +1,791 @@ +# Copyright 2012 OpenStack Foundation. +# 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 urllib + +import contextlib +import fixtures +import mock +import six +import six.moves.urllib.parse as urlparse +import sys +import testtools + +from apmecclient.common import constants +from apmecclient.common import exceptions +from apmecclient import shell +from apmecclient.apmec import v1_0 as apmecV1_0 +from apmecclient.apmec.v1_0 import ApmecCommand +from apmecclient.tests.unit import test_utils +from apmecclient.v1_0 import client + +API_VERSION = "1.0" +FORMAT = 'json' +TOKEN = 'testtoken' +ENDURL = 'localurl' + + +@contextlib.contextmanager +def capture_std_streams(): + fake_stdout, fake_stderr = six.StringIO(), six.StringIO() + stdout, stderr = sys.stdout, sys.stderr + try: + sys.stdout, sys.stderr = fake_stdout, fake_stderr + yield fake_stdout, fake_stderr + finally: + sys.stdout, sys.stderr = stdout, stderr + + +class FakeStdout(object): + + def __init__(self): + self.content = [] + + def write(self, text): + self.content.append(text) + + def make_string(self): + result = '' + for line in self.content: + result = result + line + return result + + +class MyResp(object): + def __init__(self, status_code, headers=None, reason=None): + self.status_code = status_code + self.headers = headers or {} + self.reason = reason + + +class MyApp(object): + def __init__(self, _stdout): + self.stdout = _stdout + + +def end_url(path, query=None, format=FORMAT): + _url_str = ENDURL + "/v" + API_VERSION + path + "." + format + return query and _url_str + "?" + query or _url_str + + +class MyUrlComparator(object): + def __init__(self, lhs, client): + self.lhs = lhs + self.client = client + + def equals(self, rhs): + lhsp = urlparse.urlparse(self.lhs) + rhsp = urlparse.urlparse(rhs) + + lhs_qs = urlparse.parse_qsl(lhsp.query) + rhs_qs = urlparse.parse_qsl(rhsp.query) + + return (lhsp.scheme == rhsp.scheme and + lhsp.netloc == rhsp.netloc and + lhsp.path == rhsp.path and + len(lhs_qs) == len(rhs_qs) and + set(lhs_qs) == set(rhs_qs)) + + def __str__(self): + if self.client and self.client.format != FORMAT: + lhs_parts = self.lhs.split("?", 1) + if len(lhs_parts) == 2: + lhs = ("%s.%s?%s" % (lhs_parts[0][:-4], + self.client.format, + lhs_parts[1])) + else: + lhs = ("%s.%s" % (lhs_parts[0][:-4], + self.client.format)) + return lhs + return self.lhs + + def __repr__(self): + return str(self) + + def __eq__(self, rhs): + return self.equals(rhs) + + def __ne__(self, rhs): + return not self.__eq__(rhs) + + +class MyComparator(object): + def __init__(self, lhs, client): + self.lhs = lhs + self.client = client + + def _com_dict(self, lhs, rhs): + if len(lhs) != len(rhs): + return False + for key, value in lhs.items(): + if key not in rhs: + return False + rhs_value = rhs[key] + if not self._com(value, rhs_value): + return False + return True + + def _com_list(self, lhs, rhs): + if len(lhs) != len(rhs): + return False + for lhs_value in lhs: + if lhs_value not in rhs: + return False + return True + + def _com(self, lhs, rhs): + if lhs is None: + return rhs is None + if isinstance(lhs, dict): + if not isinstance(rhs, dict): + return False + return self._com_dict(lhs, rhs) + if isinstance(lhs, list): + if not isinstance(rhs, list): + return False + return self._com_list(lhs, rhs) + if isinstance(lhs, tuple): + if not isinstance(rhs, tuple): + return False + return self._com_list(lhs, rhs) + return lhs == rhs + + def equals(self, rhs): + if self.client: + rhs = self.client.deserialize(rhs, 200) + return self._com(self.lhs, rhs) + + def __repr__(self): + if self.client: + return self.client.serialize(self.lhs) + return str(self.lhs) + + def __eq__(self, rhs): + return self.equals(rhs) + + def __ne__(self, rhs): + return not self.__eq__(rhs) + + +class CLITestV10Base(testtools.TestCase): + + format = 'json' + test_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + id_field = 'id' + + def _find_resourceid(self, client, resource, name_or_id): + return name_or_id + + def setUp(self, plurals={}): + """Prepare the test environment.""" + super(CLITestV10Base, self).setUp() + client.Client.EXTED_PLURALS.update(constants.PLURALS) + client.Client.EXTED_PLURALS.update(plurals) + self.metadata = {'plurals': client.Client.EXTED_PLURALS, + 'xmlns': constants.XML_NS_V10, + constants.EXT_NS: {'prefix': + 'http://xxxx.yy.com'}} + self.endurl = ENDURL + self.fake_stdout = FakeStdout() + self.useFixture(fixtures.MonkeyPatch('sys.stdout', self.fake_stdout)) + self.useFixture(fixtures.MonkeyPatch( + 'apmecclient.apmec.v1_0.find_resourceid_by_name_or_id', + self._find_resourceid)) + self.useFixture(fixtures.MonkeyPatch( + 'apmecclient.apmec.v1_0.find_resourceid_by_id', + self._find_resourceid)) + self.client = client.Client(token=TOKEN, endpoint_url=self.endurl) + + @mock.patch.object(ApmecCommand, 'get_client') + def _test_create_resource(self, resource, cmd, + name, myid, args, + position_names, position_values, mock_get, + tenant_id=None, get_client_called_count=1, + tags=None, admin_state_up=True, extra_body=None, + **kwargs): + mock_get.return_value = self.client + non_admin_status_resources = ['mead', 'mea', 'vim'] + if (resource in non_admin_status_resources): + body = {resource: {}, } + else: + body = {resource: {'admin_state_up': admin_state_up, }, } + if tenant_id: + body[resource].update({'tenant_id': tenant_id}) + if tags: + body[resource].update({'tags': tags}) + if extra_body: + body[resource].update(extra_body) + body[resource].update(kwargs) + + for i in range(len(position_names)): + body[resource].update({position_names[i]: position_values[i]}) + ress = {resource: + {self.id_field: myid}, } + if name: + ress[resource].update({'name': name}) + self.client.format = self.format + resstr = self.client.serialize(ress) + # url method body + resource_plural = apmecV1_0._get_resource_plural(resource, + self.client) + path = getattr(self.client, resource_plural + "_path") + # Work around for LP #1217791. XML deserializer called from + # MyComparator does not decodes XML string correctly. + if self.format == 'json': + _body = MyComparator(body, self.client) + else: + _body = self.client.serialize(body) + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + mock_req.return_value = (MyResp(200), resstr) + args.extend(['--request-format', self.format]) + cmd_parser = cmd.get_parser('create_' + resource) + shell.run_command(cmd, cmd_parser, args) + mock_req.assert_called_once_with( + end_url(path, format=self.format), 'POST', + body=_body, + headers=test_utils.ContainsKeyValue('X-Auth-Token', TOKEN)) + self.assertEqual(get_client_called_count, mock_get.call_count) + _str = self.fake_stdout.make_string() + self.assertIn(myid, _str) + if name: + self.assertIn(name, _str) + + @mock.patch.object(ApmecCommand, 'get_client') + def _test_list_columns(self, cmd, resources_collection, + resources_out, mock_get, + args=['-f', 'json']): + mock_get.return_value = self.client + self.client.format = self.format + resstr = self.client.serialize(resources_out) + + path = getattr(self.client, resources_collection + "_path") + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + mock_req.return_value = (MyResp(200), resstr) + args.extend(['--request-format', self.format]) + cmd_parser = cmd.get_parser("list_" + resources_collection) + shell.run_command(cmd, cmd_parser, args) + mock_req.assert_called_once_with( + end_url(path, format=self.format), 'GET', + body=None, + headers=test_utils.ContainsKeyValue('X-Auth-Token', TOKEN)) + mock_get.assert_called_once_with() + + def _test_list_resources(self, resources, cmd, detail=False, tags=[], + fields_1=[], fields_2=[], page_size=None, + sort_key=[], sort_dir=[], response_contents=None, + base_args=None, path=None, + template_source=None): + if response_contents is None: + contents = [{self.id_field: 'myid1', }, + {self.id_field: 'myid2', }, ] + else: + contents = response_contents + reses = {resources: contents} + self.client.format = self.format + resstr = self.client.serialize(reses) + # url method body + query = "" + args = base_args if base_args is not None else [] + if detail: + args.append('-D') + args.extend(['--request-format', self.format]) + if fields_1: + for field in fields_1: + args.append('--fields') + args.append(field) + if template_source is not None: + args.append("--template-source") + args.append(template_source) + query += 'template_source=' + template_source + + if tags: + args.append('--') + args.append("--tag") + for tag in tags: + args.append(tag) + if isinstance(tag, six.string_types): + tag = urllib.quote(tag.encode('utf-8')) + if query: + query += "&tag=" + tag + else: + query = "tag=" + tag + if (not tags) and fields_2: + args.append('--') + if fields_2: + args.append("--fields") + for field in fields_2: + args.append(field) + if detail: + query = query and query + '&verbose=True' or 'verbose=True' + fields_1.extend(fields_2) + for field in fields_1: + if query: + query += "&fields=" + field + else: + query = "fields=" + field + if page_size: + args.append("--page-size") + args.append(str(page_size)) + if query: + query += "&limit=%s" % page_size + else: + query = "limit=%s" % page_size + if sort_key: + for key in sort_key: + args.append('--sort-key') + args.append(key) + if query: + query += '&' + query += 'sort_key=%s' % key + if sort_dir: + len_diff = len(sort_key) - len(sort_dir) + if len_diff > 0: + sort_dir += ['asc'] * len_diff + elif len_diff < 0: + sort_dir = sort_dir[:len(sort_key)] + for dir in sort_dir: + args.append('--sort-dir') + args.append(dir) + if query: + query += '&' + query += 'sort_dir=%s' % dir + if path is None: + path = getattr(self.client, resources + "_path") + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + mock_req.return_value = (MyResp(200), resstr) + with mock.patch.object(ApmecCommand, 'get_client') as mock_get: + mock_get.return_value = self.client + cmd_parser = cmd.get_parser("list_" + resources) + shell.run_command(cmd, cmd_parser, args) + mock_req.assert_called_once_with( + MyUrlComparator(end_url(path, query, format=self.format), + self.client), + 'GET', + body=None, + headers=test_utils.ContainsKeyValue('X-Auth-Token', TOKEN)) + _str = self.fake_stdout.make_string() + if response_contents is None: + self.assertIn('myid1', _str) + return _str + + @mock.patch.object(ApmecCommand, 'get_client') + def _test_list_sub_resources(self, resources, api_resource, cmd, myid, + mock_get, detail=False, + tags=[], fields_1=[], fields_2=[], + page_size=None, sort_key=[], sort_dir=[], + response_contents=None, base_args=None, + path=None): + mock_get.return_value = self.client + if response_contents is None: + contents = [{self.id_field: 'myid1', }, + {self.id_field: 'myid2', }, ] + else: + contents = response_contents + reses = {api_resource: contents} + self.client.format = self.format + resstr = self.client.serialize(reses) + # url method body + query = "" + args = base_args if base_args is not None else [] + if detail: + args.append('-D') + args.extend(['--request-format', self.format]) + if fields_1: + for field in fields_1: + args.append('--fields') + args.append(field) + + if tags: + args.append('--') + args.append("--tag") + for tag in tags: + args.append(tag) + if isinstance(tag, six.string_types): + tag = urllib.quote(tag.encode('utf-8')) + if query: + query += "&tag=" + tag + else: + query = "tag=" + tag + if (not tags) and fields_2: + args.append('--') + if fields_2: + args.append("--fields") + for field in fields_2: + args.append(field) + if detail: + query = query and query + '&verbose=True' or 'verbose=True' + fields_1.extend(fields_2) + for field in fields_1: + if query: + query += "&fields=" + field + else: + query = "fields=" + field + if page_size: + args.append("--page-size") + args.append(str(page_size)) + if query: + query += "&limit=%s" % page_size + else: + query = "limit=%s" % page_size + if sort_key: + for key in sort_key: + args.append('--sort-key') + args.append(key) + if query: + query += '&' + query += 'sort_key=%s' % key + if sort_dir: + len_diff = len(sort_key) - len(sort_dir) + if len_diff > 0: + sort_dir += ['asc'] * len_diff + elif len_diff < 0: + sort_dir = sort_dir[:len(sort_key)] + for dir in sort_dir: + args.append('--sort-dir') + args.append(dir) + if query: + query += '&' + query += 'sort_dir=%s' % dir + if path is None: + path = getattr(self.client, resources + "_path") + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + mock_req.return_value = (MyResp(200), resstr) + comparator = MyUrlComparator( + end_url(path % myid, query=query, format=self.format), + self.client) + args.extend(['--request-format', self.format]) + cmd_parser = cmd.get_parser("list_" + resources) + shell.run_command(cmd, cmd_parser, args) + mock_req.assert_called_once_with( + comparator, 'GET', + body=None, + headers=test_utils.ContainsKeyValue('X-Auth-Token', TOKEN)) + _str = self.fake_stdout.make_string() + if response_contents is None: + self.assertIn('myid1', _str) + return _str + + # TODO(gongysh) add pagination unit test BUG 1633255 + # def _test_list_sub_resources_with_pagination( + # self, resources, api_resource, cmd, myid): + # self.mox.StubOutWithMock(cmd, "get_client") + # self.mox.StubOutWithMock(self.client.httpclient, "request") + # cmd.get_client().MultipleTimes().AndReturn(self.client) + # path = getattr(self.client, resources + "_path") + # fake_query = "marker=myid2&limit=2" + # reses1 = {api_resource: [{'id': 'myid1', }, + # {'id': 'myid2', }], + # '%s_links' % api_resource: [ + # {'href': end_url(path % myid, fake_query), + # 'rel': 'next'}] + # } + # reses2 = {api_resource: [{'id': 'myid3', }, + # {'id': 'myid4', }]} + # self.client.format = self.format + # resstr1 = self.client.serialize(reses1) + # resstr2 = self.client.serialize(reses2) + # self.client.httpclient.request( + # end_url(path % myid, "", format=self.format), 'GET', + # body=None, + # headers=mox.ContainsKeyValue( + # 'X-Auth-Token', TOKEN)).AndReturn((MyResp(200), resstr1)) + # self.client.httpclient.request( + # MyUrlComparator(end_url(path % myid, fake_query, + # format=self.format), self.client), 'GET', + # body=None, headers=mox.ContainsKeyValue( + # 'X-Auth-Token', TOKEN)).AndReturn((MyResp(200), resstr2)) + # self.mox.ReplayAll() + # cmd_parser = cmd.get_parser("list_" + resources) + # args = [myid, '--request-format', self.format] + # shell.run_command(cmd, cmd_parser, args) + # self.mox.VerifyAll() + # self.mox.UnsetStubs() + + # def _test_list_resources_with_pagination(self, resources, cmd): + # self.mox.StubOutWithMock(cmd, "get_client") + # self.mox.StubOutWithMock(self.client.httpclient, "request") + # cmd.get_client().MultipleTimes().AndReturn(self.client) + # path = getattr(self.client, resources + "_path") + # fake_query = "marker=myid2&limit=2" + # reses1 = {resources: [{'id': 'myid1', }, + # {'id': 'myid2', }], + # '%s_links' % resources: [ + # {'href': end_url(path, fake_query), + # 'rel': 'next'}]} + # reses2 = {resources: [{'id': 'myid3', }, + # {'id': 'myid4', }]} + # self.client.format = self.format + # resstr1 = self.client.serialize(reses1) + # resstr2 = self.client.serialize(reses2) + # self.client.httpclient.request( + # end_url(path, "", format=self.format), 'GET', + # body=None, + # headers=mox.ContainsKeyValue( + # 'X-Auth-Token', TOKEN)).AndReturn((MyResp(200), resstr1)) + # self.client.httpclient.request( + # MyUrlComparator(end_url(path, fake_query, format=self.format), + # self.client), 'GET', body=None, + # headers=mox.ContainsKeyValue( + # 'X-Auth-Token', TOKEN)).AndReturn((MyResp(200), resstr2)) + # self.mox.ReplayAll() + # cmd_parser = cmd.get_parser("list_" + resources) + # args = ['--request-format', self.format] + # shell.run_command(cmd, cmd_parser, args) + # self.mox.VerifyAll() + # self.mox.UnsetStubs() + + @mock.patch.object(ApmecCommand, 'get_client') + def _test_update_resource(self, resource, cmd, myid, args, extrafields, + mock_get, get_client_called_count=1): + mock_get.return_value = self.client + body = {resource: extrafields} + path = getattr(self.client, resource + "_path") + self.client.format = self.format + # Work around for LP #1217791. XML deserializer called from + # MyComparator does not decodes XML string correctly. + if self.format == 'json': + _body = MyComparator(body, self.client) + else: + _body = self.client.serialize(body) + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + comparator = MyUrlComparator( + end_url(path % myid, format=self.format), self.client) + mock_req.return_value = (MyResp(204), None) + args.extend(['--request-format', self.format]) + cmd_parser = cmd.get_parser("update_" + resource) + shell.run_command(cmd, cmd_parser, args) + mock_req.assert_called_once_with( + comparator, + 'PUT', + body=_body, + headers=test_utils.ContainsKeyValue('X-Auth-Token', TOKEN)) + self.assertEqual(get_client_called_count, mock_get.call_count) + _str = self.fake_stdout.make_string() + self.assertIn(myid, _str) + + def _test_show_resource(self, resource, cmd, myid, args, fields=[]): + with mock.patch.object(cmd, 'get_client') as mock_get: + mock_get.return_value = self.client + query = "&".join(["fields=%s" % field for field in fields]) + expected_res = {resource: + {self.id_field: myid, + 'name': 'myname', }, } + self.client.format = self.format + resstr = self.client.serialize(expected_res) + path = getattr(self.client, resource + "_path") + with mock.patch.object(self.client.httpclient, 'request') as\ + mock_req: + mock_req.return_value = (MyResp(200), resstr) + args.extend(['--request-format', self.format]) + cmd_parser = cmd.get_parser("show_" + resource) + shell.run_command(cmd, cmd_parser, args) + mock_req.assert_called_once_with( + end_url(path % myid, query, format=self.format), 'GET', + body=None, + headers=test_utils.ContainsKeyValue('X-Auth-Token', TOKEN)) + _str = self.fake_stdout.make_string() + mock_get.assert_called_once_with() + self.assertIn(myid, _str) + self.assertIn('myname', _str) + + @mock.patch.object(ApmecCommand, 'get_client') + def _test_delete_resource(self, resource, cmd, myid, args, mock_get): + deleted_msg = {'mea': 'delete initiated'} + mock_get.return_value = self.client + path = getattr(self.client, resource + "_path") + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + mock_req.return_value = (MyResp(204), None) + args.extend(['--request-format', self.format]) + cmd_parser = cmd.get_parser("delete_" + resource) + shell.run_command(cmd, cmd_parser, args) + mock_req.assert_called_once_with( + end_url(path % myid, format=self.format), 'DELETE', + body=None, + headers=test_utils.ContainsKeyValue('X-Auth-Token', TOKEN)) + mock_get.assert_called_once_with() + _str = self.fake_stdout.make_string() + msg = 'All specified %(resource)s(s) %(msg)s successfully\n' % { + 'msg': deleted_msg.get(resource, 'deleted'), + 'resource': resource} + self.assertEqual(msg, _str) + + @mock.patch.object(ApmecCommand, 'get_client') + def _test_update_resource_action(self, resource, cmd, myid, action, args, + body, mock_get, retval=None): + mock_get.return_value = self.client + path = getattr(self.client, resource + "_path") + path_action = '%s/%s' % (myid, action) + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + mock_req.return_value = (MyResp(204), retval) + args.extend(['--request-format', self.format]) + cmd_parser = cmd.get_parser("delete_" + resource) + shell.run_command(cmd, cmd_parser, args) + mock_req.assert_called_once_with( + end_url(path % path_action, format=self.format), 'PUT', + body=MyComparator(body, self.client), + headers=test_utils.ContainsKeyValue('X-Auth-Token', TOKEN)) + _str = self.fake_stdout.make_string() + self.assertIn(myid, _str) + + +class ClientV1TestJson(CLITestV10Base): + def test_do_request_unicode(self): + self.client.format = self.format + unicode_text = u'\u7f51\u7edc' + action = u'/test' + params = {'test': unicode_text} + body = params + expect_body = self.client.serialize(body) + self.client.httpclient.auth_token = unicode_text + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + mock_req.return_value = (MyResp(200), expect_body) + res_body = self.client.do_request('PUT', action, body=body, + params=params) + expected_uri = u'localurl/v1.0/test.json?test=%E7%BD%91%E7%BB%9C' + mock_req.assert_called_with( + expected_uri, 'PUT', body=expect_body, + headers={'X-Auth-Token': unicode_text, + 'User-Agent': 'python-apmecclient'}) + # test response with unicode + self.assertEqual(res_body, body) + + def test_do_request_error_without_response_body(self): + self.client.format = self.format + params = {'test': 'value'} + expect_query = urlparse.urlencode(params) + self.client.httpclient.auth_token = 'token' + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + mock_req.return_value = (MyResp(400, reason='An error'), '') + self.client.httpclient.request( + end_url('/test', query=expect_query, format=self.format), + 'PUT', body='', + headers={'X-Auth-Token': 'token'} + ) + error = self.assertRaises(exceptions.ApmecClientException, + self.client.do_request, 'PUT', '/test', + body='', params=params) + self.assertEqual("An error", str(error)) + + +class CLITestV10ExceptionHandler(CLITestV10Base): + + def _test_exception_handler_v10( + self, expected_exception, status_code, expected_msg, + error_type=None, error_msg=None, error_detail=None, + error_content=None): + if error_content is None: + error_content = {'ApmecError': {'type': error_type, + 'message': error_msg, + 'detail': error_detail}} + + e = self.assertRaises(expected_exception, + client.exception_handler_v10, + status_code, error_content) + self.assertEqual(status_code, e.status_code) + self.assertEqual(expected_exception.__name__, + e.__class__.__name__) + + if expected_msg is None: + if error_detail: + expected_msg = '\n'.join([error_msg, error_detail]) + else: + expected_msg = error_msg + self.assertEqual(expected_msg, e.message) + + def test_exception_handler_v10_ip_address_in_use(self): + err_msg = ('Unable to complete operation for network ' + 'fake-network-uuid. The IP address fake-ip is in use.') + self._test_exception_handler_v10( + exceptions.IpAddressInUseClient, 409, err_msg, + 'IpAddressInUse', err_msg, '') + + def test_exception_handler_v10_apmec_known_error(self): + known_error_map = [ + ('NetworkNotFound', exceptions.NetworkNotFoundClient, 404), + ('PortNotFound', exceptions.PortNotFoundClient, 404), + ('NetworkInUse', exceptions.NetworkInUseClient, 409), + ('PortInUse', exceptions.PortInUseClient, 409), + ('StateInvalid', exceptions.StateInvalidClient, 400), + ('IpAddressInUse', exceptions.IpAddressInUseClient, 409), + ('IpAddressGenerationFailure', + exceptions.IpAddressGenerationFailureClient, 409), + ('ExternalIpAddressExhausted', + exceptions.ExternalIpAddressExhaustedClient, 400), + ('OverQuota', exceptions.OverQuotaClient, 409), + ] + + error_msg = 'dummy exception message' + error_detail = 'sample detail' + for server_exc, client_exc, status_code in known_error_map: + self._test_exception_handler_v10( + client_exc, status_code, + error_msg + '\n' + error_detail, + server_exc, error_msg, error_detail) + + def test_exception_handler_v10_apmec_known_error_without_detail(self): + error_msg = 'Network not found' + error_detail = '' + self._test_exception_handler_v10( + exceptions.NetworkNotFoundClient, 404, + error_msg, + 'NetworkNotFound', error_msg, error_detail) + + def test_exception_handler_v10_unknown_error_to_per_code_exception(self): + for status_code, client_exc in exceptions.HTTP_EXCEPTION_MAP.items(): + error_msg = 'Unknown error' + error_detail = 'This is detail' + self._test_exception_handler_v10( + client_exc, status_code, + error_msg + '\n' + error_detail, + 'UnknownError', error_msg, error_detail) + + def test_exception_handler_v10_apmec_unknown_status_code(self): + error_msg = 'Unknown error' + error_detail = 'This is detail' + self._test_exception_handler_v10( + exceptions.ApmecClientException, 501, + error_msg + '\n' + error_detail, + 'UnknownError', error_msg, error_detail) + + def test_exception_handler_v10_bad_apmec_error(self): + error_content = {'ApmecError': {'unknown_key': 'UNKNOWN'}} + self._test_exception_handler_v10( + exceptions.ApmecClientException, 500, + expected_msg={'unknown_key': 'UNKNOWN'}, + error_content=error_content) + + def test_exception_handler_v10_error_dict_contains_message(self): + error_content = {'message': 'This is an error message'} + self._test_exception_handler_v10( + exceptions.ApmecClientException, 500, + expected_msg='This is an error message', + error_content=error_content) + + def test_exception_handler_v10_error_dict_not_contain_message(self): + error_content = {'error': 'This is an error message'} + expected_msg = '%s-%s' % (500, error_content) + self._test_exception_handler_v10( + exceptions.ApmecClientException, 500, + expected_msg=expected_msg, + error_content=error_content) + + def test_exception_handler_v10_default_fallback(self): + error_content = 'This is an error message' + expected_msg = '%s-%s' % (500, error_content) + self._test_exception_handler_v10( + exceptions.ApmecClientException, 500, + expected_msg=expected_msg, + error_content=error_content) diff --git a/apmecclient/tests/unit/test_cli10_extensions.py b/apmecclient/tests/unit/test_cli10_extensions.py new file mode 100644 index 0000000..c90bff8 --- /dev/null +++ b/apmecclient/tests/unit/test_cli10_extensions.py @@ -0,0 +1,47 @@ +# Copyright 2013 NEC Corporation +# 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 sys + +from apmecclient.apmec.v1_0.extension import ListExt +from apmecclient.apmec.v1_0.extension import ShowExt +from apmecclient.tests.unit.test_cli10 import CLITestV10Base +from apmecclient.tests.unit.test_cli10 import MyApp + + +class CLITestV10Extension(CLITestV10Base): + id_field = 'alias' + + def test_list_extensions(self): + resources = 'extensions' + cmd = ListExt(MyApp(sys.stdout), None) + contents = [{'alias': 'ext1', 'name': 'name1', 'other': 'other1'}, + {'alias': 'ext2', 'name': 'name2', 'other': 'other2'}] + ret = self._test_list_resources(resources, cmd, + response_contents=contents) + ret_words = set(ret.split()) + # Check only the default columns are shown. + self.assertIn('name', ret_words) + self.assertIn('alias', ret_words) + self.assertNotIn('other', ret_words) + + def test_show_extension(self): + # -F option does not work for ext-show at the moment, so -F option + # is not passed in the commandline args as other tests do. + resource = 'extension' + cmd = ShowExt(MyApp(sys.stdout), None) + args = [self.test_id] + ext_alias = self.test_id + self._test_show_resource(resource, cmd, ext_alias, args, fields=[]) diff --git a/apmecclient/tests/unit/test_command_meta.py b/apmecclient/tests/unit/test_command_meta.py new file mode 100644 index 0000000..d82f964 --- /dev/null +++ b/apmecclient/tests/unit/test_command_meta.py @@ -0,0 +1,39 @@ +# Copyright 2013 Intel Corporation +# 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 logging + +import testtools +from testtools import helpers + +from apmecclient.apmec import v1_0 as apmecV10 + + +class TestCommandMeta(testtools.TestCase): + def test_apmec_command_meta_defines_log(self): + class FakeCommand(apmecV10.ApmecCommand): + pass + + self.assertTrue(helpers.safe_hasattr(FakeCommand, 'log')) + self.assertIsInstance(FakeCommand.log, logging.getLoggerClass()) + self.assertEqual(FakeCommand.log.name, __name__ + ".FakeCommand") + + def test_apmec_command_log_defined_explicitly(self): + class FakeCommand(apmecV10.ApmecCommand): + log = None + + self.assertTrue(helpers.safe_hasattr(FakeCommand, 'log')) + self.assertIsNone(FakeCommand.log) diff --git a/apmecclient/tests/unit/test_http.py b/apmecclient/tests/unit/test_http.py new file mode 100644 index 0000000..3ef1f3d --- /dev/null +++ b/apmecclient/tests/unit/test_http.py @@ -0,0 +1,71 @@ +# Copyright (C) 2013 OpenStack Foundation. +# 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 mock +import testtools + +from apmecclient.client import HTTPClient +from apmecclient.common import exceptions +from apmecclient.tests.unit.test_cli10 import MyResp + + +AUTH_TOKEN = 'test_token' +END_URL = 'test_url' +METHOD = 'GET' +URL = 'http://test.test:1234/v1.0/test' +headers = {'User-Agent': 'python-apmecclient'} + + +class TestHTTPClient(testtools.TestCase): + + def setUp(self): + + super(TestHTTPClient, self).setUp() + self.addCleanup(mock.patch.stopall) + self.http = HTTPClient(token=AUTH_TOKEN, endpoint_url=END_URL) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_request_error(self, mock_request): + + mock_request.side_effect = Exception('error msg') + self.assertRaises( + exceptions.ConnectionFailed, + self.http._cs_request, + URL, METHOD + ) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_request_success(self, mock_request): + + rv_should_be = MyResp(200), 'test content' + mock_request.return_value = rv_should_be + self.assertEqual(rv_should_be, self.http._cs_request(URL, METHOD)) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_request_unauthorized(self, mock_request): + + mock_request.return_value = MyResp(401), 'unauthorized message' + + e = self.assertRaises(exceptions.Unauthorized, + self.http._cs_request, URL, METHOD) + self.assertEqual('unauthorized message', str(e)) + mock_request.assert_called_with(URL, METHOD, headers=headers) + + @mock.patch('apmecclient.client.HTTPClient.request') + def test_request_forbidden_is_returned_to_caller(self, mock_request): + + rv_should_be = MyResp(403), 'forbidden message' + mock_request.return_value = rv_should_be + self.assertEqual(rv_should_be, self.http._cs_request(URL, METHOD)) diff --git a/apmecclient/tests/unit/test_shell.py b/apmecclient/tests/unit/test_shell.py new file mode 100644 index 0000000..002a659 --- /dev/null +++ b/apmecclient/tests/unit/test_shell.py @@ -0,0 +1,190 @@ +# Copyright (C) 2013 Yahoo! Inc. +# 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 argparse +import logging +import os +import re +import six +import sys + +import fixtures +from keystoneclient import session +import mock +import testtools +from testtools import matchers + +from apmecclient.common import clientmanager +from apmecclient import shell as openstack_shell + + +DEFAULT_USERNAME = 'username' +DEFAULT_PASSWORD = 'password' +DEFAULT_TENANT_ID = 'tenant_id' +DEFAULT_TENANT_NAME = 'tenant_name' +DEFAULT_AUTH_URL = 'http://127.0.0.1:5000/v1.0/' +DEFAULT_TOKEN = '3bcc3d3a03f44e3d8377f9247b0ad155' +DEFAULT_URL = 'http://apmec.example.org:9896/' +DEFAULT_API_VERSION = '1.0' + + +class ShellTest(testtools.TestCase): + + FAKE_ENV = { + 'OS_USERNAME': DEFAULT_USERNAME, + 'OS_PASSWORD': DEFAULT_PASSWORD, + 'OS_TENANT_ID': DEFAULT_TENANT_ID, + 'OS_TENANT_NAME': DEFAULT_TENANT_NAME, + 'OS_AUTH_URL': DEFAULT_AUTH_URL} + + # Patch os.environ to avoid required auth info. + def setUp(self): + super(ShellTest, self).setUp() + for var in self.FAKE_ENV: + self.useFixture( + fixtures.EnvironmentVariable( + var, self.FAKE_ENV[var])) + + def shell(self, argstr, check=False): + orig = (sys.stdout, sys.stderr) + clean_env = {} + _old_env, os.environ = os.environ, clean_env.copy() + try: + sys.stdout = six.StringIO() + sys.stderr = six.StringIO() + _shell = openstack_shell.ApmecShell(DEFAULT_API_VERSION) + _shell.run(argstr.split()) + except SystemExit: + exc_type, exc_value, exc_traceback = sys.exc_info() + self.assertEqual(exc_value.code, 0) + finally: + stdout = sys.stdout.getvalue() + stderr = sys.stderr.getvalue() + sys.stdout.close() + sys.stderr.close() + sys.stdout, sys.stderr = orig + os.environ = _old_env + return stdout, stderr + + def test_run_unknown_command(self): + self.useFixture(fixtures.FakeLogger(level=logging.DEBUG)) + stdout, stderr = self.shell('fake', check=True) + self.assertFalse(stdout) + self.assertEqual("Unknown command ['fake']", stderr.strip()) + + def test_help(self): + required = 'usage:' + help_text, stderr = self.shell('help') + self.assertThat( + help_text, + matchers.MatchesRegex(required)) + self.assertFalse(stderr) + + def test_help_on_subcommand(self): + required = [ + '.*?^usage: .* mead-list'] + stdout, stderr = self.shell('help mead-list') + for r in required: + self.assertThat( + stdout, + matchers.MatchesRegex(r, re.DOTALL | re.MULTILINE)) + self.assertFalse(stderr) + + def test_help_command(self): + required = 'usage:' + help_text, stderr = self.shell('help mead-create') + self.assertThat( + help_text, + matchers.MatchesRegex(required)) + self.assertFalse(stderr) + + def test_unknown_auth_strategy(self): + self.useFixture(fixtures.FakeLogger(level=logging.DEBUG)) + stdout, stderr = self.shell('--os-auth-strategy fake ' + 'mead-list') + self.assertFalse(stdout) + + def test_auth(self): + with mock.patch.object(openstack_shell.ApmecShell, + 'run_subcommand'), \ + mock.patch.object(session, 'Session'), \ + mock.patch.object(clientmanager, 'ClientManager') as mock_cmgr: + + shell = openstack_shell.ApmecShell(DEFAULT_API_VERSION) + shell.options = mock.Mock() + auth_session = shell._get_keystone_session() + + cmdline = ('--os-username test ' + '--os-password test ' + '--os-tenant-name test ' + '--os-auth-url http://127.0.0.1:5000/ ' + '--os-auth-strategy keystone mead-list') + shell.authenticate_user() + shell.run(cmdline.split()) + + mock_cmgr.assert_called_with( + raise_errors=False, retries=0, timeout=None, + token='', url='', auth_url='http://127.0.0.1:5000/', + tenant_name='test', tenant_id='tenant_id', + username='test', user_id='', + password='test', region_name='', + api_version={'mec-orchestration': '1.0'}, + auth_strategy='keystone', + service_type='mec-orchestration', + endpoint_type='publicURL', insecure=False, ca_cert=None, + log_credentials=True, session=auth_session, auth=auth_session.auth) + + def test_build_option_parser(self): + apmec_shell = openstack_shell.ApmecShell(DEFAULT_API_VERSION) + result = apmec_shell.build_option_parser('descr', DEFAULT_API_VERSION) + self.assertIsInstance(result, argparse.ArgumentParser) + + @mock.patch.object(openstack_shell.ApmecShell, 'run') + def test_main_with_unicode(self, mock_run): + mock_run.return_value = 0 + unicode_text = u'\u7f51\u7edc' + argv = ['net-list', unicode_text, unicode_text.encode('utf-8')] + ret = openstack_shell.main(argv=argv) + mock_run.assert_called_once_with([u'net-list', unicode_text, + unicode_text]) + self.assertEqual(0, ret) + + def test_endpoint_option(self): + shell = openstack_shell.ApmecShell(DEFAULT_API_VERSION) + parser = shell.build_option_parser('descr', DEFAULT_API_VERSION) + + # Neither $OS_ENDPOINT_TYPE nor --endpoint-type + namespace = parser.parse_args([]) + self.assertEqual('publicURL', namespace.endpoint_type) + + # --endpoint-type but not $OS_ENDPOINT_TYPE + namespace = parser.parse_args(['--endpoint-type=admin']) + self.assertEqual('admin', namespace.endpoint_type) + + def test_endpoint_environment_variable(self): + fixture = fixtures.EnvironmentVariable("OS_ENDPOINT_TYPE", + "public") + self.useFixture(fixture) + + shell = openstack_shell.ApmecShell(DEFAULT_API_VERSION) + parser = shell.build_option_parser('descr', DEFAULT_API_VERSION) + + # $OS_ENDPOINT_TYPE but not --endpoint-type + namespace = parser.parse_args([]) + self.assertEqual("public", namespace.endpoint_type) + + # --endpoint-type and $OS_ENDPOINT_TYPE + namespace = parser.parse_args(['--endpoint-type=admin']) + self.assertEqual('admin', namespace.endpoint_type) diff --git a/apmecclient/tests/unit/test_ssl.py b/apmecclient/tests/unit/test_ssl.py new file mode 100644 index 0000000..a9cbfc9 --- /dev/null +++ b/apmecclient/tests/unit/test_ssl.py @@ -0,0 +1,82 @@ +# Copyright (C) 2013 OpenStack Foundation. +# 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 fixtures +from keystoneclient import session +import mock +import requests +import testtools + +from apmecclient import client +from apmecclient.common import clientmanager +from apmecclient.common import exceptions +from apmecclient import shell as openstack_shell + + +AUTH_TOKEN = 'test_token' +END_URL = 'test_url' +METHOD = 'GET' +URL = 'http://test.test:1234/v1.0/' +CA_CERT = '/tmp/test/path' +DEFAULT_API_VERSION = '1.0' + + +class TestSSL(testtools.TestCase): + def setUp(self): + super(TestSSL, self).setUp() + + self.useFixture(fixtures.EnvironmentVariable('OS_TOKEN', AUTH_TOKEN)) + self.useFixture(fixtures.EnvironmentVariable('OS_URL', END_URL)) + self.addCleanup(mock.patch.stopall) + + def _test_verify_client_manager(self, cacert): + with mock.patch.object(session, 'Session'), \ + mock.patch.object(clientmanager, 'ClientManager') as mock_cmgr: + + mock_cmgr.return_value = 0 + shell = openstack_shell.ApmecShell(DEFAULT_API_VERSION) + shell.options = mock.Mock() + auth_session = shell._get_keystone_session() + + shell.run(cacert) + + mock_cmgr.assert_called_with( + api_version={'mec-orchestration': '1.0'}, + auth=auth_session.auth, auth_strategy='keystone', + auth_url='', ca_cert=CA_CERT, endpoint_type='publicURL', + insecure=False, log_credentials=True, password='', + raise_errors=False, region_name='', retries=0, + service_type='mec-orchestration', session=auth_session, + tenant_id='', tenant_name='', timeout=None, + token='test_token', url='test_url', user_id='', username='') + + def test_ca_cert_passed(self): + cacert = ['--os-cacert', CA_CERT] + self._test_verify_client_manager(cacert) + + def test_ca_cert_passed_as_env_var(self): + self.useFixture(fixtures.EnvironmentVariable('OS_CACERT', CA_CERT)) + self._test_verify_client_manager([]) + + @mock.patch.object(client.HTTPClient, 'request') + def test_proper_exception_is_raised_when_cert_validation_fails(self, + mock_req): + http = client.HTTPClient(token=AUTH_TOKEN, endpoint_url=END_URL) + mock_req.side_effect = requests.exceptions.SSLError() + self.assertRaises( + exceptions.SslCertificateValidationError, + http._cs_request, + URL, METHOD + ) diff --git a/apmecclient/tests/unit/test_utils.py b/apmecclient/tests/unit/test_utils.py new file mode 100644 index 0000000..7b94c81 --- /dev/null +++ b/apmecclient/tests/unit/test_utils.py @@ -0,0 +1,149 @@ +# Copyright (C) 2013 Yahoo! Inc. +# 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 testtools + +from apmecclient.common import exceptions +from apmecclient.common import utils + + +class TestUtils(testtools.TestCase): + def test_string_to_bool_true(self): + self.assertTrue(utils.str2bool('true')) + + def test_string_to_bool_false(self): + self.assertFalse(utils.str2bool('false')) + + def test_string_to_bool_None(self): + self.assertIsNone(utils.str2bool(None)) + + def test_string_to_dictionary(self): + input_str = 'key1=value1,key2=value2' + expected = {'key1': 'value1', 'key2': 'value2'} + self.assertEqual(expected, utils.str2dict(input_str)) + + def test_none_string_to_dictionary(self): + input_str = '' + expected = {} + self.assertEqual(expected, utils.str2dict(input_str)) + input_str = None + expected = {} + self.assertEqual(expected, utils.str2dict(input_str)) + + def test_get_dict_item_properties(self): + item = {'name': 'test_name', 'id': 'test_id'} + fields = ('name', 'id') + actual = utils.get_item_properties(item=item, fields=fields) + self.assertEqual(('test_name', 'test_id'), actual) + + def test_get_object_item_properties_mixed_case_fields(self): + class Fake(object): + def __init__(self): + self.id = 'test_id' + self.name = 'test_name' + self.test_user = 'test' + + fields = ('name', 'id', 'test user') + mixed_fields = ('test user', 'ID') + item = Fake() + actual = utils.get_item_properties(item, fields, mixed_fields) + self.assertEqual(('test_name', 'test_id', 'test'), actual) + + def test_get_object_item_desired_fields_differ_from_item(self): + class Fake(object): + def __init__(self): + self.id = 'test_id_1' + self.name = 'test_name' + self.test_user = 'test' + + fields = ('name', 'id', 'test user') + item = Fake() + actual = utils.get_item_properties(item, fields) + self.assertNotEqual(('test_name', 'test_id', 'test'), actual) + + def test_get_object_item_desired_fields_is_empty(self): + class Fake(object): + def __init__(self): + self.id = 'test_id_1' + self.name = 'test_name' + self.test_user = 'test' + + fields = [] + item = Fake() + actual = utils.get_item_properties(item, fields) + self.assertEqual((), actual) + + def test_get_object_item_with_formatters(self): + class Fake(object): + def __init__(self): + self.id = 'test_id' + self.name = 'test_name' + self.test_user = 'test' + + class FakeCallable(object): + def __call__(self, *args, **kwargs): + return 'pass' + + fields = ('name', 'id', 'test user', 'is_public') + formatters = {'is_public': FakeCallable()} + item = Fake() + act = utils.get_item_properties(item, fields, formatters=formatters) + self.assertEqual(('test_name', 'test_id', 'test', 'pass'), act) + + +class ImportClassTestCase(testtools.TestCase): + + def test_get_client_class_invalid_version(self): + self.assertRaises( + exceptions.UnsupportedVersion, + utils.get_client_class, 'image', '2', {'image': '2'}) + + +class ContainsKeyValue(object): + """Checks whether a key/value pair is in a dict parameter. + + The ContainsKeyValue class is a helper for mock.assert_*() + method. It enables strict check than the built in mock.ANY + helper, and is the equivalent of the mox.ContainsKeyValue() + function from the legacy mox library + + Example usage could be: + + mock_some_method.assert_called_once_with( + "hello", + ContainsKeyValue('foo', bar), + mock.ANY, + "world", + ContainsKeyValue('hello', world)) + """ + def __init__(self, wantkey, wantvalue): + self.wantkey = wantkey + self.wantvalue = wantvalue + + def __eq__(self, other): + try: + return other[self.wantkey] == self.wantvalue + except (KeyError, TypeError): + return False + + def __ne__(self, other): + try: + return other[self.wantkey] != self.wantvalue + except (KeyError, TypeError): + return True + + def __repr__(self): + return "" diff --git a/apmecclient/tests/unit/test_validators.py b/apmecclient/tests/unit/test_validators.py new file mode 100644 index 0000000..d2a88af --- /dev/null +++ b/apmecclient/tests/unit/test_validators.py @@ -0,0 +1,101 @@ +# Copyright 2014 NEC Corporation +# 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 testtools + +from apmecclient.common import exceptions +from apmecclient.common import validators + + +class FakeParsedArgs(object): + pass + + +class ValidatorTest(testtools.TestCase): + + def _test_validate_int(self, attr_val, attr_name='attr1', + min_value=1, max_value=10): + obj = FakeParsedArgs() + setattr(obj, attr_name, attr_val) + ret = validators.validate_int_range(obj, attr_name, + min_value, max_value) + # Come here only if there is no exception. + self.assertIsNone(ret) + + def _test_validate_int_error(self, attr_val, expected_msg, + attr_name='attr1', expected_exc=None, + min_value=1, max_value=10): + if expected_exc is None: + expected_exc = exceptions.CommandError + e = self.assertRaises(expected_exc, + self._test_validate_int, + attr_val, attr_name, min_value, max_value) + self.assertEqual(expected_msg, str(e)) + + def test_validate_int_min_max(self): + self._test_validate_int(1) + self._test_validate_int(10) + self._test_validate_int('1') + self._test_validate_int('10') + self._test_validate_int('0x0a') + + self._test_validate_int_error( + 0, 'attr1 "0" should be an integer [1:10].') + self._test_validate_int_error( + 11, 'attr1 "11" should be an integer [1:10].') + self._test_validate_int_error( + '0x10', 'attr1 "0x10" should be an integer [1:10].') + + def test_validate_int_min_only(self): + self._test_validate_int(1, max_value=None) + self._test_validate_int(10, max_value=None) + self._test_validate_int(11, max_value=None) + self._test_validate_int_error( + 0, 'attr1 "0" should be an integer greater than or equal to 1.', + max_value=None) + + def test_validate_int_max_only(self): + self._test_validate_int(0, min_value=None) + self._test_validate_int(1, min_value=None) + self._test_validate_int(10, min_value=None) + self._test_validate_int_error( + 11, 'attr1 "11" should be an integer smaller than or equal to 10.', + min_value=None) + + def test_validate_int_no_limit(self): + self._test_validate_int(0, min_value=None, max_value=None) + self._test_validate_int(1, min_value=None, max_value=None) + self._test_validate_int(10, min_value=None, max_value=None) + self._test_validate_int(11, min_value=None, max_value=None) + self._test_validate_int_error( + 'abc', 'attr1 "abc" should be an integer.', + min_value=None, max_value=None) + + def _test_validate_subnet(self, attr_val, attr_name='attr1'): + obj = FakeParsedArgs() + setattr(obj, attr_name, attr_val) + ret = validators.validate_ip_subnet(obj, attr_name) + # Come here only if there is no exception. + self.assertIsNone(ret) + + def test_validate_ip_subnet(self): + self._test_validate_subnet('192.168.2.0/24') + self._test_validate_subnet('192.168.2.3/20') + self._test_validate_subnet('192.168.2.1') + + e = self.assertRaises(exceptions.CommandError, + self._test_validate_subnet, + '192.168.2.256') + self.assertEqual('attr1 "192.168.2.256" is not a valid CIDR.', str(e)) diff --git a/apmecclient/tests/unit/vm/__init__.py b/apmecclient/tests/unit/vm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/tests/unit/vm/samples/vim_config.yaml b/apmecclient/tests/unit/vm/samples/vim_config.yaml new file mode 100644 index 0000000..aa9dbc0 --- /dev/null +++ b/apmecclient/tests/unit/vm/samples/vim_config.yaml @@ -0,0 +1,6 @@ +auth_url: 'http://1.2.3.4:5000' +username: 'xyz' +password: '12345' +project_name: 'abc' +project_domain_name: 'prj_domain_name' +user_domain_name: 'user_domain_name' diff --git a/apmecclient/tests/unit/vm/samples/vim_config_without_auth_url.yaml b/apmecclient/tests/unit/vm/samples/vim_config_without_auth_url.yaml new file mode 100644 index 0000000..db73c40 --- /dev/null +++ b/apmecclient/tests/unit/vm/samples/vim_config_without_auth_url.yaml @@ -0,0 +1,5 @@ +username: 'xyz' +password: '12345' +project_name: 'abc' +project_domain_name: 'prj_domain_name' +user_domain_name: 'user_domain_name' diff --git a/apmecclient/tests/unit/vm/test_cli10_mea.py b/apmecclient/tests/unit/vm/test_cli10_mea.py new file mode 100644 index 0000000..9982fb6 --- /dev/null +++ b/apmecclient/tests/unit/vm/test_cli10_mea.py @@ -0,0 +1,213 @@ +# Copyright 2014 Intel Corporation +# 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 sys + +import mock + +from apmecclient import shell +from apmecclient.apmec import v1_0 as apmecV1_0 +from apmecclient.apmec.v1_0 import ApmecCommand +from apmecclient.apmec.v1_0.mem import mea +from apmecclient.tests.unit import test_cli10 +from apmecclient.tests.unit import test_utils + +API_VERSION = "1.0" +FORMAT = 'json' +TOKEN = 'testtoken' +ENDURL = 'localurl' + + +class CLITestV10VmMEAJSON(test_cli10.CLITestV10Base): + _RESOURCE = 'mea' + _RESOURCES = 'meas' + _MEA_RESOURCES = 'mea_resources' + + def setUp(self): + plurals = {'meas': 'mea', + 'resources': 'resource'} + super(CLITestV10VmMEAJSON, self).setUp(plurals=plurals) + + @mock.patch.object(ApmecCommand, 'get_client') + def _test_create_resource(self, resource, cmd, name, myid, args, + position_names, position_values, mock_get, + tenant_id=None, tags=None, admin_state_up=True, + extra_body=None, **kwargs): + mock_get.return_value = self.client + non_admin_status_resources = ['mead', 'mea'] + if (resource in non_admin_status_resources): + body = {resource: {}, } + else: + body = {resource: {'admin_state_up': admin_state_up, }, } + if tenant_id: + body[resource].update({'tenant_id': tenant_id}) + if tags: + body[resource].update({'tags': tags}) + if extra_body: + body[resource].update(extra_body) + body[resource].update(kwargs) + + for i in range(len(position_names)): + body[resource].update({position_names[i]: position_values[i]}) + ress = {resource: + {self.id_field: myid}, } + if name: + ress[resource].update({'name': name}) + self.client.format = self.format + resstr = self.client.serialize(ress) + # url method body + resource_plural = apmecV1_0._get_resource_plural(resource, + self.client) + path = getattr(self.client, resource_plural + "_path") + # Work around for LP #1217791. XML deserializer called from + # MyComparator does not decodes XML string correctly. + if self.format == 'json': + _body = test_cli10.MyComparator(body, self.client) + else: + _body = self.client.serialize(body) + with mock.patch.object(self.client.httpclient, 'request') as mock_req: + mock_req.return_value = (test_cli10.MyResp(200), resstr) + args.extend(['--request-format', self.format]) + args.extend(['--mead-id', 'mead']) + cmd_parser = cmd.get_parser('create_' + resource) + shell.run_command(cmd, cmd_parser, args) + mock_req.assert_called_once_with( + test_cli10.end_url(path, format=self.format), 'POST', + body=_body, + headers=test_utils.ContainsKeyValue('X-Auth-Token', TOKEN)) + mock_get.assert_any_call() + + def test_create_mea_all_params(self): + cmd = mea.CreateMEA(test_cli10.MyApp(sys.stdout), None) + name = 'my_name' + my_id = 'my-id' + mead_id = 'mead' + vim_id = 'vim_id' + description = 'my-description' + region_name = 'region' + key = 'key' + value = 'value' + + args = [ + name, + '--mead-id', mead_id, + '--vim-id', vim_id, + '--description', description, + '--vim-region-name', region_name, + '--%s' % key, value] + position_names = [ + 'name', + 'mead_id', + 'vim_id', + 'description', + 'attributes', + ] + position_values = [ + name, + mead_id, + vim_id, + description, + {}, + ] + extra_body = {key: value, 'placement_attr': {'region_name': + region_name}} + self._test_create_resource(self._RESOURCE, cmd, name, my_id, + args, position_names, position_values, + extra_body=extra_body) + + def test_create_mea_with_mead_id(self): + cmd = mea.CreateMEA(test_cli10.MyApp(sys.stdout), None) + name = 'my_name' + my_id = 'my-id' + mead_id = 'mead' + args = [ + name, + '--mead-id', mead_id, + ] + position_names = ['name', 'mead_id', 'attributes'] + position_values = [name, mead_id, {}] + self._test_create_resource(self._RESOURCE, cmd, name, my_id, + args, position_names, position_values) + + def test_create_mea_with_description_param(self): + cmd = mea.CreateMEA(test_cli10.MyApp(sys.stdout), None) + name = 'my_name' + my_id = 'my-id' + mead_id = 'mead' + description = 'my-description' + args = [ + name, + '--mead-id', mead_id, + '--description', description, + ] + position_names = ['name', 'mead_id', 'description', + 'attributes'] + position_values = [name, mead_id, description, {}] + self._test_create_resource(self._RESOURCE, cmd, None, my_id, + args, position_names, position_values) + + def test_list_meas(self): + cmd = mea.ListMEA(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._RESOURCES, cmd, True) + + def test_list_meas_pagenation(self): + cmd = mea.ListMEA(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._RESOURCES, cmd, True) + + def test_show_mea_id(self): + cmd = mea.ShowMEA(test_cli10.MyApp(sys.stdout), None) + args = ['--fields', 'id', self.test_id] + self._test_show_resource(self._RESOURCE, cmd, self.test_id, args, + ['id']) + + def test_show_mea_id_name(self): + cmd = mea.ShowMEA(test_cli10.MyApp(sys.stdout), None) + args = ['--fields', 'id', '--fields', 'name', self.test_id] + self._test_show_resource(self._RESOURCE, cmd, self.test_id, + args, ['id', 'name']) + + def test_update_mea(self): + cmd = mea.UpdateMEA(test_cli10.MyApp(sys.stdout), None) + my_id = 'my-id' + key = 'new_key' + value = 'new-value' + self._test_update_resource(self._RESOURCE, cmd, my_id, + [my_id, '--%s' % key, value], + {key: value}) + + def test_delete_mea(self): + cmd = mea.DeleteMEA(test_cli10.MyApp(sys.stdout), None) + my_id = 'my-id' + args = [my_id] + self._test_delete_resource(self._RESOURCE, cmd, my_id, args) + + def test_list_mea_resources(self): + cmd = mea.ListMEAResources(test_cli10.MyApp(sys.stdout), None) + base_args = [self.test_id] + response = [{'name': 'CP11', 'id': 'id1', 'type': 'NeutronPort'}, + {'name': 'CP12', 'id': 'id2', 'type': 'NeutronPort'}] + val = self._test_list_sub_resources(self._MEA_RESOURCES, 'resources', + cmd, self.test_id, + response_contents=response, + detail=True, base_args=base_args) + self.assertIn('id1', val) + self.assertIn('NeutronPort', val) + self.assertIn('CP11', val) + + def test_multi_delete_mea(self): + cmd = mea.DeleteMEA(test_cli10.MyApp(sys.stdout), None) + mea_ids = 'mea1 mea2 mea3' + args = [mea_ids] + self._test_delete_resource(self._RESOURCE, cmd, mea_ids, args) diff --git a/apmecclient/tests/unit/vm/test_cli10_mead.py b/apmecclient/tests/unit/vm/test_cli10_mead.py new file mode 100644 index 0000000..45d84dd --- /dev/null +++ b/apmecclient/tests/unit/vm/test_cli10_mead.py @@ -0,0 +1,146 @@ +# Copyright 2014 Intel Corporation +# 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. + +from mock import mock_open +from mock import patch +import sys + +from apmecclient.common.exceptions import InvalidInput +from apmecclient.apmec.v1_0.mem import mead +from apmecclient.tests.unit import test_cli10 + + +class CLITestV10VmMEADJSON(test_cli10.CLITestV10Base): + _RESOURCE = 'mead' + _RESOURCES = 'meads' + + def setUp(self): + plurals = {'meads': 'mead'} + super(CLITestV10VmMEADJSON, self).setUp(plurals=plurals) + + @patch("apmecclient.apmec.v1_0.mem.mead.open", + side_effect=mock_open(read_data="mead"), + create=True) + def test_create_mead_all_params(self, mo): + cmd = mead.CreateMEAD( + test_cli10.MyApp(sys.stdout), None) + my_id = 'my-id' + name = 'my-name' + attr_key = 'mead' + attr_val = 'mead' + args = [ + name, + '--mead-file', 'mead-file' + ] + position_names = ['name'] + position_values = [name] + extra_body = { + 'service_types': [{'service_type': 'mead'}], + 'attributes': {attr_key: attr_val}, + } + self._test_create_resource(self._RESOURCE, cmd, None, my_id, + args, position_names, position_values, + extra_body=extra_body) + + @patch("apmecclient.apmec.v1_0.mem.mead.open", + side_effect=mock_open(read_data="mead"), + create=True) + def test_create_mead_with_mandatory_params(self, mo): + cmd = mead.CreateMEAD( + test_cli10.MyApp(sys.stdout), None) + name = 'my_name' + my_id = 'my-id' + args = [name, '--mead-file', 'mead-file', ] + position_names = ['name'] + position_values = [name] + extra_body = { + 'service_types': [{'service_type': 'mead'}], + 'attributes': {'mead': 'mead'} + } + self._test_create_resource(self._RESOURCE, cmd, name, my_id, + args, position_names, position_values, + extra_body=extra_body) + + @patch("apmecclient.apmec.v1_0.mem.mead.open", + side_effect=mock_open(read_data=""), + create=True) + def test_create_mead_with_empty_file(self, mo): + cmd = mead.CreateMEAD( + test_cli10.MyApp(sys.stdout), None) + name = 'my_name' + my_id = 'my-id' + args = [name, '--mead-file', 'mead-file', ] + position_names = ['name'] + position_values = [name] + extra_body = { + 'service_types': [{'service_type': 'mead'}], + 'attributes': {'mead': 'mead'} + } + err = None + try: + self._test_create_resource(self._RESOURCE, cmd, name, my_id, + args, position_names, position_values, + extra_body=extra_body) + except InvalidInput: + err = True + self.assertEqual(True, err) + + def test_list_meads(self): + cmd = mead.ListMEAD(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._RESOURCES, cmd, True, + template_source='onboarded') + + def test_list_inline_meads(self): + cmd = mead.ListMEAD(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._RESOURCES, cmd, True, + template_source='inline') + + def test_list_all_meads(self): + cmd = mead.ListMEAD(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._RESOURCES, cmd, True, + template_source='all') + + def test_list_meads_pagenation(self): + cmd = mead.ListMEAD(test_cli10.MyApp(sys.stdout), None) + print(cmd) + self._test_list_resources(self._RESOURCES, cmd, True, + template_source='onboarded') + + def test_show_mead_id(self): + cmd = mead.ShowMEAD(test_cli10.MyApp(sys.stdout), None) + args = ['--fields', 'id', self.test_id] + self._test_show_resource(self._RESOURCE, cmd, self.test_id, args, + ['id']) + + def test_show_mead_id_name(self): + cmd = mead.ShowMEAD(test_cli10.MyApp(sys.stdout), None) + args = ['--fields', 'id', '--fields', 'name', self.test_id] + self._test_show_resource(self._RESOURCE, cmd, self.test_id, + args, ['id', 'name']) + + def test_delete_mead(self): + cmd = mead.DeleteMEAD( + test_cli10.MyApp(sys.stdout), None) + my_id = 'my-id' + args = [my_id] + self._test_delete_resource(self._RESOURCE, cmd, my_id, args) + + def test_multi_delete_mead(self): + cmd = mead.DeleteMEAD( + test_cli10.MyApp(sys.stdout), None) + mead_ids = 'my-id1 my-id2 my-id3' + args = [mead_ids] + self._test_delete_resource(self._RESOURCE, cmd, mead_ids, args) diff --git a/apmecclient/tests/unit/vm/test_cli10_v10_event.py b/apmecclient/tests/unit/vm/test_cli10_v10_event.py new file mode 100644 index 0000000..e01e37b --- /dev/null +++ b/apmecclient/tests/unit/vm/test_cli10_v10_event.py @@ -0,0 +1,69 @@ +# Copyright 2014 Intel Corporation +# 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 sys + +from apmecclient.apmec.v1_0.events import events +from apmecclient.tests.unit import test_cli10 + +API_VERSION = "1.0" +FORMAT = 'json' +TOKEN = 'testtoken' +ENDURL = 'localurl' + + +class CLITestV10EventJSON(test_cli10.CLITestV10Base): + _EVT_RESOURCE = 'event' + _EVT_RESOURCES = _EVT_RESOURCE + 's' + _MEA_EVT_RESOURCE = "mea_event" + _MEA_EVT_RESOURCES = _MEA_EVT_RESOURCE + 's' + _MEAD_EVT_RESOURCE = "mead_event" + _MEAD_EVT_RESOURCES = _MEAD_EVT_RESOURCE + 's' + _VIM_EVT_RESOURCE = "vim_event" + _VIM_EVT_RESOURCES = _VIM_EVT_RESOURCE + 's' + + def setUp(self): + plurals = {'events': 'event', 'mea_events': 'mea_event', + 'mead_events': 'mead_event', 'vim_events': 'vim_event'} + super(CLITestV10EventJSON, self).setUp(plurals=plurals) + + def test_list_events(self): + cmd = events.ListResourceEvents(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._EVT_RESOURCES, cmd, True) + + def test_show_event_id(self): + cmd = events.ShowEvent(test_cli10.MyApp(sys.stdout), None) + args = ['--fields', 'id', self.test_id] + self._test_show_resource(self._EVT_RESOURCE, cmd, self.test_id, args, + ['id']) + + def notest_list_mea_events(self): + # TODO(vishwanathj): Need to enhance _test_list_resources() + # for supporting filters to get this test working + cmd = events.ListMEAEvents(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._MEA_EVT_RESOURCES, cmd, True) + + def notest_list_mead_events(self): + # TODO(vishwanathj): Need to enhance _test_list_resources() + # for supporting filters to get this test working + cmd = events.ListMEADEvents(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._MEAD_EVT_RESOURCES, cmd, True) + + def notest_list_vim_events(self): + # TODO(vishwanathj): Need to enhance _test_list_resources() + # for supporting filters to get this test working + cmd = events.ListVIMEvents(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._VIM_EVT_RESOURCES, cmd, True) diff --git a/apmecclient/tests/unit/vm/test_cli10_vim.py b/apmecclient/tests/unit/vm/test_cli10_vim.py new file mode 100644 index 0000000..d570f51 --- /dev/null +++ b/apmecclient/tests/unit/vm/test_cli10_vim.py @@ -0,0 +1,170 @@ +# Copyright 2015-2016 Brocade Communications Systems Inc +# 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 sys + +from apmecclient.common import exceptions +from apmecclient.common import utils +from apmecclient.apmec.v1_0.meo import vim +from apmecclient.tests.unit import test_cli10 + +API_VERSION = "1.0" +FORMAT = 'json' +TOKEN = 'testtoken' +ENDURL = 'localurl' + + +class CLITestV10VIMJSON(test_cli10.CLITestV10Base): + _RESOURCE = 'vim' + _RESOURCES = 'vims' + + def setUp(self): + plurals = {'vims': 'vim'} + super(CLITestV10VIMJSON, self).setUp(plurals=plurals) + self.vim_project = { + 'name': 'abc', + 'project_domain_name': 'prj_domain_name'} + self.auth_cred = {'username': 'xyz', 'password': '12345', + 'user_domain_name': 'user_domain_name'} + self.auth_url = 'http://1.2.3.4:5000' + + def test_register_vim_all_params(self): + cmd = vim.CreateVIM(test_cli10.MyApp(sys.stdout), None) + name = 'my-name' + my_id = 'my-id' + description = 'Vim Description' + vim_config = utils.get_file_path( + 'tests/unit/vm/samples/vim_config.yaml') + args = [ + name, + '--config-file', vim_config, + '--description', description] + position_names = ['auth_cred', 'vim_project', 'auth_url'] + position_values = [self.auth_cred, self.vim_project, + self.auth_url] + extra_body = {'type': 'openstack', 'name': name, + 'description': description, 'is_default': False} + self._test_create_resource(self._RESOURCE, cmd, None, my_id, + args, position_names, position_values, + extra_body=extra_body) + + def test_register_vim_with_no_auth_url(self): + cmd = vim.CreateVIM(test_cli10.MyApp(sys.stdout), None) + my_id = 'my-id' + name = 'test_vim' + description = 'Vim Description' + vim_config = utils.get_file_path( + 'tests/unit/vm/samples/vim_config_without_auth_url.yaml') + args = [ + name, + '--config-file', vim_config, + '--description', description] + position_names = ['auth_cred', 'vim_project', 'auth_url'] + position_values = [self.auth_cred, self.vim_project, + self.auth_url] + extra_body = {'type': 'openstack', 'name': name, + 'description': description, 'is_default': False} + message = 'Auth URL must be specified' + ex = self.assertRaises(exceptions.ApmecClientException, + self._test_create_resource, + self._RESOURCE, cmd, None, my_id, args, + position_names, position_values, + extra_body=extra_body) + self.assertEqual(message, ex.message) + self.assertEqual(404, ex.status_code) + + def test_register_vim_with_mandatory_params(self): + cmd = vim.CreateVIM(test_cli10.MyApp(sys.stdout), None) + name = 'my-name' + my_id = 'my-id' + + vim_config = utils.get_file_path( + 'tests/unit/vm/samples/vim_config.yaml') + args = [ + name, + '--config-file', vim_config, + ] + position_names = ['auth_cred', 'vim_project', 'auth_url'] + position_values = [ + self.auth_cred, + self.vim_project, + self.auth_url + ] + extra_body = {'type': 'openstack', 'name': name, 'is_default': False} + self._test_create_resource(self._RESOURCE, cmd, name, my_id, args, + position_names, position_values, + extra_body=extra_body) + + def test_list_vims(self): + cmd = vim.ListVIM(test_cli10.MyApp(sys.stdout), None) + self._test_list_resources(self._RESOURCES, cmd, True) + + def test_show_vim_id(self): + cmd = vim.ShowVIM(test_cli10.MyApp(sys.stdout), None) + args = ['--fields', 'id', self.test_id] + self._test_show_resource(self._RESOURCE, cmd, self.test_id, args, + ['id']) + + def test_show_vim_id_name(self): + cmd = vim.ShowVIM(test_cli10.MyApp(sys.stdout), None) + args = ['--fields', 'id', '--fields', 'name', self.test_id] + self._test_show_resource(self._RESOURCE, cmd, self.test_id, + args, ['id', 'name']) + + def test_update_vim_all_params(self): + cmd = vim.UpdateVIM(test_cli10.MyApp(sys.stdout), None) + update_config = utils.get_file_path( + 'tests/unit/vm/samples/vim_config_without_auth_url.yaml') + my_id = 'my-id' + name = 'new_name' + description = 'new_description' + is_default = 'True' + args = [ + my_id, + '--config-file', str(update_config), + '--name', name, + '--description', description, + '--is_default', is_default] + extra_fields = {'vim_project': self.vim_project, 'auth_cred': + self.auth_cred, 'is_default': 'True', + 'name': name, 'description': description} + self._test_update_resource(self._RESOURCE, cmd, my_id, args, + extra_fields) + + def test_update_vim_with_mandatory_params(self): + cmd = vim.UpdateVIM(test_cli10.MyApp(sys.stdout), None) + update_config = utils.get_file_path( + 'tests/unit/vm/samples/vim_config_without_auth_url.yaml') + my_id = 'my-id' + args = [ + my_id, + '--config-file', str(update_config)] + extra_fields = {'vim_project': self.vim_project, + 'auth_cred': self.auth_cred} + self._test_update_resource(self._RESOURCE, cmd, my_id, args, + extra_fields) + + def test_delete_vim(self): + cmd = vim.DeleteVIM(test_cli10.MyApp(sys.stdout), None) + my_id = 'my-id' + args = [my_id] + self._test_delete_resource(self._RESOURCE, cmd, my_id, args) + + def test_multi_delete_vim(self): + cmd = vim.DeleteVIM(test_cli10.MyApp(sys.stdout), None) + vim_ids = 'my-id1 my-id2 my-id3' + args = [vim_ids] + self._test_delete_resource(self._RESOURCE, cmd, vim_ids, args) diff --git a/apmecclient/tests/unit/vm/test_vim_utils.py b/apmecclient/tests/unit/vm/test_vim_utils.py new file mode 100644 index 0000000..a3d9cec --- /dev/null +++ b/apmecclient/tests/unit/vm/test_vim_utils.py @@ -0,0 +1,69 @@ +# Copyright 2016 OpenStack Foundation. +# 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. + + +from mock import sentinel +import testtools + +from apmecclient.common import exceptions +from apmecclient.apmec.v1_0.meo import vim_utils + + +class TestVIMUtils(testtools.TestCase): + + def test_args2body_vim(self): + config_param = {'project_name': sentinel.prj_name, + 'username': sentinel.usrname1, + 'password': sentinel.password1, + 'project_domain_name': sentinel.prj_domain_name1, + 'user_domain_name': sentinel.user_domain.name, } + vim = {} + auth_cred = config_param.copy() + auth_cred.pop('project_name') + auth_cred.pop('project_domain_name') + expected_vim = {'auth_cred': auth_cred, + 'vim_project': + {'name': sentinel.prj_name, + 'project_domain_name': sentinel.prj_domain_name1}} + vim_utils.args2body_vim(config_param.copy(), vim) + self.assertEqual(expected_vim, vim) + + def test_args2body_vim_no_project(self): + config_param = {'username': sentinel.usrname1, + 'password': sentinel.password1, + 'user_domain_name': sentinel.user_domain.name, } + vim = {} + self.assertRaises(exceptions.ApmecClientException, + vim_utils.args2body_vim, + config_param, vim) + + def test_validate_auth_url_with_port(self): + auth_url = "http://localhost:8000/test" + url_parts = vim_utils.validate_auth_url(auth_url) + self.assertEqual('http', url_parts.scheme) + self.assertEqual('localhost:8000', url_parts.netloc) + self.assertEqual(8000, url_parts.port) + + def test_validate_auth_url_without_port(self): + auth_url = "http://localhost/test" + url_parts = vim_utils.validate_auth_url(auth_url) + self.assertEqual('http', url_parts.scheme) + self.assertEqual('localhost', url_parts.netloc) + + def test_validate_auth_url_exception(self): + auth_url = "localhost/test" + self.assertRaises(exceptions.ApmecClientException, + vim_utils.validate_auth_url, + auth_url) diff --git a/apmecclient/v1_0/__init__.py b/apmecclient/v1_0/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apmecclient/v1_0/client.py b/apmecclient/v1_0/client.py new file mode 100644 index 0000000..8ce722e --- /dev/null +++ b/apmecclient/v1_0/client.py @@ -0,0 +1,544 @@ +# Copyright 2012 OpenStack Foundation. +# Copyright 2015 Hewlett-Packard Development Company, L.P. +# 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 logging +import time + +import requests +import six.moves.urllib.parse as urlparse + +from apmecclient import client +from apmecclient.common import constants +from apmecclient.common import exceptions +from apmecclient.common import serializer +from apmecclient.common import utils +from apmecclient.i18n import _ + +_logger = logging.getLogger(__name__) +DEFAULT_DESC_LENGTH = 25 +DEFAULT_ERROR_REASON_LENGTH = 100 + + +def exception_handler_v10(status_code, error_content): + """Exception handler for API v1.0 client. + + This routine generates the appropriate Apmec exception according to + the contents of the response body. + + :param status_code: HTTP error status code + :param error_content: deserialized body of error response + """ + error_dict = None + if isinstance(error_content, dict): + error_dict = error_content.get('ApmecError') + # Find real error type + bad_apmec_error_flag = False + if error_dict: + # If Apmec key is found, it will definitely contain + # a 'message' and 'type' keys? + try: + error_type = error_dict['type'] + error_message = error_dict['message'] + if error_dict['detail']: + error_message += "\n" + error_dict['detail'] + except Exception: + bad_apmec_error_flag = True + if not bad_apmec_error_flag: + # If corresponding exception is defined, use it. + client_exc = getattr(exceptions, '%sClient' % error_type, None) + # Otherwise look up per status-code client exception + if not client_exc: + client_exc = exceptions.HTTP_EXCEPTION_MAP.get(status_code) + if client_exc: + raise client_exc(message=error_message, + status_code=status_code) + else: + raise exceptions.ApmecClientException( + status_code=status_code, message=error_message) + else: + raise exceptions.ApmecClientException(status_code=status_code, + message=error_dict) + else: + message = None + if isinstance(error_content, dict): + message = error_content.get('message') + if message: + raise exceptions.ApmecClientException(status_code=status_code, + message=message) + + # If we end up here the exception was not a apmec error + msg = "%s-%s" % (status_code, error_content) + raise exceptions.ApmecClientException(status_code=status_code, + message=msg) + + +class APIParamsCall(object): + """A Decorator to support formating and tenant overriding and filters.""" + + def __init__(self, function): + self.function = function + + def __get__(self, instance, owner): + def with_params(*args, **kwargs): + _format = instance.format + if 'format' in kwargs: + instance.format = kwargs['format'] + ret = self.function(instance, *args, **kwargs) + instance.format = _format + return ret + return with_params + + +class ClientBase(object): + """Client for the OpenStack Apmec v1.0 API. + + :param string username: Username for authentication. (optional) + :param string user_id: User ID for authentication. (optional) + :param string password: Password for authentication. (optional) + :param string token: Token for authentication. (optional) + :param string tenant_name: Tenant name. (optional) + :param string tenant_id: Tenant id. (optional) + :param string auth_strategy: 'keystone' by default, 'noauth' for no + authentication against keystone. (optional) + :param string auth_url: Keystone service endpoint for authorization. + :param string service_type: Network service type to pull from the + keystone catalog (e.g. 'network') (optional) + :param string endpoint_type: Network service endpoint type to pull from the + keystone catalog (e.g. 'publicURL', + 'internalURL', or 'adminURL') (optional) + :param string region_name: Name of a region to select when choosing an + endpoint from the service catalog. + :param string endpoint_url: A user-supplied endpoint URL for the apmec + service. Lazy-authentication is possible for API + service calls if endpoint is set at + instantiation.(optional) + :param integer timeout: Allows customization of the timeout for client + http requests. (optional) + :param bool insecure: SSL certificate validation. (optional) + :param bool log_credentials: Allow for logging of passwords or not. + Defaults to False. (optional) + :param string ca_cert: SSL CA bundle file to use. (optional) + :param integer retries: How many times idempotent (GET, PUT, DELETE) + requests to Apmec server should be retried if + they fail (default: 0). + :param bool raise_errors: If True then exceptions caused by connection + failure are propagated to the caller. + (default: True) + :param session: Keystone client auth session to use. (optional) + :param auth: Keystone auth plugin to use. (optional) + + Example:: + + from apmecclient.v1_0 import client + apmec = client.Client(username=USER, + password=PASS, + tenant_name=TENANT_NAME, + auth_url=KEYSTONE_URL) + + nets = apmec.list_networks() + ... + + """ + + # API has no way to report plurals, so we have to hard code them + # This variable should be overridden by a child class. + EXTED_PLURALS = {} + + def __init__(self, **kwargs): + """Initialize a new client for the Apmec v1.0 API.""" + super(ClientBase, self).__init__() + self.retries = kwargs.pop('retries', 0) + self.raise_errors = kwargs.pop('raise_errors', True) + self.httpclient = client.construct_http_client(**kwargs) + self.version = '1.0' + self.format = 'json' + self.action_prefix = "/v%s" % (self.version) + self.retry_interval = 1 + + def _handle_fault_response(self, status_code, response_body): + # Create exception with HTTP status code and message + _logger.debug("Error message: %s", response_body) + # Add deserialized error message to exception arguments + try: + des_error_body = self.deserialize(response_body, status_code) + except Exception: + # If unable to deserialized body it is probably not a + # Apmec error + des_error_body = {'message': response_body} + # Raise the appropriate exception + exception_handler_v10(status_code, des_error_body) + + def do_request(self, method, action, body=None, headers=None, params=None): + # Add format and tenant_id + action += ".%s" % self.format + action = self.action_prefix + action + if type(params) is dict and params: + params = utils.safe_encode_dict(params) + action += '?' + urlparse.urlencode(params, doseq=1) + + if body: + body = self.serialize(body) + + resp, replybody = self.httpclient.do_request( + action, method, body=body, + content_type=self.content_type()) + + status_code = resp.status_code + if status_code in (requests.codes.ok, + requests.codes.created, + requests.codes.accepted, + requests.codes.no_content): + return self.deserialize(replybody, status_code) + else: + if not replybody: + replybody = resp.reason + self._handle_fault_response(status_code, replybody) + + def get_auth_info(self): + return self.httpclient.get_auth_info() + + def serialize(self, data): + """Serializes a dictionary into either XML or JSON. + + A dictionary with a single key can be passed and it can contain any + structure. + """ + if data is None: + return None + elif type(data) is dict: + return serializer.Serializer( + self.get_attr_metadata()).serialize(data, self.content_type()) + else: + raise Exception(_("Unable to serialize object of type = '%s'") % + type(data)) + + def deserialize(self, data, status_code): + """Deserializes an XML or JSON string into a dictionary.""" + if status_code == 204: + return data + return serializer.Serializer(self.get_attr_metadata()).deserialize( + data, self.content_type())['body'] + + def get_attr_metadata(self): + if self.format == 'json': + return {} + old_request_format = self.format + self.format = 'json' + exts = self.list_extensions()['extensions'] + self.format = old_request_format + ns = dict([(ext['alias'], ext['namespace']) for ext in exts]) + self.EXTED_PLURALS.update(constants.PLURALS) + return {'plurals': self.EXTED_PLURALS, + 'xmlns': constants.XML_NS_V10, + constants.EXT_NS: ns} + + def content_type(self, _format=None): + """Returns the mime-type for either 'xml' or 'json'. + + Defaults to the currently set format. + """ + _format = _format or self.format + return "application/%s" % (_format) + + def retry_request(self, method, action, body=None, + headers=None, params=None): + """Call do_request with the default retry configuration. + + Only idempotent requests should retry failed connection attempts. + :raises: ConnectionFailed if the maximum # of retries is exceeded + """ + max_attempts = self.retries + 1 + for i in range(max_attempts): + try: + return self.do_request(method, action, body=body, + headers=headers, params=params) + except exceptions.ConnectionFailed: + # Exception has already been logged by do_request() + if i < self.retries: + _logger.debug('Retrying connection to Apmec service') + time.sleep(self.retry_interval) + elif self.raise_errors: + raise + + if self.retries: + msg = (_("Failed to connect to Apmec server after %d attempts") + % max_attempts) + else: + msg = _("Failed to connect Apmec server") + + raise exceptions.ConnectionFailed(reason=msg) + + def delete(self, action, body=None, headers=None, params=None): + return self.retry_request("DELETE", action, body=body, + headers=headers, params=params) + + def get(self, action, body=None, headers=None, params=None): + return self.retry_request("GET", action, body=body, + headers=headers, params=params) + + def post(self, action, body=None, headers=None, params=None): + # Do not retry POST requests to avoid the orphan objects problem. + return self.do_request("POST", action, body=body, + headers=headers, params=params) + + def put(self, action, body=None, headers=None, params=None): + return self.retry_request("PUT", action, body=body, + headers=headers, params=params) + + def list(self, collection, path, retrieve_all=True, **params): + if retrieve_all: + res = [] + for r in self._pagination(collection, path, **params): + res.extend(r[collection]) + return {collection: res} + else: + return self._pagination(collection, path, **params) + + def _pagination(self, collection, path, **params): + if params.get('page_reverse', False): + linkrel = 'previous' + else: + linkrel = 'next' + next = True + while next: + res = self.get(path, params=params) + yield res + next = False + try: + for link in res['%s_links' % collection]: + if link['rel'] == linkrel: + query_str = urlparse.urlparse(link['href']).query + params = urlparse.parse_qs(query_str) + next = True + break + except KeyError: + break + + +class Client(ClientBase): + + extensions_path = "/extensions" + extension_path = "/extensions/%s" + + meads_path = '/meads' + mead_path = '/meads/%s' + meas_path = '/meas' + mea_path = '/meas/%s' + mea_scale_path = '/meas/%s/actions' + mea_resources_path = '/meas/%s/resources' + + vims_path = '/vims' + vim_path = '/vims/%s' + + events_path = '/events' + event_path = '/events/%s' + + mesds_path = '/mesds' + mesd_path = '/mesds/%s' + + mess_path = '/mess' + mes_path = '/mess/%s' + + # API has no way to report plurals, so we have to hard code them + # EXTED_PLURALS = {} + + @APIParamsCall + def list_extensions(self, **_params): + """Fetch a list of all exts on server side.""" + return self.get(self.extensions_path, params=_params) + + @APIParamsCall + def show_extension(self, ext_alias, **_params): + """Fetch a list of all exts on server side.""" + return self.get(self.extension_path % ext_alias, params=_params) + + _MEAD = "mead" + _MESD = "mesd" + + @APIParamsCall + def list_meads(self, retrieve_all=True, **_params): + meads_dict = self.list(self._MEAD + 's', + self.meads_path, + retrieve_all, + **_params) + for mead in meads_dict['meads']: + if mead.get('description'): + if len(mead['description']) > DEFAULT_DESC_LENGTH: + mead['description'] = \ + mead['description'][:DEFAULT_DESC_LENGTH] + mead['description'] += '...' + return meads_dict + + @APIParamsCall + def show_mead(self, mead, **_params): + return self.get(self.mead_path % mead, + params=_params) + + @APIParamsCall + def create_mead(self, body): + body[self._MEAD]['service_types'] = [{'service_type': 'mead'}] + return self.post(self.meads_path, body) + + @APIParamsCall + def delete_mead(self, mead): + return self.delete(self.mead_path % mead) + + @APIParamsCall + def list_meas(self, retrieve_all=True, **_params): + meas = self.list('meas', self.meas_path, retrieve_all, **_params) + for mea in meas['meas']: + error_reason = mea.get('error_reason', None) + if error_reason and \ + len(error_reason) > DEFAULT_ERROR_REASON_LENGTH: + mea['error_reason'] = error_reason[ + :DEFAULT_ERROR_REASON_LENGTH] + mea['error_reason'] += '...' + return meas + + @APIParamsCall + def show_mea(self, mea, **_params): + return self.get(self.mea_path % mea, params=_params) + + @APIParamsCall + def create_mea(self, body): + return self.post(self.meas_path, body=body) + + @APIParamsCall + def delete_mea(self, mea): + return self.delete(self.mea_path % mea) + + @APIParamsCall + def update_mea(self, mea, body): + return self.put(self.mea_path % mea, body=body) + + @APIParamsCall + def list_mea_resources(self, mea, retrieve_all=True, **_params): + return self.list('resources', self.mea_resources_path % mea, + retrieve_all, **_params) + + @APIParamsCall + def scale_mea(self, mea, body=None): + return self.post(self.mea_scale_path % mea, body=body) + + @APIParamsCall + def show_vim(self, vim, **_params): + return self.get(self.vim_path % vim, params=_params) + + _VIM = "vim" + + @APIParamsCall + def create_vim(self, body): + return self.post(self.vims_path, body=body) + + @APIParamsCall + def delete_vim(self, vim): + return self.delete(self.vim_path % vim) + + @APIParamsCall + def update_vim(self, vim, body): + return self.put(self.vim_path % vim, body=body) + + @APIParamsCall + def list_vims(self, retrieve_all=True, **_params): + return self.list('vims', self.vims_path, retrieve_all, **_params) + + @APIParamsCall + def list_events(self, retrieve_all=True, **_params): + events = self.list('events', self.events_path, retrieve_all, + **_params) + return events + + @APIParamsCall + def list_mea_events(self, retrieve_all=True, **_params): + _params['resource_type'] = 'mea' + events = self.list('events', self.events_path, retrieve_all, + **_params) + mea_events = {} + mea_events['mea_events'] = events['events'] + return mea_events + + @APIParamsCall + def list_mead_events(self, retrieve_all=True, **_params): + _params['resource_type'] = 'mead' + events = self.list('events', self.events_path, retrieve_all, + **_params) + mead_events = {} + mead_events['mead_events'] = events['events'] + return mead_events + + @APIParamsCall + def list_vim_events(self, retrieve_all=True, **_params): + _params['resource_type'] = 'vim' + events = self.list('events', self.events_path, retrieve_all, + **_params) + vim_events = {} + vim_events['vim_events'] = events['events'] + return vim_events + + @APIParamsCall + def show_event(self, event_id, **_params): + return self.get(self.event_path % event_id, params=_params) + + @APIParamsCall + def list_mesds(self, retrieve_all=True, **_params): + mesds_dict = self.list(self._MESD + 's', + self.mesds_path, + retrieve_all, + **_params) + for mesd in mesds_dict['mesds']: + if 'description' in mesd.keys() and \ + len(mesd['description']) > DEFAULT_DESC_LENGTH: + mesd['description'] = mesd['description'][:DEFAULT_DESC_LENGTH] + mesd['description'] += '...' + return mesds_dict + + @APIParamsCall + def show_mesd(self, mesd, **_params): + return self.get(self.mesd_path % mesd, + params=_params) + + @APIParamsCall + def create_mesd(self, body): + return self.post(self.mesds_path, body) + + @APIParamsCall + def delete_mesd(self, mesd): + return self.delete(self.mesd_path % mesd) + + @APIParamsCall + def list_mess(self, retrieve_all=True, **_params): + mess = self.list('mess', self.mess_path, retrieve_all, **_params) + for mes in mess['mess']: + error_reason = mes.get('error_reason', None) + if error_reason and \ + len(error_reason) > DEFAULT_ERROR_REASON_LENGTH: + mes['error_reason'] = error_reason[ + :DEFAULT_ERROR_REASON_LENGTH] + mes['error_reason'] += '...' + return mess + + @APIParamsCall + def show_mes(self, mes, **_params): + return self.get(self.mes_path % mes, params=_params) + + @APIParamsCall + def create_mes(self, body): + return self.post(self.mess_path, body=body) + + @APIParamsCall + def delete_mes(self, mes): + return self.delete(self.mes_path % mes) \ No newline at end of file diff --git a/apmecclient/version.py b/apmecclient/version.py new file mode 100644 index 0000000..4c395e3 --- /dev/null +++ b/apmecclient/version.py @@ -0,0 +1,19 @@ +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# 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 pbr.version + + +__version__ = pbr.version.VersionInfo('python-apmecclient').version_string() diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 0000000..13fd382 --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,66 @@ +project = 'python-apmecclient' + +# -- General configuration --------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'openstackdocstheme' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +copyright = u'OpenStack Foundation' + +# 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 + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# -- Options for HTML output --------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +html_theme = 'openstackdocs' + +# Output file base name for HTML help builder. +htmlhelp_basename = '%sdoc' % project + + +# 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' +html_last_updated_fmt = '%Y-%m-%d %H:%M' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, +# documentclass [howto/manual]). +latex_documents = [ + ('index', + '%s.tex' % project, + u'%s Documentation' % project, + u'OpenStack Foundation', 'manual'), +] + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'http://docs.python.org/': None} + + +# -- Options for openstackdocstheme ------------------------------------------- +repository_name = 'openstack/python-apmecclient' +bug_project = 'python-apmecclient' +bug_tag = '' diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..87dbffe --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,25 @@ +Python bindings to the OpenStack Apmec API +============================================ + +In order to use the python apmec client directly, you must first obtain an auth token and identify which endpoint you wish to speak to. Once you have done so, you can use the API. + + +Command-line Tool +================= +In order to use the CLI, you must provide your OpenStack username, password, tenant, and auth endpoint. Use the corresponding configuration options (``--os-username``, ``--os-password``, ``--os-tenant-name``, and ``--os-auth-url``) or set them in environment variables:: + + export OS_USERNAME=user + export OS_PASSWORD=pass + export OS_TENANT_NAME=tenant + export OS_AUTH_URL=http://auth.example.com:5000/v2.0 + +The command line tool will attempt to reauthenticate using your provided credentials for every request. You can override this behavior by manually supplying an auth token using ``--os-url`` and ``--os-auth-token``. You can alternatively set these environment variables:: + + export OS_URL=http://apmec.example.org:9896/ + export OS_TOKEN=3bcc3d3a03f44e3d8377f9247b0ad155 + +If apmec server does not require authentication, besides these two arguments or environment variables (We can use any value as token.), we need manually supply ``--os-auth-strategy`` or set the environment variable:: + + export OS_AUTH_STRATEGY=noauth + +Once you've configured your authentication parameters, you can run ``apmec -h`` to see a complete listing of available commands. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..77ccc2c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +pbr!=2.1.0,>=2.0.0 # Apache-2.0 +cliff!=2.9.0,>=2.8.0 # Apache-2.0 +iso8601>=0.1.11 # MIT +netaddr>=0.7.18 # BSD +requests>=2.14.2 # Apache-2.0 +python-keystoneclient>=3.8.0 # Apache-2.0 +simplejson>=3.5.1 # MIT +six>=1.9.0 # MIT +stevedore>=1.20.0 # Apache-2.0 +Babel!=2.4.0,>=2.3.4 # BSD + +oslo.i18n>=3.15.3 # Apache-2.0 +oslo.log>=3.30.0 # Apache-2.0 +oslo.utils>=3.28.0 # Apache-2.0 +oslo.serialization!=2.19.1,>=2.18.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..ec7b7b0 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,45 @@ +[metadata] +name = python-apmecclient +summary = CLI and Client Library for OpenStack Apmec +description-file = + README.rst +author = OpenStack +author-email = openstack-dev@lists.openstack.org +home-page = http://docs.openstack.org/developer/apmec/ +classifier = + Environment :: OpenStack + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: Apache Software License + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + +[files] +packages = + apmecclient + +[global] +setup-hooks = + pbr.hooks.setup_hook + +[entry_points] +console_scripts = + apmec = apmecclient.shell:main + +[build_sphinx] +all_files = 1 +build-dir = doc/build +source-dir = doc/source + +[build_releasenotes] +all_files = 1 +build-dir = releasenotes/build +source-dir = releasenotes/source + +[wheel] +universal = 1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..566d844 --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +# Copyright (c) 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. + +# THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT +import setuptools + +# In python < 2.7.4, a lazy loading of package `pbr` will break +# setuptools if some other modules registered functions in `atexit`. +# solution from: http://bugs.python.org/issue15881#msg170215 +try: + import multiprocessing # noqa +except ImportError: + pass + +setuptools.setup( + setup_requires=['pbr>=2.0.0'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..e9cf701 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,19 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +hacking!=0.13.0,<0.14,>=0.12.0 # Apache-2.0 +coverage!=4.4,>=4.0 # Apache-2.0 +fixtures>=3.0.0 # Apache-2.0/BSD +flake8<2.6.0,>=2.5.4 # MIT +pep8==1.5.7 # MIT +pyflakes==0.8.1 # MIT +python-subunit>=0.0.18 # Apache-2.0/BSD +sphinx>=1.6.2 # BSD +testrepository>=0.0.18 # Apache-2.0/BSD +testtools>=1.4.0 # MIT +oslosphinx>=4.7.0 # Apache-2.0 +openstackdocstheme>=1.17.0 # Apache-2.0 + +# releasenotes +reno>=2.5.0 # Apache-2.0 +mock>=2.0.0 # BSD diff --git a/tools/apmec.bash_completion b/tools/apmec.bash_completion new file mode 100644 index 0000000..97ceb64 --- /dev/null +++ b/tools/apmec.bash_completion @@ -0,0 +1,27 @@ +_apmec_opts="" # lazy init +_apmec_flags="" # lazy init +_apmec_opts_exp="" # lazy init +_apmec() +{ + local cur prev nbc cflags + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + if [ "x$_apmec_opts" == "x" ] ; then + nbc="`apmec bash-completion`" + _apmec_opts="`echo "$nbc" | sed -e "s/--[a-z0-9_-]*//g" -e "s/\s\s*/ /g"`" + _apmec_flags="`echo " $nbc" | sed -e "s/ [^-][^-][a-z0-9_-]*//g" -e "s/\s\s*/ /g"`" + _apmec_opts_exp="`echo "$_apmec_opts" | sed -e "s/\s/|/g"`" + fi + + if [[ " ${COMP_WORDS[@]} " =~ " "($_apmec_opts_exp)" " && "$prev" != "help" ]] ; then + COMPLETION_CACHE=~/.apmecclient/*/*-cache + cflags="$_apmec_flags "$(cat $COMPLETION_CACHE 2> /dev/null | tr '\n' ' ') + COMPREPLY=($(compgen -W "${cflags}" -- ${cur})) + else + COMPREPLY=($(compgen -W "${_apmec_opts}" -- ${cur})) + fi + return 0 +} +complete -F _apmec apmec diff --git a/tools/tox_install.sh b/tools/tox_install.sh new file mode 100755 index 0000000..e61b63a --- /dev/null +++ b/tools/tox_install.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +# Client constraint file contains this client version pin that is in conflict +# with installing the client from source. We should remove the version pin in +# the constraints file before applying it for from-source installation. + +CONSTRAINTS_FILE="$1" +shift 1 + +set -e + +# NOTE(tonyb): Place this in the tox enviroment's log dir so it will get +# published to logs.openstack.org for easy debugging. +localfile="$VIRTUAL_ENV/log/upper-constraints.txt" + +if [[ "$CONSTRAINTS_FILE" != http* ]]; then + CONSTRAINTS_FILE="file://$CONSTRAINTS_FILE" +fi +# NOTE(tonyb): need to add curl to bindep.txt if the project supports bindep +curl "$CONSTRAINTS_FILE" --insecure --progress-bar --output "$localfile" + +pip install -c"$localfile" openstack-requirements + +# This is the main purpose of the script: Allow local installation of +# the current repo. It is listed in constraints file and thus any +# install will be constrained and we need to unconstrain it. +edit-constraints "$localfile" -- "$CLIENT_NAME" + +pip install -c"$localfile" -U "$@" +exit $? diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..94211d2 --- /dev/null +++ b/tox.ini @@ -0,0 +1,36 @@ +[tox] +envlist = py35,py27,pypy,pep8 +minversion = 2.0 +skipsdist = True + +[testenv] +setenv = VIRTUAL_ENV={envdir} + BRANCH_NAME=master + CLIENT_NAME=python-apmecclient + LANG=en_US.UTF-8 + LANGUAGE=en_US:en + LC_ALL=C +usedevelop = True +install_command = {toxinidir}/tools/tox_install.sh {env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt} {opts} {packages} +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = python setup.py testr --testr-args='{posargs}' + +[testenv:pep8] +commands = flake8 +distribute = false + +[testenv:venv] +commands = {posargs} + +[testenv:releasenotes] +commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html + +[testenv:cover] +commands = python setup.py testr --coverage --testr-args='{posargs}' + +[flake8] +# E125 continuation line does not distinguish itself from next logical line +ignore = E125 +show-source = true +exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,tools