From b59b288ac8ef3f2b2404c0a520ac17ec253747e7 Mon Sep 17 00:00:00 2001 From: Fabio Verboso Date: Mon, 10 Apr 2017 16:36:09 +0200 Subject: [PATCH] first commit Change-Id: Id880cbc92ee4de7da5e63044d0c68138d7ecbf64 --- .gitignore | 21 + CONTRIBUTING.rst | 17 + LICENSE | 176 +++++ README.rst | 19 + doc/source/conf.py | 75 ++ doc/source/contributing.rst | 4 + doc/source/index.rst | 25 + doc/source/installation.rst | 12 + doc/source/readme.rst | 1 + doc/source/usage.rst | 7 + iotronicclient/__init__.py | 27 + iotronicclient/client.py | 153 +++++ iotronicclient/common/__init__.py | 0 iotronicclient/common/apiclient/__init__.py | 0 iotronicclient/common/apiclient/base.py | 517 ++++++++++++++ iotronicclient/common/apiclient/exceptions.py | 469 +++++++++++++ iotronicclient/common/base.py | 252 +++++++ iotronicclient/common/cliutils.py | 293 ++++++++ iotronicclient/common/filecache.py | 104 +++ iotronicclient/common/http.py | 645 ++++++++++++++++++ iotronicclient/common/i18n.py | 31 + iotronicclient/common/utils.py | 371 ++++++++++ iotronicclient/exc.py | 71 ++ iotronicclient/shell.py | 447 ++++++++++++ iotronicclient/v1/__init__.py | 0 iotronicclient/v1/board.py | 105 +++ iotronicclient/v1/board_shell.py | 223 ++++++ iotronicclient/v1/client.py | 63 ++ iotronicclient/v1/plugin.py | 107 +++ iotronicclient/v1/plugin_injection.py | 103 +++ iotronicclient/v1/plugin_injection_shell.py | 98 +++ iotronicclient/v1/plugin_shell.py | 228 +++++++ iotronicclient/v1/resource_fields.py | 208 ++++++ iotronicclient/v1/shell.py | 38 ++ iotronicclient/v1/utils.py | 48 ++ releasenotes/notes/.placeholder | 0 releasenotes/source/_static/.placeholder | 0 releasenotes/source/_templates/.placeholder | 0 releasenotes/source/conf.py | 275 ++++++++ releasenotes/source/index.rst | 8 + releasenotes/source/unreleased.rst | 5 + requirements.txt | 17 + setup.cfg | 37 + setup.py | 29 + test-requirements.txt | 17 + tox.ini | 41 ++ 46 files changed, 5387 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.rst create mode 100644 LICENSE create mode 100644 README.rst create mode 100755 doc/source/conf.py create mode 100644 doc/source/contributing.rst create mode 100644 doc/source/index.rst create mode 100644 doc/source/installation.rst create mode 100644 doc/source/readme.rst create mode 100644 doc/source/usage.rst create mode 100644 iotronicclient/__init__.py create mode 100644 iotronicclient/client.py create mode 100644 iotronicclient/common/__init__.py create mode 100644 iotronicclient/common/apiclient/__init__.py create mode 100644 iotronicclient/common/apiclient/base.py create mode 100644 iotronicclient/common/apiclient/exceptions.py create mode 100644 iotronicclient/common/base.py create mode 100644 iotronicclient/common/cliutils.py create mode 100644 iotronicclient/common/filecache.py create mode 100644 iotronicclient/common/http.py create mode 100644 iotronicclient/common/i18n.py create mode 100644 iotronicclient/common/utils.py create mode 100644 iotronicclient/exc.py create mode 100644 iotronicclient/shell.py create mode 100644 iotronicclient/v1/__init__.py create mode 100644 iotronicclient/v1/board.py create mode 100644 iotronicclient/v1/board_shell.py create mode 100644 iotronicclient/v1/client.py create mode 100644 iotronicclient/v1/plugin.py create mode 100644 iotronicclient/v1/plugin_injection.py create mode 100644 iotronicclient/v1/plugin_injection_shell.py create mode 100644 iotronicclient/v1/plugin_shell.py create mode 100644 iotronicclient/v1/resource_fields.py create mode 100644 iotronicclient/v1/shell.py create mode 100644 iotronicclient/v1/utils.py create mode 100644 releasenotes/notes/.placeholder create mode 100644 releasenotes/source/_static/.placeholder create mode 100644 releasenotes/source/_templates/.placeholder create mode 100644 releasenotes/source/conf.py create mode 100644 releasenotes/source/index.rst create mode 100644 releasenotes/source/unreleased.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 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8556db --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.coverage +.venv +.testrepository +subunit.log +.tox +*,cover +cover +*.pyc +.idea +*.sw? +*~ +build +dist +AUTHORS +ChangeLog +*.egg +*egg-info + +# Files created by releasenotes build +releasenotes/build + diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..cebec9c --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,17 @@ +If you would like to contribute to the development of OpenStack, you must +follow the steps in this page: + + http://docs.openstack.org/infra/manual/developers.html + +If you already have a good understanding of how the system works and your +OpenStack accounts are set up, you can skip to the development workflow +section of this documentation to learn how changes to OpenStack should be +submitted for review via the Gerrit tool: + + http://docs.openstack.org/infra/manual/developers.html#development-workflow + +Pull requests submitted through GitHub will be ignored. + +Bugs should be filed on Launchpad, not GitHub: + + https://bugs.launchpad.net/iotronic diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68c771a --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + + 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. + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..52e05d6 --- /dev/null +++ b/README.rst @@ -0,0 +1,19 @@ +=============================== +replace with the name for the git repo +=============================== + +Iotronic Client + +Please fill here a long description which must be at least 3 lines wrapped on +80 cols, so that distribution package maintainers can use it in their packages. +Note that this is a hard requirement. + +* Free software: Apache license +* Documentation: http://docs.openstack.org/developer/replace with the name for the git repo +* Source: http://git.openstack.org/cgit/openstack/replace with the name for the git repo +* Bugs: http://bugs.launchpad.net/iotronic + +Features +-------- + +* TODO diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100755 index 0000000..d33da69 --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# 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 os +import sys + +sys.path.insert(0, os.path.abspath('../..')) +# -- 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', + 'oslosphinx' +] + +# autodoc generation is a bit aggressive and a nuisance when doing heavy +# text edit cycles. +# execute "export SPHINX_DEBUG=1" in your terminal to disable + +# The suffix of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'replace with the name for the git repo' +copyright = u'2016, 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_path = ["."] +# html_theme = '_theme' +# html_static_path = ['static'] + +# Output file base name for HTML help builder. +htmlhelp_basename = '%sdoc' % project + +# 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} diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst new file mode 100644 index 0000000..1728a61 --- /dev/null +++ b/doc/source/contributing.rst @@ -0,0 +1,4 @@ +============ +Contributing +============ +.. include:: ../../CONTRIBUTING.rst diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..fce3883 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,25 @@ +.. replace with the name for the git repo documentation master file, created by + sphinx-quickstart on Tue Jul 9 22:26:36 2013. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to replace with the name for the git repo's documentation! +======================================================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + readme + installation + usage + contributing + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/doc/source/installation.rst b/doc/source/installation.rst new file mode 100644 index 0000000..e358dd3 --- /dev/null +++ b/doc/source/installation.rst @@ -0,0 +1,12 @@ +============ +Installation +============ + +At the command line:: + + $ pip install replace with the name for the git repo + +Or, if you have virtualenvwrapper installed:: + + $ mkvirtualenv replace with the name for the git repo + $ pip install replace with the name for the git repo diff --git a/doc/source/readme.rst b/doc/source/readme.rst new file mode 100644 index 0000000..a6210d3 --- /dev/null +++ b/doc/source/readme.rst @@ -0,0 +1 @@ +.. include:: ../../README.rst diff --git a/doc/source/usage.rst b/doc/source/usage.rst new file mode 100644 index 0000000..a7c82ad --- /dev/null +++ b/doc/source/usage.rst @@ -0,0 +1,7 @@ +======== +Usage +======== + +To use replace with the name for the git repo in a project:: + + import iotronicclient diff --git a/iotronicclient/__init__.py b/iotronicclient/__init__.py new file mode 100644 index 0000000..e8e6a99 --- /dev/null +++ b/iotronicclient/__init__.py @@ -0,0 +1,27 @@ +# Copyright 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 + +from iotronicclient import client +from iotronicclient import exc as exceptions + +__version__ = pbr.version.VersionInfo('python-iotronicclient').version_string() + +__all__ = ( + 'client', + 'exc', + 'exceptions', +) diff --git a/iotronicclient/client.py b/iotronicclient/client.py new file mode 100644 index 0000000..086a46c --- /dev/null +++ b/iotronicclient/client.py @@ -0,0 +1,153 @@ +# 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 keystoneauth1 import loading as kaloading +from oslo_utils import importutils + +from iotronicclient.common.i18n import _ +from iotronicclient import exc + + +def get_client(api_version, os_auth_token=None, iotronic_url=None, + os_username=None, os_password=None, os_auth_url=None, + os_project_id=None, os_project_name=None, os_tenant_id=None, + os_tenant_name=None, os_region_name=None, + os_user_domain_id=None, os_user_domain_name=None, + os_project_domain_id=None, os_project_domain_name=None, + os_service_type=None, os_endpoint_type=None, + insecure=None, timeout=None, os_cacert=None, ca_file=None, + os_cert=None, cert_file=None, os_key=None, key_file=None, + os_iotronic_api_version=None, max_retries=None, + retry_interval=None, session=None, **ignored_kwargs): + """Get an authenticated client, based on the credentials. + + :param api_version: the API version to use. Valid value: '1'. + :param os_auth_token: pre-existing token to re-use + :param iotronic_url: iotronic API endpoint + :param os_username: name of a user + :param os_password: user's password + :param os_auth_url: endpoint to authenticate against + :param os_tenant_name: name of a tenant (deprecated in favour of + os_project_name) + :param os_tenant_id: ID of a tenant (deprecated in favour of + os_project_id) + :param os_project_name: name of a project + :param os_project_id: ID of a project + :param os_region_name: name of a keystone region + :param os_user_domain_name: name of a domain the user belongs to + :param os_user_domain_id: ID of a domain the user belongs to + :param os_project_domain_name: name of a domain the project belongs to + :param os_project_domain_id: ID of a domain the project belongs to + :param os_service_type: the type of service to lookup the endpoint for + :param os_endpoint_type: the type (exposure) of the endpoint + :param insecure: allow insecure SSL (no cert verification) + :param timeout: allows customization of the timeout for client HTTP + requests + :param os_cacert: path to cacert file + :param ca_file: path to cacert file, deprecated in favour of os_cacert + :param os_cert: path to cert file + :param cert_file: path to cert file, deprecated in favour of os_cert + :param os_key: path to key file + :param key_file: path to key file, deprecated in favour of os_key + :param os_iotronic_api_version: iotronic API version to use + :param max_retries: Maximum number of retries in case of conflict error + :param retry_interval: Amount of time (in seconds) between retries in case + of conflict error + :param session: Keystone session to use + :param ignored_kwargs: all the other params that are passed. Left for + backwards compatibility. They are ignored. + """ + os_service_type = os_service_type or 'iot' + os_endpoint_type = os_endpoint_type or 'publicURL' + project_id = (os_project_id or os_tenant_id) + project_name = (os_project_name or os_tenant_name) + kwargs = { + 'os_iotronic_api_version': os_iotronic_api_version, + 'max_retries': max_retries, + 'retry_interval': retry_interval, + } + endpoint = iotronic_url + cacert = os_cacert or ca_file + cert = os_cert or cert_file + key = os_key or key_file + if os_auth_token and endpoint: + kwargs.update({ + 'token': os_auth_token, + 'insecure': insecure, + 'ca_file': cacert, + 'cert_file': cert, + 'key_file': key, + 'timeout': timeout, + }) + elif os_auth_url: + auth_type = 'password' + auth_kwargs = { + 'auth_url': os_auth_url, + 'project_id': project_id, + 'project_name': project_name, + 'user_domain_id': os_user_domain_id, + 'user_domain_name': os_user_domain_name, + 'project_domain_id': os_project_domain_id, + 'project_domain_name': os_project_domain_name, + } + if os_username and os_password: + auth_kwargs.update({ + 'username': os_username, + 'password': os_password, + }) + elif os_auth_token: + auth_type = 'token' + auth_kwargs.update({ + 'token': os_auth_token, + }) + # Create new session only if it was not passed in + if not session: + loader = kaloading.get_plugin_loader(auth_type) + auth_plugin = loader.load_from_options(**auth_kwargs) + # Let keystoneauth do the necessary parameter conversions + session = kaloading.session.Session().load_from_options( + auth=auth_plugin, insecure=insecure, cacert=cacert, + cert=cert, key=key, timeout=timeout, + ) + + exception_msg = _('Must provide Keystone credentials or user-defined ' + 'endpoint and token') + if not endpoint: + if session: + try: + # Pass the endpoint, it will be used to get hostname + # and port that will be used for API version caching. It will + # be also set as endpoint_override. + endpoint = session.get_endpoint( + service_type=os_service_type, + interface=os_endpoint_type, + region_name=os_region_name + ) + except Exception as e: + raise exc.AmbiguousAuthSystem( + _('%(message)s, error was: %(error)s') % + {'message': exception_msg, 'error': e}) + else: + # Neither session, nor valid auth parameters provided + raise exc.AmbiguousAuthSystem(exception_msg) + + # Always pass the session + kwargs['session'] = session + + return Client(api_version, endpoint, **kwargs) + + +def Client(version, *args, **kwargs): + module = importutils.import_versioned_module('iotronicclient', + version, 'client') + client_class = getattr(module, 'Client') + return client_class(*args, **kwargs) diff --git a/iotronicclient/common/__init__.py b/iotronicclient/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iotronicclient/common/apiclient/__init__.py b/iotronicclient/common/apiclient/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iotronicclient/common/apiclient/base.py b/iotronicclient/common/apiclient/base.py new file mode 100644 index 0000000..0112159 --- /dev/null +++ b/iotronicclient/common/apiclient/base.py @@ -0,0 +1,517 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack Foundation +# Copyright 2012 Grid Dynamics +# 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. + +""" +Base utilities to build API operation managers and objects on top of. +""" + + +# E1102: %s is not callable +# pylint: disable=E1102 + +import abc +import copy + +from oslo_utils import strutils +import six +from six.moves import http_client +from six.moves.urllib import parse + +from iotronicclient.common.apiclient import exceptions +from iotronicclient.common.i18n import _ + + +def getid(obj): + """Return id if argument is a Resource. + + Abstracts the common pattern of allowing both an object or an object's ID + (UUID) as a parameter when dealing with relationships. + """ + try: + if obj.uuid: + return obj.uuid + except AttributeError: + pass + try: + return obj.id + except AttributeError: + return obj + + +# TODO(aababilov): call run_hooks() in HookableMixin's child classes +class HookableMixin(object): + """Mixin so classes can register and run hooks.""" + _hooks_map = {} + + @classmethod + def add_hook(cls, hook_type, hook_func): + """Add a new hook of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param hook_func: hook function + """ + if hook_type not in cls._hooks_map: + cls._hooks_map[hook_type] = [] + + cls._hooks_map[hook_type].append(hook_func) + + @classmethod + def run_hooks(cls, hook_type, *args, **kwargs): + """Run all hooks of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param args: args to be passed to every hook function + :param kwargs: kwargs to be passed to every hook function + """ + hook_funcs = cls._hooks_map.get(hook_type) or [] + for hook_func in hook_funcs: + hook_func(*args, **kwargs) + + +class BaseManager(HookableMixin): + """Basic manager type providing common operations. + + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, client): + """Initializes BaseManager with `client`. + + :param client: instance of BaseClient descendant for HTTP requests + """ + super(BaseManager, self).__init__() + self.client = client + + def _list(self, url, response_key=None, obj_class=None, json=None): + """List the collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers'. If response_key is None - all response body + will be used. + :param obj_class: class for constructing the returned objects + (self.resource_class will be used by default) + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + """ + if json: + body = self.client.post(url, json=json).json() + else: + body = self.client.get(url).json() + + if obj_class is None: + obj_class = self.resource_class + + data = body[response_key] if response_key is not None else body + # NOTE(ja): keystone returns values as list as {'values': [ ... ]} + # unlike other services which just return the list... + try: + data = data['values'] + except (KeyError, TypeError): + pass + + return [obj_class(self, res, loaded=True) for res in data if res] + + def _get(self, url, response_key=None): + """Get an object from collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'server'. If response_key is None - all response body + will be used. + """ + body = self.client.get(url).json() + data = body[response_key] if response_key is not None else body + return self.resource_class(self, data, loaded=True) + + def _head(self, url): + """Retrieve request headers for an object. + + :param url: a partial URL, e.g., '/servers' + """ + resp = self.client.head(url) + return resp.status_code == http_client.NO_CONTENT + + def _post(self, url, json, response_key=None, return_raw=False): + """Create an object. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'server'. If response_key is None - all response body + will be used. + :param return_raw: flag to force returning raw JSON instead of + Python object of self.resource_class + """ + body = self.client.post(url, json=json).json() + data = body[response_key] if response_key is not None else body + if return_raw: + return data + return self.resource_class(self, data) + + def _put(self, url, json=None, response_key=None): + """Update an object with PUT method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers'. If response_key is None - all response body + will be used. + """ + resp = self.client.put(url, json=json) + # PUT requests may not return a body + if resp.content: + body = resp.json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _patch(self, url, json=None, response_key=None): + """Update an object with PATCH method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers'. If response_key is None - all response body + will be used. + """ + body = self.client.patch(url, json=json).json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _delete(self, url): + """Delete an object. + + :param url: a partial URL, e.g., '/servers/my-server' + """ + return self.client.delete(url) + + +@six.add_metaclass(abc.ABCMeta) +class ManagerWithFind(BaseManager): + """Manager with additional `find()`/`findall()` methods.""" + + @abc.abstractmethod + def list(self): + pass + + def find(self, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + matches = self.findall(**kwargs) + num_matches = len(matches) + if num_matches == 0: + msg = _("No %(name)s matching %(args)s.") % { + 'name': self.resource_class.__name__, + 'args': kwargs + } + raise exceptions.NotFound(msg) + elif num_matches > 1: + raise exceptions.NoUniqueMatch() + else: + return matches[0] + + def findall(self, **kwargs): + """Find all items with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + found = [] + searches = kwargs.items() + + for obj in self.list(): + try: + if all(getattr(obj, attr) == value + for (attr, value) in searches): + found.append(obj) + except AttributeError: + continue + + return found + + +class CrudManager(BaseManager): + """Base manager class for manipulating entities. + + Children of this class are expected to define a `collection_key` and `key`. + + - `collection_key`: Usually a plural noun by convention (e.g. `entities`); + used to refer collections in both URL's (e.g. `/v3/entities`) and JSON + objects containing a list of member resources (e.g. `{'entities': [{}, + {}, {}]}`). + - `key`: Usually a singular noun by convention (e.g. `entity`); used to + refer to an individual member of the collection. + + """ + collection_key = None + key = None + + def build_url(self, base_url=None, **kwargs): + """Builds a resource URL for the given kwargs. + + Given an example collection where `collection_key = 'entities'` and + `key = 'entity'`, the following URL's could be generated. + + By default, the URL will represent a collection of entities, e.g.:: + + /entities + + If kwargs contains an `entity_id`, then the URL will represent a + specific member, e.g.:: + + /entities/{entity_id} + + :param base_url: if provided, the generated URL will be appended to it + """ + url = base_url if base_url is not None else '' + + url += '/%s' % self.collection_key + + # do we have a specific entity? + entity_id = kwargs.get('%s_id' % self.key) + if entity_id is not None: + url += '/%s' % entity_id + + return url + + def _filter_kwargs(self, kwargs): + """Drop null values and handle ids.""" + for key, ref in kwargs.copy().items(): + if ref is None: + kwargs.pop(key) + else: + if isinstance(ref, Resource): + kwargs.pop(key) + kwargs['%s_id' % key] = getid(ref) + return kwargs + + def create(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._post( + self.build_url(**kwargs), + {self.key: kwargs}, + self.key) + + def get(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._get( + self.build_url(**kwargs), + self.key) + + def head(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._head(self.build_url(**kwargs)) + + def list(self, base_url=None, **kwargs): + """List the collection. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % parse.urlencode(kwargs) if kwargs else '', + }, + self.collection_key) + + def put(self, base_url=None, **kwargs): + """Update an element. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._put(self.build_url(base_url=base_url, **kwargs)) + + def update(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + params = kwargs.copy() + params.pop('%s_id' % self.key) + + return self._patch( + self.build_url(**kwargs), + {self.key: params}, + self.key) + + def delete(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + + return self._delete( + self.build_url(**kwargs)) + + def find(self, base_url=None, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + rl = self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % parse.urlencode(kwargs) if kwargs else '', + }, + self.collection_key) + num = len(rl) + + if num == 0: + msg = _("No %(name)s matching %(args)s.") % { + 'name': self.resource_class.__name__, + 'args': kwargs + } + raise exceptions.NotFound(msg) + elif num > 1: + raise exceptions.NoUniqueMatch + else: + return rl[0] + + +class Extension(HookableMixin): + """Extension descriptor.""" + + SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__') + manager_class = None + + def __init__(self, name, module): + super(Extension, self).__init__() + self.name = name + self.module = module + self._parse_extension_module() + + def _parse_extension_module(self): + self.manager_class = None + for attr_name, attr_value in self.module.__dict__.items(): + if attr_name in self.SUPPORTED_HOOKS: + self.add_hook(attr_name, attr_value) + else: + try: + if issubclass(attr_value, BaseManager): + self.manager_class = attr_value + except TypeError: + pass + + def __repr__(self): + return "" % self.name + + +class Resource(object): + """Base class for OpenStack resources (tenant, user, etc.). + + This is pretty much just a bag for attributes. + """ + + HUMAN_ID = False + NAME_ATTR = 'name' + + def __init__(self, manager, info, loaded=False): + """Populate and bind to a manager. + + :param manager: BaseManager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + """ + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + def __repr__(self): + reprkeys = sorted(k + for k in self.__dict__.keys() + if k[0] != '_' and k != 'manager') + info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) + return "<%s %s>" % (self.__class__.__name__, info) + + @property + def human_id(self): + """Human-readable ID which can be used for bash completion.""" + if self.HUMAN_ID: + name = getattr(self, self.NAME_ATTR, None) + if name is not None: + return strutils.to_slug(name) + return None + + def _add_details(self, info): + for (k, v) in info.items(): + try: + setattr(self, k, v) + self._info[k] = v + except AttributeError: + # In this case we already defined the attribute on the class + pass + + def __getattr__(self, k): + if k not in self.__dict__: + # NOTE(bcwaldon): disallow lazy-loading if already loaded once + if not self.is_loaded(): + self.get() + return self.__getattr__(k) + + raise AttributeError(k) + else: + return self.__dict__[k] + + def get(self): + """Support for lazy loading details. + + Some clients, such as novaclient have the option to lazy load the + details, details which can be loaded with this function. + """ + # set_loaded() first ... so if we have to bail, we know we tried. + self.set_loaded(True) + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.id) + if new: + self._add_details(new._info) + self._add_details( + {'x_request_id': self.manager.client.last_request_id}) + + def __eq__(self, other): + if not isinstance(other, Resource): + return NotImplemented + # two resources of different types are not equal + if not isinstance(other, self.__class__): + return False + return self._info == other._info + + def is_loaded(self): + return self._loaded + + def set_loaded(self, val): + self._loaded = val + + def to_dict(self): + return copy.deepcopy(self._info) diff --git a/iotronicclient/common/apiclient/exceptions.py b/iotronicclient/common/apiclient/exceptions.py new file mode 100644 index 0000000..66fbbd3 --- /dev/null +++ b/iotronicclient/common/apiclient/exceptions.py @@ -0,0 +1,469 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 Nebula, Inc. +# Copyright 2013 Alessio Ababilov +# 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. + +""" +Exception definitions. +""" + + +import inspect +import sys + +import six +from six.moves import http_client + +from iotronicclient.common.i18n import _ + + +class ClientException(Exception): + """The base exception class for all exceptions this library raises.""" + pass + + +class ValidationError(ClientException): + """Error in validation on API client side.""" + pass + + +class UnsupportedVersion(ClientException): + """User is trying to use an unsupported version of the API.""" + pass + + +class CommandError(ClientException): + """Error in CLI tool.""" + pass + + +class AuthorizationFailure(ClientException): + """Cannot authorize API client.""" + pass + + +class ConnectionError(ClientException): + """Cannot connect to API service.""" + pass + + +class ConnectionRefused(ConnectionError): + """Connection refused while trying to connect to API service.""" + pass + + +class AuthPluginOptionsMissing(AuthorizationFailure): + """Auth plugin misses some options.""" + def __init__(self, opt_names): + super(AuthPluginOptionsMissing, self).__init__( + _("Authentication failed. Missing options: %s") % + ", ".join(opt_names)) + self.opt_names = opt_names + + +class AuthSystemNotFound(AuthorizationFailure): + """User has specified an AuthSystem that is not installed.""" + def __init__(self, auth_system): + super(AuthSystemNotFound, self).__init__( + _("AuthSystemNotFound: %r") % auth_system) + self.auth_system = auth_system + + +class NoUniqueMatch(ClientException): + """Multiple entities found instead of one.""" + pass + + +class EndpointException(ClientException): + """Something is rotten in Service Catalog.""" + pass + + +class EndpointNotFound(EndpointException): + """Could not find requested endpoint in Service Catalog.""" + pass + + +class AmbiguousEndpoints(EndpointException): + """Found more than one matching endpoint in Service Catalog.""" + def __init__(self, endpoints=None): + super(AmbiguousEndpoints, self).__init__( + _("AmbiguousEndpoints: %r") % endpoints) + self.endpoints = endpoints + + +class HttpError(ClientException): + """The base exception class for all HTTP exceptions.""" + http_status = 0 + message = _("HTTP Error") + + def __init__(self, message=None, details=None, + response=None, request_id=None, + url=None, method=None, http_status=None): + self.http_status = http_status or self.http_status + self.message = message or self.message + self.details = details + self.request_id = request_id + self.response = response + self.url = url + self.method = method + formatted_string = "%s (HTTP %s)" % (self.message, self.http_status) + if request_id: + formatted_string += " (Request-ID: %s)" % request_id + super(HttpError, self).__init__(formatted_string) + + +class HTTPRedirection(HttpError): + """HTTP Redirection.""" + message = _("HTTP Redirection") + + +class HTTPClientError(HttpError): + """Client-side HTTP error. + + Exception for cases in which the client seems to have erred. + """ + message = _("HTTP Client Error") + + +class HttpServerError(HttpError): + """Server-side HTTP error. + + Exception for cases in which the server is aware that it has + erred or is incapable of performing the request. + """ + message = _("HTTP Server Error") + + +class MultipleChoices(HTTPRedirection): + """HTTP 300 - Multiple Choices. + + Indicates multiple options for the resource that the client may follow. + """ + + http_status = http_client.MULTIPLE_CHOICES + message = _("Multiple Choices") + + +class BadRequest(HTTPClientError): + """HTTP 400 - Bad Request. + + The request cannot be fulfilled due to bad syntax. + """ + http_status = http_client.BAD_REQUEST + message = _("Bad Request") + + +class Unauthorized(HTTPClientError): + """HTTP 401 - Unauthorized. + + Similar to 403 Forbidden, but specifically for use when authentication + is required and has failed or has not yet been provided. + """ + http_status = http_client.UNAUTHORIZED + message = _("Unauthorized") + + +class PaymentRequired(HTTPClientError): + """HTTP 402 - Payment Required. + + Reserved for future use. + """ + http_status = http_client.PAYMENT_REQUIRED + message = _("Payment Required") + + +class Forbidden(HTTPClientError): + """HTTP 403 - Forbidden. + + The request was a valid request, but the server is refusing to respond + to it. + """ + http_status = http_client.FORBIDDEN + message = _("Forbidden") + + +class NotFound(HTTPClientError): + """HTTP 404 - Not Found. + + The requested resource could not be found but may be available again + in the future. + """ + http_status = http_client.NOT_FOUND + message = _("Not Found") + + +class MethodNotAllowed(HTTPClientError): + """HTTP 405 - Method Not Allowed. + + A request was made of a resource using a request method not supported + by that resource. + """ + http_status = http_client.METHOD_NOT_ALLOWED + message = _("Method Not Allowed") + + +class NotAcceptable(HTTPClientError): + """HTTP 406 - Not Acceptable. + + The requested resource is only capable of generating content not + acceptable according to the Accept headers sent in the request. + """ + http_status = http_client.NOT_ACCEPTABLE + message = _("Not Acceptable") + + +class ProxyAuthenticationRequired(HTTPClientError): + """HTTP 407 - Proxy Authentication Required. + + The client must first authenticate itself with the proxy. + """ + http_status = http_client.PROXY_AUTHENTICATION_REQUIRED + message = _("Proxy Authentication Required") + + +class RequestTimeout(HTTPClientError): + """HTTP 408 - Request Timeout. + + The server timed out waiting for the request. + """ + http_status = http_client.REQUEST_TIMEOUT + message = _("Request Timeout") + + +class Conflict(HTTPClientError): + """HTTP 409 - Conflict. + + Indicates that the request could not be processed because of conflict + in the request, such as an edit conflict. + """ + http_status = http_client.CONFLICT + message = _("Conflict") + + +class Gone(HTTPClientError): + """HTTP 410 - Gone. + + Indicates that the resource requested is no longer available and will + not be available again. + """ + http_status = http_client.GONE + message = _("Gone") + + +class LengthRequired(HTTPClientError): + """HTTP 411 - Length Required. + + The request did not specify the length of its content, which is + required by the requested resource. + """ + http_status = http_client.LENGTH_REQUIRED + message = _("Length Required") + + +class PreconditionFailed(HTTPClientError): + """HTTP 412 - Precondition Failed. + + The server does not meet one of the preconditions that the requester + put on the request. + """ + http_status = http_client.PRECONDITION_FAILED + message = _("Precondition Failed") + + +class RequestEntityTooLarge(HTTPClientError): + """HTTP 413 - Request Entity Too Large. + + The request is larger than the server is willing or able to process. + """ + http_status = http_client.REQUEST_ENTITY_TOO_LARGE + message = _("Request Entity Too Large") + + def __init__(self, *args, **kwargs): + try: + self.retry_after = int(kwargs.pop('retry_after')) + except (KeyError, ValueError): + self.retry_after = 0 + + super(RequestEntityTooLarge, self).__init__(*args, **kwargs) + + +class RequestUriTooLong(HTTPClientError): + """HTTP 414 - Request-URI Too Long. + + The URI provided was too long for the server to process. + """ + http_status = http_client.REQUEST_URI_TOO_LONG + message = _("Request-URI Too Long") + + +class UnsupportedMediaType(HTTPClientError): + """HTTP 415 - Unsupported Media Type. + + The request entity has a media type which the server or resource does + not support. + """ + http_status = http_client.UNSUPPORTED_MEDIA_TYPE + message = _("Unsupported Media Type") + + +class RequestedRangeNotSatisfiable(HTTPClientError): + """HTTP 416 - Requested Range Not Satisfiable. + + The client has asked for a portion of the file, but the server cannot + supply that portion. + """ + http_status = http_client.REQUESTED_RANGE_NOT_SATISFIABLE + message = _("Requested Range Not Satisfiable") + + +class ExpectationFailed(HTTPClientError): + """HTTP 417 - Expectation Failed. + + The server cannot meet the requirements of the Expect request-header field. + """ + http_status = http_client.EXPECTATION_FAILED + message = _("Expectation Failed") + + +class UnprocessableEntity(HTTPClientError): + """HTTP 422 - Unprocessable Entity. + + The request was well-formed but was unable to be followed due to semantic + errors. + """ + http_status = http_client.UNPROCESSABLE_ENTITY + message = _("Unprocessable Entity") + + +class InternalServerError(HttpServerError): + """HTTP 500 - Internal Server Error. + + A generic error message, given when no more specific message is suitable. + """ + http_status = http_client.INTERNAL_SERVER_ERROR + message = _("Internal Server Error") + + +# NotImplemented is a python keyword. +class HttpNotImplemented(HttpServerError): + """HTTP 501 - Not Implemented. + + The server either does not recognize the request method, or it lacks + the ability to fulfill the request. + """ + http_status = http_client.NOT_IMPLEMENTED + message = _("Not Implemented") + + +class BadGateway(HttpServerError): + """HTTP 502 - Bad Gateway. + + The server was acting as a gateway or proxy and received an invalid + response from the upstream server. + """ + http_status = http_client.BAD_GATEWAY + message = _("Bad Gateway") + + +class ServiceUnavailable(HttpServerError): + """HTTP 503 - Service Unavailable. + + The server is currently unavailable. + """ + http_status = http_client.SERVICE_UNAVAILABLE + message = _("Service Unavailable") + + +class GatewayTimeout(HttpServerError): + """HTTP 504 - Gateway Timeout. + + The server was acting as a gateway or proxy and did not receive a timely + response from the upstream server. + """ + http_status = http_client.GATEWAY_TIMEOUT + message = _("Gateway Timeout") + + +class HttpVersionNotSupported(HttpServerError): + """HTTP 505 - HttpVersion Not Supported. + + The server does not support the HTTP protocol version used in the request. + """ + http_status = http_client.HTTP_VERSION_NOT_SUPPORTED + message = _("HTTP Version Not Supported") + + +# _code_map contains all the classes that have http_status attribute. +_code_map = dict( + (getattr(obj, 'http_status', None), obj) + for name, obj in vars(sys.modules[__name__]).items() + if inspect.isclass(obj) and getattr(obj, 'http_status', False) +) + + +def from_response(response, method, url): + """Returns an instance of :class:`HttpError` or subclass based on response. + + :param response: instance of `requests.Response` class + :param method: HTTP method used for request + :param url: URL used for request + """ + + req_id = response.headers.get("x-openstack-request-id") + # NOTE(hdd) true for older versions of nova and cinder + if not req_id: + req_id = response.headers.get("x-compute-request-id") + kwargs = { + "http_status": response.status_code, + "response": response, + "method": method, + "url": url, + "request_id": req_id, + } + if "retry-after" in response.headers: + kwargs["retry_after"] = response.headers["retry-after"] + + content_type = response.headers.get("Content-Type", "") + if content_type.startswith("application/json"): + try: + body = response.json() + except ValueError: + pass + else: + if isinstance(body, dict): + error = body.get(list(body)[0]) + if isinstance(error, dict): + kwargs["message"] = (error.get("message") or + error.get("faultstring")) + kwargs["details"] = (error.get("details") or + six.text_type(body)) + elif content_type.startswith("text/"): + kwargs["details"] = getattr(response, 'text', '') + + try: + cls = _code_map[response.status_code] + except KeyError: + # 5XX status codes are server errors + if response.status_code >= http_client.INTERNAL_SERVER_ERROR: + cls = HttpServerError + # 4XX status codes are client request errors + elif (http_client.BAD_REQUEST <= response.status_code < + http_client.INTERNAL_SERVER_ERROR): + cls = HTTPClientError + else: + cls = HttpError + return cls(**kwargs) diff --git a/iotronicclient/common/base.py b/iotronicclient/common/base.py new file mode 100644 index 0000000..7ade3cf --- /dev/null +++ b/iotronicclient/common/base.py @@ -0,0 +1,252 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +import abc +import copy +import six + +import six.moves.urllib.parse as urlparse + +from iotronicclient.common.apiclient import base +from iotronicclient import exc + + +def getid(obj): + """Wrapper to get object's ID. + + Abstracts the common pattern of allowing both an object or an + object's ID (UUID) as a parameter when dealing with relationships. + """ + try: + return obj.id + except AttributeError: + return obj + + +@six.add_metaclass(abc.ABCMeta) +class Manager(object): + """Provides CRUD operations with a particular API.""" + + def __init__(self, api): + self.api = api + + def _path(self, resource_id=None): + """Returns a request path for a given resource identifier. + + :param resource_id: Identifier of the resource to generate the request + path. + """ + return ('/v1/%s/%s' % (self._resource_name, resource_id) + if resource_id else '/v1/%s' % self._resource_name) + + @abc.abstractproperty + def resource_class(self): + """The resource class + + """ + + @abc.abstractproperty + def _resource_name(self): + """The resource name. + + """ + + def _get(self, resource_id, fields=None): + """Retrieve a resource. + + :param resource_id: Identifier of the resource. + :param fields: List of specific fields to be returned. + :raises exc.ValidationError: For invalid resource_id arg value. + """ + + if not resource_id: + raise exc.ValidationError( + "The identifier argument is invalid. " + "Value provided: {!r}".format(resource_id)) + + if fields is not None: + resource_id = '%s?fields=' % resource_id + resource_id += ','.join(fields) + + try: + return self._list(self._path(resource_id))[0] + except IndexError: + return None + + def _get_as_dict(self, resource_id, fields=None): + """Retrieve a resource as a dictionary + + :param resource_id: Identifier of the resource. + :param fields: List of specific fields to be returned. + :returns: a dictionary representing the resource; may be empty + """ + + resource = self._get(resource_id, fields=fields) + if resource: + return resource.to_dict() + else: + return {} + + def _format_body_data(self, body, response_key): + if response_key: + try: + data = body[response_key] + except KeyError: + return [] + else: + data = body + + if not isinstance(data, list): + data = [data] + + return data + + def _list_pagination(self, url, response_key=None, obj_class=None, + limit=None): + """Retrieve a list of items. + + The Iotronic API is configured to return a maximum number of + items per request, (see Iotronic's api.max_limit option). This + iterates over the 'next' link (pagination) in the responses, + to get the number of items specified by 'limit'. If 'limit' + is None this function will continue pagination until there are + no more values to be returned. + + :param url: a partial URL, e.g. '/boards' + :param response_key: the key to be looked up in response + dictionary, e.g. 'boards' + :param obj_class: class for constructing the returned objects. + :param limit: maximum number of items to return. If None returns + everything. + + """ + if obj_class is None: + obj_class = self.resource_class + + if limit is not None: + limit = int(limit) + + object_list = [] + object_count = 0 + limit_reached = False + while url: + resp, body = self.api.json_request('GET', url) + data = self._format_body_data(body, response_key) + for obj in data: + object_list.append(obj_class(self, obj, loaded=True)) + object_count += 1 + if limit and object_count >= limit: + # break the for loop + limit_reached = True + break + + # break the while loop and return + if limit_reached: + break + + url = body.get('next') + if url: + # NOTE(lucasagomes): We need to edit the URL to remove + # the scheme and netloc + url_parts = list(urlparse.urlparse(url)) + url_parts[0] = url_parts[1] = '' + url = urlparse.urlunparse(url_parts) + + return object_list + + def _list(self, url, response_key=None, obj_class=None, body=None): + resp, body = self.api.json_request('GET', url) + + if obj_class is None: + obj_class = self.resource_class + + data = self._format_body_data(body, response_key) + return [obj_class(self, res, loaded=True) for res in data if res] + + def _update(self, resource_id, patch, method='PATCH'): + """Update a resource. + + :param resource_id: Resource identifier. + :param patch: New version of a given resource. + :param method: Name of the method for the request. + """ + + url = self._path(resource_id) + resp, body = self.api.json_request(method, url, body=patch) + # PATCH/PUT requests may not return a body + if body: + try: + return self.resource_class(self, body) + except Exception: + return body + + def _delete(self, resource_id): + """Delete a resource. + + :param resource_id: Resource identifier. + """ + self.api.raw_request('DELETE', self._path(resource_id)) + + +@six.add_metaclass(abc.ABCMeta) +class CreateManager(Manager): + """Provides creation operations with a particular API.""" + + @abc.abstractproperty + def _creation_attributes(self): + """A list of required creation attributes for a resource type. + + """ + + def create(self, **kwargs): + """Create a resource based on a kwargs dictionary of attributes. + + :param kwargs: A dictionary containing the attributes of the resource + that will be created. + :raises exc.InvalidAttribute: For invalid attributes that are not + needed to create the resource. + """ + + new = {} + invalid = [] + for (key, value) in kwargs.items(): + if key in self._creation_attributes: + new[key] = value + else: + invalid.append(key) + if invalid: + raise exc.InvalidAttribute( + 'The attribute(s) "%(attrs)s" are invalid; they are not ' + 'needed to create %(resource)s.' % + {'resource': self._resource_name, + 'attrs': '","'.join(invalid)}) + url = self._path() + resp, body = self.api.json_request('POST', url, body=new) + if body: + return self.resource_class(self, body) + + +class Resource(base.Resource): + """Represents a particular instance of an object (tenant, user, etc). + + This is pretty much just a bag for attributes. + """ + + def to_dict(self): + return copy.deepcopy(self._info) diff --git a/iotronicclient/common/cliutils.py b/iotronicclient/common/cliutils.py new file mode 100644 index 0000000..dc84f33 --- /dev/null +++ b/iotronicclient/common/cliutils.py @@ -0,0 +1,293 @@ +# Copyright 2012 Red Hat, 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. + +# W0603: Using the global statement +# W0621: Redefining name %s from outer scope +# pylint: disable=W0603,W0621 + +from __future__ import print_function + +import getpass +import inspect +import json +import os +import sys +import textwrap + +from oslo_utils import encodeutils +from oslo_utils import strutils +import prettytable +import six +from six import moves + +from iotronicclient.common.i18n import _ + + +class MissingArgs(Exception): + """Supplied arguments are not sufficient for calling a function.""" + def __init__(self, missing): + self.missing = missing + msg = _("Missing arguments: %s") % ", ".join(missing) + super(MissingArgs, self).__init__(msg) + + +def validate_args(fn, *args, **kwargs): + """Check that the supplied args are sufficient for calling a function. + + >>> validate_args(lambda a: None) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): a + >>> validate_args(lambda a, b, c, d: None, 0, c=1) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): b, d + + :param fn: the function to check + :param args: the positional arguments supplied + :param kwargs: the keyword arguments supplied + """ + argspec = inspect.getargspec(fn) + + num_defaults = len(argspec.defaults or []) + required_args = argspec.args[:len(argspec.args) - num_defaults] + + def isbound(method): + return getattr(method, '__self__', None) is not None + + if isbound(fn): + required_args.pop(0) + + missing = [arg for arg in required_args if arg not in kwargs] + missing = missing[len(args):] + if missing: + raise MissingArgs(missing) + + +def arg(*args, **kwargs): + """Decorator for CLI args. + + Example: + + >>> @arg("name", help="Name of the new entity") + ... def entity_create(args): + ... pass + """ + def _decorator(func): + add_arg(func, *args, **kwargs) + return func + return _decorator + + +def env(*args, **kwargs): + """Returns the first environment variable set. + + If all are empty, defaults to '' or keyword arg `default`. + """ + for arg in args: + value = os.environ.get(arg) + if value: + return value + return kwargs.get('default', '') + + +def add_arg(func, *args, **kwargs): + """Bind CLI arguments to a shell.py `do_foo` function.""" + + if not hasattr(func, 'arguments'): + func.arguments = [] + + # NOTE(sirp): avoid dups that can occur when the module is shared across + # tests. + if (args, kwargs) not in func.arguments: + # Because of the semantics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.arguments.insert(0, (args, kwargs)) + + +def unauthenticated(func): + """Adds 'unauthenticated' attribute to decorated function. + + Usage: + + >>> @unauthenticated + ... def mymethod(f): + ... pass + """ + func.unauthenticated = True + return func + + +def isunauthenticated(func): + """Checks if the function does not require authentication. + + Mark such functions with the `@unauthenticated` decorator. + + :returns: bool + """ + return getattr(func, 'unauthenticated', False) + + +def print_list(objs, fields, formatters=None, sortby_index=0, + mixed_case_fields=None, field_labels=None, json_flag=False): + """Print a list of objects or dict as a table, one row per object or dict. + + :param objs: iterable of :class:`Resource` + :param fields: attributes that correspond to columns, in order + :param formatters: `dict` of callables for field formatting + :param sortby_index: index of the field for sorting table rows + :param mixed_case_fields: fields corresponding to object attributes that + have mixed case names (e.g., 'serverId') + :param field_labels: Labels to use in the heading of the table, default to + fields. + :param json_flag: print the list as JSON instead of table + """ + def _get_name_and_data(field): + if field in formatters: + # The value of the field has to be modified. + # For example, it can be used to add extra fields. + return (field, formatters[field](o)) + + field_name = field.replace(' ', '_') + if field not in mixed_case_fields: + field_name = field.lower() + if isinstance(o, dict): + data = o.get(field_name, '') + else: + data = getattr(o, field_name, '') + return (field_name, data) + + formatters = formatters or {} + mixed_case_fields = mixed_case_fields or [] + field_labels = field_labels or fields + if len(field_labels) != len(fields): + raise ValueError(_("Field labels list %(labels)s has different number " + "of elements than fields list %(fields)s"), + {'labels': field_labels, 'fields': fields}) + + if sortby_index is None: + kwargs = {} + else: + kwargs = {'sortby': field_labels[sortby_index]} + pt = prettytable.PrettyTable(field_labels) + pt.align = 'l' + + json_array = [] + + for o in objs: + row = [] + for field in fields: + row.append(_get_name_and_data(field)) + if json_flag: + json_array.append(dict(row)) + else: + pt.add_row([r[1] for r in row]) + + if json_flag: + print(json.dumps(json_array, indent=4, separators=(',', ': '))) + elif six.PY3: + print(encodeutils.safe_encode(pt.get_string(**kwargs)).decode()) + else: + print(encodeutils.safe_encode(pt.get_string(**kwargs))) + + +def print_dict(dct, dict_property="Property", wrap=0, dict_value='Value', + json_flag=False): + """Print a `dict` as a table of two columns. + + :param dct: `dict` to print + :param dict_property: name of the first column + :param wrap: wrapping for the second column + :param dict_value: header label for the value (second) column + :param json_flag: print `dict` as JSON instead of table + """ + if json_flag: + print(json.dumps(dct, indent=4, separators=(',', ': '))) + return + pt = prettytable.PrettyTable([dict_property, dict_value]) + pt.align = 'l' + for k, v in sorted(dct.items()): + # convert dict to str to check length + if isinstance(v, dict): + v = six.text_type(v) + if wrap > 0: + v = textwrap.fill(six.text_type(v), wrap) + # if value has a newline, add in multiple rows + # e.g. fault with stacktrace + if v and isinstance(v, six.string_types) and r'\n' in v: + lines = v.strip().split(r'\n') + col1 = k + for line in lines: + pt.add_row([col1, line]) + col1 = '' + else: + pt.add_row([k, v]) + + if six.PY3: + print(encodeutils.safe_encode(pt.get_string()).decode()) + else: + print(encodeutils.safe_encode(pt.get_string())) + + +def get_password(max_password_prompts=3): + """Read password from TTY.""" + verify = strutils.bool_from_string(env("OS_VERIFY_PASSWORD")) + pw = None + if hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): + # Check for Ctrl-D + try: + for __ in moves.range(max_password_prompts): + pw1 = getpass.getpass("OS Password: ") + if verify: + pw2 = getpass.getpass("Please verify: ") + else: + pw2 = pw1 + if pw1 == pw2 and pw1: + pw = pw1 + break + except EOFError: + pass + return pw + + +def service_type(stype): + """Adds 'service_type' attribute to decorated function. + + Usage: + + .. code-block:: python + + @service_type('volume') + def mymethod(f): + ... + """ + def inner(f): + f.service_type = stype + return f + return inner + + +def get_service_type(f): + """Retrieves service type from function.""" + return getattr(f, 'service_type', None) + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def exit(msg=''): + if msg: + print(msg, file=sys.stderr) + sys.exit(1) diff --git a/iotronicclient/common/filecache.py b/iotronicclient/common/filecache.py new file mode 100644 index 0000000..f2d01df --- /dev/null +++ b/iotronicclient/common/filecache.py @@ -0,0 +1,104 @@ +# +# Copyright 2015 Rackspace, 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 logging +import os + +import appdirs +import dogpile.cache + +from iotronicclient.common.i18n import _LW + +LOG = logging.getLogger(__name__) + +AUTHOR = 'openstack' +PROGNAME = 'python-iotronicclient' + +CACHE = None +CACHE_DIR = appdirs.user_cache_dir(PROGNAME, AUTHOR) +CACHE_EXPIRY_ENV_VAR = 'IOTRONICCLIENT_CACHE_EXPIRY' # environment variable +CACHE_FILENAME = os.path.join(CACHE_DIR, 'iotronic-api-version.dbm') +DEFAULT_EXPIRY = 300 # seconds + + +def _get_cache(): + """Configure file caching.""" + global CACHE + if CACHE is None: + + # Ensure cache directory present + if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) + + # Use the cache expiry if specified in an env var + expiry_time = os.environ.get(CACHE_EXPIRY_ENV_VAR, DEFAULT_EXPIRY) + try: + expiry_time = int(expiry_time) + except ValueError: + LOG.warning(_LW("Environment variable %(env_var)s should be an " + "integer (not '%(curr_val)s'). Using default " + "expiry of %(default)s seconds instead."), + {'env_var': CACHE_EXPIRY_ENV_VAR, + 'curr_val': expiry_time, + 'default': DEFAULT_EXPIRY}) + expiry_time = DEFAULT_EXPIRY + + CACHE = dogpile.cache.make_region(key_mangler=str).configure( + 'dogpile.cache.dbm', + expiration_time=expiry_time, + arguments={ + "filename": CACHE_FILENAME, + } + ) + return CACHE + + +def _build_key(host, port): + """Build a key based upon the hostname or address supplied.""" + return "%s:%s" % (host, port) + + +def save_data(host, port, data): + """Save 'data' for a particular 'host' in the appropriate cache dir. + + param host: The host that we need to save data for + param port: The port on the host that we need to save data for + param data: The data we want saved + """ + key = _build_key(host, port) + _get_cache().set(key, data) + + +def retrieve_data(host, port, expiry=None): + """Retrieve the version stored for an iotronic 'host', if it's not stale. + + Check to see if there is valid cached data for the host/port + combination and return that if it isn't stale. + + param host: The host that we need to retrieve data for + param port: The port on the host that we need to retrieve data for + param expiry: The age in seconds before cached data is deemed invalid + """ + # Ensure that a cache file exists first + if not os.path.isfile(CACHE_FILENAME): + return None + + key = _build_key(host, port) + data = _get_cache().get(key, expiration_time=expiry) + + if data == dogpile.cache.api.NO_VALUE: + return None + return data diff --git a/iotronicclient/common/http.py b/iotronicclient/common/http.py new file mode 100644 index 0000000..ad41d7a --- /dev/null +++ b/iotronicclient/common/http.py @@ -0,0 +1,645 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy +from distutils.version import StrictVersion +import functools +import hashlib +import logging +import os +import socket +import ssl +import textwrap +import time + +from keystoneauth1 import adapter +from keystoneauth1 import exceptions as kexc +from oslo_serialization import jsonutils +from oslo_utils import strutils +import requests +import six +from six.moves import http_client +import six.moves.urllib.parse as urlparse + +from iotronicclient.common import filecache +from iotronicclient.common.i18n import _ +from iotronicclient.common.i18n import _LE +from iotronicclient.common.i18n import _LW +from iotronicclient import exc + +# NOTE(deva): Record the latest version that this client was tested with. +# We still have a lot of work to do in the client to implement +# microversion support in the client properly! See +# http://specs.openstack.org/openstack/iotronic-specs/specs/kilo/ +# api-microversions.html # noqa +# for full details. +DEFAULT_VER = '1.9' + +LOG = logging.getLogger(__name__) +USER_AGENT = 'python-iotronicclient' +CHUNKSIZE = 1024 * 64 # 64kB + +API_VERSION = '/v1' +API_VERSION_SELECTED_STATES = ('user', 'negotiated', 'cached', 'default') + +DEFAULT_MAX_RETRIES = 5 +DEFAULT_RETRY_INTERVAL = 2 +SENSITIVE_HEADERS = ('X-Auth-Token',) + +SUPPORTED_ENDPOINT_SCHEME = ('http', 'https') + + +def _trim_endpoint_api_version(url): + """Trim API version and trailing slash from endpoint.""" + return url.rstrip('/').rstrip(API_VERSION) + + +def _extract_error_json(body): + """Return error_message from the HTTP response body.""" + error_json = {} + try: + body_json = jsonutils.loads(body) + if 'error_message' in body_json: + raw_msg = body_json['error_message'] + error_json = jsonutils.loads(raw_msg) + except ValueError: + pass + + return error_json + + +def get_server(endpoint): + """Extract and return the server & port that we're connecting to.""" + if endpoint is None: + return None, None + parts = urlparse.urlparse(endpoint) + return parts.hostname, str(parts.port) + + +class VersionNegotiationMixin(object): + def negotiate_version(self, conn, resp): + """Negotiate the server version + + Assumption: Called after receiving a 406 error when doing a request. + + param conn: A connection object + param resp: The response object from http request + """ + if self.api_version_select_state not in API_VERSION_SELECTED_STATES: + raise RuntimeError( + _('Error: self.api_version_select_state should be one of the ' + 'values in: "%(valid)s" but had the value: "%(value)s"') % + {'valid': ', '.join(API_VERSION_SELECTED_STATES), + 'value': self.api_version_select_state}) + min_ver, max_ver = self._parse_version_headers(resp) + # NOTE: servers before commit 32fb6e99 did not return version headers + # on error, so we need to perform a GET to determine + # the supported version range + if not max_ver: + LOG.debug('No version header in response, requesting from server') + if self.os_iotronic_api_version: + base_version = ("/v%s" % + str(self.os_iotronic_api_version).split('.')[ + 0]) + else: + base_version = API_VERSION + resp = self._make_simple_request(conn, 'GET', base_version) + min_ver, max_ver = self._parse_version_headers(resp) + # If the user requested an explicit version or we have negotiated a + # version and still failing then error now. The server could + # support the version requested but the requested operation may not + # be supported by the requested version. + if self.api_version_select_state == 'user': + raise exc.UnsupportedVersion(textwrap.fill( + _("Requested API version %(req)s is not supported by the " + "server or the requested operation is not supported by the " + "requested version. Supported version range is %(min)s to " + "%(max)s") + % {'req': self.os_iotronic_api_version, + 'min': min_ver, 'max': max_ver})) + if self.api_version_select_state == 'negotiated': + raise exc.UnsupportedVersion(textwrap.fill( + _("No API version was specified and the requested operation " + "was not supported by the client's negotiated API version " + "%(req)s. Supported version range is: %(min)s to %(max)s") + % {'req': self.os_iotronic_api_version, + 'min': min_ver, 'max': max_ver})) + + negotiated_ver = str(min(StrictVersion(self.os_iotronic_api_version), + StrictVersion(max_ver))) + if negotiated_ver < min_ver: + negotiated_ver = min_ver + # server handles microversions, but doesn't support + # the requested version, so try a negotiated version + self.api_version_select_state = 'negotiated' + self.os_iotronic_api_version = negotiated_ver + LOG.debug('Negotiated API version is %s', negotiated_ver) + + # Cache the negotiated version for this server + host, port = get_server(self.endpoint) + filecache.save_data(host=host, port=port, data=negotiated_ver) + + return negotiated_ver + + def _generic_parse_version_headers(self, accessor_func): + min_ver = accessor_func('X-OpenStack-Iotronic-API-Minimum-Version', + None) + max_ver = accessor_func('X-OpenStack-Iotronic-API-Maximum-Version', + None) + return min_ver, max_ver + + def _parse_version_headers(self, accessor_func): + # NOTE(jlvillal): Declared for unit testing purposes + raise NotImplementedError() + + def _make_simple_request(self, conn, method, url): + # NOTE(jlvillal): Declared for unit testing purposes + raise NotImplementedError() + + +_RETRY_EXCEPTIONS = (exc.Conflict, exc.ServiceUnavailable, + exc.ConnectionRefused, kexc.RetriableConnectionFailure) + + +def with_retries(func): + """Wrapper for _http_request adding support for retries.""" + + @functools.wraps(func) + def wrapper(self, url, method, **kwargs): + if self.conflict_max_retries is None: + self.conflict_max_retries = DEFAULT_MAX_RETRIES + if self.conflict_retry_interval is None: + self.conflict_retry_interval = DEFAULT_RETRY_INTERVAL + + num_attempts = self.conflict_max_retries + 1 + for attempt in range(1, num_attempts + 1): + try: + return func(self, url, method, **kwargs) + except _RETRY_EXCEPTIONS as error: + msg = (_LE("Error contacting Iotronic server: %(error)s. " + "Attempt %(attempt)d of %(total)d") % + {'attempt': attempt, + 'total': num_attempts, + 'error': error}) + if attempt == num_attempts: + LOG.error(msg) + raise + else: + LOG.debug(msg) + time.sleep(self.conflict_retry_interval) + + return wrapper + + +class HTTPClient(VersionNegotiationMixin): + def __init__(self, endpoint, **kwargs): + self.endpoint = endpoint + self.endpoint_trimmed = _trim_endpoint_api_version(endpoint) + self.auth_token = kwargs.get('token') + self.auth_ref = kwargs.get('auth_ref') + self.os_iotronic_api_version = kwargs.get('os_iotronic_api_version', + DEFAULT_VER) + self.api_version_select_state = kwargs.get( + 'api_version_select_state', 'default') + self.conflict_max_retries = kwargs.pop('max_retries', + DEFAULT_MAX_RETRIES) + self.conflict_retry_interval = kwargs.pop('retry_interval', + DEFAULT_RETRY_INTERVAL) + self.session = requests.Session() + + parts = urlparse.urlparse(endpoint) + if parts.scheme not in SUPPORTED_ENDPOINT_SCHEME: + msg = _('Unsupported scheme: %s') % parts.scheme + raise exc.EndpointException(msg) + + if parts.scheme == 'https': + if kwargs.get('insecure') is True: + self.session.verify = False + elif kwargs.get('ca_file'): + self.session.verify = kwargs['ca_file'] + self.session.cert = (kwargs.get('cert_file'), + kwargs.get('key_file')) + + def _process_header(self, name, value): + """Redacts any sensitive header + + Redact a header that contains sensitive information, by returning an + updated header with the sha1 hash of that value. The redacted value is + prefixed by '{SHA1}' because that's the convention used within + OpenStack. + + :returns: A tuple of (name, value) + name: the safe encoding format of name + value: the redacted value if name is x-auth-token, + or the safe encoding format of name + + """ + if name in SENSITIVE_HEADERS: + v = value.encode('utf-8') + h = hashlib.sha1(v) + d = h.hexdigest() + return (name, "{SHA1}%s" % d) + else: + return (name, value) + + def log_curl_request(self, method, url, kwargs): + curl = ['curl -i -X %s' % method] + + for (key, value) in kwargs['headers'].items(): + header = '-H \'%s: %s\'' % self._process_header(key, value) + curl.append(header) + + if not self.session.verify: + curl.append('-k') + elif isinstance(self.session.verify, six.string_types): + curl.append('--cacert %s' % self.session.verify) + + if self.session.cert: + curl.append('--cert %s' % self.session.cert[0]) + curl.append('--key %s' % self.session.cert[1]) + + if 'body' in kwargs: + body = strutils.mask_password(kwargs['body']) + curl.append('-d \'%s\'' % body) + + curl.append(urlparse.urljoin(self.endpoint_trimmed, url)) + LOG.debug(' '.join(curl)) + + @staticmethod + def log_http_response(resp, body=None): + # NOTE(aarefiev): resp.raw is urllib3 response object, it's used + # only to get 'version', response from request with 'stream = True' + # should be used for raw reading. + status = (resp.raw.version / 10.0, resp.status_code, resp.reason) + dump = ['\nHTTP/%.1f %s %s' % status] + dump.extend(['%s: %s' % (k, v) for k, v in resp.headers.items()]) + dump.append('') + if body: + body = strutils.mask_password(body) + dump.extend([body, '']) + LOG.debug('\n'.join(dump)) + + def _make_connection_url(self, url): + return urlparse.urljoin(self.endpoint_trimmed, url) + + def _parse_version_headers(self, resp): + return self._generic_parse_version_headers(resp.headers.get) + + def _make_simple_request(self, conn, method, url): + return conn.request(method, self._make_connection_url(url)) + + @with_retries + def _http_request(self, url, method, **kwargs): + """Send an http request with the specified characteristics. + + Wrapper around request.Session.request to handle tasks such + as setting headers and error handling. + """ + # Copy the kwargs so we can reuse the original in case of redirects + kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {})) + kwargs['headers'].setdefault('User-Agent', USER_AGENT) + if self.os_iotronic_api_version: + kwargs['headers'].setdefault('X-OpenStack-Iotronic-API-Version', + self.os_iotronic_api_version) + if self.auth_token: + kwargs['headers'].setdefault('X-Auth-Token', self.auth_token) + + self.log_curl_request(method, url, kwargs) + + # NOTE(aarefiev): This is for backwards compatibility, request + # expected body in 'data' field, previously we used httplib, + # which expected 'body' field. + body = kwargs.pop('body', None) + if body: + kwargs['data'] = body + + conn_url = self._make_connection_url(url) + try: + resp = self.session.request(method, + conn_url, + **kwargs) + + # TODO(deva): implement graceful client downgrade when connecting + # to servers that did not support microversions. Details here: + # http://specs.openstack.org/openstack/iotronic-specs/specs/kilo/ + # api-microversions.html#use-case-3b-new-client-communicating-with + # -a-old-iotronic-user-specified # noqa + + if resp.status_code == http_client.NOT_ACCEPTABLE: + negotiated_ver = self.negotiate_version(self.session, resp) + kwargs['headers']['X-OpenStack-Iotronic-API-Version'] = ( + negotiated_ver) + return self._http_request(url, method, **kwargs) + + except requests.exceptions.RequestException as e: + message = (_("Error has occurred while handling " + "request for %(url)s: %(e)s") % + dict(url=conn_url, e=e)) + # NOTE(aarefiev): not valid request(invalid url, missing schema, + # and so on), retrying is not needed. + if isinstance(e, ValueError): + raise exc.ValidationError(message) + + raise exc.ConnectionRefused(message) + + body_str = None + if resp.headers.get('Content-Type') == 'application/octet-stream': + body_iter = resp.iter_content(chunk_size=CHUNKSIZE) + self.log_http_response(resp) + else: + # Read body into string if it isn't obviously image data + body_str = resp.text + self.log_http_response(resp, body_str) + body_iter = six.StringIO(body_str) + + if resp.status_code >= http_client.BAD_REQUEST: + error_json = _extract_error_json(body_str) + # NOTE(vdrok): exceptions from iotronic controllers' + # _lookup methods + # are constructed directly by pecan instead of wsme, and contain + # only description field + raise exc.from_response( + resp, (error_json.get('faultstring') or + error_json.get('description')), + error_json.get('debuginfo'), method, url) + elif resp.status_code in (http_client.MOVED_PERMANENTLY, + http_client.FOUND, + http_client.USE_PROXY): + # Redirected. Reissue the request to the new location. + return self._http_request(resp['location'], method, **kwargs) + elif resp.status_code == http_client.MULTIPLE_CHOICES: + raise exc.from_response(resp, method=method, url=url) + + return resp, body_iter + + def json_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + + if 'body' in kwargs: + kwargs['body'] = jsonutils.dump_as_bytes(kwargs['body']) + + resp, body_iter = self._http_request(url, method, **kwargs) + content_type = resp.headers.get('Content-Type') + + if (resp.status_code in ( + http_client.NO_CONTENT, + http_client.RESET_CONTENT) or content_type is None): + return resp, list() + + if 'application/json' in content_type: + body = ''.join([chunk for chunk in body_iter]) + try: + body = jsonutils.loads(body) + except ValueError: + LOG.error(_LE('Could not decode response body as JSON')) + else: + body = None + + return resp, body + + def raw_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', + 'application/octet-stream') + return self._http_request(url, method, **kwargs) + + +class VerifiedHTTPSConnection(six.moves.http_client.HTTPSConnection): + """httplib-compatible connection using client-side SSL authentication + + :see http://code.activestate.com/recipes/ + 577548-https-httplib-client-connection-with-certificate-v/ + """ + + def __init__(self, host, port, key_file=None, cert_file=None, + ca_file=None, timeout=None, insecure=False): + six.moves.http_client.HTTPSConnection.__init__(self, host, port, + key_file=key_file, + cert_file=cert_file) + self.key_file = key_file + self.cert_file = cert_file + if ca_file is not None: + self.ca_file = ca_file + else: + self.ca_file = self.get_system_ca_file() + self.timeout = timeout + self.insecure = insecure + + def connect(self): + """Connect to a host on a given (SSL) port. + + If ca_file is pointing somewhere, use it to check Server Certificate. + + Redefined/copied and extended from httplib.py:1105 (Python 2.6.x). + This is needed to pass cert_reqs=ssl.CERT_REQUIRED as parameter to + ssl.wrap_socket(), which forces SSL to check server certificate against + our client certificate. + """ + sock = socket.create_connection((self.host, self.port), self.timeout) + + if self._tunnel_host: + self.sock = sock + self._tunnel() + + if self.insecure is True: + kwargs = {'cert_reqs': ssl.CERT_NONE} + else: + kwargs = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': self.ca_file} + + if self.cert_file: + kwargs['certfile'] = self.cert_file + if self.key_file: + kwargs['keyfile'] = self.key_file + + self.sock = ssl.wrap_socket(sock, **kwargs) + + @staticmethod + def get_system_ca_file(): + """Return path to system default CA file.""" + # Standard CA file locations for Debian/Ubuntu, RedHat/Fedora, + # Suse, FreeBSD/OpenBSD + ca_path = ['/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/ca-bundle.pem', + '/etc/ssl/cert.pem'] + for ca in ca_path: + if os.path.exists(ca): + return ca + return None + + +class SessionClient(VersionNegotiationMixin, adapter.LegacyJsonAdapter): + """HTTP client based on Keystone client session.""" + + def __init__(self, + os_iotronic_api_version, + api_version_select_state, + max_retries, + retry_interval, + endpoint, + **kwargs): + self.os_iotronic_api_version = os_iotronic_api_version + self.api_version_select_state = api_version_select_state + self.conflict_max_retries = max_retries + self.conflict_retry_interval = retry_interval + self.endpoint = endpoint + + super(SessionClient, self).__init__(**kwargs) + + def _parse_version_headers(self, resp): + return self._generic_parse_version_headers(resp.headers.get) + + def _make_simple_request(self, conn, method, url): + # NOTE: conn is self.session for this class + return conn.request(url, method, raise_exc=False) + + @with_retries + def _http_request(self, url, method, **kwargs): + kwargs.setdefault('user_agent', USER_AGENT) + kwargs.setdefault('auth', self.auth) + if isinstance(self.endpoint_override, six.string_types): + kwargs.setdefault( + 'endpoint_override', + _trim_endpoint_api_version(self.endpoint_override) + ) + + if getattr(self, 'os_iotronic_api_version', None): + kwargs['headers'].setdefault('X-OpenStack-Iotronic-API-Version', + self.os_iotronic_api_version) + + endpoint_filter = kwargs.setdefault('endpoint_filter', {}) + endpoint_filter.setdefault('interface', self.interface) + endpoint_filter.setdefault('service_type', self.service_type) + endpoint_filter.setdefault('region_name', self.region_name) + + resp = self.session.request(url, method, + raise_exc=False, **kwargs) + if resp.status_code == http_client.NOT_ACCEPTABLE: + negotiated_ver = self.negotiate_version(self.session, resp) + kwargs['headers']['X-OpenStack-Iotronic-API-Version'] = ( + negotiated_ver) + return self._http_request(url, method, **kwargs) + if resp.status_code >= http_client.BAD_REQUEST: + error_json = _extract_error_json(resp.content) + # NOTE(vdrok): exceptions from iotronic controllers' _lookup + # methods + # are constructed directly by pecan instead of wsme, and contain + # only description field + raise exc.from_response(resp, (error_json.get('faultstring') or + error_json.get('description')), + error_json.get('debuginfo'), method, url) + elif resp.status_code in (http_client.MOVED_PERMANENTLY, + http_client.FOUND, http_client.USE_PROXY): + # Redirected. Reissue the request to the new location. + location = resp.headers.get('location') + resp = self._http_request(location, method, **kwargs) + elif resp.status_code == http_client.MULTIPLE_CHOICES: + raise exc.from_response(resp, method=method, url=url) + return resp + + def json_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + + if 'body' in kwargs: + kwargs['data'] = jsonutils.dump_as_bytes(kwargs.pop('body')) + + resp = self._http_request(url, method, **kwargs) + body = resp.content + content_type = resp.headers.get('content-type', None) + status = resp.status_code + if (status in ( + http_client.NO_CONTENT, http_client.RESET_CONTENT) or + content_type is None): + return resp, list() + if 'application/json' in content_type: + try: + body = resp.json() + except ValueError: + LOG.error(_LE('Could not decode response body as JSON')) + else: + body = None + + return resp, body + + def raw_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', + 'application/octet-stream') + return self._http_request(url, method, **kwargs) + + +def _construct_http_client(endpoint=None, + session=None, + token=None, + auth_ref=None, + os_iotronic_api_version=DEFAULT_VER, + api_version_select_state='default', + max_retries=DEFAULT_MAX_RETRIES, + retry_interval=DEFAULT_RETRY_INTERVAL, + timeout=600, + ca_file=None, + cert_file=None, + key_file=None, + insecure=None, + **kwargs): + if session: + kwargs.setdefault('service_type', 'iot') + kwargs.setdefault('user_agent', 'python-iotronicclient') + kwargs.setdefault('interface', kwargs.pop('endpoint_type', None)) + kwargs.setdefault('endpoint_override', endpoint) + + ignored = {'token': token, + 'auth_ref': auth_ref, + 'timeout': timeout != 600, + 'ca_file': ca_file, + 'cert_file': cert_file, + 'key_file': key_file, + 'insecure': insecure} + + dvars = [k for k, v in ignored.items() if v] + + if dvars: + LOG.warning(_LW('The following arguments are ignored when using ' + 'the session to construct a client: %s'), + ', '.join(dvars)) + + return SessionClient(session=session, + os_iotronic_api_version=os_iotronic_api_version, + api_version_select_state=api_version_select_state, + max_retries=max_retries, + retry_interval=retry_interval, + endpoint=endpoint, + **kwargs) + else: + if kwargs: + LOG.warning(_LW('The following arguments are being ignored when ' + 'constructing the client: %s'), ', '.join(kwargs)) + + return HTTPClient(endpoint=endpoint, + token=token, + auth_ref=auth_ref, + os_iotronic_api_version=os_iotronic_api_version, + api_version_select_state=api_version_select_state, + max_retries=max_retries, + retry_interval=retry_interval, + timeout=timeout, + ca_file=ca_file, + cert_file=cert_file, + key_file=key_file, + insecure=insecure) diff --git a/iotronicclient/common/i18n.py b/iotronicclient/common/i18n.py new file mode 100644 index 0000000..778482f --- /dev/null +++ b/iotronicclient/common/i18n.py @@ -0,0 +1,31 @@ +# Copyright (c) 2014 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 oslo_i18n + +_translators = oslo_i18n.TranslatorFactory(domain='iotronicclient') + +# The primary translation function using the well-known name "_" +_ = _translators.primary + +# Translators for log levels. +# +# The abbreviated names are meant to reflect the usual use of a short +# name like '_'. The "L" is for "log" and the other letter comes from +# the level. +_LI = _translators.log_info +_LW = _translators.log_warning +_LE = _translators.log_error +_LC = _translators.log_critical diff --git a/iotronicclient/common/utils.py b/iotronicclient/common/utils.py new file mode 100644 index 0000000..fe470e2 --- /dev/null +++ b/iotronicclient/common/utils.py @@ -0,0 +1,371 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import print_function + +import argparse +import contextlib +import gzip +import json +import os +import shutil +import subprocess +import sys +import tempfile + +from oslo_serialization import base64 +from oslo_utils import strutils + +from iotronicclient.common.i18n import _ +from iotronicclient import exc + + +class HelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + super(HelpFormatter, self).start_section(heading.capitalize()) + + +def define_command(subparsers, command, callback, cmd_mapper): + """Define a command in the subparsers collection. + + :param subparsers: subparsers collection where the command will go + :param command: command name + :param callback: function that will be used to process the command + """ + desc = callback.__doc__ or '' + help = desc.strip().split('\n')[0] + arguments = getattr(callback, 'arguments', []) + + subparser = subparsers.add_parser(command, help=help, + description=desc, + add_help=False, + formatter_class=HelpFormatter) + subparser.add_argument('-h', '--help', action='help', + help=argparse.SUPPRESS) + cmd_mapper[command] = subparser + required_args = subparser.add_argument_group(_("Required arguments")) + + for (args, kwargs) in arguments: + if kwargs.get('required'): + required_args.add_argument(*args, **kwargs) + else: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=callback) + + +def define_commands_from_module(subparsers, command_module, cmd_mapper): + """Add *do_* methods in a module and add as commands into a subparsers.""" + + for method_name in (a for a in dir(command_module) if a.startswith('do_')): + # Commands should be hypen-separated instead of underscores. + command = method_name[3:].replace('_', '-') + callback = getattr(command_module, method_name) + define_command(subparsers, command, callback, cmd_mapper) + + +def split_and_deserialize(string): + """Split and try to JSON deserialize a string. + + Gets a string with the KEY=VALUE format, split it (using '=' as the + separator) and try to JSON deserialize the VALUE. + + :returns: A tuple of (key, value). + """ + try: + key, value = string.split("=", 1) + except ValueError: + raise exc.CommandError(_('Attributes must be a list of ' + 'PATH=VALUE not "%s"') % string) + try: + value = json.loads(value) + except ValueError: + pass + + return (key, value) + + +def json_from_file(file): + with open(file, 'r') as pfil: + return json.load(pfil) + + +def key_value_pairs_to_dict(key_value_pairs): + """Convert a list of key-value pairs to a dictionary. + + :param key_value_pairs: a list of strings, each string is in the form + = + :returns: a dictionary, possibly empty + """ + if key_value_pairs: + return dict(split_and_deserialize(v) for v in key_value_pairs) + return {} + + +def args_array_to_dict(kwargs, key_to_convert): + """Convert the value in a dictionary entry to a dictionary. + + From the kwargs dictionary, converts the value of the key_to_convert + entry from a list of key-value pairs to a dictionary. + + :param kwargs: a dictionary + :param key_to_convert: the key (in kwargs), whose value is expected to + be a list of key=value strings. This value will be converted to a + dictionary. + :returns: kwargs, the (modified) dictionary + """ + values_to_convert = kwargs.get(key_to_convert) + if values_to_convert: + kwargs[key_to_convert] = key_value_pairs_to_dict(values_to_convert) + return kwargs + + +def args_array_to_patch(op, attributes): + patch = [] + for attr in attributes: + # Sanitize + if not attr.startswith('/'): + attr = '/' + attr + + if op in ['add', 'replace']: + path, value = split_and_deserialize(attr) + patch.append({'op': op, 'path': path, 'value': value}) + + elif op == "remove": + # For remove only the key is needed + patch.append({'op': op, 'path': attr}) + else: + raise exc.CommandError(_('Unknown PATCH operation: %s') % op) + return patch + + +def common_params_for_list(args, fields, field_labels): + """Generate 'params' dict that is common for every 'list' command. + + :param args: arguments from command line. + :param fields: possible fields for sorting. + :param field_labels: possible field labels for sorting. + :returns: a dict with params to pass to the client method. + """ + params = {} + if args.marker is not None: + params['marker'] = args.marker + if args.limit is not None: + if args.limit < 0: + raise exc.CommandError( + _('Expected non-negative --limit, got %s') % args.limit) + params['limit'] = args.limit + + if args.sort_key is not None: + # Support using both heading and field name for sort_key + fields_map = dict(zip(field_labels, fields)) + fields_map.update(zip(fields, fields)) + try: + sort_key = fields_map[args.sort_key] + except KeyError: + raise exc.CommandError( + _("%(sort_key)s is an invalid field for sorting, " + "valid values for --sort-key are: %(valid)s") % + {'sort_key': args.sort_key, + 'valid': list(fields_map)}) + params['sort_key'] = sort_key + if args.sort_dir is not None: + if args.sort_dir not in ('asc', 'desc'): + raise exc.CommandError( + _("%s is an invalid value for sort direction, " + "valid values for --sort-dir are: 'asc', 'desc'") % + args.sort_dir) + params['sort_dir'] = args.sort_dir + + params['detail'] = args.detail + + requested_fields = args.fields[0] if args.fields else None + if requested_fields is not None: + params['fields'] = requested_fields + + return params + + +def common_filters(marker=None, limit=None, sort_key=None, sort_dir=None, + fields=None): + """Generate common filters for any list request. + + :param marker: entity ID from which to start returning entities. + :param limit: maximum number of entities to return. + :param sort_key: field to use for sorting. + :param sort_dir: direction of sorting: 'asc' or 'desc'. + :param fields: a list with a specified set of fields of the resource + to be returned. + :returns: list of string filters. + """ + filters = [] + if isinstance(limit, int) and limit > 0: + filters.append('limit=%s' % limit) + if marker is not None: + filters.append('marker=%s' % marker) + if sort_key is not None: + filters.append('sort_key=%s' % sort_key) + if sort_dir is not None: + filters.append('sort_dir=%s' % sort_dir) + if fields is not None: + filters.append('fields=%s' % ','.join(fields)) + return filters + + +@contextlib.contextmanager +def tempdir(*args, **kwargs): + dirname = tempfile.mkdtemp(*args, **kwargs) + try: + yield dirname + finally: + shutil.rmtree(dirname) + + +def make_configdrive(path): + """Make the config drive file. + + :param path: The directory containing the config drive files. + :returns: A gzipped and base64 encoded configdrive string. + + """ + # Make sure path it's readable + if not os.access(path, os.R_OK): + raise exc.CommandError(_('The directory "%s" is not readable') % path) + + with tempfile.NamedTemporaryFile() as tmpfile: + with tempfile.NamedTemporaryFile() as tmpzipfile: + publisher = 'iotronicclient-configdrive 0.1' + try: + p = subprocess.Popen(['genisoimage', '-o', tmpfile.name, + '-ldots', '-allow-lowercase', + '-allow-multidot', '-l', + '-publisher', publisher, + '-quiet', '-J', + '-r', '-V', 'config-2', + path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + except OSError as e: + raise exc.CommandError( + _('Error generating the config drive. Make sure the ' + '"genisoimage" tool is installed. Error: %s') % e) + + stdout, stderr = p.communicate() + if p.returncode != 0: + raise exc.CommandError( + _('Error generating the config drive.' + 'Stdout: "%(stdout)s". Stderr: %(stderr)s') % + {'stdout': stdout, 'stderr': stderr}) + + # Compress file + tmpfile.seek(0) + g = gzip.GzipFile(fileobj=tmpzipfile, mode='wb') + shutil.copyfileobj(tmpfile, g) + g.close() + + tmpzipfile.seek(0) + return base64.encode_as_bytes(tmpzipfile.read()) + + +def check_empty_arg(arg, arg_descriptor): + if not arg.strip(): + raise exc.CommandError(_('%(arg)s cannot be empty or only have blank' + ' spaces') % {'arg': arg_descriptor}) + + +def bool_argument_value(arg_name, bool_str, strict=True, default=False): + """Returns the Boolean represented by bool_str. + + Returns the Boolean value for the argument named arg_name. The value is + represented by the string bool_str. If the string is an invalid Boolean + string: if strict is True, a CommandError exception is raised; otherwise + the default value is returned. + + :param arg_name: The name of the argument + :param bool_str: The string representing a Boolean value + :param strict: Used if the string is invalid. If True, raises an exception. + If False, returns the default value. + :param default: The default value to return if the string is invalid + and not strict + :returns: the Boolean value represented by bool_str or the default value + if bool_str is invalid and strict is False + :raises CommandError: if bool_str is an invalid Boolean string + + """ + try: + val = strutils.bool_from_string(bool_str, strict, default) + except ValueError as e: + raise exc.CommandError(_("argument %(arg)s: %(err)s.") + % {'arg': arg_name, 'err': e}) + return val + + +def check_for_invalid_fields(fields, valid_fields): + """Check for invalid fields. + + :param fields: A list of fields specified by the user. + :param valid_fields: A list of valid fields. + :raises CommandError: If invalid fields were specified by the user. + """ + if not fields: + return + + invalid_fields = set(fields) - set(valid_fields) + if invalid_fields: + raise exc.CommandError( + _('Invalid field(s) requested: %(invalid)s. Valid fields ' + 'are: %(valid)s.') % {'invalid': ', '.join(invalid_fields), + 'valid': ', '.join(valid_fields)}) + + +def get_from_stdin(info_desc): + """Read information from stdin. + + :param info_desc: A string description of the desired information + :raises: InvalidAttribute if there was a problem reading from stdin + :returns: the string that was read from stdin + """ + try: + info = sys.stdin.read().strip() + except Exception as e: + err = _("Cannot get %(desc)s from standard input. Error: %(err)s") + raise exc.InvalidAttribute(err % {'desc': info_desc, 'err': e}) + return info + + +def handle_json_or_file_arg(json_arg): + """Attempts to read JSON argument from file or string. + + :param json_arg: May be a file name containing the JSON, or + a JSON string. + :returns: A list or dictionary parsed from JSON. + :raises: InvalidAttribute if the argument cannot be parsed. + """ + + if os.path.isfile(json_arg): + try: + with open(json_arg, 'r') as f: + json_arg = f.read().strip() + except Exception as e: + err = _("Cannot get JSON from file '%(file)s'. " + "Error: %(err)s") % {'err': e, 'file': json_arg} + raise exc.InvalidAttribute(err) + try: + json_arg = json.loads(json_arg) + except ValueError as e: + err = (_("For JSON: '%(string)s', error: '%(err)s'") % + {'err': e, 'string': json_arg}) + raise exc.InvalidAttribute(err) + + return json_arg diff --git a/iotronicclient/exc.py b/iotronicclient/exc.py new file mode 100644 index 0000000..0fbf601 --- /dev/null +++ b/iotronicclient/exc.py @@ -0,0 +1,71 @@ +# 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 iotronicclient.common.apiclient import exceptions +from iotronicclient.common.apiclient.exceptions import * # noqa + + +# NOTE(akurilin): This alias is left here since v.0.1.3 to support backwards +# compatibility. +InvalidEndpoint = EndpointException +CommunicationError = ConnectionRefused +HTTPBadRequest = BadRequest +HTTPInternalServerError = InternalServerError +HTTPNotFound = NotFound +HTTPServiceUnavailable = ServiceUnavailable + + +class AmbiguousAuthSystem(ClientException): + """Could not obtain token and endpoint using provided credentials.""" + pass + +# Alias for backwards compatibility +AmbigiousAuthSystem = AmbiguousAuthSystem + + +class InvalidAttribute(ClientException): + pass + + +class StateTransitionFailed(ClientException): + """Failed to reach a requested provision state.""" + + +class StateTransitionTimeout(ClientException): + """Timed out while waiting for a requested provision state.""" + + +def from_response(response, message=None, traceback=None, method=None, + url=None): + """Return an HttpError instance based on response from httplib/requests.""" + + error_body = {} + if message: + error_body['message'] = message + if traceback: + error_body['details'] = traceback + + if hasattr(response, 'status') and not hasattr(response, 'status_code'): + # NOTE(akurilin): These modifications around response object give + # ability to get all necessary information in method `from_response` + # from common code, which expecting response object from `requests` + # library instead of object from `httplib/httplib2` library. + response.status_code = response.status + response.headers = { + 'Content-Type': response.getheader('content-type', "")} + + if hasattr(response, 'status_code'): + # NOTE(jiangfei): These modifications allow SessionClient + # to handle faultstring. + response.json = lambda: {'error': error_body} + + return exceptions.from_response(response, method=method, url=url) diff --git a/iotronicclient/shell.py b/iotronicclient/shell.py new file mode 100644 index 0000000..41dbc96 --- /dev/null +++ b/iotronicclient/shell.py @@ -0,0 +1,447 @@ +# 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 OpenStack Bare Metal Provisioning API. +""" + +from __future__ import print_function + +import argparse +import getpass +import logging +import os +import pkgutil +import re +import sys + +from keystoneauth1.loading import session as kasession +from oslo_utils import encodeutils +from oslo_utils import importutils +import six + +import iotronicclient +from iotronicclient.common.apiclient import exceptions +from iotronicclient.common import cliutils +from iotronicclient.common import http +from iotronicclient.common.i18n import _ +from iotronicclient.common import utils +from iotronicclient import exc + + +LATEST_API_VERSION = ('1', 'latest') + + +class IotronicShell(object): + + def get_base_parser(self): + parser = argparse.ArgumentParser( + prog='iotronic', + description=__doc__.strip(), + epilog=_('See "iotronic help COMMAND" ' + 'for help on a specific command.'), + add_help=False, + formatter_class=HelpFormatter, + ) + + # Register global Keystone args first so their defaults are respected. + # See https://bugs.launchpad.net/python-iotronicclient/+bug/1463581 + kasession.register_argparse_arguments(parser) + + # Global arguments + parser.add_argument('-h', '--help', + action='store_true', + help=argparse.SUPPRESS, + ) + + parser.add_argument('--version', + action='version', + version=iotronicclient.__version__) + + parser.add_argument('--debug', + default=bool(cliutils.env('IOTRONICCLIENT_DEBUG')), + action='store_true', + help=_('Defaults to env[IOTRONICCLIENT_DEBUG]')) + + parser.add_argument('--json', + default=False, + action='store_true', + help=_('Print JSON response without formatting.')) + + parser.add_argument('-v', '--verbose', + default=False, action="store_true", + help=_('Print more verbose output')) + + # for backward compatibility only + parser.add_argument('--cert-file', + dest='os_cert', + help=_('DEPRECATED! Use --os-cert.')) + + # for backward compatibility only + parser.add_argument('--key-file', + dest='os_key', + help=_('DEPRECATED! Use --os-key.')) + + # for backward compatibility only + parser.add_argument('--ca-file', + dest='os_cacert', + help=_('DEPRECATED! Use --os-cacert.')) + + parser.add_argument('--os-username', + default=cliutils.env('OS_USERNAME'), + help=_('Defaults to env[OS_USERNAME]')) + + parser.add_argument('--os_username', + help=argparse.SUPPRESS) + + parser.add_argument('--os-password', + default=cliutils.env('OS_PASSWORD'), + help=_('Defaults to env[OS_PASSWORD]')) + + parser.add_argument('--os_password', + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-id', + default=cliutils.env('OS_TENANT_ID'), + help=_('Defaults to env[OS_TENANT_ID]')) + + parser.add_argument('--os_tenant_id', + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-name', + default=cliutils.env('OS_TENANT_NAME'), + help=_('Defaults to env[OS_TENANT_NAME]')) + + parser.add_argument('--os_tenant_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-auth-url', + default=cliutils.env('OS_AUTH_URL'), + help=_('Defaults to env[OS_AUTH_URL]')) + + parser.add_argument('--os_auth_url', + help=argparse.SUPPRESS) + + parser.add_argument('--os-region-name', + default=cliutils.env('OS_REGION_NAME'), + help=_('Defaults to env[OS_REGION_NAME]')) + + parser.add_argument('--os_region_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-auth-token', + default=cliutils.env('OS_AUTH_TOKEN'), + help=_('Defaults to env[OS_AUTH_TOKEN]')) + + parser.add_argument('--os_auth_token', + help=argparse.SUPPRESS) + + parser.add_argument('--iotronic-url', + default=cliutils.env('IOTRONIC_URL'), + help=_('Defaults to env[IOTRONIC_URL]')) + + parser.add_argument('--iotronic_url', + help=argparse.SUPPRESS) + + parser.add_argument('--iotronic-api-version', + default=cliutils.env( + 'IOTRONIC_API_VERSION', default='1'), + help=_('Accepts 1.x (where "x" is microversion) ' + 'or "latest", Defaults to ' + 'env[IOTRONIC_API_VERSION] or 1')) + + parser.add_argument('--iotronic_api_version', + help=argparse.SUPPRESS) + + parser.add_argument('--os-service-type', + default=cliutils.env('OS_SERVICE_TYPE'), + help=_('Defaults to env[OS_SERVICE_TYPE] or ' + '"iot"')) + + parser.add_argument('--os_service_type', + help=argparse.SUPPRESS) + + parser.add_argument('--os-endpoint', + dest='iotronic_url', + default=cliutils.env('OS_SERVICE_ENDPOINT'), + help=_('Specify an endpoint to use instead of ' + 'retrieving one from the service catalog ' + '(via authentication). ' + 'Defaults to env[OS_SERVICE_ENDPOINT].')) + + parser.add_argument('--os_endpoint', + dest='iotronic_url', + help=argparse.SUPPRESS) + + parser.add_argument('--os-endpoint-type', + default=cliutils.env('OS_ENDPOINT_TYPE'), + help=_('Defaults to env[OS_ENDPOINT_TYPE] or ' + '"publicURL"')) + + parser.add_argument('--os_endpoint_type', + help=argparse.SUPPRESS) + + parser.add_argument('--os-user-domain-id', + default=cliutils.env('OS_USER_DOMAIN_ID'), + help=_('Defaults to env[OS_USER_DOMAIN_ID].')) + + parser.add_argument('--os-user-domain-name', + default=cliutils.env('OS_USER_DOMAIN_NAME'), + help=_('Defaults to env[OS_USER_DOMAIN_NAME].')) + + parser.add_argument('--os-project-id', + default=cliutils.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-project-name', + default=cliutils.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-project-domain-id', + default=cliutils.env('OS_PROJECT_DOMAIN_ID'), + help=_('Defaults to env[OS_PROJECT_DOMAIN_ID].')) + + parser.add_argument('--os-project-domain-name', + default=cliutils.env('OS_PROJECT_DOMAIN_NAME'), + help=_('Defaults to env[OS_PROJECT_DOMAIN_NAME].')) + + msg = _('Maximum number of retries in case of conflict error ' + '(HTTP 409). Defaults to env[IOTRONIC_MAX_RETRIES] or %d. ' + 'Use 0 to disable retrying.') % http.DEFAULT_MAX_RETRIES + parser.add_argument('--max-retries', type=int, help=msg, + default=cliutils.env( + 'IOTRONIC_MAX_RETRIES', + default=str(http.DEFAULT_MAX_RETRIES))) + + msg = _('Amount of time (in seconds) between retries ' + 'in case of conflict error (HTTP 409). ' + 'Defaults to env[IOTRONIC_RETRY_INTERVAL] ' + 'or %d.') % http.DEFAULT_RETRY_INTERVAL + parser.add_argument('--retry-interval', type=int, help=msg, + default=cliutils.env( + 'IOTRONIC_RETRY_INTERVAL', + default=str(http.DEFAULT_RETRY_INTERVAL))) + + return parser + + def get_available_major_versions(self): + matcher = re.compile(r"^v[0-9]+$") + submodules = pkgutil.iter_modules([os.path.dirname(__file__)]) + available_versions = [name[1:] for loader, name, ispkg in submodules + if matcher.search(name)] + + return available_versions + + def get_subcommand_parser(self, version): + parser = self.get_base_parser() + + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='', + dest='subparser_name') + try: + submodule = importutils.import_versioned_module('iotronicclient', + version, 'shell') + except ImportError as e: + msg = _("Invalid client version '%(version)s'. " + "Major part must be one of: '%(major)s'") % { + "version": version, + "major": ", ".join(self.get_available_major_versions())} + raise exceptions.UnsupportedVersion( + _('%(message)s, error was: %(error)s') % + {'message': msg, 'error': e}) + submodule.enhance_parser(parser, subparsers, self.subcommands) + utils.define_commands_from_module(subparsers, self, self.subcommands) + return parser + + def _setup_debugging(self, debug): + if debug: + logging.basicConfig( + format="%(levelname)s (%(module)s:%(lineno)d) %(message)s", + level=logging.DEBUG) + else: + logging.basicConfig( + format="%(levelname)s %(message)s", + level=logging.CRITICAL) + + def do_bash_completion(self): + """Prints all of the commands and options for bash-completion.""" + commands = set() + options = set() + for sc_str, sc in self.subcommands.items(): + commands.add(sc_str) + for option in sc._optionals._option_string_actions.keys(): + options.add(option) + + commands.remove('bash-completion') + print(' '.join(commands | options)) + + def _check_version(self, api_version): + if api_version == 'latest': + return LATEST_API_VERSION + else: + try: + versions = tuple(int(i) for i in api_version.split('.')) + except ValueError: + versions = () + if len(versions) == 1: + # Default value of iotronic_api_version is '1'. + # If user not specify the value of api version, not passing + # headers at all. + os_iotronic_api_version = None + elif len(versions) == 2: + os_iotronic_api_version = api_version + # In the case of '1.0' + if versions[1] == 0: + os_iotronic_api_version = None + else: + msg = _("The requested API version %(ver)s is an unexpected " + "format. Acceptable formats are 'X', 'X.Y', or the " + "literal string '%(latest)s'." + ) % {'ver': api_version, 'latest': 'latest'} + raise exc.CommandError(msg) + + api_major_version = versions[0] + return (api_major_version, os_iotronic_api_version) + + def main(self, argv): + # Parse args once to find version + parser = self.get_base_parser() + (options, args) = parser.parse_known_args(argv) + self._setup_debugging(options.debug) + + # build available subcommands based on version + (api_major_version, os_iotronic_api_version) = ( + self._check_version(options.iotronic_api_version)) + + subcommand_parser = self.get_subcommand_parser(api_major_version) + self.parser = subcommand_parser + + # Handle top-level --help/-h before attempting to parse + # a command off the command line + if options.help or not argv: + self.do_help(options) + return 0 + + # Parse args again and call whatever callback was selected + args = subcommand_parser.parse_args(argv) + + # Short-circuit and deal with these commands right away. + if args.func == self.do_help: + self.do_help(args) + return 0 + elif args.func == self.do_bash_completion: + self.do_bash_completion() + return 0 + + if not (args.os_auth_token and (args.iotronic_url or args.os_auth_url) + ): + if not args.os_username: + raise exc.CommandError(_("You must provide a username via " + "either --os-username or via " + "env[OS_USERNAME]")) + + if not args.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: + args.os_password = getpass.getpass( + 'OpenStack Password: ') + except EOFError: + pass + # No password because we didn't have a tty or the + # user Ctl-D when prompted. + if not args.os_password: + raise exc.CommandError(_("You must provide a password via " + "either --os-password, " + "env[OS_PASSWORD], " + "or prompted response")) + + if not (args.os_tenant_id or args.os_tenant_name or + args.os_project_id or args.os_project_name): + raise exc.CommandError( + _("You must provide a project name or" + " project id via --os-project-name, --os-project-id," + " env[OS_PROJECT_ID] or env[OS_PROJECT_NAME]. You may" + " use os-project and os-tenant interchangeably.")) + + if not args.os_auth_url: + raise exc.CommandError(_("You must provide an auth url via " + "either --os-auth-url or via " + "env[OS_AUTH_URL]")) + + if args.max_retries < 0: + raise exc.CommandError(_("You must provide value >= 0 for " + "--max-retries")) + if args.retry_interval < 1: + raise exc.CommandError(_("You must provide value >= 1 for " + "--retry-interval")) + client_args = ( + 'os_auth_token', 'iotronic_url', 'os_username', 'os_password', + 'os_auth_url', 'os_project_id', 'os_project_name', 'os_tenant_id', + 'os_tenant_name', 'os_region_name', 'os_user_domain_id', + 'os_user_domain_name', 'os_project_domain_id', + 'os_project_domain_name', 'os_service_type', 'os_endpoint_type', + 'os_cacert', 'os_cert', 'os_key', 'max_retries', 'retry_interval', + 'timeout', 'insecure' + ) + kwargs = {} + for key in client_args: + kwargs[key] = getattr(args, key) + kwargs['os_iotronic_api_version'] = os_iotronic_api_version + client = iotronicclient.client.get_client(api_major_version, **kwargs) + + try: + args.func(client, args) + except exc.Unauthorized: + raise exc.CommandError(_("Invalid OpenStack Identity credentials")) + except exc.CommandError as e: + subcommand_parser = self.subcommands[args.subparser_name] + subcommand_parser.error(e) + + @cliutils.arg('command', metavar='', nargs='?', + help=_('Display help for ')) + def do_help(self, args): + """Display help about this program or one of its subcommands.""" + if getattr(args, 'command', None): + if args.command in self.subcommands: + self.subcommands[args.command].print_help() + else: + raise exc.CommandError(_("'%s' is not a valid subcommand") % + args.command) + else: + self.parser.print_help() + + +class HelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + super(HelpFormatter, self).start_section(heading.capitalize()) + + +def main(): + try: + IotronicShell().main(sys.argv[1:]) + except KeyboardInterrupt: + print(_("... terminating iotronic client"), file=sys.stderr) + return 130 + except Exception as e: + print(encodeutils.safe_encode(six.text_type(e)), file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/iotronicclient/v1/__init__.py b/iotronicclient/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iotronicclient/v1/board.py b/iotronicclient/v1/board.py new file mode 100644 index 0000000..009a931 --- /dev/null +++ b/iotronicclient/v1/board.py @@ -0,0 +1,105 @@ +# 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 iotronicclient.common import base +from iotronicclient.common.i18n import _ +from iotronicclient.common import utils +from iotronicclient import exc + +LOG = logging.getLogger(__name__) +_DEFAULT_POLL_INTERVAL = 2 + + +class Board(base.Resource): + def __repr__(self): + return "" % self._info + + +class BoardManager(base.CreateManager): + resource_class = Board + _creation_attributes = ['name', 'code', 'type', 'location', 'mobile', + 'extra'] + _resource_name = 'boards' + + def list(self, status=None, marker=None, limit=None, + detail=False, sort_key=None, sort_dir=None, fields=None, + project=None): + """Retrieve a list of boards. + + :param marker: Optional, the UUID of a board, eg the last + board from a previous result set. Return + the next result set. + :param limit: The maximum number of results to return per + request, if: + + 1) limit > 0, the maximum number of boards to return. + 2) limit == 0, return the entire list of boards. + 3) limit param is NOT specified (None), the number of items + returned respect the maximum imposed by the Iotronic API + (see Iotronic's api.max_limit option). + + :param detail: Optional, boolean whether to return detailed information + about boards. + + :param sort_key: Optional, field used for sorting. + + :param sort_dir: Optional, direction of sorting, either 'asc' (the + default) or 'desc'. + + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. Can not be used + when 'detail' is set. + + :param project: Optional string value to get + only boards of the project. + + :returns: A list of boards. + + """ + + if limit is not None: + limit = int(limit) + + if detail and fields: + raise exc.InvalidAttribute(_("Can't fetch a subset of fields " + "with 'detail' set")) + + filters = utils.common_filters(marker, limit, sort_key, sort_dir, + fields) + if project is not None: + filters.append('project=%s' % project) + if status is not None: + filters.append('status=%s' % status) + + path = '' + if detail: + path += 'detail' + if filters: + path += '?' + '&'.join(filters) + + if limit is None: + return self._list(self._path(path), "boards") + else: + return self._list_pagination(self._path(path), "boards", + limit=limit) + + def get(self, board_id, fields=None): + return self._get(resource_id=board_id, fields=fields) + + def delete(self, board_id): + return self._delete(resource_id=board_id) + + def update(self, board_id, patch, http_method='PATCH'): + return self._update(resource_id=board_id, patch=patch, + method=http_method) diff --git a/iotronicclient/v1/board_shell.py b/iotronicclient/v1/board_shell.py new file mode 100644 index 0000000..28700d3 --- /dev/null +++ b/iotronicclient/v1/board_shell.py @@ -0,0 +1,223 @@ +# 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 iotronicclient.common.apiclient import exceptions +from iotronicclient.common import cliutils +from iotronicclient.common.i18n import _ +from iotronicclient.common import utils +from iotronicclient.v1 import resource_fields as res_fields + + +def _print_board_show(board, fields=None, json=False): + if fields is None: + fields = res_fields.BOARD_DETAILED_RESOURCE.fields + + data = dict( + [(f, getattr(board, f, '')) for f in fields]) + cliutils.print_dict(data, wrap=72, json_flag=json) + + +@cliutils.arg( + 'board', + metavar='', + help="Name or UUID of the board ") +@cliutils.arg( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + default=[], + help="One or more board fields. Only these fields will be fetched from " + "the server.") +def do_board_show(cc, args): + """Show detailed information about a board.""" + fields = args.fields[0] if args.fields else None + utils.check_empty_arg(args.board, '') + utils.check_for_invalid_fields( + fields, res_fields.BOARD_DETAILED_RESOURCE.fields) + board = cc.board.get(args.board, fields=fields) + _print_board_show(board, fields=fields, json=args.json) + + +@cliutils.arg( + '--limit', + metavar='', + type=int, + help='Maximum number of boards to return per request, ' + '0 for no limit. Default is the maximum number used ' + 'by the Iotronic API Service.') +@cliutils.arg( + '--marker', + metavar='', + help='Board UUID (for example, of the last board in the list from ' + 'a previous request). Returns the list of boards after this UUID.') +@cliutils.arg( + '--sort-key', + metavar='', + help='Board field that will be used for sorting.') +@cliutils.arg( + '--status', + metavar='', + help='Filter by board status ') +@cliutils.arg( + '--sort-dir', + metavar='', + choices=['asc', 'desc'], + help='Sort direction: "asc" (the default) or "desc".') +@cliutils.arg( + '--project', + metavar='', + help="Project of the list.") +@cliutils.arg( + '--detail', + dest='detail', + action='store_true', + default=False, + help="Show detailed information about the boards.") +@cliutils.arg( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + default=[], + help="One or more board fields. Only these fields will be fetched from " + "the server. Can not be used when '--detail' is specified.") +def do_board_list(cc, args): + """List the boards which are registered with the Iotronic service.""" + params = {} + + if args.status: + params['status'] = args.status + + if args.project is not None: + params['project'] = args.project + + if args.detail: + fields = res_fields.BOARD_DETAILED_RESOURCE.fields + field_labels = res_fields.BOARD_DETAILED_RESOURCE.labels + elif args.fields: + utils.check_for_invalid_fields( + args.fields[0], res_fields.BOARD_DETAILED_RESOURCE.fields) + resource = res_fields.Resource(args.fields[0]) + fields = resource.fields + field_labels = resource.labels + else: + fields = res_fields.BOARD_RESOURCE.fields + field_labels = res_fields.BOARD_RESOURCE.labels + + sort_fields = res_fields.BOARD_DETAILED_RESOURCE.sort_fields + sort_field_labels = res_fields.BOARD_DETAILED_RESOURCE.sort_labels + + params.update(utils.common_params_for_list(args, + sort_fields, + sort_field_labels)) + + boards = cc.board.list(**params) + cliutils.print_list(boards, fields, + field_labels=field_labels, + sortby_index=None, + json_flag=args.json) + + +@cliutils.arg( + 'name', + metavar='', + help="Name or UUID of the board ") +@cliutils.arg( + 'code', + metavar='', + help="Codeof the board ") +@cliutils.arg( + 'type', + metavar='', + help="Type of the board ") +@cliutils.arg( + 'latitude', + metavar='', + help="Latitude of the board ") +@cliutils.arg( + 'longitude', + metavar='', + help="Longitude of the board ") +@cliutils.arg( + 'altitude', + metavar='', + help="Altitude of the board ") +@cliutils.arg( + '--mobile', + dest='mobile', + action='store_true', + default=False, + help="Set a mobile board") +@cliutils.arg( + '-e', '--extra', + metavar='', + action='append', + help="Record arbitrary key/value metadata. " + "Can be specified multiple times.") +def do_board_create(cc, args): + """Register a new board with the Iotronic service.""" + field_list = ['name', 'code', 'type', 'mobile', 'extra'] + + fields = dict((k, v) for (k, v) in vars(args).items() + if k in field_list and not (v is None)) + fields = utils.args_array_to_dict(fields, 'extra') + + fields['location'] = [ + {'latitude': args.latitude, 'longitude': args.longitude, + 'altitude': args.altitude}] + + board = cc.board.create(**fields) + data = dict([(f, getattr(board, f, '')) for f in + res_fields.BOARD_DETAILED_RESOURCE.fields]) + cliutils.print_dict(data, wrap=72, json_flag=args.json) + + +@cliutils.arg('board', + metavar='', + nargs='+', + help="Name or UUID of the board.") +def do_board_delete(cc, args): + """Unregister board(s) from the Iotronic service. + + Returns errors for any boards that could not be unregistered. + """ + + failures = [] + for n in args.board: + try: + cc.board.delete(n) + print(_('Deleted board %s') % n) + except exceptions.ClientException as e: + failures.append(_("Failed to delete board %(board)s: %(error)s") + % {'board': n, 'error': e}) + if failures: + raise exceptions.ClientException("\n".join(failures)) + + +@cliutils.arg('board', metavar='', help="Name or UUID of the board.") +@cliutils.arg( + 'attributes', + metavar='', + nargs='+', + action='append', + default=[], + help="Values to be changed.") +def do_board_update(cc, args): + """Update information about a registered board.""" + + patch = {k: v for k, v in (x.split('=') for x in args.attributes[0])} + + board = cc.board.update(args.board, patch) + _print_board_show(board, json=args.json) diff --git a/iotronicclient/v1/client.py b/iotronicclient/v1/client.py new file mode 100644 index 0000000..dc4b953 --- /dev/null +++ b/iotronicclient/v1/client.py @@ -0,0 +1,63 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from iotronicclient.common import filecache +from iotronicclient.common import http +from iotronicclient.common.http import DEFAULT_VER +from iotronicclient.common.i18n import _ +from iotronicclient import exc +from iotronicclient.v1 import board +from iotronicclient.v1 import plugin +from iotronicclient.v1 import plugin_injection + + +class Client(object): + """Client for the Iotronic v1 API. + + :param string endpoint: A user-supplied endpoint URL for the iotronic + service. + :param function token: Provides token for authentication. + :param integer timeout: Allows customization of the timeout for client + http requests. (optional) + """ + + def __init__(self, endpoint=None, *args, **kwargs): + """Initialize a new client for the Iotronic v1 API.""" + if kwargs.get('os_iotronic_api_version'): + kwargs['api_version_select_state'] = "user" + else: + if not endpoint: + raise exc.EndpointException( + _("Must provide 'endpoint' if os_iotronic_api_version " + "isn't specified")) + + # If the user didn't specify a version, use a cached version if + # one has been stored + host, netport = http.get_server(endpoint) + saved_version = filecache.retrieve_data(host=host, port=netport) + if saved_version: + kwargs['api_version_select_state'] = "cached" + kwargs['os_iotronic_api_version'] = saved_version + else: + kwargs['api_version_select_state'] = "default" + kwargs['os_iotronic_api_version'] = DEFAULT_VER + + self.http_client = http._construct_http_client( + endpoint, *args, **kwargs) + + self.board = board.BoardManager(self.http_client) + self.plugin = plugin.PluginManager(self.http_client) + self.plugin_injection = plugin_injection.InjectionPluginManager( + self.http_client) diff --git a/iotronicclient/v1/plugin.py b/iotronicclient/v1/plugin.py new file mode 100644 index 0000000..e2e5a43 --- /dev/null +++ b/iotronicclient/v1/plugin.py @@ -0,0 +1,107 @@ +# 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 iotronicclient.common import base +from iotronicclient.common.i18n import _ +from iotronicclient.common import utils +from iotronicclient import exc + +LOG = logging.getLogger(__name__) +_DEFAULT_POLL_INTERVAL = 2 + + +class Plugin(base.Resource): + def __repr__(self): + return "" % self._info + + +class PluginManager(base.CreateManager): + resource_class = Plugin + _creation_attributes = ['name', 'code', 'public', 'callable', 'parameters', + 'extra'] + _resource_name = 'plugins' + + def list(self, marker=None, limit=None, + detail=False, sort_key=None, sort_dir=None, fields=None, + with_public=False, all_plugins=False): + """Retrieve a list of plugins. + + :param marker: Optional, the UUID of a plugin, eg the last + plugin from a previous result set. Return + the next result set. + :param limit: The maximum number of results to return per + request, if: + + 1) limit > 0, the maximum number of plugins to return. + 2) limit == 0, return the entire list of plugins. + 3) limit param is NOT specified (None), the number of items + returned respect the maximum imposed by the Iotronic API + (see Iotronic's api.max_limit option). + + :param detail: Optional, boolean whether to return detailed information + about plugins. + + :param sort_key: Optional, field used for sorting. + + :param sort_dir: Optional, direction of sorting, either 'asc' (the + default) or 'desc'. + + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. Can not be used + when 'detail' is set. + + :param with_public: Optional boolean value to get also public plugins. + + :param all_plugins: Optional boolean value to get all plugins. + + :returns: A list of plugins. + + """ + if limit is not None: + limit = int(limit) + + if detail and fields: + raise exc.InvalidAttribute(_("Can't fetch a subset of fields " + "with 'detail' set")) + + filters = utils.common_filters(marker, limit, sort_key, sort_dir, + fields) + + if with_public: + filters.append('with_public=true') + if all_plugins: + filters.append('all_plugins=true') + + path = '' + if detail: + path += 'detail' + + if filters: + path += '?' + '&'.join(filters) + + if limit is None: + return self._list(self._path(path), "plugins") + else: + return self._list_pagination(self._path(path), "plugins", + limit=limit) + + def get(self, plugin_id, fields=None): + return self._get(resource_id=plugin_id, fields=fields) + + def delete(self, plugin_id): + return self._delete(resource_id=plugin_id) + + def update(self, plugin_id, patch, http_method='PATCH'): + return self._update(resource_id=plugin_id, patch=patch, + method=http_method) diff --git a/iotronicclient/v1/plugin_injection.py b/iotronicclient/v1/plugin_injection.py new file mode 100644 index 0000000..13e8f7b --- /dev/null +++ b/iotronicclient/v1/plugin_injection.py @@ -0,0 +1,103 @@ +# 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 iotronicclient.common import base +from iotronicclient.common.i18n import _ +from iotronicclient import exc + +LOG = logging.getLogger(__name__) +_DEFAULT_POLL_INTERVAL = 2 + + +class InjectionPlugin(base.Resource): + def __repr__(self): + return "" % self._info + + +class InjectionPluginManager(base.Manager): + resource_class = InjectionPlugin + _resource_name = 'boards' + + def plugin_inject(self, board_ident, plugin_ident, onboot=False): + path = "%s/plugins" % board_ident + body = {"plugin": plugin_ident, + "onboot": onboot} + + return self._update(path, body, method='PUT') + + def plugin_remove(self, board_ident, plugin_ident): + path = "%(board)s/plugins/%(plugin)s" % {'board': board_ident, + 'plugin': plugin_ident} + return self._delete(resource_id=path) + + def plugin_action(self, board_ident, plugin_ident, action, params={}): + path = "%(board)s/plugins/%(plugin)s" % {'board': board_ident, + 'plugin': plugin_ident} + body = {"action": action, + "parameters": params + } + return self._update(path, body, method='POST') + + def plugins_on_board(self, board_ident, marker=None, limit=None, + detail=False, sort_key=None, sort_dir=None, + fields=None): + """Retrieve a list of boards. + + :param board_ident: the UUID or name of the board. + + :param marker: Optional, the UUID of a board, eg the last + board from a previous result set. Return + the next result set. + :param limit: The maximum number of results to return per + request, if: + + 1) limit > 0, the maximum number of boards to return. + 2) limit == 0, return the entire list of boards. + 3) limit param is NOT specified (None), the number of items + returned respect the maximum imposed by the Iotronic API + (see Iotronic's api.max_limit option). + + :param detail: Optional, boolean whether to return detailed information + about boards. + + :param sort_key: Optional, field used for sorting. + + :param sort_dir: Optional, direction of sorting, either 'asc' (the + default) or 'desc'. + + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. Can not be used + when 'detail' is set. + + :returns: A list of plugins injected on a board. + + """ + if limit is not None: + limit = int(limit) + + if detail and fields: + raise exc.InvalidAttribute(_("Can't fetch a subset of fields " + "with 'detail' set")) + + # filters = utils.common_filters(marker, limit, sort_key, sort_dir, + # fields) + + path = "%s/plugins" % board_ident + + if limit is None: + return self._list(self._path(path), "injections") + else: + + return self._list_pagination(self._path(path), "injections", + limit=limit) diff --git a/iotronicclient/v1/plugin_injection_shell.py b/iotronicclient/v1/plugin_injection_shell.py new file mode 100644 index 0000000..a57f6b1 --- /dev/null +++ b/iotronicclient/v1/plugin_injection_shell.py @@ -0,0 +1,98 @@ +# 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 iotronicclient.common.apiclient import exceptions +from iotronicclient.common import cliutils +from iotronicclient.common.i18n import _ +from iotronicclient.v1 import resource_fields as res_fields + + +def _print_injected(injection, fields=None, json=False): + if fields is None: + fields = res_fields.PLUGIN_INJECT_RESOURCE.fields + + data = dict( + [(f, getattr(injection, f, '')) for f in fields]) + cliutils.print_dict(data, wrap=72, json_flag=json) + + +@cliutils.arg('board', + metavar='', + help="Name or UUID of the board.") +@cliutils.arg('plugin', + metavar='', + help="Name or UUID of the plugin.") +@cliutils.arg( + '--onboot', + dest='onboot', + action='store_true', + default=False, + help="Start the plugin on boot") +def do_plugin_inject(cc, args): + onboot = False + if args.onboot: + onboot = True + try: + cc.plugin_injection.plugin_inject(args.board, args.plugin, onboot) + print(_('Injected plugin %(plugin)s from board %(board)s') % { + 'board': args.board, 'plugin': args.plugin}) + except exceptions.ClientException as e: + exceptions.ClientException( + "Failed to inject plugin on board %(board)s: %(error)s" % { + 'board': args.board, 'error': e}) + + +@cliutils.arg('board', + metavar='', + help="Name or UUID of the board.") +@cliutils.arg('plugin', + metavar='', + help="Name or UUID of the plugin.") +def do_plugin_remove(cc, args): + try: + cc.plugin_injection.plugin_remove(args.board, args.plugin) + print(_('Removed plugin %(plugin)s from board %(board)s') % { + 'board': args.board, 'plugin': args.plugin}) + except exceptions.ClientException as e: + exceptions.ClientException( + "Failed to remove plugin from board %(board)s: %(error)s" % { + 'board': args.board, 'error': e}) + + +@cliutils.arg('board', + metavar='', + help="Name or UUID of the board.") +@cliutils.arg('plugin', + metavar='', + help="Name or UUID of the plugin.") +@cliutils.arg('action', + metavar='', + help="action of the plugin.") +def do_plugin_action(cc, args): + result = cc.plugin_injection.plugin_action(args.board, args.plugin, + args.action) + print(_('%s') % result) + + +@cliutils.arg( + 'board', + metavar='', + help="Name or UUID of the board ") +def do_plugins_on_board(cc, args): + fields = res_fields.PLUGIN_INJECT_RESOURCE_ON_BOARD.fields + field_labels = res_fields.PLUGIN_INJECT_RESOURCE_ON_BOARD.labels + """Show detailed information about a board.""" + list = cc.plugin_injection.plugins_on_board(args.board) + cliutils.print_list(list, fields=fields, + field_labels=field_labels, + sortby_index=None, + json_flag=args.json) diff --git a/iotronicclient/v1/plugin_shell.py b/iotronicclient/v1/plugin_shell.py new file mode 100644 index 0000000..7eaf532 --- /dev/null +++ b/iotronicclient/v1/plugin_shell.py @@ -0,0 +1,228 @@ +# 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 iotronicclient.common.apiclient import exceptions +from iotronicclient.common import cliutils +from iotronicclient.common.i18n import _ +from iotronicclient.common import utils +from iotronicclient.v1 import resource_fields as res_fields + + +def _print_plugin_show(plugin, fields=None, json=False): + if fields is None: + fields = res_fields.PLUGIN_DETAILED_RESOURCE.fields + + data = dict( + [(f, getattr(plugin, f, '')) for f in fields]) + cliutils.print_dict(data, wrap=72, json_flag=json) + + +@cliutils.arg( + 'plugin', + metavar='', + help="Name or UUID of the plugin ") +@cliutils.arg( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + default=[], + help="One or more plugin fields. Only these fields will be fetched from " + "the server.") +def do_plugin_show(cc, args): + """Show detailed information about a plugin.""" + fields = args.fields[0] if args.fields else None + utils.check_empty_arg(args.plugin, '') + utils.check_for_invalid_fields( + fields, res_fields.PLUGIN_DETAILED_RESOURCE.fields) + plugin = cc.plugin.get(args.plugin, fields=fields) + _print_plugin_show(plugin, fields=fields, json=args.json) + + +@cliutils.arg( + '--limit', + metavar='', + type=int, + help='Maximum number of plugins to return per request, ' + '0 for no limit. Default is the maximum number used ' + 'by the Iotronic API Service.') +@cliutils.arg( + '--marker', + metavar='', + help='Plugin UUID (for example, of the last plugin in the list from ' + 'a previous request). Returns the list of plugins after this UUID.') +@cliutils.arg( + '--sort-key', + metavar='', + help='Plugin field that will be used for sorting.') +@cliutils.arg( + '--sort-dir', + metavar='', + choices=['asc', 'desc'], + help='Sort direction: "asc" (the default) or "desc".') +@cliutils.arg( + '--detail', + dest='detail', + action='store_true', + default=False, + help="Show detailed information about the plugins.") +@cliutils.arg( + '--with-publics', + dest='with_public', + action='store_true', + default=False, + help="with public plugins") +@cliutils.arg( + '--all-plugins', + dest='all_plugins', + action='store_true', + default=False, + help="all plugins") +@cliutils.arg( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + default=[], + help="One or more plugin fields. Only these fields will be fetched from " + "the server. Can not be used when '--detail' is specified.") +def do_plugin_list(cc, args): + """List the plugins which are registered with the Iotronic service.""" + params = {} + + if args.detail: + fields = res_fields.PLUGIN_DETAILED_RESOURCE.fields + field_labels = res_fields.PLUGIN_DETAILED_RESOURCE.labels + elif args.fields: + utils.check_for_invalid_fields( + args.fields[0], res_fields.PLUGIN_DETAILED_RESOURCE.fields) + resource = res_fields.Resource(args.fields[0]) + fields = resource.fields + field_labels = resource.labels + else: + fields = res_fields.PLUGIN_RESOURCE.fields + field_labels = res_fields.PLUGIN_RESOURCE.labels + + sort_fields = res_fields.PLUGIN_DETAILED_RESOURCE.sort_fields + sort_field_labels = res_fields.PLUGIN_DETAILED_RESOURCE.sort_labels + + params.update(utils.common_params_for_list(args, + sort_fields, + sort_field_labels)) + + if args.with_public: + params['with_public'] = args.with_public + + if args.all_plugins: + params['all_plugins'] = args.all_plugins + + plugins = cc.plugin.list(**params) + cliutils.print_list(plugins, fields, + field_labels=field_labels, + sortby_index=None, + json_flag=args.json) + + +@cliutils.arg( + 'name', + metavar='', + help="Name or UUID of the plugin ") +@cliutils.arg( + 'code', + metavar='', + help="Code of the plugin") +@cliutils.arg( + '--callable', + dest='callable', + action='store_true', + default=False, + help="Set a callable plugin") +@cliutils.arg( + '--is-plublic', + dest='public', + action='store_true', + default=False, + help="Set a public plugin") +@cliutils.arg( + '--params', + metavar='', + help="Parameters file for the plugin") +@cliutils.arg( + '-e', '--extra', + metavar='', + action='append', + help="Record arbitrary key/value metadata. " + "Can be specified multiple times.") +def do_plugin_create(cc, args): + """Register a new plugin with the Iotronic service.""" + + field_list = ['name', 'code', 'callable', 'public', 'extra'] + + fields = dict((k, v) for (k, v) in vars(args).items() + if k in field_list and not (v is None)) + + fields = utils.args_array_to_dict(fields, 'extra') + + fl = fields['code'] + with open(fl, 'r') as fil: + fields['code'] = fil.read() + + if args.params: + fields['parameters'] = utils.json_from_file(args.params) + + plugin = cc.plugin.create(**fields) + + data = dict([(f, getattr(plugin, f, '')) for f in + res_fields.PLUGIN_DETAILED_RESOURCE.fields]) + + cliutils.print_dict(data, wrap=72, json_flag=args.json) + + +@cliutils.arg('plugin', + metavar='', + nargs='+', + help="Name or UUID of the plugin.") +def do_plugin_delete(cc, args): + """Unregister plugin(s) from the Iotronic service. + + Returns errors for any plugins that could not be unregistered. + """ + + failures = [] + for n in args.plugin: + try: + cc.plugin.delete(n) + print(_('Deleted plugin %s') % n) + except exceptions.ClientException as e: + failures.append(_("Failed to delete plugin %(plugin)s: %(error)s") + % {'plugin': n, 'error': e}) + if failures: + raise exceptions.ClientException("\n".join(failures)) + + +@cliutils.arg('plugin', metavar='', help="Name or UUID of the plugin.") +@cliutils.arg( + 'attributes', + metavar='', + nargs='+', + action='append', + default=[], + help="Values to be changed.") +def do_plugin_update(cc, args): + """Update information about a registered plugin.""" + + patch = {k: v for k, v in (x.split('=') for x in args.attributes[0])} + + plugin = cc.plugin.update(args.plugin, patch) + _print_plugin_show(plugin, json=args.json) diff --git a/iotronicclient/v1/resource_fields.py b/iotronicclient/v1/resource_fields.py new file mode 100644 index 0000000..93fe409 --- /dev/null +++ b/iotronicclient/v1/resource_fields.py @@ -0,0 +1,208 @@ +# Copyright 2014 Red Hat, 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 iotronicclient.common.i18n import _ + + +class Resource(object): + """Resource class + + This class is used to manage the various fields that a resource (e.g. + Chassis, Board, Port) contains. An individual field consists of a + 'field_id' (key) and a 'label' (value). The caller only provides the + 'field_ids' when instantiating the object. + + Ordering of the 'field_ids' will be preserved as specified by the caller. + + It also provides the ability to exclude some of these fields when they are + being used for sorting. + """ + + FIELDS = { + 'name': 'Name', + 'project': 'Project', + 'uuid': 'UUID', + 'extra': 'Extra', + 'updated_at': 'Updated At', + 'id': 'ID', + 'created_at': 'Created At', + 'status': 'Status', + 'code': 'Code', + 'mobile': 'Mobile', + 'session': 'Session', + 'location': 'Location', + 'owner': 'Owner', + 'type': 'Type', + 'callable': 'Callable', + 'public': 'Public', + 'onboot': 'On Boot', + 'board_uuid': 'Board uuid', + 'plugin_uuid': 'Plugin uuid', + 'plugin': 'Plugin', + 'parameters': 'Parameters', + + # + # 'address': 'Address', + # 'async': 'Async', + # 'attach': 'Response is attachment', + # 'chassis_uuid': 'Chassis UUID', + # 'clean_step': 'Clean Step', + # 'console_enabled': 'Console Enabled', + # 'description': 'Description', + # 'http_methods': 'Supported HTTP methods', + # 'inspection_finished_at': 'Inspection Finished At', + # 'inspection_started_at': 'Inspection Started At', + # 'instance_info': 'Instance Info', + # 'instance_uuid': 'Instance UUID', + # 'internal_info': 'Internal Info', + # 'last_error': 'Last Error', + # 'maintenance': 'Maintenance', + # 'maintenance_reason': 'Maintenance Reason', + # 'mode': 'Mode', + # 'power_state': 'Power State', + # 'properties': 'Properties', + # 'provision_state': 'Provisioning State', + # 'provision_updated_at': 'Provision Updated At', + # 'raid_config': 'Current RAID configuration', + # 'reservation': 'Reservation', + # 'resource_class': 'Resource Class', + # 'target_power_state': 'Target Power State', + # 'target_provision_state': 'Target Provision State', + # 'target_raid_config': 'Target RAID configuration', + # 'local_link_connection': 'Local Link Connection', + # 'pxe_enabled': 'PXE boot enabled', + # 'portgroup_uuid': 'Portgroup UUID', + # 'boot_interface': 'Boot Interface', + # 'console_interface': 'Console Interface', + # 'deploy_interface': 'Deploy Interface', + # 'inspect_interface': 'Inspect Interface', + # 'management_interface': 'Management Interface', + # 'network_interface': 'Network Interface', + # 'power_interface': 'Power Interface', + # 'raid_interface': 'RAID Interface', + # 'vendor_interface': 'Vendor Interface', + # 'standalone_ports_supported': 'Standalone Ports Supported', + + } + + def __init__(self, field_ids, sort_excluded=None): + """Create a Resource object + + :param field_ids: A list of strings that the Resource object will + contain. Each string must match an existing key in + FIELDS. + :param sort_excluded: Optional. A list of strings that will not be used + for sorting. Must be a subset of 'field_ids'. + + :raises: ValueError if sort_excluded contains value not in field_ids + """ + self._fields = tuple(field_ids) + self._labels = tuple([self.FIELDS[x] for x in field_ids]) + if sort_excluded is None: + sort_excluded = [] + not_existing = set(sort_excluded) - set(field_ids) + if not_existing: + raise ValueError( + _("sort_excluded specified with value not contained in " + "field_ids. Unknown value(s): %s") % ','.join(not_existing)) + self._sort_fields = tuple( + [x for x in field_ids if x not in sort_excluded]) + self._sort_labels = tuple([self.FIELDS[x] for x in self._sort_fields]) + + @property + def fields(self): + return self._fields + + @property + def labels(self): + return self._labels + + @property + def sort_fields(self): + return self._sort_fields + + @property + def sort_labels(self): + return self._sort_labels + + +# Boards +BOARD_DETAILED_RESOURCE = Resource( + [ + 'uuid', + 'name', + 'type', + 'status', + 'code', + 'session', + 'mobile', + 'extra', + 'created_at', + 'updated_at', + 'location', + 'project', + 'owner', + + ], + sort_excluded=[ + 'extra', 'location', 'session', + ]) +BOARD_RESOURCE = Resource( + ['uuid', + 'name', + 'type', + 'status', + 'session', + ]) + +# Plugins +PLUGIN_DETAILED_RESOURCE = Resource( + ['uuid', + 'name', + 'owner', + 'code', + 'public', + 'callable', + 'extra' + + ], + sort_excluded=[ + 'extra', 'code', + ]) +PLUGIN_RESOURCE = Resource( + ['uuid', + 'name', + 'owner', + 'public', + 'callable', + ]) + +PLUGIN_INJECT_RESOURCE_ON_BOARD = Resource( + [ + 'plugin', + 'status', + 'onboot', + 'created_at', + 'updated_at', + ]) + +PLUGIN_INJECT_RESOURCE = Resource( + ['board_uuid', + 'plugin_uuid', + 'status', + 'onboot', + 'created_at', + 'updated_at', + ]) diff --git a/iotronicclient/v1/shell.py b/iotronicclient/v1/shell.py new file mode 100644 index 0000000..6589eef --- /dev/null +++ b/iotronicclient/v1/shell.py @@ -0,0 +1,38 @@ +# 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 iotronicclient.common import utils +from iotronicclient.v1 import board_shell +from iotronicclient.v1 import plugin_injection_shell +from iotronicclient.v1 import plugin_shell + +COMMAND_MODULES = [ + board_shell, + plugin_shell, + plugin_injection_shell, +] + + +def enhance_parser(parser, subparsers, cmd_mapper): + """Enhance parser with API version specific options. + + Take a basic (nonversioned) parser and enhance it with + commands and options specific for this version of API. + + :param parser: top level parser + :param subparsers: top level parser's subparsers collection + where subcommands will go + """ + for command_module in COMMAND_MODULES: + utils.define_commands_from_module(subparsers, command_module, + cmd_mapper) diff --git a/iotronicclient/v1/utils.py b/iotronicclient/v1/utils.py new file mode 100644 index 0000000..7c18e60 --- /dev/null +++ b/iotronicclient/v1/utils.py @@ -0,0 +1,48 @@ +# Copyright 2016 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. + +HTTP_METHODS = ['POST', 'PUT', 'GET', 'DELETE', 'PATCH'] + +BOOT_DEVICES = ['pxe', 'disk', 'cdrom', 'bios', 'safe'] + +# Polling intervals in seconds. +_LONG_ACTION_POLL_INTERVAL = 10 +_SHORT_ACTION_POLL_INTERVAL = 2 +# This dict acts as both list of possible provision actions and arguments for +# wait_for_provision_state invocation. +PROVISION_ACTIONS = { + 'active': {'expected_state': 'active', + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'deleted': {'expected_state': 'available', + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'rebuild': {'expected_state': 'active', + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'inspect': {'expected_state': 'manageable', + # This is suboptimal for in-band inspection, but it's probably + # not worth making people wait 10 seconds for OOB inspection + 'poll_interval': _SHORT_ACTION_POLL_INTERVAL}, + 'provide': {'expected_state': 'available', + # This assumes cleaning is in place + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'manage': {'expected_state': 'manageable', + 'poll_interval': _SHORT_ACTION_POLL_INTERVAL}, + 'clean': {'expected_state': 'manageable', + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'adopt': {'expected_state': 'active', + 'poll_interval': _SHORT_ACTION_POLL_INTERVAL}, + 'abort': None, # no support for --wait in abort +} + +PROVISION_STATES = list(PROVISION_ACTIONS) diff --git a/releasenotes/notes/.placeholder b/releasenotes/notes/.placeholder new file mode 100644 index 0000000..e69de29 diff --git a/releasenotes/source/_static/.placeholder b/releasenotes/source/_static/.placeholder new file mode 100644 index 0000000..e69de29 diff --git a/releasenotes/source/_templates/.placeholder b/releasenotes/source/_templates/.placeholder new file mode 100644 index 0000000..e69de29 diff --git a/releasenotes/source/conf.py b/releasenotes/source/conf.py new file mode 100644 index 0000000..4c45783 --- /dev/null +++ b/releasenotes/source/conf.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +# 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. + +# Glance Release Notes documentation build configuration file, created by +# sphinx-quickstart on Tue Nov 3 17:40:50 2015. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'oslosphinx', + 'reno.sphinxext', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'iotronicclient Release Notes' +copyright = u'2016, OpenStack Foundation' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +# The full version, including alpha/beta/rc tags. +release = '' +# The short X.Y version. +version = '' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +# html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'GlanceReleaseNotesdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # 'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'GlanceReleaseNotes.tex', u'Glance Release Notes Documentation', + u'Glance Developers', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# If true, show page references after internal links. +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'glancereleasenotes', u'Glance Release Notes Documentation', + [u'Glance Developers'], 1) +] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'GlanceReleaseNotes', u'Glance Release Notes Documentation', + u'Glance Developers', 'GlanceReleaseNotes', + 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# texinfo_no_detailmenu = False + +# -- Options for Internationalization output ------------------------------ +locale_dirs = ['locale/'] diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst new file mode 100644 index 0000000..e75fdb3 --- /dev/null +++ b/releasenotes/source/index.rst @@ -0,0 +1,8 @@ +============================================ + iotronicclient Release Notes +============================================ + +.. toctree:: + :maxdepth: 1 + + unreleased diff --git a/releasenotes/source/unreleased.rst b/releasenotes/source/unreleased.rst new file mode 100644 index 0000000..cd22aab --- /dev/null +++ b/releasenotes/source/unreleased.rst @@ -0,0 +1,5 @@ +============================== + Current Series Release Notes +============================== + +.. release-notes:: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..220e3dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# 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.0.0 # Apache-2.0 +appdirs>=1.3.0 # MIT License +dogpile.cache>=0.6.2 # BSD +jsonschema!=2.5.0,<3.0.0,>=2.0.0 # MIT +keystoneauth1>=2.18.0 # Apache-2.0 +osc-lib>=1.2.0 # Apache-2.0 +oslo.i18n>=2.1.0 # Apache-2.0 +oslo.serialization>=1.10.0 # Apache-2.0 +oslo.utils>=3.20.0 # Apache-2.0 +PrettyTable<0.8,>=0.7.1 # BSD +python-openstackclient>=3.3.0 # Apache-2.0 +PyYAML>=3.10.0 # MIT +requests!=2.12.2,!=2.13.0,>=2.10.0 # Apache-2.0 +six>=1.9.0 # MIT diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..32448e8 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,37 @@ +[metadata] +name = python-iotronicclient +summary = Iotronic Client +description-file = + README.rst +author = OpenStack +author-email = openstack-dev@lists.openstack.org +home-page = http://www.openstack.org/ +classifier = + Environment :: OpenStack + 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.3 + Programming Language :: Python :: 3.4 + +[files] +packages = + iotronicclient + +[entry_points] +console_scripts = + iotronic = iotronicclient.shell:main + +[build_sphinx] +source-dir = doc/source +build-dir = doc/build +all_files = 1 + +[pbr] +autodoc_index_modules = True +warnerrors = True \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..782bb21 --- /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>=1.8'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..0b28321 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,17 @@ +# 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.12.0,!=0.13.0,<0.14 # Apache-2.0 + +coverage>=4.0 # Apache-2.0 +python-subunit>=0.0.18 # Apache-2.0/BSD +sphinx>=1.5.1 # BSD +oslosphinx>=4.7.0 # Apache-2.0 +oslotest>=1.10.0 # Apache-2.0 +testrepository>=0.0.18 # Apache-2.0/BSD +testscenarios>=0.4 # Apache-2.0/BSD +testtools>=1.4.0 # MIT + +# releasenotes +reno>=1.8.0 # Apache-2.0 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..db03f6c --- /dev/null +++ b/tox.ini @@ -0,0 +1,41 @@ +[tox] +minversion = 2.0 +envlist = py35,py34,py27,pypy,pep8 +skipsdist = True + +[testenv] +usedevelop = True +install_command = pip install -c{env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt} {opts} {packages} +setenv = + VIRTUAL_ENV={envdir} + PYTHONWARNINGS=default::DeprecationWarning +deps = -r{toxinidir}/test-requirements.txt +commands = + find . -type f -name "*.pyc" -delete + +[testenv:pep8] +commands = flake8 {posargs} + +[testenv:venv] +commands = {posargs} + +[testenv:cover] +commands = python setup.py test --coverage --testr-args='{posargs}' + +[testenv:docs] +commands = python setup.py build_sphinx + +[testenv:releasenotes] +commands = + sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html + +[testenv:debug] +commands = oslo_debug_helper {posargs} + +[flake8] +# E123, E125 skipped as they are invalid PEP-8. + +show-source = True +ignore = E123,E125 +builtins = _ +exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build