From f85dcba1a1fba5600faac6081228076495138af1 Mon Sep 17 00:00:00 2001 From: Dale Smith Date: Thu, 17 Sep 2015 16:41:56 +0100 Subject: [PATCH] Initial version of python-stacktaskclient Based on python-heatclient 04b3880 (2015-06-04) Change-Id: Ie54c889a4b89ec32f9a00b954560929f35712021 --- .gitignore | 18 + .testr.conf | 4 + CONTRIBUTING.rst | 11 + LICENSE | 175 + MANIFEST.in | 10 + README.rst | 23 + babel.cfg | 1 + doc/.gitignore | 2 + doc/Makefile | 90 + doc/source/conf.py | 266 + doc/source/ext/gen_ref.py | 59 + doc/source/index.rst | 75 + doc/source/man/heat.rst | 98 + openstack-common.conf | 8 + .../locale/python-stacktaskclient.pot | 0 requirements.txt | 17 + run_tests.sh | 121 + setup.cfg | 57 + setup.py | 29 + stacktaskclient/__init__.py | 16 + stacktaskclient/client.py | 19 + stacktaskclient/common/__init__.py | 0 stacktaskclient/common/deployment_utils.py | 147 + stacktaskclient/common/environment_format.py | 52 + stacktaskclient/common/event_utils.py | 128 + stacktaskclient/common/http.py | 355 ++ stacktaskclient/common/template_format.py | 63 + stacktaskclient/common/template_utils.py | 224 + stacktaskclient/common/utils.py | 280 + stacktaskclient/exc.py | 196 + stacktaskclient/openstack/__init__.py | 0 stacktaskclient/openstack/common/__init__.py | 0 stacktaskclient/openstack/common/_i18n.py | 45 + .../openstack/common/apiclient/__init__.py | 0 .../openstack/common/apiclient/auth.py | 234 + .../openstack/common/apiclient/base.py | 532 ++ .../openstack/common/apiclient/client.py | 388 ++ .../openstack/common/apiclient/exceptions.py | 479 ++ .../openstack/common/apiclient/fake_client.py | 190 + .../openstack/common/apiclient/utils.py | 100 + stacktaskclient/openstack/common/cliutils.py | 271 + stacktaskclient/shell.py | 676 +++ stacktaskclient/tests/__init__.py | 0 stacktaskclient/tests/functional/__init__.py | 0 stacktaskclient/tests/functional/base.py | 42 + .../tests/functional/hooks/post_test_hook.sh | 50 + .../functional/templates/heat_minimal.yaml | 18 + .../templates/heat_minimal_hot.yaml | 19 + .../tests/functional/test_readonly_heat.py | 102 + stacktaskclient/tests/unit/__init__.py | 0 stacktaskclient/tests/unit/fakes.py | 256 + stacktaskclient/tests/unit/test_actions.py | 108 + stacktaskclient/tests/unit/test_build_info.py | 41 + .../tests/unit/test_common_http.py | 861 ++++ .../tests/unit/test_deployment_utils.py | 326 ++ .../tests/unit/test_environment_format.py | 80 + .../tests/unit/test_event_utils.py | 130 + stacktaskclient/tests/unit/test_events.py | 153 + .../tests/unit/test_resource_types.py | 91 + stacktaskclient/tests/unit/test_resources.py | 210 + stacktaskclient/tests/unit/test_service.py | 59 + stacktaskclient/tests/unit/test_shell.py | 4581 +++++++++++++++++ .../tests/unit/test_software_configs.py | 99 + .../tests/unit/test_software_deployments.py | 166 + stacktaskclient/tests/unit/test_stacks.py | 316 ++ .../tests/unit/test_template_format.py | 50 + .../tests/unit/test_template_utils.py | 1017 ++++ .../tests/unit/test_template_versions.py | 57 + stacktaskclient/tests/unit/test_utils.py | 278 + stacktaskclient/tests/unit/v1/__init__.py | 0 stacktaskclient/tests/unit/v1/test_hooks.py | 318 ++ .../tests/unit/var/adopt_stack_data.json | 6 + .../tests/unit/var/minimal.template | 9 + stacktaskclient/v1/__init__.py | 18 + stacktaskclient/v1/build_info.py | 34 + stacktaskclient/v1/client.py | 51 + stacktaskclient/v1/roles.py | 75 + stacktaskclient/v1/shell.py | 170 + stacktaskclient/v1/users.py | 243 + test-requirements.txt | 19 + tools/heat.bash_completion | 27 + tools/install_venv.py | 74 + tools/install_venv_common.py | 172 + tools/with_venv.sh | 10 + tox.ini | 51 + 85 files changed, 15846 insertions(+) create mode 100644 .gitignore create mode 100644 .testr.conf create mode 100644 CONTRIBUTING.rst create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 babel.cfg create mode 100644 doc/.gitignore create mode 100644 doc/Makefile create mode 100644 doc/source/conf.py create mode 100644 doc/source/ext/gen_ref.py create mode 100644 doc/source/index.rst create mode 100644 doc/source/man/heat.rst create mode 100644 openstack-common.conf create mode 100644 python-stacktaskclient/locale/python-stacktaskclient.pot create mode 100644 requirements.txt create mode 100755 run_tests.sh create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 stacktaskclient/__init__.py create mode 100644 stacktaskclient/client.py create mode 100644 stacktaskclient/common/__init__.py create mode 100644 stacktaskclient/common/deployment_utils.py create mode 100644 stacktaskclient/common/environment_format.py create mode 100644 stacktaskclient/common/event_utils.py create mode 100644 stacktaskclient/common/http.py create mode 100644 stacktaskclient/common/template_format.py create mode 100644 stacktaskclient/common/template_utils.py create mode 100644 stacktaskclient/common/utils.py create mode 100644 stacktaskclient/exc.py create mode 100644 stacktaskclient/openstack/__init__.py create mode 100644 stacktaskclient/openstack/common/__init__.py create mode 100644 stacktaskclient/openstack/common/_i18n.py create mode 100644 stacktaskclient/openstack/common/apiclient/__init__.py create mode 100644 stacktaskclient/openstack/common/apiclient/auth.py create mode 100644 stacktaskclient/openstack/common/apiclient/base.py create mode 100644 stacktaskclient/openstack/common/apiclient/client.py create mode 100644 stacktaskclient/openstack/common/apiclient/exceptions.py create mode 100644 stacktaskclient/openstack/common/apiclient/fake_client.py create mode 100644 stacktaskclient/openstack/common/apiclient/utils.py create mode 100644 stacktaskclient/openstack/common/cliutils.py create mode 100644 stacktaskclient/shell.py create mode 100644 stacktaskclient/tests/__init__.py create mode 100644 stacktaskclient/tests/functional/__init__.py create mode 100644 stacktaskclient/tests/functional/base.py create mode 100755 stacktaskclient/tests/functional/hooks/post_test_hook.sh create mode 100644 stacktaskclient/tests/functional/templates/heat_minimal.yaml create mode 100644 stacktaskclient/tests/functional/templates/heat_minimal_hot.yaml create mode 100644 stacktaskclient/tests/functional/test_readonly_heat.py create mode 100644 stacktaskclient/tests/unit/__init__.py create mode 100644 stacktaskclient/tests/unit/fakes.py create mode 100644 stacktaskclient/tests/unit/test_actions.py create mode 100644 stacktaskclient/tests/unit/test_build_info.py create mode 100644 stacktaskclient/tests/unit/test_common_http.py create mode 100644 stacktaskclient/tests/unit/test_deployment_utils.py create mode 100644 stacktaskclient/tests/unit/test_environment_format.py create mode 100644 stacktaskclient/tests/unit/test_event_utils.py create mode 100644 stacktaskclient/tests/unit/test_events.py create mode 100644 stacktaskclient/tests/unit/test_resource_types.py create mode 100644 stacktaskclient/tests/unit/test_resources.py create mode 100644 stacktaskclient/tests/unit/test_service.py create mode 100644 stacktaskclient/tests/unit/test_shell.py create mode 100644 stacktaskclient/tests/unit/test_software_configs.py create mode 100644 stacktaskclient/tests/unit/test_software_deployments.py create mode 100644 stacktaskclient/tests/unit/test_stacks.py create mode 100644 stacktaskclient/tests/unit/test_template_format.py create mode 100644 stacktaskclient/tests/unit/test_template_utils.py create mode 100644 stacktaskclient/tests/unit/test_template_versions.py create mode 100644 stacktaskclient/tests/unit/test_utils.py create mode 100644 stacktaskclient/tests/unit/v1/__init__.py create mode 100644 stacktaskclient/tests/unit/v1/test_hooks.py create mode 100644 stacktaskclient/tests/unit/var/adopt_stack_data.json create mode 100644 stacktaskclient/tests/unit/var/minimal.template create mode 100644 stacktaskclient/v1/__init__.py create mode 100644 stacktaskclient/v1/build_info.py create mode 100644 stacktaskclient/v1/client.py create mode 100644 stacktaskclient/v1/roles.py create mode 100644 stacktaskclient/v1/shell.py create mode 100644 stacktaskclient/v1/users.py create mode 100644 test-requirements.txt create mode 100644 tools/heat.bash_completion create mode 100644 tools/install_venv.py create mode 100644 tools/install_venv_common.py create mode 100755 tools/with_venv.sh create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79717f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.coverage +.venv +*,cover +cover +*.pyc +AUTHORS +build +dist +ChangeLog +run_tests.err.log +.tox +doc/source/api +doc/build +*.egg +stacktaskclient/versioninfo +*.egg-info +*.log +.testrepository diff --git a/.testr.conf b/.testr.conf new file mode 100644 index 0000000..c8af2c3 --- /dev/null +++ b/.testr.conf @@ -0,0 +1,4 @@ +[DEFAULT] +test_command=${PYTHON:-python} -m subunit.run discover -t ./ ${OS_TEST_PATH:-./stacktaskclient/tests/unit} $LISTOPT $IDOPTION +test_id_option=--load-list $IDFILE +test_list_option=--list diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..46249a2 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,11 @@ +If you would like to contribute to the development of OpenStack, +you must follow the steps documented at: + + http://docs.openstack.org/infra/manual/developers.html#development-workflow + +Once those steps have been completed, changes to OpenStack +should be submitted for review via the Gerrit tool, following +the workflow documented at: + + http://docs.openstack.org/infra/manual/developers.html#development-workflow + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..67db858 --- /dev/null +++ b/LICENSE @@ -0,0 +1,175 @@ + + 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/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..44f7c2e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +include AUTHORS +include babel.cfg +include LICENSE +include README.rst +include ChangeLog +include tox.ini +include .testr.conf +recursive-include doc * +recursive-include tools * +recursive-include python-stacktaskclient *.po *.pot diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..db763f7 --- /dev/null +++ b/README.rst @@ -0,0 +1,23 @@ +================= +python-stacktaskclient +================= + +OpenStack Orchestration API Client Library + +This is a client library for Stacktask built on the Catalyst Stacktask API. It +provides a Python API (the ``stacktaskclient`` module) and a command-line tool +(``stacktask``). + +* Free software: Apache license +* Documentation: http://docs.openstack.org/developer/python-heatclient +* Source: http://git.openstack.org/cgit/openstack/python-heatclient +* Bugs: http://bugs.launchpad.net/python-heatclient + +Setup: +python tools/install_venv.py +source .tox/venv/bin/activate +source openrc.sh +stacktask user-tenant-list + +'pip list' should give: python-stacktaskclient (0.6.1.dev63, /home/dale/dale/dev/openstack/python-heatclient) +'which stacktask' should give: /home/dale/dale/dev/openstack/python-heatclient/.tox/venv/bin/stacktask diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..efceab8 --- /dev/null +++ b/babel.cfg @@ -0,0 +1 @@ +[python: **.py] diff --git a/doc/.gitignore b/doc/.gitignore new file mode 100644 index 0000000..8e0be80 --- /dev/null +++ b/doc/.gitignore @@ -0,0 +1,2 @@ +build/ +source/ref/ diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..6239df3 --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,90 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXSOURCE = source +PAPER = +BUILDDIR = build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) $(SPHINXSOURCE) + +.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-heatclient.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-heatclient.qhc" + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ + "run these through (pdf)latex." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 0000000..e611dbe --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,266 @@ +# -*- 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. +# +# python-heatclient documentation build configuration file, created by +# sphinx-quickstart on Sun Dec 6 14:19:25 2009. +# +# This file is execfile()d with the current directory set to its containing +# dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import os + +# 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.append(os.path.abspath('.')) + +exec(open(os.path.join("ext", "gen_ref.py")).read()) +# -- 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', 'oslosphinx'] + +# Add any paths that contain templates here, relative to this directory. +if os.getenv('HUDSON_PUBLISH_DOCS'): + templates_path = ['_ga', '_templates'] +else: + templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'python-heatclient' +copyright = 'OpenStack Contributors' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '2.13' +# The full version, including alpha/beta/rc tags. +release = '2.13.0' + +# 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 = [] + +primary_domain = 'py' +nitpicky = 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_path = ['.'] +# html_theme = '_theme' + +# 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 = { + "nosidebar": "false" +} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' +git_cmd = "git log --pretty=format:'%ad, commit %h' --date=local -n1" +html_last_updated_fmt = os.popen(git_cmd).read() + +# 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 = 'python-heatclientdoc' + + +# -- 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]) +latex_documents = [ + ('index', 'python-heatclient.tex', 'python-heatclient Documentation', + u'OpenStack Foundation', '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 = [ + ('man/heat', 'heat', + u'Command line access to the heat project.', + [u'Heat 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', 'Heat', u'Heat Documentation', + u'Heat Developers', 'Heat', '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' diff --git a/doc/source/ext/gen_ref.py b/doc/source/ext/gen_ref.py new file mode 100644 index 0000000..15ff818 --- /dev/null +++ b/doc/source/ext/gen_ref.py @@ -0,0 +1,59 @@ +# -*- 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 + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(BASE_DIR, "..", "..", "..")) + +sys.path.insert(0, ROOT) +sys.path.insert(0, BASE_DIR) + + +def gen_ref(ver, title, names): + refdir = os.path.join(BASE_DIR, "ref") + pkg = "heatclient" + if ver: + pkg = "%s.%s" % (pkg, ver) + refdir = os.path.join(refdir, ver) + if not os.path.exists(refdir): + os.makedirs(refdir) + idxpath = os.path.join(refdir, "index.rst") + with open(idxpath, "w") as idx: + idx.write(("%(title)s\n" + "%(signs)s\n" + "\n" + ".. toctree::\n" + " :maxdepth: 1\n" + "\n") % {"title": title, "signs": "=" * len(title)}) + for name in names: + idx.write(" %s\n" % name) + rstpath = os.path.join(refdir, "%s.rst" % name) + with open(rstpath, "w") as rst: + rst.write(("%(title)s\n" + "%(signs)s\n" + "\n" + ".. automodule:: %(pkg)s.%(name)s\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n" + " :noindex:\n") + % {"title": name.capitalize(), + "signs": "=" * len(name), + "pkg": pkg, "name": name}) + +gen_ref("", "Client Reference", ["client", "exc"]) +gen_ref("v1", "Version 1 API Reference", + ["stacks", "resources", "events", "actions", + "software_configs", "software_deployments"]) diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..ec0bdc5 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,75 @@ +Python bindings to the OpenStack Heat API +========================================= + +This is a client for OpenStack Heat API. There's a Python API +(the :mod:`heatclient` module), and a command-line script +(installed as :program:`heat`). + +Python API +========== + +In order to use the python api directly, you must first obtain an auth +token and identify which endpoint you wish to speak to:: + + >>> tenant_id = 'b363706f891f48019483f8bd6503c54b' + >>> heat_url = 'http://heat.example.org:8004/v1/%s' % tenant_id + >>> auth_token = '3bcc3d3a03f44e3d8377f9247b0ad155' + +Once you have done so, you can use the API like so:: + + >>> from heatclient.client import Client + >>> heat = Client('1', endpoint=heat_url, token=auth_token) + +Reference +--------- + +.. toctree:: + :maxdepth: 1 + + ref/index + ref/v1/index + +Command-line Tool +================= + +In order to use the CLI, you must provide your OpenStack username, +password, tenant, and auth endpoint. Use the corresponding +configuration options (``--os-username``, ``--os-password``, +``--os-tenant-id``, and ``--os-auth-url``) or set them in environment +variables:: + + export OS_USERNAME=user + export OS_PASSWORD=pass + export OS_TENANT_ID=b363706f891f48019483f8bd6503c54b + export OS_AUTH_URL=http://auth.example.com:5000/v2.0 + +The command line tool will attempt to reauthenticate using your +provided credentials for every request. You can override this behavior +by manually supplying an auth token using ``--heat-url`` and +``--os-auth-token``. You can alternatively set these environment +variables:: + + export HEAT_URL=http://heat.example.org:8004/v1/b363706f891f48019483f8bd6503c54b + export OS_AUTH_TOKEN=3bcc3d3a03f44e3d8377f9247b0ad155 + +Once you've configured your authentication parameters, you can run +``heat help`` to see a complete listing of available commands. + +Man Pages +========= + +.. toctree:: + :maxdepth: 1 + + man/heat + +Contributing +============ + +Code is hosted `on GitHub`_. Submit bugs to the Heat project on +`Launchpad`_. Submit code to the openstack/python-heatclient project +using `Gerrit`_. + +.. _on GitHub: https://github.com/openstack/python-heatclient +.. _Launchpad: https://launchpad.net/python-heatclient +.. _Gerrit: http://docs.openstack.org/infra/manual/developers.html#development-workflow diff --git a/doc/source/man/heat.rst b/doc/source/man/heat.rst new file mode 100644 index 0000000..1b29111 --- /dev/null +++ b/doc/source/man/heat.rst @@ -0,0 +1,98 @@ +==== +heat +==== + +.. program:: heat + +SYNOPSIS +======== + + `heat` [options] [command-options] + + `heat help` + + `heat help` + + +DESCRIPTION +=========== + +`heat` is a command line client for controlling OpenStack Heat. + +Before the `heat` command is issued, ensure the environment contains +the necessary variables so that the CLI can pass user credentials to +the server. +See `Getting Credentials for a CLI` section of `OpenStack CLI Guide` +for more info. + + +OPTIONS +======= + +To get a list of available commands and options run:: + + heat help + +To get usage and options of a command run:: + + heat help + + +EXAMPLES +======== + +Get information about stack-create command:: + + heat help stack-create + +List available stacks:: + + heat stack-list + +List available resources in a stack:: + + heat resource-list + +Create a stack:: + + heat stack-create mystack -f some-template.yaml -P "KeyName=mine" + +View stack information:: + + heat stack-show mystack + +List stack outputs:: + + heat output-list + +Show the value of a single output:: + + heat output-show + +List events:: + + heat event-list mystack + +Delete a stack:: + + heat stack-delete mystack + +Abandon a stack:: + + heat stack-abandon mystack + +Adopt a stack :: + + heat stack-adopt -a mystack + +List heat-engines running status :: + + heat service-list + +Note: stack-adopt and stack-abandon commands are not available by default. +Please ask your Openstack operator to enable this feature. + +BUGS +==== + +Heat client is hosted in Launchpad so you can view current bugs at https://bugs.launchpad.net/python-heatclient/. diff --git a/openstack-common.conf b/openstack-common.conf new file mode 100644 index 0000000..2902223 --- /dev/null +++ b/openstack-common.conf @@ -0,0 +1,8 @@ +[DEFAULT] + +# The list of modules to copy from openstack-common +modules=apiclient +module=cliutils + +# The base module to hold the copy of openstack.common +base=stacktaskclient diff --git a/python-stacktaskclient/locale/python-stacktaskclient.pot b/python-stacktaskclient/locale/python-stacktaskclient.pot new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f606237 --- /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. + +Babel>=1.3 +pbr<2.0,>=1.4 +argparse +iso8601>=0.1.9 +PrettyTable<0.8,>=0.7 +oslo.i18n>=1.5.0 # Apache-2.0 +oslo.serialization>=1.4.0 # Apache-2.0 +oslo.utils>=2.0.0 # Apache-2.0 +python-keystoneclient>=1.6.0 +python-swiftclient>=2.2.0 +PyYAML>=3.1.0 +requests>=2.5.2 +six>=1.9.0 diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..2981e7a --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +BASE_DIR=`dirname $0` + +function usage { + echo "Usage: $0 [OPTION]..." + echo "Run stacktaskclient test suite(s)" + echo "" + echo " -V, --virtual-env Use virtualenv. Install automatically if not present." + echo " (Default is to run tests in local environment)" + echo " -F, --force Force a clean re-build of the virtual environment. Useful when dependencies have been added." + echo " -f, --func Functional tests have been removed." + echo " -u, --unit Run unit tests (default when nothing specified)" + echo " -p, --pep8 Run pep8 tests" + echo " --all Run pep8 and unit tests" + echo " -c, --coverage Generate coverage report" + echo " -d, --debug Run tests with testtools instead of testr. This allows you to use the debugger." + echo " -h, --help Print this usage message" + exit +} + +# must not assign -a as an option, needed for selecting custom attributes +no_venv=1 +function process_option { + case "$1" in + -V|--virtual-env) no_venv=0;; + -F|--force) force=1;; + -f|--func) test_func=1;; + -u|--unit) test_unit=1;; + -p|--pep8) test_pep8=1;; + --all) test_unit=1; test_pep8=1;; + -c|--coverage) coverage=1;; + -d|--debug) debug=1;; + -h|--help) usage;; + *) args="$args $1"; test_unit=1;; + esac +} + +venv=.venv +with_venv=tools/with_venv.sh +wrapper="" +debug=0 + +function run_tests { + echo 'Running tests' + + if [ $debug -eq 1 ]; then + echo "Debugging..." + if [ "$args" = "" ]; then + # Default to running all tests if specific test is not + # provided. + testrargs="discover ./stacktaskclient/tests" + fi + ${wrapper} python -m testtools.run $args $testrargs + + # Short circuit because all of the testr and coverage stuff + # below does not make sense when running testtools.run for + # debugging purposes. + return $? + fi + + # Just run the test suites in current environment + if [ -n "$args" ] ; then + args="-t $args" + fi + ${wrapper} python setup.py testr --slowest $args +} + +function run_pep8 { + echo "Running flake8..." + bash -c "${wrapper} flake8" +} + +# run unit tests with pep8 when no arguments are specified +# otherwise process CLI options +if [[ $# == 0 ]]; then + test_pep8=1 + test_unit=1 +else + for arg in "$@"; do + process_option $arg + done +fi + +if [ "$no_venv" == 0 ] +then + # Remove the virtual environment if --force used + if [ "$force" == 1 ]; then + echo "Cleaning virtualenv..." + rm -rf ${venv} + fi + if [ -e ${venv} ]; then + wrapper="${with_venv}" + else + # Automatically install the virtualenv + python tools/install_venv.py + wrapper="${with_venv}" + fi +fi + +result=0 + +# If functional or unit tests have been selected, run them +if [ "$test_unit" == 1 ] || [ "$debug" == 1 ] ; then + run_tests + result=$? +fi + +# Run pep8 if it was selected +if [ "$test_pep8" == 1 ]; then + run_pep8 +fi + +# Generate coverage report +if [ "$coverage" == 1 ]; then + echo "Generating coverage report in ./cover" + ${wrapper} python setup.py testr --coverage --slowest + ${wrapper} python -m coverage report --show-missing +fi + +exit $result diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0d50166 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,57 @@ +[metadata] +name = python-stacktaskclient +summary = OpenStack Orchestration API Client Library +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 :: 2.6 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.4 + +[files] +packages = + stacktaskclient + +[entry_points] +console_scripts = + stacktask = stacktaskclient.shell:main + +[global] +setup-hooks = + pbr.hooks.setup_hook + +[build_sphinx] +source-dir = doc/source +build-dir = doc/build +all_files = 1 + +[upload_sphinx] +upload-dir = doc/build/html + +[wheel] +universal = 1 + +[extract_messages] +keywords = _ gettext ngettext l_ lazy_gettext +mapping_file = babel.cfg +output_file = python-stacktaskclient/locale/python-stacktaskclient.pot + +[compile_catalog] +directory = python-stacktaskclient/locale +domain = python-stacktaskclient + +[update_catalog] +domain = python-stacktaskclient +output_dir = python-stacktaskclient/locale +input_file = python-stacktaskclient/locale/python-stacktaskclient.pot diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d8080d0 --- /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.3'], + pbr=True) diff --git a/stacktaskclient/__init__.py b/stacktaskclient/__init__.py new file mode 100644 index 0000000..b126fc8 --- /dev/null +++ b/stacktaskclient/__init__.py @@ -0,0 +1,16 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import pbr.version + + +__version__ = pbr.version.VersionInfo('python-stacktaskclient').version_string() diff --git a/stacktaskclient/client.py b/stacktaskclient/client.py new file mode 100644 index 0000000..417813b --- /dev/null +++ b/stacktaskclient/client.py @@ -0,0 +1,19 @@ +# 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 stacktaskclient.common import utils + + +def Client(version, *args, **kwargs): + module = utils.import_versioned_module(version, 'client') + client_class = getattr(module, 'Client') + return client_class(*args, **kwargs) diff --git a/stacktaskclient/common/__init__.py b/stacktaskclient/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stacktaskclient/common/deployment_utils.py b/stacktaskclient/common/deployment_utils.py new file mode 100644 index 0000000..c35691c --- /dev/null +++ b/stacktaskclient/common/deployment_utils.py @@ -0,0 +1,147 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy +import six +from six.moves.urllib import parse as urlparse +from swiftclient import client as sc +from swiftclient import utils as swiftclient_utils +import uuid + +from stacktaskclient import exc +from stacktaskclient.openstack.common._i18n import _ + + +def build_derived_config_params(action, source, name, input_values, + server_id, signal_transport, signal_id=None): + + input_values = input_values or {} + inputs = copy.deepcopy(source.get('inputs')) or [] + + for inp in inputs: + input_key = inp['name'] + inp['value'] = input_values.pop(input_key, inp.get('default')) + + # for any input values that do not have a declared input, add + # a derived declared input so that they can be used as config + # inputs + for inpk, inpv in input_values.items(): + inputs.append({ + 'name': inpk, + 'type': 'String', + 'value': inpv + }) + + inputs.extend([{ + 'name': 'deploy_server_id', + 'description': _('ID of the server being deployed to'), + 'type': 'String', + 'value': server_id + }, { + 'name': 'deploy_action', + 'description': _('Name of the current action being deployed'), + 'type': 'String', + 'value': action + }, { + 'name': 'deploy_signal_transport', + 'description': _('How the server should signal to stacktask with ' + 'the deployment output values.'), + 'type': 'String', + 'value': signal_transport + }]) + + if signal_transport == 'TEMP_URL_SIGNAL': + inputs.append({ + 'name': 'deploy_signal_id', + 'description': _('ID of signal to use for signaling ' + 'output values'), + 'type': 'String', + 'value': signal_id + }) + inputs.append({ + 'name': 'deploy_signal_verb', + 'description': _('HTTP verb to use for signaling ' + 'output values'), + 'type': 'String', + 'value': 'PUT' + }) + elif signal_transport != 'NO_SIGNAL': + raise exc.CommandError( + _('Unsupported signal transport %s') % signal_transport) + + return { + 'group': source.get('group') or 'Heat::Ungrouped', + 'config': source.get('config') or '', + 'options': source.get('options') or {}, + 'inputs': inputs, + 'outputs': source.get('outputs') or [], + 'name': name + } + + +def create_temp_url(swift_client, name, timeout, container=None): + + container = container or '%(name)s-%(uuid)s' % { + 'name': name, 'uuid': uuid.uuid4()} + object_name = str(uuid.uuid4()) + + swift_client.put_container(container) + key_header = 'x-account-meta-temp-url-key' + if key_header not in swift_client.head_account(): + swift_client.post_account({ + key_header: six.text_type(uuid.uuid4())[:32]}) + + key = swift_client.head_account()[key_header] + project_path = swift_client.url.split('/')[-1] + path = '/v1/%s/%s/%s' % (project_path, container, object_name) + timeout_secs = timeout * 60 + tempurl = swiftclient_utils.generate_temp_url(path, timeout_secs, key, + 'PUT') + sw_url = urlparse.urlparse(swift_client.url) + put_url = '%s://%s%s' % (sw_url.scheme, sw_url.netloc, tempurl) + swift_client.put_object(container, object_name, '') + return put_url + + +def build_signal_id(hc, args): + if args.signal_transport != 'TEMP_URL_SIGNAL': + return + + if args.os_no_client_auth: + raise exc.CommandError(_( + 'Cannot use --os-no-client-auth, auth required to create ' + 'a Swift TempURL.')) + swift_client = create_swift_client( + hc.http_client.auth, hc.http_client.session, args) + + return create_temp_url(swift_client, args.name, args.timeout) + + +def create_swift_client(auth, session, args): + auth_token = auth.get_token(session) + endpoint = auth.get_endpoint(session, + service_type='object-store', + region_name=args.os_region_name) + project_name = args.os_project_name or args.os_tenant_name + swift_args = { + 'auth_version': '2.0', + 'tenant_name': project_name, + 'user': args.os_username, + 'key': None, + 'authurl': None, + 'preauthtoken': auth_token, + 'preauthurl': endpoint, + 'cacert': args.os_cacert, + 'insecure': args.insecure + } + + return sc.Connection(**swift_args) diff --git a/stacktaskclient/common/environment_format.py b/stacktaskclient/common/environment_format.py new file mode 100644 index 0000000..cab9299 --- /dev/null +++ b/stacktaskclient/common/environment_format.py @@ -0,0 +1,52 @@ +# 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 stacktaskclient.common import template_format +from stacktaskclient.openstack.common._i18n import _ + +import yaml + + +SECTIONS = (PARAMETER_DEFAULTS, PARAMETERS, RESOURCE_REGISTRY) = \ + ('parameter_defaults', 'parameters', 'resource_registry') + + +def parse(env_str): + '''Takes a string and returns a dict containing the parsed structure. + + This includes determination of whether the string is using the + YAML format. + ''' + try: + env = yaml.load(env_str, Loader=template_format.yaml_loader) + except yaml.YAMLError as yea: + raise ValueError(yea) + else: + if env is None: + env = {} + elif not isinstance(env, dict): + raise ValueError(_('The environment is not a valid ' + 'YAML mapping data type.')) + + for param in env: + if param not in SECTIONS: + raise ValueError(_('environment has wrong section "%s"') % param) + + return env + + +def default_for_missing(env): + '''Checks a parsed environment for missing sections. + ''' + for param in SECTIONS: + if param not in env: + env[param] = {} diff --git a/stacktaskclient/common/event_utils.py b/stacktaskclient/common/event_utils.py new file mode 100644 index 0000000..fde4704 --- /dev/null +++ b/stacktaskclient/common/event_utils.py @@ -0,0 +1,128 @@ +# Copyright 2015 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. + +from stacktaskclient.common import utils +import stacktaskclient.exc as exc + +from stacktaskclient.openstack.common._i18n import _ + + +def get_hook_events(hc, stack_id, event_args, nested_depth=0, + hook_type='pre-create'): + if hook_type == 'pre-create': + stack_action_reason = 'Stack CREATE started' + hook_event_reason = 'CREATE paused until Hook pre-create is cleared' + hook_clear_event_reason = 'Hook pre-create is cleared' + elif hook_type == 'pre-update': + stack_action_reason = 'Stack UPDATE started' + hook_event_reason = 'UPDATE paused until Hook pre-update is cleared' + hook_clear_event_reason = 'Hook pre-update is cleared' + else: + raise exc.CommandError(_('Unexpected hook type %s') % hook_type) + + events = get_events(hc, stack_id=stack_id, event_args=event_args, + nested_depth=nested_depth) + + # Get the most recent event associated with this action, which gives us the + # event when we moved into IN_PROGRESS for the hooks we're interested in. + stack_name = stack_id.split("/")[0] + action_start_event = [e for e in enumerate(events) + if e[1].resource_status_reason == stack_action_reason + and e[1].stack_name == stack_name][-1] + # Slice the events with the index from the enumerate + action_start_index = action_start_event[0] + events = events[action_start_index:] + + # Get hook events still pending by some list filtering/comparison + # We build a map hook events per-resource, and remove any event + # for which there is a corresponding hook-clear event. + resource_event_map = {} + for e in events: + stack_resource = (e.stack_name, e.resource_name) + if e.resource_status_reason == hook_event_reason: + resource_event_map[(e.stack_name, e.resource_name)] = e + elif e.resource_status_reason == hook_clear_event_reason: + if resource_event_map.get(stack_resource): + del(resource_event_map[(e.stack_name, e.resource_name)]) + return list(resource_event_map.values()) + + +def get_events(hc, stack_id, event_args, nested_depth=0, + marker=None, limit=None): + events = _get_stack_events(hc, stack_id, event_args) + if nested_depth > 0: + events.extend(_get_nested_events(hc, nested_depth, + stack_id, event_args)) + # Because there have been multiple stacks events mangled into + # one list, we need to sort before passing to print_list + # Note we can't use the prettytable sortby_index here, because + # the "start" option doesn't allow post-sort slicing, which + # will be needed to make "--marker" work for nested_depth lists + events.sort(key=lambda x: x.event_time) + + # Slice the list if marker is specified + if marker: + marker_index = [e.id for e in events].index(marker) + events = events[marker_index:] + + # Slice the list if limit is specified + if limit: + limit_index = min(int(limit), len(events)) + events = events[:limit_index] + return events + + +def _get_nested_ids(hc, stack_id): + nested_ids = [] + try: + resources = hc.resources.list(stack_id=stack_id) + except exc.HTTPNotFound: + raise exc.CommandError(_('Stack not found: %s') % stack_id) + for r in resources: + nested_id = utils.resource_nested_identifier(r) + if nested_id: + nested_ids.append(nested_id) + return nested_ids + + +def _get_nested_events(hc, nested_depth, stack_id, event_args): + # FIXME(shardy): this is very inefficient, we should add nested_depth to + # the event_list API in a future stacktask version, but this will be required + # until kilo stacktask is EOL. + nested_ids = _get_nested_ids(hc, stack_id) + nested_events = [] + for n_id in nested_ids: + stack_events = _get_stack_events(hc, n_id, event_args) + if stack_events: + nested_events.extend(stack_events) + if nested_depth > 1: + next_depth = nested_depth - 1 + nested_events.extend(_get_nested_events( + hc, next_depth, n_id, event_args)) + return nested_events + + +def _get_stack_events(hc, stack_id, event_args): + event_args['stack_id'] = stack_id + try: + events = hc.events.list(**event_args) + except exc.HTTPNotFound as ex: + # it could be the stack or resource that is not found + # just use the message that the server sent us. + raise exc.CommandError(str(ex)) + else: + # Show which stack the event comes from (for nested events) + for e in events: + e.stack_name = stack_id.split("/")[0] + return events diff --git a/stacktaskclient/common/http.py b/stacktaskclient/common/http.py new file mode 100644 index 0000000..06b9349 --- /dev/null +++ b/stacktaskclient/common/http.py @@ -0,0 +1,355 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy +import hashlib +import logging +import os +import socket + +from oslo_serialization import jsonutils +from oslo_utils import encodeutils +from oslo_utils import importutils +import requests +import six +from six.moves.urllib import parse + +from stacktaskclient.common import utils +from stacktaskclient import exc +from stacktaskclient.openstack.common._i18n import _ +from stacktaskclient.openstack.common._i18n import _LW +from keystoneclient import adapter + +LOG = logging.getLogger(__name__) +USER_AGENT = 'python-stacktaskclient' +CHUNKSIZE = 1024 * 64 # 64kB +SENSITIVE_HEADERS = ('X-Auth-Token',) +osprofiler_web = importutils.try_import("osprofiler.web") + + +def get_system_ca_file(): + """Return path to system default CA file.""" + # Standard CA file locations for Debian/Ubuntu, RedHat/Fedora, + # Suse, FreeBSD/OpenBSD, MacOSX, and the bundled ca + ca_path = ['/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/ca-bundle.pem', + '/etc/ssl/cert.pem', + '/System/Library/OpenSSL/certs/cacert.pem', + requests.certs.where()] + for ca in ca_path: + LOG.debug("Looking for ca file %s", ca) + if os.path.exists(ca): + LOG.debug("Using ca file %s", ca) + return ca + LOG.warn(_LW("System ca file could not be found.")) + + +class HTTPClient(object): + + def __init__(self, endpoint, **kwargs): + self.endpoint = endpoint + self.auth_url = kwargs.get('auth_url') + self.auth_token = kwargs.get('token') + self.username = kwargs.get('username') + self.password = kwargs.get('password') + self.region_name = kwargs.get('region_name') + self.include_pass = kwargs.get('include_pass') + self.endpoint_url = endpoint + + self.cert_file = kwargs.get('cert_file') + self.key_file = kwargs.get('key_file') + self.timeout = kwargs.get('timeout') + + self.ssl_connection_params = { + 'ca_file': kwargs.get('ca_file'), + 'cert_file': kwargs.get('cert_file'), + 'key_file': kwargs.get('key_file'), + 'insecure': kwargs.get('insecure'), + } + + self.verify_cert = None + if parse.urlparse(endpoint).scheme == "https": + if kwargs.get('insecure'): + self.verify_cert = False + else: + self.verify_cert = kwargs.get('ca_file', get_system_ca_file()) + + # FIXME(shardy): We need this for compatibility with the oslo apiclient + # we should move to inheriting this class from the oslo HTTPClient + self.last_request_id = None + + def safe_header(self, name, value): + if name in SENSITIVE_HEADERS: + # because in python3 byte string handling is ... ug + v = value.encode('utf-8') + h = hashlib.sha1(v) + d = h.hexdigest() + return encodeutils.safe_decode(name), "{SHA1}%s" % d + else: + return (encodeutils.safe_decode(name), + encodeutils.safe_decode(value)) + + def log_curl_request(self, method, url, kwargs): + curl = ['curl -g -i -X %s' % method] + + for (key, value) in kwargs['headers'].items(): + header = '-H \'%s: %s\'' % self.safe_header(key, value) + curl.append(header) + + conn_params_fmt = [ + ('key_file', '--key %s'), + ('cert_file', '--cert %s'), + ('ca_file', '--cacert %s'), + ] + for (key, fmt) in conn_params_fmt: + value = self.ssl_connection_params.get(key) + if value: + curl.append(fmt % value) + + if self.ssl_connection_params.get('insecure'): + curl.append('-k') + + if 'data' in kwargs: + curl.append('-d \'%s\'' % kwargs['data']) + + curl.append('%s%s' % (self.endpoint, url)) + LOG.debug(' '.join(curl)) + + @staticmethod + def log_http_response(resp): + 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 resp.content: + content = resp.content + if isinstance(content, six.binary_type): + content = content.decode() + dump.extend([content, '']) + LOG.debug('\n'.join(dump)) + + def _http_request(self, url, method, **kwargs): + """Send an http request with the specified characteristics. + + Wrapper around requests.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.auth_token: + kwargs['headers'].setdefault('X-Auth-Token', self.auth_token) + else: + kwargs['headers'].update(self.credentials_headers()) + if self.auth_url: + kwargs['headers'].setdefault('X-Auth-Url', self.auth_url) + if self.region_name: + kwargs['headers'].setdefault('X-Region-Name', self.region_name) + if self.include_pass and not 'X-Auth-Key' in kwargs['headers']: + kwargs['headers'].update(self.credentials_headers()) + if osprofiler_web: + kwargs['headers'].update(osprofiler_web.get_trace_id_headers()) + + self.log_curl_request(method, url, kwargs) + + if self.cert_file and self.key_file: + kwargs['cert'] = (self.cert_file, self.key_file) + + if self.verify_cert is not None: + kwargs['verify'] = self.verify_cert + + if self.timeout is not None: + kwargs['timeout'] = float(self.timeout) + + # Allow caller to specify not to follow redirects, in which case we + # just return the redirect response. Useful for using stacks:lookup. + redirect = kwargs.pop('redirect', True) + + # Since requests does not follow the RFC when doing redirection to sent + # back the same method on a redirect we are simply bypassing it. For + # example if we do a DELETE/POST/PUT on a URL and we get a 302 RFC says + # that we should follow that URL with the same method as before, + # requests doesn't follow that and send a GET instead for the method. + # Hopefully this could be fixed as they say in a comment in a future + # point version i.e.: 3.x + # See issue: https://github.com/kennethreitz/requests/issues/1704 + allow_redirects = False + + try: + resp = requests.request( + method, + self.endpoint_url + url, + allow_redirects=allow_redirects, + **kwargs) + except socket.gaierror as e: + message = (_("Error finding address for %(url)s: %(e)s") % + {'url': self.endpoint_url + url, 'e': e}) + raise exc.InvalidEndpoint(message=message) + except (socket.error, socket.timeout) as e: + endpoint = self.endpoint + message = (_("Error communicating with %(endpoint)s %(e)s") % + {'endpoint': endpoint, 'e': e}) + raise exc.CommunicationError(message=message) + + self.log_http_response(resp) + + if not 'X-Auth-Key' in kwargs['headers'] and \ + (resp.status_code == 401 or + (resp.status_code == 500 and "(HTTP 401)" in resp.content)): + raise exc.HTTPUnauthorized(_("Authentication failed. Please try" + " again with option %(option)s or " + "export %(var)s\n%(content)s") % + { + 'option': '--include-password', + 'var': 'HEAT_INCLUDE_PASSWORD=1', + 'content': resp.content + }) + elif 400 <= resp.status_code < 600: + raise exc.from_response(resp) + elif resp.status_code in (301, 302, 305): + # Redirected. Reissue the request to the new location, + # unless caller specified redirect=False + if redirect: + location = resp.headers.get('location') + path = self.strip_endpoint(location) + resp = self._http_request(path, method, **kwargs) + elif resp.status_code == 300: + raise exc.from_response(resp) + + return resp + + def strip_endpoint(self, location): + if location is None: + message = _("Location not returned with 302") + raise exc.InvalidEndpoint(message=message) + elif location.lower().startswith(self.endpoint.lower()): + return location[len(self.endpoint):] + else: + message = _("Prohibited endpoint redirect %s") % location + raise exc.InvalidEndpoint(message=message) + + def credentials_headers(self): + creds = {} + # NOTE(dhu): (shardy) When deferred_auth_method=password, Heat + # encrypts and stores username/password. For Keystone v3, the + # intent is to use trusts since SHARDY is working towards + # deferred_auth_method=trusts as the default. + # TODO(dhu): Make Keystone v3 work in Heat standalone mode. Maye + # require X-Auth-User-Domain. + if self.username: + creds['X-Auth-User'] = self.username + if self.password: + creds['X-Auth-Key'] = self.password + return creds + + def json_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + + if 'data' in kwargs: + kwargs['data'] = jsonutils.dumps(kwargs['data']) + + resp = self._http_request(url, method, **kwargs) + body = utils.get_response_body(resp) + 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 client_request(self, method, url, **kwargs): + resp, body = self.json_request(method, url, **kwargs) + return resp + + def head(self, url, **kwargs): + return self.client_request("HEAD", url, **kwargs) + + def get(self, url, **kwargs): + return self.client_request("GET", url, **kwargs) + + def post(self, url, **kwargs): + return self.client_request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.client_request("PUT", url, **kwargs) + + def delete(self, url, **kwargs): + return self.raw_request("DELETE", url, **kwargs) + + def patch(self, url, **kwargs): + return self.client_request("PATCH", url, **kwargs) + + +class SessionClient(adapter.LegacyJsonAdapter): + """HTTP client based on Keystone client session.""" + + def request(self, url, method, **kwargs): + redirect = kwargs.get('redirect') + kwargs.setdefault('user_agent', USER_AGENT) + + try: + kwargs.setdefault('json', kwargs.pop('data')) + except KeyError: + pass + + resp, body = super(SessionClient, self).request( + url, method, + raise_exc=False, + **kwargs) + + if 400 <= resp.status_code < 600: + raise exc.from_response(resp) + elif resp.status_code in (301, 302, 305): + if redirect: + location = resp.headers.get('location') + path = self.strip_endpoint(location) + resp = self.request(path, method, **kwargs) + elif resp.status_code == 300: + raise exc.from_response(resp) + + return resp + + def credentials_headers(self): + return {} + + def strip_endpoint(self, location): + if location is None: + message = _("Location not returned with 302") + raise exc.InvalidEndpoint(message=message) + if (self.endpoint_override is not None and + location.lower().startswith(self.endpoint_override.lower())): + return location[len(self.endpoint_override):] + else: + return location + + +def _construct_http_client(endpoint=None, username=None, password=None, + include_pass=None, endpoint_type=None, + auth_url=None, **kwargs): + session = kwargs.pop('session', None) + auth = kwargs.pop('auth', None) + + if session: + kwargs['endpoint_override'] = endpoint + return SessionClient(session, auth=auth, **kwargs) + else: + return HTTPClient(endpoint=endpoint, username=username, + password=password, include_pass=include_pass, + endpoint_type=endpoint_type, auth_url=auth_url, + **kwargs) diff --git a/stacktaskclient/common/template_format.py b/stacktaskclient/common/template_format.py new file mode 100644 index 0000000..6ae80fc --- /dev/null +++ b/stacktaskclient/common/template_format.py @@ -0,0 +1,63 @@ +# 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 json +import yaml + +from stacktaskclient.openstack.common._i18n import _ + +if hasattr(yaml, 'CSafeLoader'): + yaml_loader = yaml.CSafeLoader +else: + yaml_loader = yaml.SafeLoader + +if hasattr(yaml, 'CSafeDumper'): + yaml_dumper = yaml.CSafeDumper +else: + yaml_dumper = yaml.SafeDumper + + +def _construct_yaml_str(self, node): + # Override the default string handling function + # to always return unicode objects + return self.construct_scalar(node) +yaml_loader.add_constructor(u'tag:yaml.org,2002:str', _construct_yaml_str) +# Unquoted dates like 2013-05-23 in yaml files get loaded as objects of type +# datetime.data which causes problems in API layer when being processed by +# openstack.common.jsonutils. Therefore, make unicode string out of timestamps +# until jsonutils can handle dates. +yaml_loader.add_constructor(u'tag:yaml.org,2002:timestamp', + _construct_yaml_str) + + +def parse(tmpl_str): + '''Takes a string and returns a dict containing the parsed structure. + + This includes determination of whether the string is using the + JSON or YAML format. + ''' + if tmpl_str.startswith('{'): + tpl = json.loads(tmpl_str) + else: + try: + tpl = yaml.load(tmpl_str, Loader=yaml_loader) + except yaml.YAMLError as yea: + raise ValueError(yea) + else: + if tpl is None: + tpl = {} + # Looking for supported version keys in the loaded template + if not ('HeatTemplateFormatVersion' in tpl + or 'heat_template_version' in tpl + or 'AWSTemplateFormatVersion' in tpl): + raise ValueError(_("Template format version not found.")) + return tpl diff --git a/stacktaskclient/common/template_utils.py b/stacktaskclient/common/template_utils.py new file mode 100644 index 0000000..669fa18 --- /dev/null +++ b/stacktaskclient/common/template_utils.py @@ -0,0 +1,224 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import collections +from oslo_serialization import jsonutils +import six +from six.moves.urllib import parse +from six.moves.urllib import request + +from stacktaskclient.common import environment_format +from stacktaskclient.common import template_format +from stacktaskclient.common import utils +from stacktaskclient import exc +from stacktaskclient.openstack.common._i18n import _ + + +def get_template_contents(template_file=None, template_url=None, + template_object=None, object_request=None, + files=None, existing=False): + + # Transform a bare file path to a file:// URL. + if template_file: + template_url = utils.normalise_file_path_to_url(template_file) + + if template_url: + tpl = request.urlopen(template_url).read() + + elif template_object: + template_url = template_object + tpl = object_request and object_request('GET', + template_object) + elif existing: + return {}, None + else: + raise exc.CommandError(_('Need to specify exactly one of ' + '%(arg1)s, %(arg2)s or %(arg3)s') % + { + 'arg1': '--template-file', + 'arg2': '--template-url', + 'arg3': '--template-object' + }) + + if not tpl: + raise exc.CommandError(_('Could not fetch template from %s') + % template_url) + + try: + if isinstance(tpl, six.binary_type): + tpl = tpl.decode('utf-8') + template = template_format.parse(tpl) + except ValueError as e: + raise exc.CommandError(_('Error parsing template %(url)s %(error)s') % + {'url': template_url, 'error': e}) + + tmpl_base_url = utils.base_url_for_url(template_url) + if files is None: + files = {} + resolve_template_get_files(template, files, tmpl_base_url) + return files, template + + +def resolve_template_get_files(template, files, template_base_url): + + def ignore_if(key, value): + if key != 'get_file' and key != 'type': + return True + if not isinstance(value, six.string_types): + return True + if (key == 'type' and + not value.endswith(('.yaml', '.template'))): + return True + return False + + def recurse_if(value): + return isinstance(value, (dict, list)) + + get_file_contents(template, files, template_base_url, + ignore_if, recurse_if) + + +def is_template(file_content): + try: + if isinstance(file_content, six.binary_type): + file_content = file_content.decode('utf-8') + template_format.parse(file_content) + except (ValueError, TypeError): + return False + return True + + +def get_file_contents(from_data, files, base_url=None, + ignore_if=None, recurse_if=None): + + if recurse_if and recurse_if(from_data): + if isinstance(from_data, dict): + recurse_data = six.itervalues(from_data) + else: + recurse_data = from_data + for value in recurse_data: + get_file_contents(value, files, base_url, ignore_if, recurse_if) + + if isinstance(from_data, dict): + for key, value in iter(from_data.items()): + if ignore_if and ignore_if(key, value): + continue + + if base_url and not base_url.endswith('/'): + base_url = base_url + '/' + + str_url = parse.urljoin(base_url, value) + if str_url not in files: + file_content = utils.read_url_content(str_url) + if is_template(file_content): + template = get_template_contents( + template_url=str_url, files=files)[1] + file_content = jsonutils.dumps(template) + files[str_url] = file_content + # replace the data value with the normalised absolute URL + from_data[key] = str_url + + +def read_url_content(url): + '''DEPRECATED! Use 'utils.read_url_content' instead.''' + return utils.read_url_content(url) + + +def base_url_for_url(url): + '''DEPRECATED! Use 'utils.base_url_for_url' instead.''' + return utils.base_url_for_url(url) + + +def normalise_file_path_to_url(path): + '''DEPRECATED! Use 'utils.normalise_file_path_to_url' instead.''' + return utils.normalise_file_path_to_url(path) + + +def deep_update(old, new): + '''Merge nested dictionaries.''' + for k, v in new.items(): + if isinstance(v, collections.Mapping): + r = deep_update(old.get(k, {}), v) + old[k] = r + else: + old[k] = new[k] + return old + + +def process_multiple_environments_and_files(env_paths=None, template=None, + template_url=None): + merged_files = {} + merged_env = {} + + if env_paths: + for env_path in env_paths: + files, env = process_environment_and_files(env_path, template, + template_url) + + # 'files' looks like {"filename1": contents, "filename2": contents} + # so a simple update is enough for merging + merged_files.update(files) + + # 'env' can be a deeply nested dictionary, so a simple update is + # not enough + merged_env = deep_update(merged_env, env) + + return merged_files, merged_env + + +def process_environment_and_files(env_path=None, template=None, + template_url=None): + files = {} + env = {} + + if env_path: + env_url = utils.normalise_file_path_to_url(env_path) + env_base_url = utils.base_url_for_url(env_url) + raw_env = request.urlopen(env_url).read() + env = environment_format.parse(raw_env) + + resolve_environment_urls( + env.get('resource_registry'), + files, + env_base_url) + + return files, env + + +def resolve_environment_urls(resource_registry, files, env_base_url): + if resource_registry is None: + return + + rr = resource_registry + base_url = rr.get('base_url', env_base_url) + + def ignore_if(key, value): + if key == 'base_url': + return True + if isinstance(value, dict): + return True + if '::' in value: + # Built in providers like: "X::Compute::Server" + # don't need downloading. + return True + if key == 'hooks': + return True + + get_file_contents(rr, files, base_url, ignore_if) + + for res_name, res_dict in iter(rr.get('resources', {}).items()): + res_base_url = res_dict.get('base_url', base_url) + get_file_contents( + res_dict, files, res_base_url, ignore_if) diff --git a/stacktaskclient/common/utils.py b/stacktaskclient/common/utils.py new file mode 100644 index 0000000..346eb1e --- /dev/null +++ b/stacktaskclient/common/utils.py @@ -0,0 +1,280 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import base64 +import logging +import os +import textwrap +import uuid + +from oslo_serialization import jsonutils +from oslo_utils import importutils +import prettytable +from six.moves.urllib import error +from six.moves.urllib import parse +from six.moves.urllib import request +import yaml + +from stacktaskclient import exc +from stacktaskclient.openstack.common._i18n import _ +from stacktaskclient.openstack.common._i18n import _LE +from stacktaskclient.openstack.common import cliutils + +LOG = logging.getLogger(__name__) + + +supported_formats = { + "json": lambda x: jsonutils.dumps(x, indent=2), + "yaml": yaml.safe_dump +} + +# Using common methods from oslo cliutils +arg = cliutils.arg +env = cliutils.env +print_list = cliutils.print_list + + +def link_formatter(links): + def format_link(l): + if 'rel' in l: + return "%s (%s)" % (l.get('href', ''), l.get('rel', '')) + else: + return "%s" % (l.get('href', '')) + return '\n'.join(format_link(l) for l in links or []) + + +def resource_nested_identifier(rsrc): + nested_link = [l for l in rsrc.links or [] + if l.get('rel') == 'nested'] + if nested_link: + nested_href = nested_link[0].get('href') + nested_identifier = nested_href.split("/")[-2:] + return "/".join(nested_identifier) + + +def json_formatter(js): + return jsonutils.dumps(js, indent=2, ensure_ascii=False, + separators=(', ', ': ')) + + +def text_wrap_formatter(d): + return '\n'.join(textwrap.wrap(d or '', 55)) + + +def newline_list_formatter(r): + return '\n'.join(r or []) + + +def print_dict(d, formatters=None): + formatters = formatters or {} + pt = prettytable.PrettyTable(['Property', 'Value'], + caching=False, print_empty=False) + pt.align = 'l' + + for field in d.keys(): + if field in formatters: + pt.add_row([field, formatters[field](d[field])]) + else: + pt.add_row([field, d[field]]) + print(pt.get_string(sortby='Property')) + + +def event_log_formatter(events): + """Return the events in log format.""" + event_log = [] + log_format = _("%(event_date)s %(event_time)s %(event_id)s " + "[%(rsrc_name)s]: %(rsrc_status)s %(rsrc_status_reason)s") + for event in events: + event_time = getattr(event, 'event_time', '') + time_date = event_time.split('T') + try: + event_time = time_date[0] + event_date = time_date[1][:-1] + except IndexError: + event_time = event_date = '' + + log = log_format % { + 'event_date': event_date, 'event_time': event_time, + 'event_id': getattr(event, 'id', ''), + 'rsrc_name': getattr(event, 'resource_name', ''), + 'rsrc_status': getattr(event, 'resource_status', ''), + 'rsrc_status_reason': getattr(event, 'resource_status_reason', '') + } + event_log.append(log) + + return "\n".join(event_log) + + +def find_resource(manager, name_or_id): + """Helper for the _find_* methods.""" + # first try to get entity as integer id + try: + if isinstance(name_or_id, int) or name_or_id.isdigit(): + return manager.get(int(name_or_id)) + except exc.NotFound: + pass + + # now try to get entity as uuid + try: + uuid.UUID(str(name_or_id)) + return manager.get(name_or_id) + except (ValueError, exc.NotFound): + pass + + # finally try to find entity by name + try: + return manager.find(name=name_or_id) + except exc.NotFound: + msg = _("No %(name)s with a name or ID of " + "'%(name_or_id)s' exists.") % \ + { + 'name': manager.resource_class.__name__.lower(), + 'name_or_id': name_or_id + } + raise exc.CommandError(msg) + + +def import_versioned_module(version, submodule=None): + module = 'stacktaskclient.v%s' % version + if submodule: + module = '.'.join((module, submodule)) + return importutils.import_module(module) + + +def format_parameters(params, parse_semicolon=True): + '''Reformat parameters into dict of format expected by the API.''' + + if not params: + return {} + + if parse_semicolon: + # expect multiple invocations of --parameters but fall back + # to ; delimited if only one --parameters is specified + if len(params) == 1: + params = params[0].split(';') + + parameters = {} + for p in params: + try: + (n, v) = p.split(('='), 1) + except ValueError: + msg = _('Malformed parameter(%s). Use the key=value format.') % p + raise exc.CommandError(msg) + + if n not in parameters: + parameters[n] = v + else: + if not isinstance(parameters[n], list): + parameters[n] = [parameters[n]] + parameters[n].append(v) + + return parameters + + +def format_all_parameters(params, param_files, + template_file=None, template_url=None): + parameters = {} + parameters.update(format_parameters(params)) + parameters.update(format_parameter_file( + param_files, + template_file, + template_url)) + return parameters + + +def format_parameter_file(param_files, template_file=None, + template_url=None): + '''Reformat file parameters into dict of format expected by the API.''' + if not param_files: + return {} + params = format_parameters(param_files, False) + + template_base_url = None + if template_file or template_url: + template_base_url = base_url_for_url(get_template_url( + template_file, template_url)) + + param_file = {} + for key, value in iter(params.items()): + param_file[key] = resolve_param_get_file(value, + template_base_url) + return param_file + + +def resolve_param_get_file(file, base_url): + if base_url and not base_url.endswith('/'): + base_url = base_url + '/' + str_url = parse.urljoin(base_url, file) + return read_url_content(str_url) + + +def format_output(output, format='yaml'): + """Format the supplied dict as specified.""" + output_format = format.lower() + try: + return supported_formats[output_format](output) + except KeyError: + raise exc.HTTPUnsupported(_("The format(%s) is unsupported.") + % output_format) + + +def parse_query_url(url): + base_url, query_params = url.split('?') + return base_url, parse.parse_qs(query_params) + + +def get_template_url(template_file=None, template_url=None): + if template_file: + template_url = normalise_file_path_to_url(template_file) + return template_url + + +def read_url_content(url): + try: + content = request.urlopen(url).read() + except error.URLError: + raise exc.CommandError(_('Could not fetch contents for %s') % url) + + if content: + try: + content.decode('utf-8') + except ValueError: + content = base64.encodestring(content) + return content + + +def base_url_for_url(url): + parsed = parse.urlparse(url) + parsed_dir = os.path.dirname(parsed.path) + return parse.urljoin(url, parsed_dir) + + +def normalise_file_path_to_url(path): + if parse.urlparse(path).scheme: + return path + path = os.path.abspath(path) + return parse.urljoin('file:', request.pathname2url(path)) + + +def get_response_body(resp): + body = resp.content + if 'application/json' in resp.headers.get('content-type', ''): + try: + body = resp.json() + except ValueError: + LOG.error(_LE('Could not decode response body as JSON')) + else: + body = None + return body diff --git a/stacktaskclient/exc.py b/stacktaskclient/exc.py new file mode 100644 index 0000000..6caa26b --- /dev/null +++ b/stacktaskclient/exc.py @@ -0,0 +1,196 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import sys + +from oslo_serialization import jsonutils + +from stacktaskclient.openstack.common._i18n import _ + +verbose = 0 + + +class BaseException(Exception): + """An error occurred.""" + def __init__(self, message=None): + self.message = message + + def __str__(self): + return self.message or self.__class__.__doc__ + + +class CommandError(BaseException): + """Invalid usage of CLI.""" + + +class InvalidEndpoint(BaseException): + """The provided endpoint is invalid.""" + + +class CommunicationError(BaseException): + """Unable to communicate with server.""" + + +class HTTPException(BaseException): + """Base exception for all HTTP-derived exceptions.""" + code = 'N/A' + + def __init__(self, message=None): + super(HTTPException, self).__init__(message) + try: + self.error = jsonutils.loads(message) + if 'error' not in self.error: + raise KeyError(_('Key "error" not exists')) + except KeyError: + # NOTE(jianingy): If key 'error' happens not exist, + # self.message becomes no sense. In this case, we + # return doc of current exception class instead. + self.error = {'error': + {'message': self.__class__.__doc__}} + except Exception: + self.error = {'error': + {'message': self.message or self.__class__.__doc__}} + + def __str__(self): + message = self.error['error'].get('message', 'Internal Error') + if verbose: + traceback = self.error['error'].get('traceback', '') + return (_('ERROR: %(message)s\n%(traceback)s') % + {'message': message, 'traceback': traceback}) + else: + return _('ERROR: %s') % message + + +class HTTPMultipleChoices(HTTPException): + code = 300 + + def __str__(self): + self.details = _("Requested version of Stacktask API is not" + "available.") + return (_("%(name)s (HTTP %(code)s) %(details)s") % + { + 'name': self.__class__.__name__, + 'code': self.code, + 'details': self.details + }) + + +class BadRequest(HTTPException): + """DEPRECATED.""" + code = 400 + + +class HTTPBadRequest(BadRequest): + pass + + +class Unauthorized(HTTPException): + """DEPRECATED.""" + code = 401 + + +class HTTPUnauthorized(Unauthorized): + pass + + +class Forbidden(HTTPException): + """DEPRECATED.""" + code = 403 + + +class HTTPForbidden(Forbidden): + pass + + +class NotFound(HTTPException): + """DEPRECATED.""" + code = 404 + + +class HTTPNotFound(NotFound): + pass + + +class HTTPMethodNotAllowed(HTTPException): + code = 405 + + +class Conflict(HTTPException): + """DEPRECATED.""" + code = 409 + + +class HTTPConflict(Conflict): + pass + + +class OverLimit(HTTPException): + """DEPRECATED.""" + code = 413 + + +class HTTPOverLimit(OverLimit): + pass + + +class HTTPUnsupported(HTTPException): + code = 415 + + +class HTTPInternalServerError(HTTPException): + code = 500 + + +class HTTPNotImplemented(HTTPException): + code = 501 + + +class HTTPBadGateway(HTTPException): + code = 502 + + +class ServiceUnavailable(HTTPException): + """DEPRECATED.""" + code = 503 + + +class HTTPServiceUnavailable(ServiceUnavailable): + pass + + +#NOTE(bcwaldon): Build a mapping of HTTP codes to corresponding exception +# classes +_code_map = {} +for obj_name in dir(sys.modules[__name__]): + if obj_name.startswith('HTTP'): + obj = getattr(sys.modules[__name__], obj_name) + _code_map[obj.code] = obj + + +def from_response(response): + """Return an instance of an HTTPException based on requests response.""" + cls = _code_map.get(response.status_code, HTTPException) + return cls(response.content) + + +class NoTokenLookupException(Exception): + """DEPRECATED.""" + pass + + +class EndpointNotFound(Exception): + """DEPRECATED.""" + pass + + +class StackFailure(Exception): + pass diff --git a/stacktaskclient/openstack/__init__.py b/stacktaskclient/openstack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stacktaskclient/openstack/common/__init__.py b/stacktaskclient/openstack/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stacktaskclient/openstack/common/_i18n.py b/stacktaskclient/openstack/common/_i18n.py new file mode 100644 index 0000000..e3f8323 --- /dev/null +++ b/stacktaskclient/openstack/common/_i18n.py @@ -0,0 +1,45 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""oslo.i18n integration module. + +See http://docs.openstack.org/developer/oslo.i18n/usage.html + +""" + +try: + import oslo_i18n + + # NOTE(dhellmann): This reference to o-s-l-o will be replaced by the + # application name when this module is synced into the separate + # repository. It is OK to have more than one translation function + # using the same domain, since there will still only be one message + # catalog. + _translators = oslo_i18n.TranslatorFactory(domain='stacktaskclient') + + # 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 +except ImportError: + # NOTE(dims): Support for cases where a project wants to use + # code from oslo-incubator, but is not ready to be internationalized + # (like tempest) + _ = _LI = _LW = _LE = _LC = lambda x: x diff --git a/stacktaskclient/openstack/common/apiclient/__init__.py b/stacktaskclient/openstack/common/apiclient/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stacktaskclient/openstack/common/apiclient/auth.py b/stacktaskclient/openstack/common/apiclient/auth.py new file mode 100644 index 0000000..1bd12d5 --- /dev/null +++ b/stacktaskclient/openstack/common/apiclient/auth.py @@ -0,0 +1,234 @@ +# Copyright 2013 OpenStack Foundation +# Copyright 2013 Spanish National Research Council. +# 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. + +# E0202: An attribute inherited from %s hide this method +# pylint: disable=E0202 + +######################################################################## +# +# THIS MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + +import abc +import argparse +import os + +import six +from stevedore import extension + +from stacktaskclient.openstack.common.apiclient import exceptions + + +_discovered_plugins = {} + + +def discover_auth_systems(): + """Discover the available auth-systems. + + This won't take into account the old style auth-systems. + """ + global _discovered_plugins + _discovered_plugins = {} + + def add_plugin(ext): + _discovered_plugins[ext.name] = ext.plugin + + ep_namespace = "stacktaskclient.openstack.common.apiclient.auth" + mgr = extension.ExtensionManager(ep_namespace) + mgr.map(add_plugin) + + +def load_auth_system_opts(parser): + """Load options needed by the available auth-systems into a parser. + + This function will try to populate the parser with options from the + available plugins. + """ + group = parser.add_argument_group("Common auth options") + BaseAuthPlugin.add_common_opts(group) + for name, auth_plugin in six.iteritems(_discovered_plugins): + group = parser.add_argument_group( + "Auth-system '%s' options" % name, + conflict_handler="resolve") + auth_plugin.add_opts(group) + + +def load_plugin(auth_system): + try: + plugin_class = _discovered_plugins[auth_system] + except KeyError: + raise exceptions.AuthSystemNotFound(auth_system) + return plugin_class(auth_system=auth_system) + + +def load_plugin_from_args(args): + """Load required plugin and populate it with options. + + Try to guess auth system if it is not specified. Systems are tried in + alphabetical order. + + :type args: argparse.Namespace + :raises: AuthPluginOptionsMissing + """ + auth_system = args.os_auth_system + if auth_system: + plugin = load_plugin(auth_system) + plugin.parse_opts(args) + plugin.sufficient_options() + return plugin + + for plugin_auth_system in sorted(six.iterkeys(_discovered_plugins)): + plugin_class = _discovered_plugins[plugin_auth_system] + plugin = plugin_class() + plugin.parse_opts(args) + try: + plugin.sufficient_options() + except exceptions.AuthPluginOptionsMissing: + continue + return plugin + raise exceptions.AuthPluginOptionsMissing(["auth_system"]) + + +@six.add_metaclass(abc.ABCMeta) +class BaseAuthPlugin(object): + """Base class for authentication plugins. + + An authentication plugin needs to override at least the authenticate + method to be a valid plugin. + """ + + auth_system = None + opt_names = [] + common_opt_names = [ + "auth_system", + "username", + "password", + "tenant_name", + "token", + "auth_url", + ] + + def __init__(self, auth_system=None, **kwargs): + self.auth_system = auth_system or self.auth_system + self.opts = dict((name, kwargs.get(name)) + for name in self.opt_names) + + @staticmethod + def _parser_add_opt(parser, opt): + """Add an option to parser in two variants. + + :param opt: option name (with underscores) + """ + dashed_opt = opt.replace("_", "-") + env_var = "OS_%s" % opt.upper() + arg_default = os.environ.get(env_var, "") + arg_help = "Defaults to env[%s]." % env_var + parser.add_argument( + "--os-%s" % dashed_opt, + metavar="<%s>" % dashed_opt, + default=arg_default, + help=arg_help) + parser.add_argument( + "--os_%s" % opt, + metavar="<%s>" % dashed_opt, + help=argparse.SUPPRESS) + + @classmethod + def add_opts(cls, parser): + """Populate the parser with the options for this plugin. + """ + for opt in cls.opt_names: + # use `BaseAuthPlugin.common_opt_names` since it is never + # changed in child classes + if opt not in BaseAuthPlugin.common_opt_names: + cls._parser_add_opt(parser, opt) + + @classmethod + def add_common_opts(cls, parser): + """Add options that are common for several plugins. + """ + for opt in cls.common_opt_names: + cls._parser_add_opt(parser, opt) + + @staticmethod + def get_opt(opt_name, args): + """Return option name and value. + + :param opt_name: name of the option, e.g., "username" + :param args: parsed arguments + """ + return (opt_name, getattr(args, "os_%s" % opt_name, None)) + + def parse_opts(self, args): + """Parse the actual auth-system options if any. + + This method is expected to populate the attribute `self.opts` with a + dict containing the options and values needed to make authentication. + """ + self.opts.update(dict(self.get_opt(opt_name, args) + for opt_name in self.opt_names)) + + def authenticate(self, http_client): + """Authenticate using plugin defined method. + + The method usually analyses `self.opts` and performs + a request to authentication server. + + :param http_client: client object that needs authentication + :type http_client: HTTPClient + :raises: AuthorizationFailure + """ + self.sufficient_options() + self._do_authenticate(http_client) + + @abc.abstractmethod + def _do_authenticate(self, http_client): + """Protected method for authentication. + """ + + def sufficient_options(self): + """Check if all required options are present. + + :raises: AuthPluginOptionsMissing + """ + missing = [opt + for opt in self.opt_names + if not self.opts.get(opt)] + if missing: + raise exceptions.AuthPluginOptionsMissing(missing) + + @abc.abstractmethod + def token_and_endpoint(self, endpoint_type, service_type): + """Return token and endpoint. + + :param service_type: Service type of the endpoint + :type service_type: string + :param endpoint_type: Type of endpoint. + Possible values: public or publicURL, + internal or internalURL, + admin or adminURL + :type endpoint_type: string + :returns: tuple of token and endpoint strings + :raises: EndpointException + """ diff --git a/stacktaskclient/openstack/common/apiclient/base.py b/stacktaskclient/openstack/common/apiclient/base.py new file mode 100644 index 0000000..c28ed0c --- /dev/null +++ b/stacktaskclient/openstack/common/apiclient/base.py @@ -0,0 +1,532 @@ +# 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. +""" + +######################################################################## +# +# THIS MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + + +# E1102: %s is not callable +# pylint: disable=E1102 + +import abc +import copy + +from oslo_utils import strutils +import six +from six.moves.urllib import parse + +from stacktaskclient.openstack.common._i18n import _ +from stacktaskclient.openstack.common.apiclient import exceptions + + +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 == 204 + + 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 six.iteritems(kwargs.copy()): + 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 six.iteritems(info): + 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 + if hasattr(self, 'id') and hasattr(other, 'id'): + return self.id == other.id + 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/stacktaskclient/openstack/common/apiclient/client.py b/stacktaskclient/openstack/common/apiclient/client.py new file mode 100644 index 0000000..e414227 --- /dev/null +++ b/stacktaskclient/openstack/common/apiclient/client.py @@ -0,0 +1,388 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack Foundation +# Copyright 2011 Piston Cloud Computing, Inc. +# Copyright 2013 Alessio Ababilov +# Copyright 2013 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. + +""" +OpenStack Client interface. Handles the REST calls and responses. +""" + +# E0202: An attribute inherited from %s hide this method +# pylint: disable=E0202 + +import hashlib +import logging +import time + +try: + import simplejson as json +except ImportError: + import json + +from oslo_utils import encodeutils +from oslo_utils import importutils +import requests + +from stacktaskclient.openstack.common._i18n import _ +from stacktaskclient.openstack.common.apiclient import exceptions + +_logger = logging.getLogger(__name__) +SENSITIVE_HEADERS = ('X-Auth-Token', 'X-Subject-Token',) + + +class HTTPClient(object): + """This client handles sending HTTP requests to OpenStack servers. + + Features: + + - share authentication information between several clients to different + services (e.g., for compute and image clients); + - reissue authentication request for expired tokens; + - encode/decode JSON bodies; + - raise exceptions on HTTP errors; + - pluggable authentication; + - store authentication information in a keyring; + - store time spent for requests; + - register clients for particular services, so one can use + `http_client.identity` or `http_client.compute`; + - log requests and responses in a format that is easy to copy-and-paste + into terminal and send the same request with curl. + """ + + user_agent = "stacktaskclient.openstack.common.apiclient" + + def __init__(self, + auth_plugin, + region_name=None, + endpoint_type="publicURL", + original_ip=None, + verify=True, + cert=None, + timeout=None, + timings=False, + keyring_saver=None, + debug=False, + user_agent=None, + http=None): + self.auth_plugin = auth_plugin + + self.endpoint_type = endpoint_type + self.region_name = region_name + + self.original_ip = original_ip + self.timeout = timeout + self.verify = verify + self.cert = cert + + self.keyring_saver = keyring_saver + self.debug = debug + self.user_agent = user_agent or self.user_agent + + self.times = [] # [("item", starttime, endtime), ...] + self.timings = timings + + # requests within the same session can reuse TCP connections from pool + self.http = http or requests.Session() + + self.cached_token = None + self.last_request_id = None + + def _safe_header(self, name, value): + if name in SENSITIVE_HEADERS: + # because in python3 byte string handling is ... ug + v = value.encode('utf-8') + h = hashlib.sha1(v) + d = h.hexdigest() + return encodeutils.safe_decode(name), "{SHA1}%s" % d + else: + return (encodeutils.safe_decode(name), + encodeutils.safe_decode(value)) + + def _http_log_req(self, method, url, kwargs): + if not self.debug: + return + + string_parts = [ + "curl -g -i", + "-X '%s'" % method, + "'%s'" % url, + ] + + for element in kwargs['headers']: + header = ("-H '%s: %s'" % + self._safe_header(element, kwargs['headers'][element])) + string_parts.append(header) + + _logger.debug("REQ: %s" % " ".join(string_parts)) + if 'data' in kwargs: + _logger.debug("REQ BODY: %s\n" % (kwargs['data'])) + + def _http_log_resp(self, resp): + if not self.debug: + return + _logger.debug( + "RESP: [%s] %s\n", + resp.status_code, + resp.headers) + if resp._content_consumed: + _logger.debug( + "RESP BODY: %s\n", + resp.text) + + def serialize(self, kwargs): + if kwargs.get('json') is not None: + kwargs['headers']['Content-Type'] = 'application/json' + kwargs['data'] = json.dumps(kwargs['json']) + try: + del kwargs['json'] + except KeyError: + pass + + def get_timings(self): + return self.times + + def reset_timings(self): + self.times = [] + + def request(self, method, url, **kwargs): + """Send an http request with the specified characteristics. + + Wrapper around `requests.Session.request` to handle tasks such as + setting headers, JSON encoding/decoding, and error handling. + + :param method: method of HTTP request + :param url: URL of HTTP request + :param kwargs: any other parameter that can be passed to + requests.Session.request (such as `headers`) or `json` + that will be encoded as JSON and used as `data` argument + """ + kwargs.setdefault("headers", {}) + kwargs["headers"]["User-Agent"] = self.user_agent + if self.original_ip: + kwargs["headers"]["Forwarded"] = "for=%s;by=%s" % ( + self.original_ip, self.user_agent) + if self.timeout is not None: + kwargs.setdefault("timeout", self.timeout) + kwargs.setdefault("verify", self.verify) + if self.cert is not None: + kwargs.setdefault("cert", self.cert) + self.serialize(kwargs) + + self._http_log_req(method, url, kwargs) + if self.timings: + start_time = time.time() + resp = self.http.request(method, url, **kwargs) + if self.timings: + self.times.append(("%s %s" % (method, url), + start_time, time.time())) + self._http_log_resp(resp) + + self.last_request_id = resp.headers.get('x-openstack-request-id') + + if resp.status_code >= 400: + _logger.debug( + "Request returned failure status: %s", + resp.status_code) + raise exceptions.from_response(resp, method, url) + + return resp + + @staticmethod + def concat_url(endpoint, url): + """Concatenate endpoint and final URL. + + E.g., "http://keystone/v2.0/" and "/tokens" are concatenated to + "http://keystone/v2.0/tokens". + + :param endpoint: the base URL + :param url: the final URL + """ + return "%s/%s" % (endpoint.rstrip("/"), url.strip("/")) + + def client_request(self, client, method, url, **kwargs): + """Send an http request using `client`'s endpoint and specified `url`. + + If request was rejected as unauthorized (possibly because the token is + expired), issue one authorization attempt and send the request once + again. + + :param client: instance of BaseClient descendant + :param method: method of HTTP request + :param url: URL of HTTP request + :param kwargs: any other parameter that can be passed to + `HTTPClient.request` + """ + + filter_args = { + "endpoint_type": client.endpoint_type or self.endpoint_type, + "service_type": client.service_type, + } + token, endpoint = (self.cached_token, client.cached_endpoint) + just_authenticated = False + if not (token and endpoint): + try: + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + except exceptions.EndpointException: + pass + if not (token and endpoint): + self.authenticate() + just_authenticated = True + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + if not (token and endpoint): + raise exceptions.AuthorizationFailure( + _("Cannot find endpoint or token for request")) + + old_token_endpoint = (token, endpoint) + kwargs.setdefault("headers", {})["X-Auth-Token"] = token + self.cached_token = token + client.cached_endpoint = endpoint + # Perform the request once. If we get Unauthorized, then it + # might be because the auth token expired, so try to + # re-authenticate and try again. If it still fails, bail. + try: + return self.request( + method, self.concat_url(endpoint, url), **kwargs) + except exceptions.Unauthorized as unauth_ex: + if just_authenticated: + raise + self.cached_token = None + client.cached_endpoint = None + if self.auth_plugin.opts.get('token'): + self.auth_plugin.opts['token'] = None + if self.auth_plugin.opts.get('endpoint'): + self.auth_plugin.opts['endpoint'] = None + self.authenticate() + try: + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + except exceptions.EndpointException: + raise unauth_ex + if (not (token and endpoint) or + old_token_endpoint == (token, endpoint)): + raise unauth_ex + self.cached_token = token + client.cached_endpoint = endpoint + kwargs["headers"]["X-Auth-Token"] = token + return self.request( + method, self.concat_url(endpoint, url), **kwargs) + + def add_client(self, base_client_instance): + """Add a new instance of :class:`BaseClient` descendant. + + `self` will store a reference to `base_client_instance`. + + Example: + + >>> def test_clients(): + ... from keystoneclient.auth import keystone + ... from openstack.common.apiclient import client + ... auth = keystone.KeystoneAuthPlugin( + ... username="user", password="pass", tenant_name="tenant", + ... auth_url="http://auth:5000/v2.0") + ... openstack_client = client.HTTPClient(auth) + ... # create nova client + ... from novaclient.v1_1 import client + ... client.Client(openstack_client) + ... # create keystone client + ... from keystoneclient.v2_0 import client + ... client.Client(openstack_client) + ... # use them + ... openstack_client.identity.tenants.list() + ... openstack_client.compute.servers.list() + """ + service_type = base_client_instance.service_type + if service_type and not hasattr(self, service_type): + setattr(self, service_type, base_client_instance) + + def authenticate(self): + self.auth_plugin.authenticate(self) + # Store the authentication results in the keyring for later requests + if self.keyring_saver: + self.keyring_saver.save(self) + + +class BaseClient(object): + """Top-level object to access the OpenStack API. + + This client uses :class:`HTTPClient` to send requests. :class:`HTTPClient` + will handle a bunch of issues such as authentication. + """ + + service_type = None + endpoint_type = None # "publicURL" will be used + cached_endpoint = None + + def __init__(self, http_client, extensions=None): + self.http_client = http_client + http_client.add_client(self) + + # Add in any extensions... + if extensions: + for extension in extensions: + if extension.manager_class: + setattr(self, extension.name, + extension.manager_class(self)) + + def client_request(self, method, url, **kwargs): + return self.http_client.client_request( + self, method, url, **kwargs) + + @property + def last_request_id(self): + return self.http_client.last_request_id + + def head(self, url, **kwargs): + return self.client_request("HEAD", url, **kwargs) + + def get(self, url, **kwargs): + return self.client_request("GET", url, **kwargs) + + def post(self, url, **kwargs): + return self.client_request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.client_request("PUT", url, **kwargs) + + def delete(self, url, **kwargs): + return self.client_request("DELETE", url, **kwargs) + + def patch(self, url, **kwargs): + return self.client_request("PATCH", url, **kwargs) + + @staticmethod + def get_class(api_name, version, version_map): + """Returns the client class for the requested API version + + :param api_name: the name of the API, e.g. 'compute', 'image', etc + :param version: the requested API version + :param version_map: a dict of client classes keyed by version + :rtype: a client class for the requested API version + """ + try: + client_path = version_map[str(version)] + except (KeyError, ValueError): + msg = _("Invalid %(api_name)s client version '%(version)s'. " + "Must be one of: %(version_map)s") % { + 'api_name': api_name, + 'version': version, + 'version_map': ', '.join(version_map.keys())} + raise exceptions.UnsupportedVersion(msg) + + return importutils.import_class(client_path) diff --git a/stacktaskclient/openstack/common/apiclient/exceptions.py b/stacktaskclient/openstack/common/apiclient/exceptions.py new file mode 100644 index 0000000..7b8bc5d --- /dev/null +++ b/stacktaskclient/openstack/common/apiclient/exceptions.py @@ -0,0 +1,479 @@ +# 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. +""" + +######################################################################## +# +# THIS MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + +import inspect +import sys + +import six + +from stacktaskclient.openstack.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 = 300 + message = _("Multiple Choices") + + +class BadRequest(HTTPClientError): + """HTTP 400 - Bad Request. + + The request cannot be fulfilled due to bad syntax. + """ + http_status = 400 + 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 = 401 + message = _("Unauthorized") + + +class PaymentRequired(HTTPClientError): + """HTTP 402 - Payment Required. + + Reserved for future use. + """ + http_status = 402 + 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 = 403 + 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 = 404 + 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 = 405 + 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 = 406 + message = _("Not Acceptable") + + +class ProxyAuthenticationRequired(HTTPClientError): + """HTTP 407 - Proxy Authentication Required. + + The client must first authenticate itself with the proxy. + """ + http_status = 407 + message = _("Proxy Authentication Required") + + +class RequestTimeout(HTTPClientError): + """HTTP 408 - Request Timeout. + + The server timed out waiting for the request. + """ + http_status = 408 + 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 = 409 + 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 = 410 + 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 = 411 + 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 = 412 + 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 = 413 + 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 = 414 + 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 = 415 + 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 = 416 + 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 = 417 + 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 = 422 + 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 = 500 + 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 = 501 + 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 = 502 + message = _("Bad Gateway") + + +class ServiceUnavailable(HttpServerError): + """HTTP 503 - Service Unavailable. + + The server is currently unavailable. + """ + http_status = 503 + 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 = 504 + 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 = 505 + 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 six.iteritems(vars(sys.modules[__name__])) + 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: + if 500 <= response.status_code < 600: + cls = HttpServerError + elif 400 <= response.status_code < 500: + cls = HTTPClientError + else: + cls = HttpError + return cls(**kwargs) diff --git a/stacktaskclient/openstack/common/apiclient/fake_client.py b/stacktaskclient/openstack/common/apiclient/fake_client.py new file mode 100644 index 0000000..25d3ada --- /dev/null +++ b/stacktaskclient/openstack/common/apiclient/fake_client.py @@ -0,0 +1,190 @@ +# 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. + +""" +A fake server that "responds" to API methods with pre-canned responses. + +All of these responses come from the spec, so if for some reason the spec's +wrong the tests might raise AssertionError. I've indicated in comments the +places where actual behavior differs from the spec. +""" + +######################################################################## +# +# THIS MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + +# W0102: Dangerous default value %s as argument +# pylint: disable=W0102 + +import json + +import requests +import six +from six.moves.urllib import parse + +from stacktaskclient.openstack.common.apiclient import client + + +def assert_has_keys(dct, required=None, optional=None): + required = required or [] + optional = optional or [] + for k in required: + try: + assert k in dct + except AssertionError: + extra_keys = set(dct.keys()).difference(set(required + optional)) + raise AssertionError("found unexpected keys: %s" % + list(extra_keys)) + + +class TestResponse(requests.Response): + """Wrap requests.Response and provide a convenient initialization. + """ + + def __init__(self, data): + super(TestResponse, self).__init__() + self._content_consumed = True + if isinstance(data, dict): + self.status_code = data.get('status_code', 200) + # Fake the text attribute to streamline Response creation + text = data.get('text', "") + if isinstance(text, (dict, list)): + self._content = json.dumps(text) + default_headers = { + "Content-Type": "application/json", + } + else: + self._content = text + default_headers = {} + if six.PY3 and isinstance(self._content, six.string_types): + self._content = self._content.encode('utf-8', 'strict') + self.headers = data.get('headers') or default_headers + else: + self.status_code = data + + def __eq__(self, other): + return (self.status_code == other.status_code and + self.headers == other.headers and + self._content == other._content) + + +class FakeHTTPClient(client.HTTPClient): + + def __init__(self, *args, **kwargs): + self.callstack = [] + self.fixtures = kwargs.pop("fixtures", None) or {} + if not args and "auth_plugin" not in kwargs: + args = (None, ) + super(FakeHTTPClient, self).__init__(*args, **kwargs) + + def assert_called(self, method, url, body=None, pos=-1): + """Assert than an API method was just called. + """ + expected = (method, url) + called = self.callstack[pos][0:2] + assert self.callstack, \ + "Expected %s %s but no calls were made." % expected + + assert expected == called, 'Expected %s %s; got %s %s' % \ + (expected + called) + + if body is not None: + if self.callstack[pos][3] != body: + raise AssertionError('%r != %r' % + (self.callstack[pos][3], body)) + + def assert_called_anytime(self, method, url, body=None): + """Assert than an API method was called anytime in the test. + """ + expected = (method, url) + + assert self.callstack, \ + "Expected %s %s but no calls were made." % expected + + found = False + entry = None + for entry in self.callstack: + if expected == entry[0:2]: + found = True + break + + assert found, 'Expected %s %s; got %s' % \ + (method, url, self.callstack) + if body is not None: + assert entry[3] == body, "%s != %s" % (entry[3], body) + + self.callstack = [] + + def clear_callstack(self): + self.callstack = [] + + def authenticate(self): + pass + + def client_request(self, client, method, url, **kwargs): + # Check that certain things are called correctly + if method in ["GET", "DELETE"]: + assert "json" not in kwargs + + # Note the call + self.callstack.append( + (method, + url, + kwargs.get("headers") or {}, + kwargs.get("json") or kwargs.get("data"))) + try: + fixture = self.fixtures[url][method] + except KeyError: + pass + else: + return TestResponse({"headers": fixture[0], + "text": fixture[1]}) + + # Call the method + args = parse.parse_qsl(parse.urlparse(url)[4]) + kwargs.update(args) + munged_url = url.rsplit('?', 1)[0] + munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') + munged_url = munged_url.replace('-', '_') + + callback = "%s_%s" % (method.lower(), munged_url) + + if not hasattr(self, callback): + raise AssertionError('Called unknown API method: %s %s, ' + 'expected fakes method name: %s' % + (method, url, callback)) + + resp = getattr(self, callback)(**kwargs) + if len(resp) == 3: + status, headers, body = resp + else: + status, body = resp + headers = {} + self.last_request_id = headers.get('x-openstack-request-id', + 'req-test') + return TestResponse({ + "status_code": status, + "text": body, + "headers": headers, + }) diff --git a/stacktaskclient/openstack/common/apiclient/utils.py b/stacktaskclient/openstack/common/apiclient/utils.py new file mode 100644 index 0000000..8f7200d --- /dev/null +++ b/stacktaskclient/openstack/common/apiclient/utils.py @@ -0,0 +1,100 @@ +# +# 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 MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + +from oslo_utils import encodeutils +from oslo_utils import uuidutils +import six + +from stacktaskclient.openstack.common._i18n import _ +from stacktaskclient.openstack.common.apiclient import exceptions + + +def find_resource(manager, name_or_id, **find_args): + """Look for resource in a given manager. + + Used as a helper for the _find_* methods. + Example: + + .. code-block:: python + + def _find_hypervisor(cs, hypervisor): + #Get a hypervisor by name or ID. + return cliutils.find_resource(cs.hypervisors, hypervisor) + """ + # first try to get entity as integer id + try: + return manager.get(int(name_or_id)) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # now try to get entity as uuid + try: + if six.PY2: + tmp_id = encodeutils.safe_encode(name_or_id) + else: + tmp_id = encodeutils.safe_decode(name_or_id) + + if uuidutils.is_uuid_like(tmp_id): + return manager.get(tmp_id) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # for str id which is not uuid + if getattr(manager, 'is_alphanum_id_allowed', False): + try: + return manager.get(name_or_id) + except exceptions.NotFound: + pass + + try: + try: + return manager.find(human_id=name_or_id, **find_args) + except exceptions.NotFound: + pass + + # finally try to find entity by name + try: + resource = getattr(manager, 'resource_class', None) + name_attr = resource.NAME_ATTR if resource else 'name' + kwargs = {name_attr: name_or_id} + kwargs.update(find_args) + return manager.find(**kwargs) + except exceptions.NotFound: + msg = _("No %(name)s with a name or " + "ID of '%(name_or_id)s' exists.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) + except exceptions.NoUniqueMatch: + msg = _("Multiple %(name)s matches found for " + "'%(name_or_id)s', use an ID to be more specific.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) diff --git a/stacktaskclient/openstack/common/cliutils.py b/stacktaskclient/openstack/common/cliutils.py new file mode 100644 index 0000000..c642256 --- /dev/null +++ b/stacktaskclient/openstack/common/cliutils.py @@ -0,0 +1,271 @@ +# 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 os +import sys +import textwrap + +from oslo_utils import encodeutils +from oslo_utils import strutils +import prettytable +import six +from six import moves + +from stacktaskclient.openstack.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 arg: 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): + """Print a list or objects as a table, one row per object. + + :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. + """ + 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' + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + data = getattr(o, field_name, '') + row.append(data) + pt.add_row(row) + + if 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): + """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 + """ + pt = prettytable.PrettyTable([dict_property, 'Value']) + pt.align = 'l' + for k, v in six.iteritems(dct): + # 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/stacktaskclient/shell.py b/stacktaskclient/shell.py new file mode 100644 index 0000000..80fa608 --- /dev/null +++ b/stacktaskclient/shell.py @@ -0,0 +1,676 @@ +# 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 Stacktask API. +""" + +from __future__ import print_function + +import argparse +import logging +import sys + +from oslo_utils import encodeutils +from oslo_utils import importutils +import six +import six.moves.urllib.parse as urlparse + +from keystoneclient.auth.identity import v2 as v2_auth +from keystoneclient.auth.identity import v3 as v3_auth +from keystoneclient import discover +from keystoneclient.openstack.common.apiclient import exceptions as ks_exc +from keystoneclient import session as kssession + +import stacktaskclient +from stacktaskclient import client as stacktask_client +from stacktaskclient.common import utils +from stacktaskclient import exc +from stacktaskclient.openstack.common._i18n import _ + +logger = logging.getLogger(__name__) +osprofiler_profiler = importutils.try_import("osprofiler.profiler") + + +class StacktaskShell(object): + + def _append_global_identity_args(self, parser): + # FIXME(gyee): these are global identity (Keystone) arguments which + # should be consistent and shared by all service clients. Therefore, + # they should be provided by python-keystoneclient. We will need to + # refactor this code once this functionality is avaible in + # python-keystoneclient. + parser.add_argument('-k', '--insecure', + default=False, + action='store_true', + help=_('Explicitly allow this client to perform ' + '\"insecure SSL\" (https) requests. The server\'s ' + 'certificate will not be verified against any ' + 'certificate authorities. This option should ' + 'be used with caution.')) + + parser.add_argument('--os-cert', + help=_('Path of certificate file to use in SSL ' + 'connection. This file can optionally be ' + 'prepended with the private key.')) + + parser.add_argument('--os-key', + help=_('Path of client key to use in SSL ' + 'connection. This option is not necessary ' + 'if your key is prepended to your cert file.')) + + parser.add_argument('--os-cacert', + metavar='', + dest='os_cacert', + default=utils.env('OS_CACERT'), + help=_('Path of CA TLS certificate(s) used to ' + 'verify the remote server\'s certificate. ' + 'Without this option glance looks for the ' + 'default system CA certificates.')) + + parser.add_argument('--os-username', + default=utils.env('OS_USERNAME'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_USERNAME]' + }) + + parser.add_argument('--os_username', + help=argparse.SUPPRESS) + + parser.add_argument('--os-user-id', + default=utils.env('OS_USER_ID'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_USER_ID]' + }) + + parser.add_argument('--os_user_id', + help=argparse.SUPPRESS) + + parser.add_argument('--os-user-domain-id', + default=utils.env('OS_USER_DOMAIN_ID'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_USER_DOMAIN_ID]' + }) + + parser.add_argument('--os_user_domain_id', + help=argparse.SUPPRESS) + + parser.add_argument('--os-user-domain-name', + default=utils.env('OS_USER_DOMAIN_NAME'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_USER_DOMAIN_NAME]' + }) + + parser.add_argument('--os_user_domain_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-project-id', + default=utils.env('OS_PROJECT_ID'), + help=(_('Another way to specify tenant ID. ' + 'This option is mutually exclusive with ' + '%(arg)s. Defaults to %(value)s.') % + { + 'arg': '--os-tenant-id', + 'value': 'env[OS_PROJECT_ID]' + })) + + parser.add_argument('--os_project_id', + help=argparse.SUPPRESS) + + parser.add_argument('--os-project-name', + default=utils.env('OS_PROJECT_NAME'), + help=(_('Another way to specify tenant name. ' + 'This option is mutually exclusive with ' + '%(arg)s. Defaults to %(value)s.') % + { + 'arg': '--os-tenant-name', + 'value': 'env[OS_PROJECT_NAME]' + })) + + parser.add_argument('--os_project_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-project-domain-id', + default=utils.env('OS_PROJECT_DOMAIN_ID'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_PROJECT_DOMAIN_ID]' + }) + + parser.add_argument('--os_project_domain_id', + help=argparse.SUPPRESS) + + parser.add_argument('--os-project-domain-name', + default=utils.env('OS_PROJECT_DOMAIN_NAME'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_PROJECT_DOMAIN_NAME]' + }) + + parser.add_argument('--os_project_domain_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-password', + default=utils.env('OS_PASSWORD'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_PASSWORD]' + }) + + parser.add_argument('--os_password', + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-id', + default=utils.env('OS_TENANT_ID'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_TENANT_ID]' + }) + + parser.add_argument('--os_tenant_id', + default=utils.env('OS_TENANT_ID'), + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-name', + default=utils.env('OS_TENANT_NAME'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_TENANT_NAME]' + }) + + parser.add_argument('--os_tenant_name', + default=utils.env('OS_TENANT_NAME'), + help=argparse.SUPPRESS) + + parser.add_argument('--os-auth-url', + default=utils.env('OS_AUTH_URL'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_AUTH_URL]' + }) + + parser.add_argument('--os_auth_url', + help=argparse.SUPPRESS) + + parser.add_argument('--os-region-name', + default=utils.env('OS_REGION_NAME'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_REGION_NAME]' + }) + + parser.add_argument('--os_region_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-auth-token', + default=utils.env('OS_AUTH_TOKEN'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_AUTH_TOKEN]' + }) + + parser.add_argument('--os_auth_token', + help=argparse.SUPPRESS) + + parser.add_argument('--os-service-type', + default=utils.env('OS_SERVICE_TYPE'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_SERVICE_TYPE]' + }) + + parser.add_argument('--os_service_type', + help=argparse.SUPPRESS) + + parser.add_argument('--os-endpoint-type', + default=utils.env('OS_ENDPOINT_TYPE'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[OS_ENDPOINT_TYPE]' + }) + + parser.add_argument('--os_endpoint_type', + help=argparse.SUPPRESS) + + def get_base_parser(self): + parser = argparse.ArgumentParser( + prog='stacktask', + description=__doc__.strip(), + epilog=_('See "%(arg)s" for help on a specific command.') % { + 'arg': 'stacktask help COMMAND' + }, + add_help=False, + formatter_class=HelpFormatter, + ) + + # Global arguments + parser.add_argument('-h', '--help', + action='store_true', + help=argparse.SUPPRESS) + + parser.add_argument('--version', + action='version', + version=stacktaskclient.__version__, + help=_("Shows the client version and exits.")) + + parser.add_argument('-d', '--debug', + default=bool(utils.env('STACKTASKCLIENT_DEBUG')), + action='store_true', + help=_('Defaults to %(value)s.') % { + 'value': 'env[STACKTASKCLIENT_DEBUG]' + }) + + parser.add_argument('-v', '--verbose', + default=False, action="store_true", + help=_("Print more verbose output.")) + + parser.add_argument('--api-timeout', + help=_('Number of seconds to wait for an ' + 'API response, ' + 'defaults to system socket timeout')) + + # os-no-client-auth tells stacktaskclient to use token, instead of + # env[OS_AUTH_URL] + parser.add_argument('--os-no-client-auth', + default=utils.env('OS_NO_CLIENT_AUTH'), + action='store_true', + help=(_("Do not contact keystone for a token. " + "Defaults to %(value)s.") % + {'value': 'env[OS_NO_CLIENT_AUTH]'})) + + parser.add_argument('--stacktask-url', + default=utils.env('STACKTASK_URL'), + help=_('Defaults to %(value)s.') % { + 'value': 'env[STACKTASK_URL]' + }) + + parser.add_argument('--api-version', + default=utils.env('STACKTASK_API_VERSION', default='1'), + help=_('Defaults to %(value)s or 1.') % { + 'value': 'env[STACKTASK_API_VERSION]' + }) + + + # FIXME(gyee): this method should come from python-keystoneclient. + # Will refactor this code once it is available. + # https://bugs.launchpad.net/python-keystoneclient/+bug/1332337 + + self._append_global_identity_args(parser) + + if osprofiler_profiler: + parser.add_argument('--profile', + metavar='HMAC_KEY', + help=_('HMAC key to use for encrypting ' + 'context data for performance profiling of ' + 'operation. This key should be the value of ' + 'HMAC key configured in osprofiler middleware ' + 'in heat, it is specified in the paste ' + 'configuration (/etc/heat/api-paste.ini). ' + 'Without the key, profiling will not be ' + 'triggered even if osprofiler is enabled ' + 'on server side.')) + return parser + + def get_subcommand_parser(self, version): + parser = self.get_base_parser() + + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='') + submodule = utils.import_versioned_module(version, 'shell') + self._find_actions(subparsers, submodule) + self._find_actions(subparsers, self) + self._add_bash_completion_subparser(subparsers) + + return parser + + def _add_bash_completion_subparser(self, subparsers): + subparser = subparsers.add_parser( + 'bash_completion', + add_help=False, + formatter_class=HelpFormatter + ) + self.subcommands['bash_completion'] = subparser + subparser.set_defaults(func=self.do_bash_completion) + + def _find_actions(self, subparsers, actions_module): + for attr in (a for a in dir(actions_module) if a.startswith('do_')): + # I prefer to be hyphen-separated instead of underscores. + command = attr[3:].replace('_', '-') + callback = getattr(actions_module, attr) + 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) + self.subcommands[command] = subparser + for (args, kwargs) in arguments: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=callback) + + def _setup_logging(self, debug): + log_lvl = logging.DEBUG if debug else logging.WARNING + logging.basicConfig( + format="%(levelname)s (%(module)s) %(message)s", + level=log_lvl) + logging.getLogger('iso8601').setLevel(logging.WARNING) + logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING) + + def _setup_verbose(self, verbose): + if verbose: + exc.verbose = 1 + + def _discover_auth_versions(self, session, auth_url): + # discover the API versions the server is supporting base on the + # given URL + v2_auth_url = None + v3_auth_url = None + try: + ks_discover = discover.Discover(session=session, auth_url=auth_url) + v2_auth_url = ks_discover.url_for('2.0') + v3_auth_url = ks_discover.url_for('3.0') + except ks_exc.ClientException: + # Identity service may not support discover API version. + # Lets trying to figure out the API version from the original URL. + url_parts = urlparse.urlparse(auth_url) + (scheme, netloc, path, params, query, fragment) = url_parts + path = path.lower() + if path.startswith('/v3'): + v3_auth_url = auth_url + elif path.startswith('/v2'): + v2_auth_url = auth_url + else: + # not enough information to determine the auth version + msg = _('Unable to determine the Keystone version ' + 'to authenticate with using the given ' + 'auth_url. Identity service may not support API ' + 'version discovery. Please provide a versioned ' + 'auth_url instead.') + raise exc.CommandError(msg) + + return (v2_auth_url, v3_auth_url) + + def _get_keystone_session(self, **kwargs): + # first create a Keystone session + cacert = kwargs.pop('cacert', None) + cert = kwargs.pop('cert', None) + key = kwargs.pop('key', None) + insecure = kwargs.pop('insecure', False) + timeout = kwargs.pop('timeout', None) + verify = kwargs.pop('verify', None) + + # FIXME(gyee): this code should come from keystoneclient + if verify is None: + if insecure: + verify = False + else: + # TODO(gyee): should we do + # stacktaskclient.common.http.get_system_ca_fle()? + verify = cacert or True + + return kssession.Session(verify=verify, cert=cert, timeout=timeout) + + def _get_keystone_v3_auth(self, v3_auth_url, **kwargs): + auth_token = kwargs.pop('auth_token', None) + if auth_token: + return v3_auth.Token(v3_auth_url, auth_token) + else: + return v3_auth.Password(v3_auth_url, **kwargs) + + def _get_keystone_v2_auth(self, v2_auth_url, **kwargs): + auth_token = kwargs.pop('auth_token', None) + tenant_id = kwargs.pop('project_id', None) + tenant_name = kwargs.pop('project_name', None) + if auth_token: + return v2_auth.Token(v2_auth_url, auth_token, + tenant_id=tenant_id, + tenant_name=tenant_name) + else: + return v2_auth.Password(v2_auth_url, + username=kwargs.pop('username', None), + password=kwargs.pop('password', None), + tenant_id=tenant_id, + tenant_name=tenant_name) + + def _get_keystone_auth(self, session, auth_url, **kwargs): + # FIXME(dhu): this code should come from keystoneclient + + # discover the supported keystone versions using the given url + (v2_auth_url, v3_auth_url) = self._discover_auth_versions( + session=session, + auth_url=auth_url) + + # Determine which authentication plugin to use. First inspect the + # auth_url to see the supported version. If both v3 and v2 are + # supported, then use the highest version if possible. + auth = None + if v3_auth_url and v2_auth_url: + user_domain_name = kwargs.get('user_domain_name', None) + user_domain_id = kwargs.get('user_domain_id', None) + project_domain_name = kwargs.get('project_domain_name', None) + project_domain_id = kwargs.get('project_domain_id', None) + + # support both v2 and v3 auth. Use v3 if domain information is + # provided. + if (user_domain_name or user_domain_id or project_domain_name or + project_domain_id): + auth = self._get_keystone_v3_auth(v3_auth_url, **kwargs) + else: + auth = self._get_keystone_v2_auth(v2_auth_url, **kwargs) + elif v3_auth_url: + # support only v3 + auth = self._get_keystone_v3_auth(v3_auth_url, **kwargs) + elif v2_auth_url: + # support only v2 + auth = self._get_keystone_v2_auth(v2_auth_url, **kwargs) + else: + raise exc.CommandError(_('Unable to determine the Keystone ' + 'version to authenticate with using the ' + 'given auth_url.')) + + return auth + + def main(self, argv): + # Parse args once to find version + parser = self.get_base_parser() + (options, args) = parser.parse_known_args(argv) + self._setup_logging(options.debug) + self._setup_verbose(options.verbose) + + # build available subcommands based on version + api_version = options.api_version + subcommand_parser = self.get_subcommand_parser(api_version) + self.parser = subcommand_parser + + # Handle top-level --help/-h before attempting to parse + # a command off the command line + if not args and 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 help command 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(args) + return 0 + + if not args.os_username and not args.os_auth_token: + raise exc.CommandError(_("You must provide a username via" + " either --os-username or env[OS_USERNAME]" + " or a token via --os-auth-token or" + " env[OS_AUTH_TOKEN]")) + + if not args.os_password and not args.os_auth_token: + raise exc.CommandError(_("You must provide a password via" + " either --os-password or env[OS_PASSWORD]" + " or a token via --os-auth-token or" + " env[OS_AUTH_TOKEN]")) + + if args.os_no_client_auth: + if not args.heat_url: + raise exc.CommandError(_("If you specify --os-no-client-auth" + " you must also specify a Heat API URL" + " via either --heat-url or" + " env[HEAT_URL]")) + else: + # Tenant/project name or ID is needed to make keystoneclient + # retrieve a service catalog, it's not required if + # os_no_client_auth is specified, neither is the auth URL + + 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 tenant id via" + " either --os-tenant-id or" + " env[OS_TENANT_ID] or a tenant name" + " via either --os-tenant-name or" + " env[OS_TENANT_NAME] or a project id" + " via either --os-project-id or" + " env[OS_PROJECT_ID] or a project" + " name via either --os-project-name or" + " env[OS_PROJECT_NAME]")) + + 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]")) + + kwargs = { + 'insecure': args.insecure, + 'cacert': args.os_cacert, + 'cert': args.os_cert, + 'key': args.os_key, + 'timeout': args.api_timeout + } + + endpoint = None # args.heat_url + service_type = args.os_service_type or 'registration' + if args.os_no_client_auth: + # Do not use session since no_client_auth means using heat to + # to authenticate + kwargs = { + 'username': args.os_username, + 'password': args.os_password, + 'auth_url': args.os_auth_url, + 'token': args.os_auth_token, + 'include_pass': None, # args.include_password, + 'insecure': args.insecure, + 'timeout': args.api_timeout + } + else: + keystone_session = self._get_keystone_session(**kwargs) + project_id = args.os_project_id or args.os_tenant_id + project_name = args.os_project_name or args.os_tenant_name + endpoint_type = args.os_endpoint_type or 'publicURL' + kwargs = { + 'username': args.os_username, + 'user_id': args.os_user_id, + 'user_domain_id': args.os_user_domain_id, + 'user_domain_name': args.os_user_domain_name, + 'password': args.os_password, + 'auth_token': args.os_auth_token, + 'project_id': project_id, + 'project_name': project_name, + 'project_domain_id': args.os_project_domain_id, + 'project_domain_name': args.os_project_domain_name, + } + keystone_auth = self._get_keystone_auth(keystone_session, + args.os_auth_url, + **kwargs) + if not endpoint: + svc_type = service_type + region_name = args.os_region_name + endpoint = keystone_auth.get_endpoint(keystone_session, + service_type=svc_type, + interface=endpoint_type, + region_name=region_name) + kwargs = { + 'auth_url': args.os_auth_url, + 'session': keystone_session, + 'auth': keystone_auth, + 'service_type': service_type, + 'endpoint_type': endpoint_type, + 'region_name': args.os_region_name, + 'username': args.os_username, + 'password': args.os_password, + 'include_pass': None # args.include_password + } + + client = stacktask_client.Client(api_version, endpoint, **kwargs) + + profile = osprofiler_profiler and options.profile + if profile: + osprofiler_profiler.init(options.profile) + + args.func(client, args) + + if profile: + trace_id = osprofiler_profiler.get().get_base_id() + print(_("Trace ID: %s") % trace_id) + print(_("To display trace use next command:\n" + "osprofiler trace show --html %s ") % trace_id) + + def do_bash_completion(self, args): + """Prints all of the commands and options to stdout. + + The heat.bash_completion script doesn't have to hard code them. + """ + commands = set() + options = set() + for sc_str, sc in self.subcommands.items(): + commands.add(sc_str) + for option in list(sc._optionals._option_string_actions): + options.add(option) + + commands.remove('bash-completion') + commands.remove('bash_completion') + print(' '.join(commands | options)) + + @utils.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): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(HelpFormatter, self).start_section(heading) + + +def main(args=None): + try: + if args is None: + args = sys.argv[1:] + + StacktaskShell().main(args) + except KeyboardInterrupt: + print(_("... terminating stacktask client"), file=sys.stderr) + sys.exit(130) + except Exception as e: + if '--debug' in args or '-d' in args: + raise + else: + print(encodeutils.safe_encode(six.text_type(e)), file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/stacktaskclient/tests/__init__.py b/stacktaskclient/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stacktaskclient/tests/functional/__init__.py b/stacktaskclient/tests/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stacktaskclient/tests/functional/base.py b/stacktaskclient/tests/functional/base.py new file mode 100644 index 0000000..2f83341 --- /dev/null +++ b/stacktaskclient/tests/functional/base.py @@ -0,0 +1,42 @@ +# 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 + +from tempest_lib.cli import base + + +class ClientTestBase(base.ClientTestBase): + """This is a first pass at a simple read only python-heatclient test. + This only exercises client commands that are read only. + + This should test commands: + * as a regular user + * as a admin user + * with and without optional parameters + * initially just check return codes, and later test command outputs + """ + + def _get_clients(self): + cli_dir = os.environ.get( + 'OS_HEATCLIENT_EXEC_DIR', + os.path.join(os.path.abspath('.'), '.tox/functional/bin')) + + return base.CLIClient( + username=os.environ.get('OS_USERNAME'), + password=os.environ.get('OS_PASSWORD'), + tenant_name=os.environ.get('OS_TENANT_NAME'), + uri=os.environ.get('OS_AUTH_URL'), + cli_dir=cli_dir) + + def heat(self, *args, **kwargs): + return self.clients.heat(*args, **kwargs) diff --git a/stacktaskclient/tests/functional/hooks/post_test_hook.sh b/stacktaskclient/tests/functional/hooks/post_test_hook.sh new file mode 100755 index 0000000..cc46670 --- /dev/null +++ b/stacktaskclient/tests/functional/hooks/post_test_hook.sh @@ -0,0 +1,50 @@ +#!/bin/bash -xe + +# 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 script is executed inside post_test_hook function in devstack gate. + +function generate_testr_results { + if [ -f .testrepository/0 ]; then + sudo .tox/functional/bin/testr last --subunit > $WORKSPACE/testrepository.subunit + sudo mv $WORKSPACE/testrepository.subunit $BASE/logs/testrepository.subunit + sudo .tox/functional/bin/python /usr/local/jenkins/slave_scripts/subunit2html.py $BASE/logs/testrepository.subunit $BASE/logs/testr_results.html + sudo gzip -9 $BASE/logs/testrepository.subunit + sudo gzip -9 $BASE/logs/testr_results.html + sudo chown jenkins:jenkins $BASE/logs/testrepository.subunit.gz $BASE/logs/testr_results.html.gz + sudo chmod a+r $BASE/logs/testrepository.subunit.gz $BASE/logs/testr_results.html.gz + fi +} + +export HEATCLIENT_DIR="$BASE/new/python-heatclient" + +# Get admin credentials +cd $BASE/new/devstack +source openrc admin admin + +# Go to the heatclient dir +cd $HEATCLIENT_DIR + +sudo chown -R jenkins:stack $HEATCLIENT_DIR + +# Run tests +echo "Running heatclient functional test suite" +set +e +# Preserve env for OS_ credentials +sudo -E -H -u jenkins tox -efunctional +EXIT_CODE=$? +set -e + +# Collect and parse result +generate_testr_results +exit $EXIT_CODE diff --git a/stacktaskclient/tests/functional/templates/heat_minimal.yaml b/stacktaskclient/tests/functional/templates/heat_minimal.yaml new file mode 100644 index 0000000..d85e22c --- /dev/null +++ b/stacktaskclient/tests/functional/templates/heat_minimal.yaml @@ -0,0 +1,18 @@ +HeatTemplateFormatVersion: '2012-12-12' +Description: Minimal template to test validation +Parameters: + InstanceImage: + Description: Glance image name + Type: String + InstanceType: + Description: Nova instance type + Type: String + Default: m1.small + AllowedValues: [m1.tiny, m1.small, m1.medium, m1.large, m1.nano, m1.xlarge, m1.micro, m1.heat] + ConstraintDescription: must be a valid nova instance type. +Resources: + InstanceResource: + Type: OS::Nova::Server + Properties: + flavor: {Ref: InstanceType} + image: {Ref: InstanceImage} diff --git a/stacktaskclient/tests/functional/templates/heat_minimal_hot.yaml b/stacktaskclient/tests/functional/templates/heat_minimal_hot.yaml new file mode 100644 index 0000000..c4eb8b4 --- /dev/null +++ b/stacktaskclient/tests/functional/templates/heat_minimal_hot.yaml @@ -0,0 +1,19 @@ +heat_template_version: 2015-04-30 +description: A minimal HOT test template +parameters: + instance_image: + description: Glance image name + type: string + instance_type: + description: Nova instance type + type: string + default: m1.small + constraints: + - allowed_values: [m1.tiny, m1.small, m1.medium, m1.large, m1.nano, m1.xlarge, m1.micro, m1.heat] + description: must be a valid nova instance type. +resources: + instance: + type: OS::Nova::Server + properties: + image: { get_param: instance_image } + flavor: { get_param: instance_type } diff --git a/stacktaskclient/tests/functional/test_readonly_heat.py b/stacktaskclient/tests/functional/test_readonly_heat.py new file mode 100644 index 0000000..84fe049 --- /dev/null +++ b/stacktaskclient/tests/functional/test_readonly_heat.py @@ -0,0 +1,102 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import os + +from tempest_lib import exceptions +import yaml + +from heatclient.tests.functional import base + + +class SimpleReadOnlyHeatClientTest(base.ClientTestBase): + """Basic, read-only tests for Heat CLI client. + Basic smoke test for the heat CLI commands which do not require + creating or modifying stacks. + """ + + def test_heat_fake_action(self): + self.assertRaises(exceptions.CommandFailed, + self.heat, + 'this-does-not-exist') + + def test_heat_stack_list(self): + self.heat('stack-list') + + def test_heat_stack_list_debug(self): + self.heat('stack-list', flags='--debug') + + def test_heat_resource_template_fmt_default(self): + ret = self.heat('resource-template OS::Nova::Server') + self.assertIn('Type: OS::Nova::Server', ret) + + def test_heat_resource_template_fmt_arg_short_yaml(self): + ret = self.heat('resource-template -F yaml OS::Nova::Server') + self.assertIn('Type: OS::Nova::Server', ret) + self.assertIsInstance(yaml.safe_load(ret), dict) + + def test_heat_resource_template_fmt_arg_long_json(self): + ret = self.heat('resource-template --format json OS::Nova::Server') + self.assertIn('"Type": "OS::Nova::Server"', ret) + self.assertIsInstance(json.loads(ret), dict) + + def test_heat_resource_type_list(self): + ret = self.heat('resource-type-list') + rsrc_types = self.parser.listing(ret) + self.assertTableStruct(rsrc_types, ['resource_type']) + + def test_heat_resource_type_show(self): + rsrc_schema = self.heat('resource-type-show OS::Heat::RandomString') + # resource-type-show returns a json resource schema + self.assertIsInstance(json.loads(rsrc_schema), dict) + + def _template_validate(self, templ_name): + heat_template_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'templates/%s' % templ_name) + ret = self.heat('template-validate -f %s' % heat_template_path) + # On success template-validate returns a json representation + # of the template parameters + self.assertIsInstance(json.loads(ret), dict) + + def test_heat_template_validate_yaml(self): + self._template_validate('heat_minimal.yaml') + + def test_heat_template_validate_hot(self): + self._template_validate('heat_minimal_hot.yaml') + + def test_heat_help(self): + self.heat('help') + + def test_heat_bash_completion(self): + self.heat('bash-completion') + + def test_heat_help_cmd(self): + # Check requesting help for a specific command works + help_text = self.heat('help resource-template') + lines = help_text.split('\n') + self.assertFirstLineStartsWith(lines, 'usage: heat resource-template') + + def test_heat_version(self): + self.heat('', flags='--version') + + def test_heat_template_version_list(self): + ret = self.heat('template-version-list') + tmpl_types = self.parser.listing(ret) + self.assertTableStruct(tmpl_types, ['version', 'type']) + + def test_heat_template_function_list(self): + ret = self.heat('template-function-list ' + 'heat_template_version.2013-05-23') + tmpl_functions = self.parser.listing(ret) + self.assertTableStruct(tmpl_functions, ['functions', 'description']) diff --git a/stacktaskclient/tests/unit/__init__.py b/stacktaskclient/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stacktaskclient/tests/unit/fakes.py b/stacktaskclient/tests/unit/fakes.py new file mode 100644 index 0000000..2469fad --- /dev/null +++ b/stacktaskclient/tests/unit/fakes.py @@ -0,0 +1,256 @@ +# 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 oslo_serialization import jsonutils + +from heatclient.common import http +from heatclient import exc + + +def script_heat_list(url=None, show_nested=False, client=http.HTTPClient): + if url is None: + url = '/stacks?' + + resp_dict = {"stacks": [ + { + "id": "1", + "stack_name": "teststack", + "stack_owner": "testowner", + "project": "testproject", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }, + { + "id": "2", + "stack_name": "teststack2", + "stack_owner": "testowner", + "project": "testproject", + "stack_status": 'IN_PROGRESS', + "creation_time": "2012-10-25T01:58:47Z" + }] + } + if show_nested: + nested = { + "id": "3", + "stack_name": "teststack_nested", + "stack_status": 'IN_PROGRESS', + "creation_time": "2012-10-25T01:58:47Z", + "parent": "theparentof3" + } + resp_dict["stacks"].append(nested) + resp = FakeHTTPResponse(200, + 'success, you', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if client == http.SessionClient: + client.request(url, 'GET').AndReturn(resp) + else: + client.json_request('GET', url).AndReturn((resp, resp_dict)) + + +def mock_script_heat_list(show_nested=False): + resp_dict = {"stacks": [ + { + "id": "1", + "stack_name": "teststack", + "stack_owner": "testowner", + "project": "testproject", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }, + { + "id": "2", + "stack_name": "teststack2", + "stack_owner": "testowner", + "project": "testproject", + "stack_status": 'IN_PROGRESS', + "creation_time": "2012-10-25T01:58:47Z" + }] + } + if show_nested: + nested = { + "id": "3", + "stack_name": "teststack_nested", + "stack_status": 'IN_PROGRESS', + "creation_time": "2012-10-25T01:58:47Z", + "parent": "theparentof3" + } + resp_dict["stacks"].append(nested) + resp = FakeHTTPResponse(200, + 'success, you', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + return resp, resp_dict + + +def mock_script_event_list( + stack_name="teststack", resource_name=None, + rsrc_eventid1="7fecaeed-d237-4559-93a5-92d5d9111205", + rsrc_eventid2="e953547a-18f8-40a7-8e63-4ec4f509648b", + action="CREATE", final_state="COMPLETE", fakehttp=True): + + resp_dict = {"events": [ + {"event_time": "2013-12-05T14:14:31Z", + "id": rsrc_eventid1, + "links": [{"href": "http://heat.example.com:8004/foo", + "rel": "self"}, + {"href": "http://heat.example.com:8004/foo2", + "rel": "resource"}, + {"href": "http://heat.example.com:8004/foo3", + "rel": "stack"}], + "logical_resource_id": "myDeployment", + "physical_resource_id": None, + "resource_name": resource_name if resource_name else "testresource", + "resource_status": "%s_IN_PROGRESS" % action, + "resource_status_reason": "state changed"}, + {"event_time": "2013-12-05T14:14:32Z", + "id": rsrc_eventid2, + "links": [{"href": "http://heat.example.com:8004/foo", + "rel": "self"}, + {"href": "http://heat.example.com:8004/foo2", + "rel": "resource"}, + {"href": "http://heat.example.com:8004/foo3", + "rel": "stack"}], + "logical_resource_id": "myDeployment", + "physical_resource_id": "bce15ec4-8919-4a02-8a90-680960fb3731", + "resource_name": resource_name if resource_name else "testresource", + "resource_status": "%s_%s" % (action, final_state), + "resource_status_reason": "state changed"}]} + + if resource_name is None: + # if resource_name is not specified, + # then request is made for stack events. Hence include the stack event + stack_event1 = "0159dccd-65e1-46e8-a094-697d20b009e5" + stack_event2 = "8f591a36-7190-4adb-80da-00191fe22388" + resp_dict["events"].insert( + 0, {"event_time": "2013-12-05T14:14:30Z", + "id": stack_event1, + "links": [{"href": "http://heat.example.com:8004/foo", + "rel": "self"}, + {"href": "http://heat.example.com:8004/foo2", + "rel": "resource"}, + {"href": "http://heat.example.com:8004/foo3", + "rel": "stack"}], + "logical_resource_id": "aResource", + "physical_resource_id": None, + "resource_name": stack_name, + "resource_status": "%s_IN_PROGRESS" % action, + "resource_status_reason": "state changed"}) + resp_dict["events"].append( + {"event_time": "2013-12-05T14:14:33Z", + "id": stack_event2, + "links": [{"href": "http://heat.example.com:8004/foo", + "rel": "self"}, + {"href": "http://heat.example.com:8004/foo2", + "rel": "resource"}, + {"href": "http://heat.example.com:8004/foo3", + "rel": "stack"}], + "logical_resource_id": "aResource", + "physical_resource_id": None, + "resource_name": stack_name, + "resource_status": "%s_%s" % (action, final_state), + "resource_status_reason": "state changed"}) + + resp = FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) if fakehttp else None + + return resp, resp_dict + + +def script_heat_normal_error(client=http.HTTPClient): + resp_dict = { + "explanation": "The resource could not be found.", + "code": 404, + "error": { + "message": "The Stack (bad) could not be found.", + "type": "StackNotFound", + "traceback": "", + }, + "title": "Not Found" + } + resp = FakeHTTPResponse(400, + 'The resource could not be found', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if client == http.SessionClient: + client.request('/stacks/bad', 'GET').AndRaise(exc.from_response(resp)) + else: + client.json_request('GET', + '/stacks/bad').AndRaise(exc.from_response(resp)) + + +def script_heat_error(resp_string, client=http.HTTPClient): + resp = FakeHTTPResponse(400, + 'The resource could not be found', + {'content-type': 'application/json'}, + resp_string) + if client == http.SessionClient: + client.request('/stacks/bad', 'GET').AndRaise(exc.from_response(resp)) + else: + client.json_request('GET', + '/stacks/bad').AndRaise(exc.from_response(resp)) + + +def fake_headers(): + return {'X-Auth-Token': 'abcd1234', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'} + + +class FakeServiceCatalog(): + def url_for(self, endpoint_type, service_type): + return 'http://192.168.1.5:8004/v1/f14b41234' + + +class FakeKeystone(): + service_catalog = FakeServiceCatalog() + + def __init__(self, auth_token): + self.auth_token = auth_token + + +class FakeRaw(): + version = 110 + + +class FakeHTTPResponse(): + + version = 1.1 + + def __init__(self, status_code, reason, headers, content): + self.headers = headers + self.content = content + self.status_code = status_code + self.reason = reason + self.raw = FakeRaw() + + def getheader(self, name, default=None): + return self.headers.get(name, default) + + def getheaders(self): + return self.headers.items() + + def read(self, amt=None): + b = self.content + self.content = None + return b + + def iter_content(self, chunksize): + return self.content + + def json(self): + return jsonutils.loads(self.content) diff --git a/stacktaskclient/tests/unit/test_actions.py b/stacktaskclient/tests/unit/test_actions.py new file mode 100644 index 0000000..1fcfe8a --- /dev/null +++ b/stacktaskclient/tests/unit/test_actions.py @@ -0,0 +1,108 @@ +# 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 heatclient.tests.unit import fakes +from heatclient.v1 import actions + +import testtools + + +class ActionManagerTest(testtools.TestCase): + + def setUp(self): + super(ActionManagerTest, self).setUp() + + def _base_test(self, expect_args, expect_kwargs): + + class FakeAPI(object): + """Fake API and ensure request url is correct.""" + + def json_request(self, *args, **kwargs): + assert expect_args == args + assert expect_kwargs['data'] == kwargs['data'] + return fakes.FakeHTTPResponse( + '200', + '', + {'content-type': 'application/json'}, + {}), {} + + def raw_request(self, *args, **kwargs): + assert expect_args == args + return fakes.FakeHTTPResponse( + '200', + '', + {}, + {}) + + def head(self, url, **kwargs): + resp, body = self.json_request("HEAD", url, **kwargs) + return resp + + def get(self, url, **kwargs): + resp, body = self.json_request("GET", url, **kwargs) + return resp + + def post(self, url, **kwargs): + resp, body = self.json_request("POST", url, **kwargs) + return resp + + def put(self, url, **kwargs): + resp, body = self.json_request("PUT", url, **kwargs) + return resp + + def delete(self, url, **kwargs): + resp, body = self.raw_request("DELETE", url, **kwargs) + return resp + + def patch(self, url, **kwargs): + resp, body = self.json_request("PATCH", url, **kwargs) + return resp + + manager = actions.ActionManager(FakeAPI()) + return manager + + def test_suspend(self): + fields = {'stack_id': 'teststack%2Fabcd1234'} + expect_args = ('POST', + '/stacks/teststack%2Fabcd1234/actions') + expect_kwargs = {'data': {'suspend': None}} + + manager = self._base_test(expect_args, expect_kwargs) + manager.suspend(**fields) + + def test_resume(self): + fields = {'stack_id': 'teststack%2Fabcd1234'} + expect_args = ('POST', + '/stacks/teststack%2Fabcd1234/actions') + expect_kwargs = {'data': {'resume': None}} + + manager = self._base_test(expect_args, expect_kwargs) + manager.resume(**fields) + + def test_cancel_update(self): + fields = {'stack_id': 'teststack%2Fabcd1234'} + expect_args = ('POST', + '/stacks/teststack%2Fabcd1234/actions') + expect_kwargs = {'data': {'cancel_update': None}} + + manager = self._base_test(expect_args, expect_kwargs) + manager.cancel_update(**fields) + + def test_check(self): + fields = {'stack_id': 'teststack%2Fabcd1234'} + expect_args = ('POST', + '/stacks/teststack%2Fabcd1234/actions') + expect_kwargs = {'data': {'check': None}} + + manager = self._base_test(expect_args, expect_kwargs) + manager.check(**fields) diff --git a/stacktaskclient/tests/unit/test_build_info.py b/stacktaskclient/tests/unit/test_build_info.py new file mode 100644 index 0000000..9ee93da --- /dev/null +++ b/stacktaskclient/tests/unit/test_build_info.py @@ -0,0 +1,41 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import mock +from oslo_serialization import jsonutils +import testtools + +from heatclient.tests.unit import fakes +from heatclient.v1 import build_info + + +class BuildInfoManagerTest(testtools.TestCase): + def setUp(self): + super(BuildInfoManagerTest, self).setUp() + self.client = mock.Mock() + self.client.get.return_value = fakes.FakeHTTPResponse( + 200, + None, + {'content-type': 'application/json'}, + jsonutils.dumps('body') + ) + self.manager = build_info.BuildInfoManager(self.client) + + def test_build_info_makes_a_call_to_the_api(self): + self.manager.build_info() + self.client.get.assert_called_once_with('/build_info') + + def test_build_info_returns_the_response_body(self): + response = self.manager.build_info() + self.assertEqual('body', response) diff --git a/stacktaskclient/tests/unit/test_common_http.py b/stacktaskclient/tests/unit/test_common_http.py new file mode 100644 index 0000000..bb2fa20 --- /dev/null +++ b/stacktaskclient/tests/unit/test_common_http.py @@ -0,0 +1,861 @@ +# -*- 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 logging +import mock +import os +import socket + + +from oslo_serialization import jsonutils +import requests +import six +import testtools + +from heatclient.common import http +from heatclient.common import utils +from heatclient import exc +from heatclient.tests.unit import fakes +from keystoneclient import adapter +from mox3 import mox + + +class HttpClientTest(testtools.TestCase): + + # Patch os.environ to avoid required auth info. + def setUp(self): + super(HttpClientTest, self).setUp() + self.m = mox.Mox() + self.m.StubOutWithMock(requests, 'request') + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def test_http_raw_request(self): + headers = {'Content-Type': 'application/octet-stream', + 'User-Agent': 'python-heatclient'} + + # Record a 200 + mock_conn = http.requests.request('GET', 'http://example.com:8004', + allow_redirects=False, + headers=headers) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/octet-stream'}, + '')) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + resp = client.raw_request('GET', '') + self.assertEqual(200, resp.status_code) + self.assertEqual('', ''.join([x for x in resp.content])) + + def test_token_or_credentials(self): + # Record a 200 + fake200 = fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/octet-stream'}, + '') + + # no token or credentials + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/octet-stream', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn(fake200) + + # credentials + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/octet-stream', + 'User-Agent': 'python-heatclient', + 'X-Auth-Key': 'pass', + 'X-Auth-User': 'user'}) + mock_conn.AndReturn(fake200) + + # token suppresses credentials + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/octet-stream', + 'User-Agent': 'python-heatclient', + 'X-Auth-Token': 'abcd1234'}) + mock_conn.AndReturn(fake200) + + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + resp = client.raw_request('GET', '') + self.assertEqual(200, resp.status_code) + + client.username = 'user' + client.password = 'pass' + resp = client.raw_request('GET', '') + self.assertEqual(200, resp.status_code) + + client.auth_token = 'abcd1234' + resp = client.raw_request('GET', '') + self.assertEqual(200, resp.status_code) + + def test_include_pass(self): + # Record a 200 + fake200 = fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/octet-stream'}, + '') + + # no token or credentials + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/octet-stream', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn(fake200) + + # credentials + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/octet-stream', + 'User-Agent': 'python-heatclient', + 'X-Auth-Key': 'pass', + 'X-Auth-User': 'user'}) + mock_conn.AndReturn(fake200) + + # token suppresses credentials + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/octet-stream', + 'User-Agent': 'python-heatclient', + 'X-Auth-Token': 'abcd1234', + 'X-Auth-Key': 'pass', + 'X-Auth-User': 'user'}) + mock_conn.AndReturn(fake200) + + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + resp = client.raw_request('GET', '') + self.assertEqual(200, resp.status_code) + + client.username = 'user' + client.password = 'pass' + client.include_pass = True + resp = client.raw_request('GET', '') + self.assertEqual(200, resp.status_code) + + client.auth_token = 'abcd1234' + resp = client.raw_request('GET', '') + self.assertEqual(200, resp.status_code) + + def test_not_include_pass(self): + # Record a 200 + fake500 = fakes.FakeHTTPResponse( + 500, 'ERROR', + {'content-type': 'application/octet-stream'}, + '(HTTP 401)') + + # no token or credentials + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/octet-stream', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn(fake500) + + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + e = self.assertRaises(exc.HTTPUnauthorized, + client.raw_request, 'GET', '') + self.assertIn('include-password', str(e)) + + def test_region_name(self): + # Record a 200 + fake200 = fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/octet-stream'}, + '') + + # Specify region name + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/octet-stream', + 'X-Region-Name': 'RegionOne', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn(fake200) + + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + client.region_name = 'RegionOne' + resp = client.raw_request('GET', '') + self.assertEqual(200, resp.status_code) + + def test_http_json_request(self): + # Record a 200 + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + '{}')) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + resp, body = client.json_request('GET', '') + self.assertEqual(200, resp.status_code) + self.assertEqual({}, body) + + def test_http_json_request_argument_passed_to_requests(self): + """Check that we have sent the proper arguments to requests.""" + # Record a 200 + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + cert=('RANDOM_CERT_FILE', 'RANDOM_KEY_FILE'), + verify=True, + data='"text"', + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Auth-Url': 'http://AUTH_URL', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + '{}')) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + client.verify_cert = True + client.cert_file = 'RANDOM_CERT_FILE' + client.key_file = 'RANDOM_KEY_FILE' + client.auth_url = 'http://AUTH_URL' + resp, body = client.json_request('GET', '', data='text') + self.assertEqual(200, resp.status_code) + self.assertEqual({}, body) + + def test_http_json_request_w_req_body(self): + # Record a 200 + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + body='test-body', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + '{}')) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + resp, body = client.json_request('GET', '', body='test-body') + self.assertEqual(200, resp.status_code) + self.assertEqual({}, body) + + def test_http_json_request_non_json_resp_cont_type(self): + # Record a 200 + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', body='test-body', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'not/json'}, + {})) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + resp, body = client.json_request('GET', '', body='test-body') + self.assertEqual(200, resp.status_code) + self.assertIsNone(body) + + def test_http_json_request_invalid_json(self): + # Record a 200 + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + 'invalid-json')) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + resp, body = client.json_request('GET', '') + self.assertEqual(200, resp.status_code) + self.assertEqual('invalid-json', body) + + def test_http_manual_redirect_delete(self): + mock_conn = http.requests.request( + 'DELETE', 'http://example.com:8004/foo', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 302, 'Found', + {'location': 'http://example.com:8004/foo/bar'}, + '')) + mock_conn = http.requests.request( + 'DELETE', 'http://example.com:8004/foo/bar', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + '{}')) + + self.m.ReplayAll() + + client = http.HTTPClient('http://example.com:8004/foo') + resp, body = client.json_request('DELETE', '') + + self.assertEqual(200, resp.status_code) + + def test_http_manual_redirect_post(self): + mock_conn = http.requests.request( + 'POST', 'http://example.com:8004/foo', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 302, 'Found', + {'location': 'http://example.com:8004/foo/bar'}, + '')) + mock_conn = http.requests.request( + 'POST', 'http://example.com:8004/foo/bar', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + '{}')) + + self.m.ReplayAll() + + client = http.HTTPClient('http://example.com:8004/foo') + resp, body = client.json_request('POST', '') + + self.assertEqual(200, resp.status_code) + + def test_http_manual_redirect_put(self): + mock_conn = http.requests.request( + 'PUT', 'http://example.com:8004/foo', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 302, 'Found', + {'location': 'http://example.com:8004/foo/bar'}, + '')) + mock_conn = http.requests.request( + 'PUT', 'http://example.com:8004/foo/bar', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + '{}')) + + self.m.ReplayAll() + + client = http.HTTPClient('http://example.com:8004/foo') + resp, body = client.json_request('PUT', '') + + self.assertEqual(200, resp.status_code) + + def test_http_manual_redirect_put_uppercase(self): + mock_conn = http.requests.request( + 'PUT', 'http://EXAMPLE.com:8004/foo', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 302, 'Found', + {'location': 'http://example.com:8004/foo/bar'}, + '')) + mock_conn = http.requests.request( + 'PUT', 'http://EXAMPLE.com:8004/foo/bar', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + '{}')) + + self.m.ReplayAll() + + client = http.HTTPClient('http://EXAMPLE.com:8004/foo') + resp, body = client.json_request('PUT', '') + + self.assertEqual(200, resp.status_code) + + def test_http_manual_redirect_prohibited(self): + mock_conn = http.requests.request( + 'DELETE', 'http://example.com:8004/foo', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 302, 'Found', + {'location': 'http://example.com:8004/'}, + '')) + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004/foo') + self.assertRaises(exc.InvalidEndpoint, + client.json_request, 'DELETE', '') + + def test_http_manual_redirect_error_without_location(self): + mock_conn = http.requests.request( + 'DELETE', 'http://example.com:8004/foo', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 302, 'Found', + {}, + '')) + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004/foo') + self.assertRaises(exc.InvalidEndpoint, + client.json_request, 'DELETE', '') + + def test_http_json_request_redirect(self): + # Record the 302 + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 302, 'Found', + {'location': 'http://example.com:8004'}, + '')) + # Record the following 200 + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + '{}')) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + resp, body = client.json_request('GET', '') + self.assertEqual(200, resp.status_code) + self.assertEqual({}, body) + + def test_http_404_json_request(self): + # Record a 404 + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 404, 'OK', {'content-type': 'application/json'}, + '{}')) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + e = self.assertRaises(exc.HTTPNotFound, client.json_request, 'GET', '') + # Assert that the raised exception can be converted to string + self.assertIsNotNone(str(e)) + + def test_http_300_json_request(self): + # Record a 300 + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 300, 'OK', {'content-type': 'application/json'}, + '{}')) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004') + e = self.assertRaises( + exc.HTTPMultipleChoices, client.json_request, 'GET', '') + # Assert that the raised exception can be converted to string + self.assertIsNotNone(str(e)) + + def test_fake_json_request(self): + headers = {'User-Agent': 'python-heatclient'} + mock_conn = http.requests.request('GET', 'fake://example.com:8004/', + allow_redirects=False, + headers=headers) + mock_conn.AndRaise(socket.gaierror) + self.m.ReplayAll() + + client = http.HTTPClient('fake://example.com:8004') + self.assertRaises(exc.InvalidEndpoint, + client._http_request, "/", "GET") + + def test_debug_curl_command(self): + self.m.StubOutWithMock(logging.Logger, 'debug') + + ssl_connection_params = {'ca_file': 'TEST_CA', + 'cert_file': 'TEST_CERT', + 'key_file': 'TEST_KEY', + 'insecure': 'TEST_NSA'} + + headers = {'key': 'value'} + + mock_logging_debug = logging.Logger.debug( + "curl -g -i -X GET -H 'key: value' --key TEST_KEY " + "--cert TEST_CERT --cacert TEST_CA " + "-k -d 'text' http://foo/bar" + ) + mock_logging_debug.AndReturn(None) + self.m.ReplayAll() + + client = http.HTTPClient('http://foo') + client.ssl_connection_params = ssl_connection_params + client.log_curl_request('GET', '/bar', {'headers': headers, + 'data': 'text'}) + + def test_http_request_socket_error(self): + headers = {'User-Agent': 'python-heatclient'} + mock_conn = http.requests.request('GET', 'http://example.com:8004/', + allow_redirects=False, + headers=headers) + mock_conn.AndRaise(socket.error) + self.m.ReplayAll() + + client = http.HTTPClient('http://example.com:8004') + self.assertRaises(exc.CommunicationError, + client._http_request, "/", "GET") + + def test_http_request_socket_timeout(self): + headers = {'User-Agent': 'python-heatclient'} + mock_conn = http.requests.request('GET', 'http://example.com:8004/', + allow_redirects=False, + headers=headers) + mock_conn.AndRaise(socket.timeout) + self.m.ReplayAll() + + client = http.HTTPClient('http://example.com:8004') + self.assertRaises(exc.CommunicationError, + client._http_request, "/", "GET") + + def test_http_request_specify_timeout(self): + mock_conn = http.requests.request( + 'GET', 'http://example.com:8004', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-heatclient'}, + timeout=float(123)) + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + '{}')) + # Replay, create client, assert + self.m.ReplayAll() + client = http.HTTPClient('http://example.com:8004', timeout='123') + resp, body = client.json_request('GET', '') + self.assertEqual(200, resp.status_code) + self.assertEqual({}, body) + + def test_get_system_ca_file(self): + chosen = '/etc/ssl/certs/ca-certificates.crt' + self.m.StubOutWithMock(os.path, 'exists') + os.path.exists(chosen).AndReturn(chosen) + self.m.ReplayAll() + + ca = http.get_system_ca_file() + self.assertEqual(chosen, ca) + + def test_insecure_verify_cert_None(self): + client = http.HTTPClient('https://foo', insecure=True) + self.assertFalse(client.verify_cert) + + def test_passed_cert_to_verify_cert(self): + client = http.HTTPClient('https://foo', ca_file="NOWHERE") + self.assertEqual("NOWHERE", client.verify_cert) + + self.m.StubOutWithMock(http, 'get_system_ca_file') + http.get_system_ca_file().AndReturn("SOMEWHERE") + self.m.ReplayAll() + client = http.HTTPClient('https://foo') + self.assertEqual("SOMEWHERE", client.verify_cert) + + def test_curl_log_i18n_headers(self): + self.m.StubOutWithMock(logging.Logger, 'debug') + kwargs = {'headers': {'Key': b'foo\xe3\x8a\x8e'}} + + mock_logging_debug = logging.Logger.debug( + u"curl -g -i -X GET -H 'Key: foo㊎' http://somewhere" + ) + mock_logging_debug.AndReturn(None) + + self.m.ReplayAll() + + client = http.HTTPClient('http://somewhere') + client.log_curl_request("GET", '', kwargs=kwargs) + + +class SessionClientTest(testtools.TestCase): + def setUp(self): + super(SessionClientTest, self).setUp() + self.request = mock.patch.object(adapter.LegacyJsonAdapter, + 'request').start() + + def test_session_simple_request(self): + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/octet-stream'}, + '') + self.request.return_value = (resp, '') + + client = http.SessionClient(session=mock.ANY, + auth=mock.ANY) + response = client.request(method='GET', url='') + self.assertEqual(200, response.status_code) + self.assertEqual('', ''.join([x for x in response.content])) + + def test_session_json_request(self): + fake = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps({'some': 'body'})) + self.request.return_value = (fake, {}) + + client = http.SessionClient(session=mock.ANY, + auth=mock.ANY) + + resp = client.request('', 'GET') + self.assertEqual(200, resp.status_code) + self.assertEqual({'some': 'body'}, resp.json()) + + def test_404_error_response(self): + fake = fakes.FakeHTTPResponse( + 404, + 'FAIL', + {'content-type': 'application/octet-stream'}, + '') + self.request.return_value = (fake, '') + + client = http.SessionClient(session=mock.ANY, + auth=mock.ANY) + e = self.assertRaises(exc.HTTPNotFound, + client.request, '', 'GET') + # Assert that the raised exception can be converted to string + self.assertIsNotNone(six.text_type(e)) + + def test_redirect_302_location(self): + fake1 = fakes.FakeHTTPResponse( + 302, + 'OK', + {'location': 'http://no.where/ishere'}, + '' + ) + fake2 = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps({'Mount': 'Fuji'}) + ) + self.request.side_effect = [ + (fake1, ''), (fake2, jsonutils.dumps({'Mount': 'Fuji'}))] + + client = http.SessionClient(session=mock.ANY, + auth=mock.ANY, + endpoint_override='http://no.where/') + resp = client.request('', 'GET', redirect=True) + + self.assertEqual(200, resp.status_code) + self.assertEqual({'Mount': 'Fuji'}, utils.get_response_body(resp)) + + self.assertEqual(('', 'GET'), self.request.call_args_list[0][0]) + self.assertEqual(('ishere', 'GET'), self.request.call_args_list[1][0]) + for call in self.request.call_args_list: + self.assertEqual({'user_agent': 'python-heatclient', + 'raise_exc': False, + 'redirect': True}, call[1]) + + def test_302_location_no_endpoint(self): + fake1 = fakes.FakeHTTPResponse( + 302, + 'OK', + {'location': 'http://no.where/ishere'}, + '' + ) + fake2 = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps({'Mount': 'Fuji'}) + ) + self.request.side_effect = [ + (fake1, ''), (fake2, jsonutils.dumps({'Mount': 'Fuji'}))] + + client = http.SessionClient(session=mock.ANY, + auth=mock.ANY) + resp = client.request('', 'GET', redirect=True) + + self.assertEqual(200, resp.status_code) + self.assertEqual({'Mount': 'Fuji'}, utils.get_response_body(resp)) + + self.assertEqual(('', 'GET'), self.request.call_args_list[0][0]) + self.assertEqual(('http://no.where/ishere', + 'GET'), self.request.call_args_list[1][0]) + for call in self.request.call_args_list: + self.assertEqual({'user_agent': 'python-heatclient', + 'raise_exc': False, + 'redirect': True}, call[1]) + + def test_redirect_302_no_location(self): + fake = fakes.FakeHTTPResponse( + 302, + 'OK', + {}, + '' + ) + self.request.side_effect = [(fake, '')] + + client = http.SessionClient(session=mock.ANY, + auth=mock.ANY) + e = self.assertRaises(exc.InvalidEndpoint, + client.request, '', 'GET', redirect=True) + self.assertEqual("Location not returned with 302", six.text_type(e)) + + def test_no_redirect_302_no_location(self): + fake = fakes.FakeHTTPResponse( + 302, + 'OK', + {'location': 'http://no.where/ishere'}, + '' + ) + self.request.side_effect = [(fake, '')] + + client = http.SessionClient(session=mock.ANY, + auth=mock.ANY) + + self.assertEqual(fake, client.request('', 'GET')) + + def test_300_error_response(self): + fake = fakes.FakeHTTPResponse( + 300, + 'FAIL', + {'content-type': 'application/octet-stream'}, + '') + self.request.return_value = (fake, '') + + client = http.SessionClient(session=mock.ANY, + auth=mock.ANY) + e = self.assertRaises(exc.HTTPMultipleChoices, + client.request, '', 'GET') + # Assert that the raised exception can be converted to string + self.assertIsNotNone(six.text_type(e)) + + def test_kwargs(self): + fake = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + {} + ) + kwargs = dict(endpoint_override='http://no.where/', + data='some_data') + + client = http.SessionClient(mock.ANY) + + self.request.return_value = (fake, {}) + + resp = client.request('', 'GET', **kwargs) + + self.assertEqual({'endpoint_override': 'http://no.where/', + 'json': 'some_data', + 'user_agent': 'python-heatclient', + 'raise_exc': False}, self.request.call_args[1]) + self.assertEqual(200, resp.status_code) + + def test_methods(self): + fake = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + {} + ) + self.request.return_value = (fake, {}) + + client = http.SessionClient(mock.ANY) + methods = [client.get, client.put, client.post, client.patch, + client.delete, client.head] + for method in methods: + resp = method('') + self.assertEqual(200, resp.status_code) + + def test_credentials_headers(self): + client = http.SessionClient(mock.ANY) + self.assertEqual({}, client.credentials_headers()) diff --git a/stacktaskclient/tests/unit/test_deployment_utils.py b/stacktaskclient/tests/unit/test_deployment_utils.py new file mode 100644 index 0000000..69a2899 --- /dev/null +++ b/stacktaskclient/tests/unit/test_deployment_utils.py @@ -0,0 +1,326 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mock +import six +import swiftclient.client +import testscenarios +import testtools + +from heatclient.common import deployment_utils +from heatclient import exc +from testtools import matchers + + +load_tests = testscenarios.load_tests_apply_scenarios + + +def mock_sc(group=None, config=None, options=None, + inputs=None, outputs=None): + return { + 'group': group, + 'config': config, + 'options': options or {}, + 'inputs': inputs or [], + 'outputs': outputs or [], + } + + +class DerivedConfigTest(testtools.TestCase): + + scenarios = [ + ('defaults', dict( + action='UPDATE', + source=mock_sc(), + name='s1', + input_values=None, + server_id='1234', + signal_transport='NO_SIGNAL', + signal_id=None, + result={ + 'config': '', + 'group': 'Heat::Ungrouped', + 'inputs': [{ + 'description': 'ID of the server being deployed to', + 'name': 'deploy_server_id', + 'type': 'String', + 'value': '1234' + }, { + 'description': 'Name of the current action ' + 'being deployed', + 'name': 'deploy_action', + 'type': 'String', + 'value': 'UPDATE' + }, { + 'description': 'How the server should signal to ' + 'heat with the deployment output values.', + 'name': 'deploy_signal_transport', + 'type': 'String', + 'value': 'NO_SIGNAL'}], + 'name': 's1', + 'options': {}, + 'outputs': []})), + ('config_values', dict( + action='UPDATE', + source=mock_sc( + group='puppet', + config='do the foo', + inputs=[ + {'name': 'one', 'default': '1'}, + {'name': 'two'}], + options={'option1': 'value'}, + outputs=[ + {'name': 'output1'}, + {'name': 'output2'}], + ), + name='s2', + input_values={'one': 'foo', 'two': 'bar', 'three': 'baz'}, + server_id='1234', + signal_transport='NO_SIGNAL', + signal_id=None, + result={ + 'config': 'do the foo', + 'group': 'puppet', + 'inputs': [{ + 'name': 'one', + 'default': '1', + 'value': 'foo' + }, { + 'name': 'two', + 'value': 'bar' + }, { + 'name': 'three', + 'type': 'String', + 'value': 'baz' + }, { + 'description': 'ID of the server being deployed to', + 'name': 'deploy_server_id', + 'type': 'String', + 'value': '1234' + }, { + 'description': 'Name of the current action ' + 'being deployed', + 'name': 'deploy_action', + 'type': 'String', + 'value': 'UPDATE' + }, { + 'description': 'How the server should signal to ' + 'heat with the deployment output values.', + 'name': 'deploy_signal_transport', + 'type': 'String', + 'value': 'NO_SIGNAL' + }], + 'name': 's2', + 'options': {'option1': 'value'}, + 'outputs': [ + {'name': 'output1'}, + {'name': 'output2'}]})), + ('temp_url', dict( + action='UPDATE', + source=mock_sc(), + name='s1', + input_values=None, + server_id='1234', + signal_transport='TEMP_URL_SIGNAL', + signal_id='http://192.0.2.1:8080/foo', + result={ + 'config': '', + 'group': 'Heat::Ungrouped', + 'inputs': [{ + 'description': 'ID of the server being deployed to', + 'name': 'deploy_server_id', + 'type': 'String', + 'value': '1234' + }, { + 'description': 'Name of the current action ' + 'being deployed', + 'name': 'deploy_action', + 'type': 'String', + 'value': 'UPDATE' + }, { + 'description': 'How the server should signal to ' + 'heat with the deployment output values.', + 'name': 'deploy_signal_transport', + 'type': 'String', + 'value': 'TEMP_URL_SIGNAL' + }, { + 'description': 'ID of signal to use for signaling ' + 'output values', + 'name': 'deploy_signal_id', + 'type': 'String', + 'value': 'http://192.0.2.1:8080/foo' + }, { + 'description': 'HTTP verb to use for signaling ' + 'output values', + 'name': 'deploy_signal_verb', + 'type': 'String', + 'value': 'PUT'}], + 'name': 's1', + 'options': {}, + 'outputs': []})), + ('unsupported', dict( + action='UPDATE', + source=mock_sc(), + name='s1', + input_values=None, + server_id='1234', + signal_transport='ASDF', + signal_id=None, + result_error=exc.CommandError, + result_error_msg='Unsupported signal transport ASDF', + result=None)), + ] + + def test_build_derived_config_params(self): + try: + self.assertEqual( + self.result, + deployment_utils.build_derived_config_params( + action=self.action, + source=self.source, + name=self.name, + input_values=self.input_values, + server_id=self.server_id, + signal_transport=self.signal_transport, + signal_id=self.signal_id)) + except Exception as e: + if not self.result_error: + raise e + self.assertIsInstance(e, self.result_error) + self.assertEqual(self.result_error_msg, six.text_type(e)) + + +class TempURLSignalTest(testtools.TestCase): + + @mock.patch.object(swiftclient.client, 'Connection') + def test_create_swift_client(self, sc_conn): + auth = mock.MagicMock() + auth.get_token.return_value = '1234' + auth.get_endpoint.return_value = 'http://192.0.2.1:8080' + + session = mock.MagicMock() + + args = mock.MagicMock() + args.os_region_name = 'Region1' + args.os_project_name = 'project' + args.os_username = 'user' + args.os_cacert = None + args.insecure = True + + sc_conn.return_value = mock.MagicMock() + + sc = deployment_utils.create_swift_client(auth, session, args) + + self.assertEqual(sc_conn.return_value, sc) + + self.assertEqual( + mock.call(session), + auth.get_token.call_args) + + self.assertEqual( + mock.call( + session, + service_type='object-store', + region_name='Region1'), + auth.get_endpoint.call_args) + + self.assertEqual( + mock.call( + cacert=None, + insecure=True, + key=None, + tenant_name='project', + preauthtoken='1234', + authurl=None, + user='user', + preauthurl='http://192.0.2.1:8080', + auth_version='2.0'), + sc_conn.call_args) + + def test_create_temp_url(self): + swift_client = mock.MagicMock() + swift_client.url = ("http://fake-host.com:8080/v1/AUTH_demo") + swift_client.head_account = mock.Mock(return_value={ + 'x-account-meta-temp-url-key': '123456'}) + swift_client.post_account = mock.Mock() + + uuid_pattern = ('[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB]' + '[a-f0-9]{3}-[a-f0-9]{12}') + url = deployment_utils.create_temp_url(swift_client, 'bar', 60) + self.assertFalse(swift_client.post_account.called) + regexp = ("http://fake-host.com:8080/v1/AUTH_demo/bar-%s" + "/%s\?temp_url_sig=[0-9a-f]{40}&" + "temp_url_expires=[0-9]{10}" % (uuid_pattern, uuid_pattern)) + self.assertThat(url, matchers.MatchesRegex(regexp)) + + timeout = int(url.split('=')[-1]) + self.assertTrue(timeout < 2147483647) + + def test_get_temp_url_no_account_key(self): + swift_client = mock.MagicMock() + swift_client.url = ("http://fake-host.com:8080/v1/AUTH_demo") + head_account = {} + + def post_account(data): + head_account.update(data) + + swift_client.head_account = mock.Mock(return_value=head_account) + swift_client.post_account = post_account + + self.assertNotIn('x-account-meta-temp-url-key', head_account) + deployment_utils.create_temp_url(swift_client, 'bar', 60, 'foo') + self.assertIn('x-account-meta-temp-url-key', head_account) + + def test_build_signal_id_no_signal(self): + hc = mock.MagicMock() + args = mock.MagicMock() + args.signal_transport = 'NO_SIGNAL' + self.assertIsNone(deployment_utils.build_signal_id(hc, args)) + + def test_build_signal_id_no_client_auth(self): + hc = mock.MagicMock() + args = mock.MagicMock() + args.os_no_client_auth = True + args.signal_transport = 'TEMP_URL_SIGNAL' + e = self.assertRaises(exc.CommandError, + deployment_utils.build_signal_id, hc, args) + self.assertEqual(( + 'Cannot use --os-no-client-auth, auth required to create ' + 'a Swift TempURL.'), + six.text_type(e)) + + @mock.patch.object(deployment_utils, 'create_temp_url') + @mock.patch.object(deployment_utils, 'create_swift_client') + def test_build_signal_id(self, csc, ctu): + hc = mock.MagicMock() + args = mock.MagicMock() + args.name = 'foo' + args.timeout = 60 + args.os_no_client_auth = False + args.signal_transport = 'TEMP_URL_SIGNAL' + csc.return_value = mock.MagicMock() + temp_url = ( + 'http://fake-host.com:8080/v1/AUTH_demo/foo/' + 'a81a74d5-c395-4269-9670-ddd0824fd696' + '?temp_url_sig=6a68371d602c7a14aaaa9e3b3a63b8b85bd9a503' + '&temp_url_expires=1425270977') + ctu.return_value = temp_url + + self.assertEqual( + temp_url, deployment_utils.build_signal_id(hc, args)) + self.assertEqual( + mock.call(hc.http_client.auth, hc.http_client.session, args), + csc.call_args) + self.assertEqual( + mock.call(csc.return_value, 'foo', 60), + ctu.call_args) diff --git a/stacktaskclient/tests/unit/test_environment_format.py b/stacktaskclient/tests/unit/test_environment_format.py new file mode 100644 index 0000000..702619d --- /dev/null +++ b/stacktaskclient/tests/unit/test_environment_format.py @@ -0,0 +1,80 @@ +# 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 heatclient.common import environment_format + +import mock +import testscenarios +import testtools +import yaml + + +load_tests = testscenarios.load_tests_apply_scenarios + + +class YamlEnvironmentTest(testtools.TestCase): + + def test_minimal_yaml(self): + yaml1 = '' + yaml2 = ''' +parameter_defaults: {} +parameters: {} +resource_registry: {} +''' + tpl1 = environment_format.parse(yaml1) + environment_format.default_for_missing(tpl1) + tpl2 = environment_format.parse(yaml2) + self.assertEqual(tpl2, tpl1) + + def test_wrong_sections(self): + env = ''' +parameters: {} +resource_regis: {} +''' + self.assertRaises(ValueError, environment_format.parse, env) + + def test_bad_yaml(self): + env = ''' +parameters: } +''' + self.assertRaises(ValueError, environment_format.parse, env) + + def test_parse_string_environment(self): + env = 'just string' + expect = 'The environment is not a valid YAML mapping data type.' + e = self.assertRaises(ValueError, environment_format.parse, env) + self.assertIn(expect, str(e)) + + def test_parse_document(self): + env = '["foo", "bar"]' + expect = 'The environment is not a valid YAML mapping data type.' + e = self.assertRaises(ValueError, environment_format.parse, env) + self.assertIn(expect, str(e)) + + +class YamlParseExceptions(testtools.TestCase): + + scenarios = [ + ('scanner', dict(raised_exception=yaml.scanner.ScannerError())), + ('parser', dict(raised_exception=yaml.parser.ParserError())), + ('reader', + dict(raised_exception=yaml.reader.ReaderError('', '', '', '', ''))), + ] + + def test_parse_to_value_exception(self): + text = 'not important' + + with mock.patch.object(yaml, 'load') as yaml_loader: + yaml_loader.side_effect = self.raised_exception + + self.assertRaises(ValueError, + environment_format.parse, text) diff --git a/stacktaskclient/tests/unit/test_event_utils.py b/stacktaskclient/tests/unit/test_event_utils.py new file mode 100644 index 0000000..bba77b4 --- /dev/null +++ b/stacktaskclient/tests/unit/test_event_utils.py @@ -0,0 +1,130 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import testtools + +from heatclient.common import event_utils +from heatclient.v1 import events as hc_ev +from heatclient.v1 import resources as hc_res + + +class ShellTestEventUtils(testtools.TestCase): + @staticmethod + def _mock_resource(resource_id, nested_id=None): + res_info = {"links": [{"href": "http://heat/foo", "rel": "self"}, + {"href": "http://heat/foo2", "rel": "resource"}], + "logical_resource_id": resource_id, + "physical_resource_id": resource_id, + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Nested::Server", + "updated_time": "2014-01-06T16:14:26Z"} + if nested_id: + nested_link = {"href": "http://heat/%s" % nested_id, + "rel": "nested"} + res_info["links"].append(nested_link) + return hc_res.Resource(manager=None, info=res_info) + + @staticmethod + def _mock_event(event_id, resource_id): + ev_info = {"links": [{"href": "http://heat/foo", "rel": "self"}], + "logical_resource_id": resource_id, + "physical_resource_id": resource_id, + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "event_time": "2014-12-05T14:14:30Z", + "id": event_id} + return hc_ev.Event(manager=None, info=ev_info) + + def test_get_nested_ids(self): + def list_stub(stack_id): + return [self._mock_resource('aresource', 'foo3/3id')] + mock_client = mock.MagicMock() + mock_client.resources.list.side_effect = list_stub + ids = event_utils._get_nested_ids(hc=mock_client, + stack_id='astack/123') + mock_client.resources.list.assert_called_once_with( + stack_id='astack/123') + self.assertEqual(['foo3/3id'], ids) + + def test_get_stack_events(self): + def event_stub(stack_id, argfoo): + return [self._mock_event('event1', 'aresource')] + mock_client = mock.MagicMock() + mock_client.events.list.side_effect = event_stub + ev_args = {'argfoo': 123} + evs = event_utils._get_stack_events(hc=mock_client, + stack_id='astack/123', + event_args=ev_args) + mock_client.events.list.assert_called_once_with( + stack_id='astack/123', argfoo=123) + self.assertEqual(1, len(evs)) + self.assertEqual('event1', evs[0].id) + self.assertEqual('astack', evs[0].stack_name) + + def test_get_nested_events(self): + resources = {'parent': self._mock_resource('resource1', 'foo/child1'), + 'foo/child1': self._mock_resource('res_child1', + 'foo/child2'), + 'foo/child2': self._mock_resource('res_child2', + 'foo/child3'), + 'foo/child3': self._mock_resource('res_child3', + 'foo/END')} + + def resource_list_stub(stack_id): + return [resources[stack_id]] + mock_client = mock.MagicMock() + mock_client.resources.list.side_effect = resource_list_stub + + events = {'foo/child1': self._mock_event('event1', 'res_child1'), + 'foo/child2': self._mock_event('event2', 'res_child2'), + 'foo/child3': self._mock_event('event3', 'res_child3')} + + def event_list_stub(stack_id, argfoo): + return [events[stack_id]] + mock_client.events.list.side_effect = event_list_stub + + ev_args = {'argfoo': 123} + # Check nested_depth=1 (non recursive).. + evs = event_utils._get_nested_events(hc=mock_client, + nested_depth=1, + stack_id='parent', + event_args=ev_args) + + rsrc_calls = [mock.call(stack_id='parent')] + mock_client.resources.list.assert_has_calls(rsrc_calls) + ev_calls = [mock.call(stack_id='foo/child1', argfoo=123)] + mock_client.events.list.assert_has_calls(ev_calls) + self.assertEqual(1, len(evs)) + self.assertEqual('event1', evs[0].id) + + # ..and the recursive case via nested_depth=3 + mock_client.resources.list.reset_mock() + mock_client.events.list.reset_mock() + evs = event_utils._get_nested_events(hc=mock_client, + nested_depth=3, + stack_id='parent', + event_args=ev_args) + + rsrc_calls = [mock.call(stack_id='parent'), + mock.call(stack_id='foo/child1'), + mock.call(stack_id='foo/child2')] + mock_client.resources.list.assert_has_calls(rsrc_calls) + ev_calls = [mock.call(stack_id='foo/child1', argfoo=123), + mock.call(stack_id='foo/child2', argfoo=123), + mock.call(stack_id='foo/child3', argfoo=123)] + mock_client.events.list.assert_has_calls(ev_calls) + self.assertEqual(3, len(evs)) + self.assertEqual('event1', evs[0].id) + self.assertEqual('event2', evs[1].id) + self.assertEqual('event3', evs[2].id) diff --git a/stacktaskclient/tests/unit/test_events.py b/stacktaskclient/tests/unit/test_events.py new file mode 100644 index 0000000..82e0bb8 --- /dev/null +++ b/stacktaskclient/tests/unit/test_events.py @@ -0,0 +1,153 @@ +# Copyright 2013 IBM Corp. +# +# 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 heatclient.common import utils +from heatclient.v1 import events + +import mock +from mox3 import mox +import testtools + + +class EventManagerTest(testtools.TestCase): + + def setUp(self): + super(EventManagerTest, self).setUp() + self.m = mox.Mox() + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def test_list_event(self): + stack_id = 'teststack', + resource_name = 'testresource' + manager = events.EventManager(None) + self.m.StubOutWithMock(manager, '_resolve_stack_id') + manager._resolve_stack_id(stack_id).AndReturn('teststack/abcd1234') + self.m.ReplayAll() + manager._list = mock.MagicMock() + manager.list(stack_id, resource_name) + # Make sure url is correct. + manager._list.assert_called_once_with('/stacks/teststack%2Fabcd1234/' + 'resources/testresource/events', + "events") + + def test_list_event_with_unicode_resource_name(self): + stack_id = 'teststack', + resource_name = u'\u5de5\u4f5c' + manager = events.EventManager(None) + self.m.StubOutWithMock(manager, '_resolve_stack_id') + manager._resolve_stack_id(stack_id).AndReturn('teststack/abcd1234') + self.m.ReplayAll() + manager._list = mock.MagicMock() + manager.list(stack_id, resource_name) + # Make sure url is correct. + manager._list.assert_called_once_with('/stacks/teststack%2Fabcd1234/' + 'resources/%E5%B7%A5%E4%BD%9C/' + 'events', "events") + + def test_list_event_with_none_resource_name(self): + stack_id = 'teststack', + manager = events.EventManager(None) + manager._list = mock.MagicMock() + manager.list(stack_id) + # Make sure url is correct. + manager._list.assert_called_once_with('/stacks/teststack/' + 'events', "events") + + def test_list_event_with_kwargs(self): + stack_id = 'teststack', + resource_name = 'testresource' + kwargs = {'limit': 2, + 'marker': '6d6935f4-0ae5', + 'filters': { + 'resource_action': 'CREATE', + 'resource_status': 'COMPLETE' + }} + manager = events.EventManager(None) + self.m.StubOutWithMock(manager, '_resolve_stack_id') + manager._resolve_stack_id(stack_id).AndReturn('teststack/abcd1234') + self.m.ReplayAll() + manager._list = mock.MagicMock() + manager.list(stack_id, resource_name, **kwargs) + # Make sure url is correct. + self.assertEqual(1, manager._list.call_count) + args = manager._list.call_args + self.assertEqual(2, len(args[0])) + url, param = args[0] + self.assertEqual("events", param) + base_url, query_params = utils.parse_query_url(url) + expected_base_url = ('/stacks/teststack%2Fabcd1234/' + 'resources/testresource/events') + self.assertEqual(expected_base_url, base_url) + expected_query_dict = {'marker': ['6d6935f4-0ae5'], + 'limit': ['2'], + 'resource_action': ['CREATE'], + 'resource_status': ['COMPLETE']} + self.assertEqual(expected_query_dict, query_params) + + def test_get_event(self): + fields = {'stack_id': 'teststack', + 'resource_name': 'testresource', + 'event_id': '1'} + + class FakeAPI(object): + """Fake API and ensure request url is correct.""" + + def json_request(self, *args, **kwargs): + expect = ('GET', + '/stacks/teststack%2Fabcd1234/resources' + '/testresource/events/1') + assert args == expect + return {}, {'event': []} + + def get(self, *args, **kwargs): + pass + + manager = events.EventManager(FakeAPI()) + with mock.patch('heatclient.v1.events.Event'): + self.m.StubOutWithMock(manager, '_resolve_stack_id') + self.m.StubOutWithMock(utils, 'get_response_body') + utils.get_response_body(mox.IgnoreArg()).AndReturn({'event': []}) + manager._resolve_stack_id('teststack').AndReturn( + 'teststack/abcd1234') + self.m.ReplayAll() + manager.get(**fields) + + def test_get_event_with_unicode_resource_name(self): + fields = {'stack_id': 'teststack', + 'resource_name': u'\u5de5\u4f5c', + 'event_id': '1'} + + class FakeAPI(object): + """Fake API and ensure request url is correct.""" + + def json_request(self, *args, **kwargs): + expect = ('GET', + '/stacks/teststack%2Fabcd1234/resources' + '/%E5%B7%A5%E4%BD%9C/events/1') + assert args == expect + return {}, {'event': []} + + def get(self, *args, **kwargs): + pass + + manager = events.EventManager(FakeAPI()) + with mock.patch('heatclient.v1.events.Event'): + self.m.StubOutWithMock(manager, '_resolve_stack_id') + self.m.StubOutWithMock(utils, 'get_response_body') + utils.get_response_body(mox.IgnoreArg()).AndReturn({'event': []}) + manager._resolve_stack_id('teststack').AndReturn( + 'teststack/abcd1234') + self.m.ReplayAll() + manager.get(**fields) diff --git a/stacktaskclient/tests/unit/test_resource_types.py b/stacktaskclient/tests/unit/test_resource_types.py new file mode 100644 index 0000000..d315303 --- /dev/null +++ b/stacktaskclient/tests/unit/test_resource_types.py @@ -0,0 +1,91 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import mock +import testtools + +from heatclient.common import utils +from heatclient.v1 import resource_types + + +class ResourceTypeManagerTest(testtools.TestCase): + + def _base_test(self, expect, key): + + class FakeAPI(object): + """Fake API and ensure request url is correct.""" + + def get(self, *args, **kwargs): + assert ('GET', args[0]) == expect + + def json_request(self, *args, **kwargs): + assert args == expect + ret = key and {key: []} or {} + return {}, {key: ret} + + def raw_request(self, *args, **kwargs): + assert args == expect + return {} + + def head(self, url, **kwargs): + return self.json_request("HEAD", url, **kwargs) + + def post(self, url, **kwargs): + return self.json_request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.json_request("PUT", url, **kwargs) + + def delete(self, url, **kwargs): + return self.raw_request("DELETE", url, **kwargs) + + def patch(self, url, **kwargs): + return self.json_request("PATCH", url, **kwargs) + + manager = resource_types.ResourceTypeManager(FakeAPI()) + return manager + + def test_list_types(self): + key = 'resource_types' + expect = ('GET', '/resource_types') + + class FakeResponse(object): + def json(self): + return {key: {}} + + class FakeClient(object): + def get(self, *args, **kwargs): + assert ('GET', args[0]) == expect + return FakeResponse() + + manager = resource_types.ResourceTypeManager(FakeClient()) + manager.list() + + @mock.patch.object(utils, 'get_response_body') + def test_get(self, mock_utils): + key = 'resource_types' + resource_type = 'OS::Nova::KeyPair' + expect = ('GET', '/resource_types/OS%3A%3ANova%3A%3AKeyPair') + manager = self._base_test(expect, key) + mock_utils.return_value = None + manager.get(resource_type) + + @mock.patch.object(utils, 'get_response_body') + def test_generate_template(self, mock_utils): + key = 'resource_types' + resource_type = 'OS::Nova::KeyPair' + template_type = 'cfn' + expect = ('GET', '/resource_types/OS%3A%3ANova%3A%3AKeyPair/template' + '?template_type=cfn') + manager = self._base_test(expect, key) + mock_utils.return_value = None + manager.generate_template(resource_type, template_type) diff --git a/stacktaskclient/tests/unit/test_resources.py b/stacktaskclient/tests/unit/test_resources.py new file mode 100644 index 0000000..120b1c4 --- /dev/null +++ b/stacktaskclient/tests/unit/test_resources.py @@ -0,0 +1,210 @@ +# Copyright 2013 IBM Corp. +# +# 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 heatclient.common import utils + +from heatclient.v1 import resources +from six.moves.urllib import parse + +from mox3 import mox +import testtools + + +class ResourceManagerTest(testtools.TestCase): + + def setUp(self): + super(ResourceManagerTest, self).setUp() + self.m = mox.Mox() + self.addCleanup(self.m.UnsetStubs) + + def _base_test(self, expect, key): + + class FakeAPI(object): + """Fake API and ensure request url is correct.""" + + def get(self, *args, **kwargs): + assert ('GET', args[0]) == expect + + def json_request(self, *args, **kwargs): + assert args == expect + ret = key and {key: []} or {} + return {}, {key: ret} + + def raw_request(self, *args, **kwargs): + assert args == expect + return {} + + def head(self, url, **kwargs): + return self.json_request("HEAD", url, **kwargs) + + def post(self, url, **kwargs): + return self.json_request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.json_request("PUT", url, **kwargs) + + def delete(self, url, **kwargs): + return self.raw_request("DELETE", url, **kwargs) + + def patch(self, url, **kwargs): + return self.json_request("PATCH", url, **kwargs) + + manager = resources.ResourceManager(FakeAPI()) + self.m.StubOutWithMock(manager, '_resolve_stack_id') + self.m.StubOutWithMock(utils, 'get_response_body') + utils.get_response_body(mox.IgnoreArg()).AndReturn( + {key: key and {key: []} or {}}) + manager._resolve_stack_id('teststack').AndReturn('teststack/abcd1234') + self.m.ReplayAll() + + return manager + + def test_get(self): + fields = {'stack_id': 'teststack', + 'resource_name': 'testresource'} + expect = ('GET', + '/stacks/teststack%2Fabcd1234/resources' + '/testresource') + key = 'resource' + + manager = self._base_test(expect, key) + manager.get(**fields) + self.m.VerifyAll() + + def test_get_with_attr(self): + fields = {'stack_id': 'teststack', + 'resource_name': 'testresource', + 'with_attr': ['attr_a', 'attr_b']} + expect = ('GET', + '/stacks/teststack%2Fabcd1234/resources' + '/testresource?with_attr=attr_a&with_attr=attr_b') + key = 'resource' + + manager = self._base_test(expect, key) + manager.get(**fields) + self.m.VerifyAll() + + def test_get_with_unicode_resource_name(self): + fields = {'stack_id': 'teststack', + 'resource_name': u'\u5de5\u4f5c'} + expect = ('GET', + '/stacks/teststack%2Fabcd1234/resources' + '/%E5%B7%A5%E4%BD%9C') + key = 'resource' + + manager = self._base_test(expect, key) + manager.get(**fields) + self.m.VerifyAll() + + def test_list(self): + self._test_list( + fields={'stack_id': 'teststack'}, + expect='/stacks/teststack/resources') + + def test_list_nested(self): + self._test_list( + fields={'stack_id': 'teststack', 'nested_depth': '99'}, + expect='/stacks/teststack/resources?%s' % parse.urlencode({ + 'nested_depth': 99, + }, True) + ) + + def test_list_detail(self): + self._test_list( + fields={'stack_id': 'teststack', 'with_detail': 'True'}, + expect='/stacks/teststack/resources?%s' % parse.urlencode({ + 'with_detail': True, + }, True) + ) + + def _test_list(self, fields, expect): + key = 'resources' + + class FakeResponse(object): + def json(self): + return {key: {}} + + class FakeClient(object): + def get(self, *args, **kwargs): + assert args[0] == expect + return FakeResponse() + + manager = resources.ResourceManager(FakeClient()) + manager.list(**fields) + + def test_metadata(self): + fields = {'stack_id': 'teststack', + 'resource_name': 'testresource'} + expect = ('GET', + '/stacks/teststack%2Fabcd1234/resources' + '/testresource/metadata') + key = 'metadata' + + manager = self._base_test(expect, key) + manager.metadata(**fields) + self.m.VerifyAll() + + def test_generate_template(self): + fields = {'resource_name': 'testresource'} + expect = ('GET', '/resource_types/testresource/template') + key = None + + class FakeAPI(object): + """Fake API and ensure request url is correct.""" + + def get(self, *args, **kwargs): + assert ('GET', args[0]) == expect + + def json_request(self, *args, **kwargs): + assert args == expect + ret = key and {key: []} or {} + return {}, {key: ret} + + manager = resources.ResourceManager(FakeAPI()) + self.m.StubOutWithMock(utils, 'get_response_body') + utils.get_response_body(mox.IgnoreArg()).AndReturn( + {key: key and {key: []} or {}}) + self.m.ReplayAll() + + manager.generate_template(**fields) + self.m.VerifyAll() + + def test_signal(self): + fields = {'stack_id': 'teststack', + 'resource_name': 'testresource', + 'data': 'Some content'} + expect = ('POST', + '/stacks/teststack%2Fabcd1234/resources' + '/testresource/signal') + key = 'signal' + + manager = self._base_test(expect, key) + manager.signal(**fields) + self.m.VerifyAll() + + +class ResourceStackNameTest(testtools.TestCase): + + def test_stack_name(self): + resource = resources.Resource(None, {"links": [{ + "href": "http://heat.example.com:8004/foo/12/resources/foobar", + "rel": "self" + }, { + "href": "http://heat.example.com:8004/foo/12", + "rel": "stack" + }]}) + self.assertEqual('foo', resource.stack_name) + + def test_stack_name_no_links(self): + resource = resources.Resource(None, {}) + self.assertIsNone(resource.stack_name) diff --git a/stacktaskclient/tests/unit/test_service.py b/stacktaskclient/tests/unit/test_service.py new file mode 100644 index 0000000..4213cab --- /dev/null +++ b/stacktaskclient/tests/unit/test_service.py @@ -0,0 +1,59 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from heatclient import exc +import testtools + + +from heatclient.v1 import services + + +class ManageServiceTest(testtools.TestCase): + def setUp(self): + super(ManageServiceTest, self).setUp() + + def test_service_list(self): + class FakeResponse(object): + def json(self): + return {'services': []} + + class FakeClient(object): + def get(self, *args, **kwargs): + assert args[0] == ('/services') + return FakeResponse() + + manager = services.ServiceManager(FakeClient()) + self.assertEqual([], manager.list()) + + def test_service_list_403(self): + class FakeClient403(object): + + def get(self, *args, **kwargs): + assert args[0] == ('/services') + raise exc.HTTPForbidden() + + manager = services.ServiceManager(FakeClient403()) + self.assertRaises(exc.HTTPForbidden, + manager.list) + + def test_service_list_503(self): + class FakeClient503(object): + def get(self, *args, **kwargs): + assert args[0] == ('/services') + raise exc.HTTPServiceUnavailable() + + manager = services.ServiceManager(FakeClient503()) + self.assertRaises(exc.HTTPServiceUnavailable, + manager.list) diff --git a/stacktaskclient/tests/unit/test_shell.py b/stacktaskclient/tests/unit/test_shell.py new file mode 100644 index 0000000..d24a5de --- /dev/null +++ b/stacktaskclient/tests/unit/test_shell.py @@ -0,0 +1,4581 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import fixtures +import os +from oslotest import mockpatch +import re +import requests +import six +from six.moves.urllib import parse +from six.moves.urllib import request +import sys +import tempfile +import testscenarios +import testtools +import uuid +import yaml + +from oslo_serialization import jsonutils +from oslo_utils import encodeutils +from requests_mock.contrib import fixture as rm_fixture + +from keystoneclient import fixture as keystone_fixture + +from mox3 import mox + +from heatclient.common import http +from heatclient.common import utils +from heatclient import exc +import heatclient.shell +from heatclient.tests.unit import fakes +import heatclient.v1.shell + +load_tests = testscenarios.load_tests_apply_scenarios +TEST_VAR_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), + 'var')) + +BASE_HOST = 'http://keystone.example.com' +BASE_URL = "%s:5000/" % BASE_HOST +V2_URL = "%sv2.0" % BASE_URL +V3_URL = "%sv3" % BASE_URL + + +FAKE_ENV_KEYSTONE_V2 = { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': BASE_URL, +} + +FAKE_ENV_KEYSTONE_V3 = { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': BASE_URL, + 'OS_USER_DOMAIN_ID': 'default', + 'OS_PROJECT_DOMAIN_ID': 'default', +} + + +class TestCase(testtools.TestCase): + + tokenid = uuid.uuid4().hex + + def setUp(self): + super(TestCase, self).setUp() + self.requests = self.useFixture(rm_fixture.Fixture()) + # httpretty doesn't work as expected if http proxy environmen + # variable is set. + self.useFixture(fixtures.EnvironmentVariable('http_proxy')) + self.useFixture(fixtures.EnvironmentVariable('https_proxy')) + + def set_fake_env(self, fake_env): + client_env = ('OS_USERNAME', 'OS_PASSWORD', 'OS_TENANT_ID', + 'OS_TENANT_NAME', 'OS_AUTH_URL', 'OS_REGION_NAME', + 'OS_AUTH_TOKEN', 'OS_NO_CLIENT_AUTH', 'OS_SERVICE_TYPE', + 'OS_ENDPOINT_TYPE', 'HEAT_URL') + + for key in client_env: + self.useFixture( + fixtures.EnvironmentVariable(key, fake_env.get(key))) + + # required for testing with Python 2.6 + def assertRegexpMatches(self, text, expected_regexp, msg=None): + """Fail the test unless the text matches the regular expression.""" + if isinstance(expected_regexp, six.string_types): + expected_regexp = re.compile(expected_regexp) + if not expected_regexp.search(text): + msg = msg or "Regexp didn't match" + msg = '%s: %r not found in %r' % ( + msg, expected_regexp.pattern, text) + raise self.failureException(msg) + + # required for testing with Python 2.6 + def assertNotRegexpMatches(self, text, expected_regexp, msg=None): + try: + self.assertRegexpMatches(text, expected_regexp, msg) + except self.failureException: + pass + else: + raise self.failureException(msg) + + def shell_error(self, argstr, error_match): + orig = sys.stderr + sys.stderr = six.StringIO() + _shell = heatclient.shell.HeatShell() + e = self.assertRaises(Exception, _shell.main, argstr.split()) + self.assertRegexpMatches(e.__str__(), error_match) + err = sys.stderr.getvalue() + sys.stderr.close() + sys.stderr = orig + return err + + def register_keystone_v2_token_fixture(self): + v2_token = keystone_fixture.V2Token(token_id=self.tokenid) + service = v2_token.add_service('orchestration') + service.add_endpoint('http://heat.example.com', + admin='http://heat-admin.localdomain', + internal='http://heat.localdomain', + region='RegionOne') + self.requests.post('%s/tokens' % V2_URL, json=v2_token) + + def register_keystone_v3_token_fixture(self): + v3_token = keystone_fixture.V3Token() + service = v3_token.add_service('orchestration') + service.add_standard_endpoints(public='http://heat.example.com', + admin='http://heat-admin.localdomain', + internal='http://heat.localdomain') + self.requests.post('%s/auth/tokens' % V3_URL, + json=v3_token, + headers={'X-Subject-Token': self.tokenid}) + + def register_keystone_auth_fixture(self): + self.register_keystone_v2_token_fixture() + self.register_keystone_v3_token_fixture() + + version_list = keystone_fixture.DiscoveryList(href=BASE_URL) + self.requests.get(BASE_URL, json=version_list) + + # NOTE(tlashchova): this overrides the testtools.TestCase.patch method + # that does simple monkey-patching in favor of mock's patching + def patch(self, target, **kwargs): + mockfixture = self.useFixture(mockpatch.Patch(target, **kwargs)) + return mockfixture.mock + + +class EnvVarTest(TestCase): + + scenarios = [ + ('username', dict( + remove='OS_USERNAME', + err='You must provide a username')), + ('password', dict( + remove='OS_PASSWORD', + err='You must provide a password')), + ('tenant_name', dict( + remove='OS_TENANT_NAME', + err='You must provide a tenant id')), + ('auth_url', dict( + remove='OS_AUTH_URL', + err='You must provide an auth url')), + ] + + def test_missing_auth(self): + + fake_env = { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': 'http://no.where', + } + fake_env[self.remove] = None + self.set_fake_env(fake_env) + self.shell_error('stack-list', self.err) + + +class EnvVarTestToken(TestCase): + + scenarios = [ + ('tenant_id', dict( + remove='OS_TENANT_ID', + err='You must provide a tenant id')), + ('auth_url', dict( + remove='OS_AUTH_URL', + err='You must provide an auth url')), + ] + + def test_missing_auth(self): + + fake_env = { + 'OS_AUTH_TOKEN': 'atoken', + 'OS_TENANT_ID': 'tenant_id', + 'OS_AUTH_URL': 'http://no.where', + } + fake_env[self.remove] = None + self.set_fake_env(fake_env) + self.shell_error('stack-list', self.err) + + +class ShellParamValidationTest(TestCase): + + scenarios = [ + ('stack-create', dict( + command='stack-create ts -P "ab"', + err='Malformed parameter')), + ('stack-update', dict( + command='stack-update ts -P "a-b"', + err='Malformed parameter')), + ] + + def setUp(self): + super(ShellParamValidationTest, self).setUp() + self.m = mox.Mox() + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def test_bad_parameters(self): + self.register_keystone_auth_fixture() + fake_env = { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': BASE_URL, + } + self.set_fake_env(fake_env) + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + cmd = '%s --template-file=%s ' % (self.command, template_file) + self.shell_error(cmd, self.err) + + +class ShellValidationTest(TestCase): + + def setUp(self): + super(ShellValidationTest, self).setUp() + self.m = mox.Mox() + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def test_failed_auth(self): + self.register_keystone_auth_fixture() + self.m.StubOutWithMock(http.SessionClient, 'request') + failed_msg = 'Unable to authenticate user with credentials provided' + http.SessionClient.request( + '/stacks?', 'GET').AndRaise(exc.Unauthorized(failed_msg)) + + self.m.ReplayAll() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + self.shell_error('stack-list', failed_msg) + + def test_stack_create_validation(self): + self.register_keystone_auth_fixture() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + self.shell_error( + 'stack-create teststack ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"', + 'Need to specify exactly one of') + + def test_stack_create_with_paramfile_validation(self): + self.register_keystone_auth_fixture() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + self.shell_error( + 'stack-create teststack ' + '--parameter-file private_key=private_key.env ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"', + 'Need to specify exactly one of') + + def test_stack_create_validation_keystone_v3(self): + self.register_keystone_auth_fixture() + self.set_fake_env(FAKE_ENV_KEYSTONE_V3) + self.shell_error( + 'stack-create teststack ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"', + 'Need to specify exactly one of') + + +class ShellBase(TestCase): + + def setUp(self): + super(ShellBase, self).setUp() + self.m = mox.Mox() + self.m.StubOutWithMock(http.HTTPClient, 'json_request') + self.m.StubOutWithMock(http.HTTPClient, 'raw_request') + self.m.StubOutWithMock(http.SessionClient, 'request') + self.client = http.SessionClient + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + # Some tests set exc.verbose = 1, so reset on cleanup + def unset_exc_verbose(): + exc.verbose = 0 + + self.addCleanup(unset_exc_verbose) + + def shell(self, argstr): + orig = sys.stdout + try: + sys.stdout = six.StringIO() + _shell = heatclient.shell.HeatShell() + _shell.main(argstr.split()) + self.subcommands = _shell.subcommands.keys() + except SystemExit: + exc_type, exc_value, exc_traceback = sys.exc_info() + self.assertEqual(0, exc_value.code) + finally: + out = sys.stdout.getvalue() + sys.stdout.close() + sys.stdout = orig + + return out + + +class ShellTestNoMox(TestCase): + # NOTE(dhu): This class is reserved for no Mox usage. Instead, + # use requests_mock to expose errors from json_request. + def setUp(self): + super(ShellTestNoMox, self).setUp() + self._set_fake_env() + + def _set_fake_env(self): + self.set_fake_env({ + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + 'HEAT_URL': 'http://heat.example.com', + 'OS_AUTH_URL': BASE_URL, + 'OS_NO_CLIENT_AUTH': 'True' + }) + + def shell(self, argstr): + orig = sys.stdout + try: + sys.stdout = six.StringIO() + _shell = heatclient.shell.HeatShell() + _shell.main(argstr.split()) + self.subcommands = _shell.subcommands.keys() + except SystemExit: + exc_type, exc_value, exc_traceback = sys.exc_info() + self.assertEqual(0, exc_value.code) + finally: + out = sys.stdout.getvalue() + sys.stdout.close() + sys.stdout = orig + + return out + + # This function tests err msg handling + def test_stack_create_parameter_missing_err_msg(self): + self.register_keystone_auth_fixture() + + resp_dict = {"error": + {"message": 'The Parameter (key_name) was not provided.', + "type": "UserParameterMissing"}} + + self.requests.post('http://heat.example.com/stacks', + status_code=400, + headers={'Content-Type': 'application/json'}, + json=resp_dict) + + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + + self.shell_error('stack-create -f %s stack' % template_file, + 'The Parameter \(key_name\) was not provided.') + + def test_event_list(self): + eventid1 = uuid.uuid4().hex + eventid2 = uuid.uuid4().hex + self.register_keystone_auth_fixture() + + h = {'Content-Type': 'text/plain; charset=UTF-8', + 'location': 'http://heat.example.com/stacks/myStack/60f83b5e'} + self.requests.get('http://heat.example.com/stacks/myStack', + status_code=302, + headers=h) + + resp, resp_dict = fakes.mock_script_event_list( + resource_name="myDeployment", rsrc_eventid1=eventid1, + rsrc_eventid2=eventid2, fakehttp=False + ) + + self.requests.get('http://heat.example.com/stacks/myStack%2F60f83b5e/' + 'resources/myDeployment/events', + headers={'Content-Type': 'application/json'}, + json=resp_dict) + + list_text = self.shell('event-list -r myDeployment myStack') + + required = [ + 'resource_name', + 'id', + 'resource_status_reason', + 'resource_status', + 'event_time', + 'myDeployment', + eventid1, + eventid2, + 'state changed', + 'CREATE_IN_PROGRESS', + '2013-12-05T14:14:31Z', + '2013-12-05T14:14:32Z', + ] + + for r in required: + self.assertRegexpMatches(list_text, r) + + +class ShellTestNoMoxV3(ShellTestNoMox): + + def _set_fake_env(self): + fake_env_kwargs = {'OS_NO_CLIENT_AUTH': 'True', + 'HEAT_URL': 'http://heat.example.com'} + fake_env_kwargs.update(FAKE_ENV_KEYSTONE_V3) + self.set_fake_env(fake_env_kwargs) + + +class ShellTestEndpointType(TestCase): + + def setUp(self): + super(ShellTestEndpointType, self).setUp() + self.m = mox.Mox() + self.m.StubOutWithMock(http, '_construct_http_client') + self.m.StubOutWithMock(heatclient.v1.shell, 'do_stack_list') + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def test_endpoint_type_public_url(self): + self.register_keystone_auth_fixture() + kwargs = { + 'auth_url': 'http://keystone.example.com:5000/', + 'session': mox.IgnoreArg(), + 'auth': mox.IgnoreArg(), + 'service_type': 'orchestration', + 'endpoint_type': 'publicURL', + 'region_name': '', + 'username': 'username', + 'password': 'password', + 'include_pass': False + } + http._construct_http_client(u'http://heat.example.com', **kwargs) + heatclient.v1.shell.do_stack_list(mox.IgnoreArg(), mox.IgnoreArg()) + + self.m.ReplayAll() + heatclient.shell.main(('stack-list',)) + + def test_endpoint_type_admin_url(self): + self.register_keystone_auth_fixture() + kwargs = { + 'auth_url': 'http://keystone.example.com:5000/', + 'session': mox.IgnoreArg(), + 'auth': mox.IgnoreArg(), + 'service_type': 'orchestration', + 'endpoint_type': 'adminURL', + 'region_name': '', + 'username': 'username', + 'password': 'password', + 'include_pass': False + } + http._construct_http_client(u'http://heat-admin.localdomain', **kwargs) + heatclient.v1.shell.do_stack_list(mox.IgnoreArg(), mox.IgnoreArg()) + + self.m.ReplayAll() + heatclient.shell.main(('--os-endpoint-type=adminURL', 'stack-list',)) + + def test_endpoint_type_internal_url(self): + self.register_keystone_auth_fixture() + self.useFixture(fixtures.EnvironmentVariable('OS_ENDPOINT_TYPE', + 'internalURL')) + kwargs = { + 'auth_url': 'http://keystone.example.com:5000/', + 'session': mox.IgnoreArg(), + 'auth': mox.IgnoreArg(), + 'service_type': 'orchestration', + 'endpoint_type': 'internalURL', + 'region_name': '', + 'username': 'username', + 'password': 'password', + 'include_pass': False + } + http._construct_http_client(u'http://heat.localdomain', **kwargs) + heatclient.v1.shell.do_stack_list(mox.IgnoreArg(), mox.IgnoreArg()) + + self.m.ReplayAll() + heatclient.shell.main(('stack-list',)) + + +class ShellTestCommon(ShellBase): + + def setUp(self): + super(ShellTestCommon, self).setUp() + self.client = http.SessionClient + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def test_help_unknown_command(self): + self.assertRaises(exc.CommandError, self.shell, 'help foofoo') + + def test_help(self): + required = [ + '^usage: heat', + '(?m)^See "heat help COMMAND" for help on a specific command', + ] + for argstr in ['--help', 'help']: + help_text = self.shell(argstr) + for r in required: + self.assertRegexpMatches(help_text, r) + + def test_command_help(self): + output = self.shell('help help') + self.assertIn('usage: heat help []', output) + subcommands = list(self.subcommands) + for command in subcommands: + if command.replace('_', '-') == 'bash-completion': + continue + output1 = self.shell('help %s' % command) + output2 = self.shell('%s --help' % command) + self.assertEqual(output1, output2) + self.assertRegexpMatches(output1, '^usage: heat %s' % command) + + def test_debug_switch_raises_error(self): + self.register_keystone_auth_fixture() + if self.client == http.SessionClient: + self.client.request( + '/stacks?', 'GET').AndRaise(exc.Unauthorized("FAIL")) + else: + self.client.json_request( + 'GET', '/stacks?').AndRaise(exc.Unauthorized("FAIL")) + + self.m.ReplayAll() + + args = ['--debug', 'stack-list'] + self.assertRaises(exc.Unauthorized, heatclient.shell.main, args) + + def test_dash_d_switch_raises_error(self): + self.register_keystone_auth_fixture() + if self.client == http.SessionClient: + self.client.request( + '/stacks?', 'GET').AndRaise(exc.CommandError("FAIL")) + else: + self.client.json_request( + 'GET', '/stacks?').AndRaise(exc.CommandError("FAIL")) + + self.m.ReplayAll() + + args = ['-d', 'stack-list'] + self.assertRaises(exc.CommandError, heatclient.shell.main, args) + + def test_no_debug_switch_no_raises_errors(self): + self.register_keystone_auth_fixture() + if self.client == http.SessionClient: + self.client.request( + '/stacks?', 'GET').AndRaise(exc.Unauthorized("FAIL")) + else: + self.client.json_request( + 'GET', '/stacks?').AndRaise(exc.Unauthorized("FAIL")) + + self.m.ReplayAll() + + args = ['stack-list'] + self.assertRaises(SystemExit, heatclient.shell.main, args) + + def test_help_on_subcommand(self): + required = [ + '^usage: heat stack-list', + "(?m)^List the user's stacks", + ] + argstrings = [ + 'help stack-list', + ] + for argstr in argstrings: + help_text = self.shell(argstr) + for r in required: + self.assertRegexpMatches(help_text, r) + + +class ShellTestUserPass(ShellBase): + + def setUp(self): + super(ShellTestUserPass, self).setUp() + if self.client is None: + self.client = http.SessionClient + self._set_fake_env() + + def _set_fake_env(self): + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def test_stack_list(self): + self.register_keystone_auth_fixture() + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + list_text = self.shell('stack-list') + + required = [ + 'id', + 'stack_status', + 'creation_time', + 'teststack', + '1', + 'CREATE_COMPLETE', + 'IN_PROGRESS', + ] + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'parent') + + def test_stack_list_show_nested(self): + self.register_keystone_auth_fixture() + expected_url = '/stacks?%s' % parse.urlencode({ + 'show_nested': True, + }, True) + fakes.script_heat_list(expected_url, show_nested=True, + client=self.client) + + self.m.ReplayAll() + + list_text = self.shell('stack-list' + ' --show-nested') + + required = [ + 'teststack', + 'teststack2', + 'teststack_nested', + 'parent', + 'theparentof3' + ] + for r in required: + self.assertRegexpMatches(list_text, r) + + def test_stack_list_show_owner(self): + self.register_keystone_auth_fixture() + fakes.script_heat_list(client=self.client) + self.m.ReplayAll() + + list_text = self.shell('stack-list --show-owner') + + required = [ + 'stack_owner', + 'testowner', + ] + for r in required: + self.assertRegexpMatches(list_text, r) + + def test_parsable_error(self): + self.register_keystone_auth_fixture() + message = "The Stack (bad) could not be found." + resp_dict = { + "explanation": "The resource could not be found.", + "code": 404, + "error": { + "message": message, + "type": "StackNotFound", + "traceback": "", + }, + "title": "Not Found" + } + + fakes.script_heat_error(jsonutils.dumps(resp_dict), + client=self.client) + + self.m.ReplayAll() + + e = self.assertRaises(exc.HTTPException, self.shell, "stack-show bad") + self.assertEqual("ERROR: " + message, str(e)) + + def test_parsable_verbose(self): + self.register_keystone_auth_fixture() + message = "The Stack (bad) could not be found." + resp_dict = { + "explanation": "The resource could not be found.", + "code": 404, + "error": { + "message": message, + "type": "StackNotFound", + "traceback": "", + }, + "title": "Not Found" + } + + fakes.script_heat_error(jsonutils.dumps(resp_dict), self.client) + + self.m.ReplayAll() + + exc.verbose = 1 + + e = self.assertRaises(exc.HTTPException, self.shell, "stack-show bad") + self.assertIn(message, str(e)) + + def test_parsable_malformed_error(self): + self.register_keystone_auth_fixture() + invalid_json = "ERROR: {Invalid JSON Error." + fakes.script_heat_error(invalid_json, client=self.client) + self.m.ReplayAll() + e = self.assertRaises(exc.HTTPException, self.shell, "stack-show bad") + self.assertEqual("ERROR: " + invalid_json, str(e)) + + def test_parsable_malformed_error_missing_message(self): + self.register_keystone_auth_fixture() + missing_message = { + "explanation": "The resource could not be found.", + "code": 404, + "error": { + "type": "StackNotFound", + "traceback": "", + }, + "title": "Not Found" + } + + fakes.script_heat_error(jsonutils.dumps(missing_message), + client=self.client) + self.m.ReplayAll() + + e = self.assertRaises(exc.HTTPException, self.shell, "stack-show bad") + self.assertEqual("ERROR: Internal Error", str(e)) + + def test_parsable_malformed_error_missing_traceback(self): + self.register_keystone_auth_fixture() + message = "The Stack (bad) could not be found." + resp_dict = { + "explanation": "The resource could not be found.", + "code": 404, + "error": { + "message": message, + "type": "StackNotFound", + }, + "title": "Not Found" + } + + fakes.script_heat_error(jsonutils.dumps(resp_dict), client=self.client) + self.m.ReplayAll() + + exc.verbose = 1 + + e = self.assertRaises(exc.HTTPException, self.shell, "stack-show bad") + self.assertEqual("ERROR: The Stack (bad) could not be found.\n", + str(e)) + + def test_stack_show(self): + self.register_keystone_auth_fixture() + resp_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/1', 'GET').AndReturn(resp) + else: + self.client.json_request( + 'GET', '/stacks/teststack/1').AndReturn((resp, resp_dict)) + + self.m.ReplayAll() + + list_text = self.shell('stack-show teststack/1') + + required = [ + 'id', + 'stack_name', + 'stack_status', + 'creation_time', + 'teststack', + 'CREATE_COMPLETE', + '2012-10-25T01:58:47Z' + ] + for r in required: + self.assertRegexpMatches(list_text, r) + + def _output_fake_response(self): + + resp_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z", + "outputs": [ + { + "output_value": "value1", + "output_key": "output1", + "description": "test output 1", + }, + { + "output_value": ["output", "value", "2"], + "output_key": "output2", + "description": "test output 2", + }, + { + "output_value": u"test\u2665", + "output_key": "output_uni", + "description": "test output unicode", + }, + ], + "creation_time": "2012-10-25T01:58:47Z" + }} + + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/1', 'GET').MultipleTimes().AndReturn( + resp) + else: + self.client.json_request( + 'GET', '/stacks/teststack/1').MultipleTimes().AndReturn( + (resp, resp_dict)) + + self.m.ReplayAll() + + def _error_output_fake_response(self): + + resp_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z", + "outputs": [ + { + "output_value": "null", + "output_key": "output1", + "description": "test output 1", + "output_error": "The Referenced Attribute (0 PublicIP) " + "is incorrect." + }, + ], + "creation_time": "2012-10-25T01:58:47Z" + }} + + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/1', 'GET').AndReturn(resp) + else: + self.client.json_request( + 'GET', '/stacks/teststack/1').AndReturn((resp, resp_dict)) + + self.m.ReplayAll() + + def test_output_list(self): + self.register_keystone_auth_fixture() + self._output_fake_response() + list_text = self.shell('output-list teststack/1') + for r in ['output1', 'output2', 'output_uni']: + self.assertRegexpMatches(list_text, r) + + def test_output_show(self): + self.register_keystone_auth_fixture() + + self._output_fake_response() + list_text = self.shell('output-show teststack/1 output1') + self.assertEqual('"value1"\n', list_text) + + list_text = self.shell('output-show -F raw teststack/1 output1') + self.assertEqual('value1\n', list_text) + + list_text = self.shell('output-show -F raw teststack/1 output2') + self.assertEqual('[\n "output", \n "value", \n "2"\n]\n', + list_text) + + list_text = self.shell('output-show -F json teststack/1 output2') + self.assertEqual('[\n "output", \n "value", \n "2"\n]\n', + list_text) + + def test_output_show_unicode(self): + self.register_keystone_auth_fixture() + self._output_fake_response() + list_text = self.shell('output-show teststack/1 output_uni') + self.assertEqual(u'"test\u2665"\n', list_text) + + def test_output_show_all(self): + self.register_keystone_auth_fixture() + self._output_fake_response() + list_text = self.shell('output-show teststack/1 --all') + for r in ['output1', 'value1', 'output2', 'test output unicode']: + self.assertRegexpMatches(list_text, r) + + def test_output_show_missing_arg(self): + self.register_keystone_auth_fixture() + error = self.assertRaises( + exc.CommandError, self.shell, 'output-show teststack/1') + self.assertIn('either or --all argument is needed.', + str(error)) + + def test_output_show_error(self): + self.register_keystone_auth_fixture() + self._error_output_fake_response() + error = self.assertRaises( + exc.CommandError, self.shell, + 'output-show teststack/1 output1') + self.assertIn('The Referenced Attribute (0 PublicIP) is incorrect.', + str(error)) + + def test_template_show_cfn(self): + self.register_keystone_auth_fixture() + template_data = open(os.path.join(TEST_VAR_DIR, + 'minimal.template')).read() + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + template_data) + resp_dict = jsonutils.loads(template_data) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/template', 'GET').AndReturn(resp) + else: + self.client.json_request( + 'GET', '/stacks/teststack/template').AndReturn((resp, + resp_dict)) + + self.m.ReplayAll() + + show_text = self.shell('template-show teststack') + required = [ + '{', + ' "AWSTemplateFormatVersion": "2010-09-09"', + ' "Outputs": {}', + ' "Resources": {}', + ' "Parameters": {}', + '}' + ] + for r in required: + self.assertRegexpMatches(show_text, r) + + def test_template_show_cfn_unicode(self): + self.register_keystone_auth_fixture() + resp_dict = {"AWSTemplateFormatVersion": "2010-09-09", + "Description": u"test\u2665", + "Outputs": {}, + "Resources": {}, + "Parameters": {}} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/template', 'GET').AndReturn(resp) + else: + self.client.json_request( + 'GET', '/stacks/teststack/template').AndReturn((resp, + resp_dict)) + + self.m.ReplayAll() + + show_text = self.shell('template-show teststack') + required = [ + '{', + ' "AWSTemplateFormatVersion": "2010-09-09"', + ' "Outputs": {}', + ' "Parameters": {}', + u' "Description": "test\u2665"', + ' "Resources": {}', + '}' + ] + for r in required: + self.assertRegexpMatches(show_text, r) + + def test_template_show_hot(self): + self.register_keystone_auth_fixture() + resp_dict = {"heat_template_version": "2013-05-23", + "parameters": {}, + "resources": {}, + "outputs": {}} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/template', 'GET').AndReturn(resp) + else: + self.client.json_request( + 'GET', '/stacks/teststack/template').AndReturn((resp, + resp_dict)) + + self.m.ReplayAll() + + show_text = self.shell('template-show teststack') + required = [ + "heat_template_version: '2013-05-23'", + "outputs: {}", + "parameters: {}", + "resources: {}" + ] + for r in required: + self.assertRegexpMatches(show_text, r) + + def _test_stack_preview(self, timeout=None, enable_rollback=False): + self.register_keystone_auth_fixture() + resp_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "resources": {'1': {'name': 'r1'}}, + "creation_time": "2012-10-25T01:58:47Z", + "timeout_mins": timeout, + "disable_rollback": not(enable_rollback) + }} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2', + 'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks/preview', 'POST', data=mox.IgnoreArg(), + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'POST', '/stacks/preview', data=mox.IgnoreArg(), + headers=headers + ).AndReturn((resp, resp_dict)) + + self.m.ReplayAll() + + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + cmd = ('stack-preview teststack ' + '--template-file=%s ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17" ' % template_file) + if enable_rollback: + cmd += '-r ' + if timeout: + cmd += '--timeout=%d ' % timeout + preview_text = self.shell(cmd) + + required = [ + 'stack_name', + 'id', + 'teststack', + '1', + 'resources', + 'timeout_mins', + 'disable_rollback' + ] + + for r in required: + self.assertRegexpMatches(preview_text, r) + + def test_stack_preview(self): + self._test_stack_preview() + + def test_stack_preview_timeout(self): + self._test_stack_preview(300, True) + + def test_stack_create(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks', 'POST', data=mox.IgnoreArg(), + headers=headers).AndReturn(resp) + else: + self.client.json_request( + 'POST', '/stacks', data=mox.IgnoreArg(), + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + self.m.ReplayAll() + + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + create_text = self.shell( + 'stack-create teststack ' + '--template-file=%s ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack', + '1' + ] + + for r in required: + self.assertRegexpMatches(create_text, r) + + def test_create_success_with_poll(self): + self.register_keystone_auth_fixture() + + stack_create_resp_dict = {"stack": { + "id": "teststack2/2", + "stack_name": "teststack2", + "stack_status": 'CREATE_IN_PROGRESS', + "creation_time": "2012-10-25T01:58:47Z" + }} + stack_create_resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + jsonutils.dumps(stack_create_resp_dict)) + if self.client == http.SessionClient: + headers = {} + self.client.request( + '/stacks', 'POST', data=mox.IgnoreArg(), + headers=headers).AndReturn(stack_create_resp) + else: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + self.client.json_request( + 'POST', '/stacks', data=mox.IgnoreArg(), + headers=headers + ).AndReturn((stack_create_resp, None)) + fakes.script_heat_list(client=self.client) + + stack_show_resp_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + stack_show_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(stack_show_resp_dict)) + + event_list_resp, event_list_resp_dict = fakes.mock_script_event_list( + stack_name="teststack2") + stack_id = 'teststack2' + + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2', 'GET').MultipleTimes().AndReturn( + stack_show_resp) + self.client.request( + '/stacks/%s/events?sort_dir=asc' % stack_id, 'GET' + ).MultipleTimes().AndReturn(event_list_resp) + else: + self.client.json_request( + 'GET', '/stacks/teststack2').MultipleTimes().AndReturn( + (stack_show_resp, stack_show_resp_dict)) + http.HTTPClient.json_request( + 'GET', '/stacks/%s/events?sort_dir=asc' % stack_id + ).MultipleTimes().AndReturn((event_list_resp, + event_list_resp_dict)) + self.m.ReplayAll() + + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + create_text = self.shell( + 'stack-create teststack2 ' + '--poll 4 ' + '--template-file=%s ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'id', + 'stack_name', + 'stack_status', + '2', + 'teststack2', + 'IN_PROGRESS', + '14:14:30', '2013-12-05', '0159dccd-65e1-46e8-a094-697d20b009e5', + 'CREATE_IN_PROGRESS', 'state changed', + '14:14:31', '7fecaeed-d237-4559-93a5-92d5d9111205', + 'testresource', + '14:14:32', 'e953547a-18f8-40a7-8e63-4ec4f509648b', + 'CREATE_COMPLETE', + '14:14:33', '8f591a36-7190-4adb-80da-00191fe22388' + ] + + for r in required: + self.assertRegexpMatches(create_text, r) + + def test_create_failed_with_poll(self): + self.register_keystone_auth_fixture() + stack_create_resp_dict = {"stack": { + "id": "teststack2/2", + "stack_name": "teststack2", + "stack_status": 'CREATE_IN_PROGRESS', + "creation_time": "2012-10-25T01:58:47Z" + }} + stack_create_resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + jsonutils.dumps(stack_create_resp_dict)) + if self.client == http.SessionClient: + headers = {} + self.client.request( + '/stacks', 'POST', data=mox.IgnoreArg(), + headers=headers).AndReturn(stack_create_resp) + else: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + self.client.json_request( + 'POST', '/stacks', data=mox.IgnoreArg(), + headers=headers + ).AndReturn((stack_create_resp, None)) + fakes.script_heat_list(client=self.client) + + stack_show_resp_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + stack_show_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(stack_show_resp_dict)) + + event_list_resp, event_list_resp_dict = fakes.mock_script_event_list( + stack_name="teststack2", action="CREATE", final_state="FAILED") + stack_id = 'teststack2' + + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2', 'GET').MultipleTimes().AndReturn( + stack_show_resp) + self.client.request( + '/stacks/%s/events?sort_dir=asc' % stack_id, 'GET' + ).MultipleTimes().AndReturn(event_list_resp) + else: + self.client.json_request( + 'GET', '/stacks/teststack2').MultipleTimes().AndReturn( + (stack_show_resp, stack_show_resp_dict)) + http.HTTPClient.json_request( + 'GET', '/stacks/%s/events?sort_dir=asc' % stack_id + ).MultipleTimes().AndReturn((event_list_resp, + event_list_resp_dict)) + + self.m.ReplayAll() + + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + + e = self.assertRaises(exc.StackFailure, self.shell, + 'stack-create teststack2 --poll ' + '--template-file=%s --parameters="InstanceType=' + 'm1.large;DBUsername=wp;DBPassword=password;' + 'KeyName=heat_key;LinuxDistribution=F17' % + template_file) + self.assertEqual("\n Stack teststack2 CREATE_FAILED \n", + str(e)) + + def test_stack_create_param_file(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks', 'POST', data=mox.IgnoreArg(), + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'POST', '/stacks', data=mox.IgnoreArg(), + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.StubOutWithMock(utils, 'read_url_content') + url = 'file://%s/private_key.env' % TEST_VAR_DIR + utils.read_url_content(url).AndReturn('xxxxxx') + self.m.ReplayAll() + + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + create_text = self.shell( + 'stack-create teststack ' + '--template-file=%s ' + '--parameter-file private_key=private_key.env ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack', + '1' + ] + + for r in required: + self.assertRegexpMatches(create_text, r) + + def test_stack_create_only_param_file(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks', 'POST', data=mox.IgnoreArg(), + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'POST', '/stacks', data=mox.IgnoreArg(), + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.StubOutWithMock(utils, 'read_url_content') + url = 'file://%s/private_key.env' % TEST_VAR_DIR + utils.read_url_content(url).AndReturn('xxxxxx') + self.m.ReplayAll() + + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + create_text = self.shell( + 'stack-create teststack ' + '--template-file=%s ' + '--parameter-file private_key=private_key.env ' + % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack', + '1' + ] + + for r in required: + self.assertRegexpMatches(create_text, r) + + def test_stack_create_timeout(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + expected_data = { + 'files': {}, + 'disable_rollback': True, + 'parameters': {'DBUsername': 'wp', + 'KeyName': 'heat_key', + 'LinuxDistribution': 'F17"', + '"InstanceType': 'm1.large', + 'DBPassword': 'verybadpassword'}, + 'stack_name': 'teststack', + 'environment': {}, + 'template': jsonutils.loads(template_data), + 'timeout_mins': 123} + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks', 'POST', data=expected_data, + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'POST', '/stacks', data=expected_data, + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + create_text = self.shell( + 'stack-create teststack ' + '--template-file=%s ' + '--timeout=123 ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack', + '1' + ] + + for r in required: + self.assertRegexpMatches(create_text, r) + + def test_stack_update_timeout(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + + expected_data = { + 'files': {}, + 'environment': {}, + 'template': jsonutils.loads(template_data), + 'parameters': {'DBUsername': 'wp', + 'KeyName': 'heat_key', + 'LinuxDistribution': 'F17"', + '"InstanceType': 'm1.large', + 'DBPassword': 'verybadpassword'}, + 'timeout_mins': 123, + 'disable_rollback': True} + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/2', 'PUT', + data=expected_data, + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'PUT', '/stacks/teststack2/2', + data=expected_data, + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + update_text = self.shell( + 'stack-update teststack2/2 ' + '--template-file=%s ' + '--timeout 123 ' + '--rollback off ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_create_url(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + self.m.StubOutWithMock(request, 'urlopen') + request.urlopen('http://no.where/minimal.template').AndReturn( + six.StringIO('{"AWSTemplateFormatVersion" : "2010-09-09"}')) + + expected_data = { + 'files': {}, + 'disable_rollback': True, + 'stack_name': 'teststack', + 'environment': {}, + 'template': {"AWSTemplateFormatVersion": "2010-09-09"}, + 'parameters': {'DBUsername': 'wp', + 'KeyName': 'heat_key', + 'LinuxDistribution': 'F17"', + '"InstanceType': 'm1.large', + 'DBPassword': 'verybadpassword'}} + + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks', 'POST', data=expected_data, + headers=headers).AndReturn(resp) + else: + self.client.json_request( + 'POST', '/stacks', data=expected_data, + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + create_text = self.shell( + 'stack-create teststack ' + '--template-url=http://no.where/minimal.template ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"') + + required = [ + 'stack_name', + 'id', + 'teststack2', + '2' + ] + for r in required: + self.assertRegexpMatches(create_text, r) + + def test_stack_create_object(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + + raw_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {}, + template_data) + + if self.client == http.SessionClient: + self.client.request( + 'http://no.where/container/minimal.template', + 'GET' + ).AndReturn(raw_resp) + else: + self.client.raw_request( + 'GET', + 'http://no.where/container/minimal.template', + ).AndReturn(raw_resp) + + resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks', 'POST', data=mox.IgnoreArg(), + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'POST', '/stacks', data=mox.IgnoreArg(), + headers=headers + ).AndReturn((resp, None)) + + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + create_text = self.shell( + 'stack-create teststack2 ' + '--template-object=http://no.where/container/minimal.template ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"') + + required = [ + 'stack_name', + 'id', + 'teststack2', + '2' + ] + for r in required: + self.assertRegexpMatches(create_text, r) + + def test_stack_create_with_tags(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + expected_data = { + 'files': {}, + 'disable_rollback': True, + 'parameters': {'DBUsername': 'wp', + 'KeyName': 'heat_key', + 'LinuxDistribution': 'F17"', + '"InstanceType': 'm1.large', + 'DBPassword': 'verybadpassword'}, + 'stack_name': 'teststack', + 'environment': {}, + 'template': jsonutils.loads(template_data), + 'tags': 'tag1,tag2'} + if self.client == http.SessionClient: + self.client.request( + '/stacks', 'POST', data=expected_data, + headers={}).AndReturn(resp) + else: + http.HTTPClient.json_request( + 'POST', '/stacks', data=expected_data, + headers={'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + create_text = self.shell( + 'stack-create teststack ' + '--template-file=%s ' + '--tags=tag1,tag2 ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack', + '1' + ] + + for r in required: + self.assertRegexpMatches(create_text, r) + + def test_stack_abandon(self): + self.register_keystone_auth_fixture() + + resp_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + + abandoned_stack = { + "action": "CREATE", + "status": "COMPLETE", + "name": "teststack", + "id": "1", + "resources": { + "foo": { + "name": "foo", + "resource_id": "test-res-id", + "action": "CREATE", + "status": "COMPLETE", + "resource_data": {}, + "metadata": {}, + } + } + } + + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + abandoned_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(abandoned_stack)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/1', 'GET').AndReturn(resp) + self.client.request( + '/stacks/teststack/1/abandon', + 'DELETE').AndReturn(abandoned_resp) + else: + http.HTTPClient.json_request( + 'GET', '/stacks/teststack/1').AndReturn((resp, resp_dict)) + http.HTTPClient.raw_request( + 'DELETE', '/stacks/teststack/1/abandon').AndReturn( + abandoned_resp) + + self.m.ReplayAll() + abandon_resp = self.shell('stack-abandon teststack/1') + self.assertEqual(abandoned_stack, jsonutils.loads(abandon_resp)) + + def test_stack_abandon_with_outputfile(self): + self.register_keystone_auth_fixture() + + resp_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + + abandoned_stack = { + "action": "CREATE", + "status": "COMPLETE", + "name": "teststack", + "id": "1", + "resources": { + "foo": { + "name": "foo", + "resource_id": "test-res-id", + "action": "CREATE", + "status": "COMPLETE", + "resource_data": {}, + "metadata": {}, + } + } + } + + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + abandoned_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(abandoned_stack)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/1', 'GET').AndReturn(resp) + self.client.request( + '/stacks/teststack/1/abandon', + 'DELETE').AndReturn(abandoned_resp) + else: + http.HTTPClient.json_request( + 'GET', '/stacks/teststack/1').AndReturn((resp, resp_dict)) + http.HTTPClient.raw_request( + 'DELETE', '/stacks/teststack/1/abandon').AndReturn( + abandoned_resp) + + self.m.ReplayAll() + + with tempfile.NamedTemporaryFile() as file_obj: + self.shell('stack-abandon teststack/1 -O %s' % file_obj.name) + result = jsonutils.loads(file_obj.read().decode()) + self.assertEqual(abandoned_stack, result) + + def test_stack_adopt(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack/1'}, + None) + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks', 'POST', data=mox.IgnoreArg(), + headers=headers).AndReturn(resp) + else: + self.client.json_request( + 'POST', '/stacks', data=mox.IgnoreArg(), + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + adopt_data_file = os.path.join(TEST_VAR_DIR, 'adopt_stack_data.json') + adopt_text = self.shell( + 'stack-adopt teststack ' + '--adopt-file=%s ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % (adopt_data_file)) + + required = [ + 'stack_name', + 'id', + 'teststack', + '1' + ] + + for r in required: + self.assertRegexpMatches(adopt_text, r) + + def test_stack_adopt_without_data(self): + self.register_keystone_auth_fixture() + failed_msg = 'Need to specify --adopt-file' + self.m.ReplayAll() + self.shell_error('stack-adopt teststack ', failed_msg) + + def test_stack_update_enable_rollback(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + with open(template_file, 'rb') as f: + template_data = jsonutils.load(f) + expected_data = {'files': {}, + 'environment': {}, + 'template': template_data, + 'disable_rollback': False, + 'parameters': mox.IgnoreArg() + } + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/2', 'PUT', + data=expected_data, + headers=headers + ).AndReturn((resp, None)) + else: + self.client.json_request( + 'PUT', '/stacks/teststack2/2', + data=expected_data, + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + self.m.ReplayAll() + + update_text = self.shell( + 'stack-update teststack2/2 ' + '--rollback on ' + '--template-file=%s ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_update_disable_rollback(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + with open(template_file, 'rb') as f: + template_data = jsonutils.load(f) + expected_data = {'files': {}, + 'environment': {}, + 'template': template_data, + 'disable_rollback': True, + 'parameters': mox.IgnoreArg() + } + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2', 'PUT', + data=expected_data, + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'PUT', '/stacks/teststack2', + data=expected_data, + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + self.m.ReplayAll() + + update_text = self.shell( + 'stack-update teststack2 ' + '--template-file=%s ' + '--rollback off ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_update_fault_rollback_value(self): + self.register_keystone_auth_fixture() + self.m.ReplayAll() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + self.shell_error('stack-update teststack2/2 ' + '--rollback Foo ' + '--template-file=%s' % template_file, + "Unrecognized value 'Foo', acceptable values are:" + ) + + def test_stack_update_rollback_default(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + with open(template_file, 'rb') as f: + template_data = jsonutils.load(f) + expected_data = {'files': {}, + 'environment': {}, + 'template': template_data, + 'parameters': mox.IgnoreArg() + } + resp_update = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2', 'PUT', + data=expected_data, + headers=headers + ).AndReturn(resp_update) + else: + self.client.json_request( + 'PUT', '/stacks/teststack2', + data=expected_data, + headers=headers + ).AndReturn((resp_update, None)) + fakes.script_heat_list(client=self.client) + self.m.ReplayAll() + + update_text = self.shell( + 'stack-update teststack2 ' + '--template-file=%s ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack2', + '2' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_update_with_existing_parameters(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + expected_data = { + 'files': {}, + 'environment': {}, + 'template': jsonutils.loads(template_data), + 'parameters': {}, + 'disable_rollback': False} + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/2', 'PATCH', + data=expected_data, + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'PATCH', '/stacks/teststack2/2', + data=expected_data, + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + update_text = self.shell( + 'stack-update teststack2/2 ' + '--template-file=%s ' + '--enable-rollback ' + '--existing' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_update_with_patched_existing_parameters(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + expected_data = { + 'files': {}, + 'environment': {}, + 'template': jsonutils.loads(template_data), + 'parameters': {'"KeyPairName': 'updated_key"'}, + 'disable_rollback': False} + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/2', 'PATCH', + data=expected_data, + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'PATCH', '/stacks/teststack2/2', + data=expected_data, + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + update_text = self.shell( + 'stack-update teststack2/2 ' + '--template-file=%s ' + '--enable-rollback ' + '--parameters="KeyPairName=updated_key" ' + '--existing' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_update_with_existing_and_default_parameters(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + expected_data = { + 'files': {}, + 'environment': {}, + 'template': jsonutils.loads(template_data), + 'parameters': {}, + 'clear_parameters': ['InstanceType', 'DBUsername', + 'DBPassword', 'KeyPairName', + 'LinuxDistribution'], + 'disable_rollback': False} + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/2', 'PATCH', + data=expected_data, + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'PATCH', '/stacks/teststack2/2', + data=expected_data, + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + update_text = self.shell( + 'stack-update teststack2/2 ' + '--template-file=%s ' + '--enable-rollback ' + '--existing ' + '--clear-parameter=InstanceType ' + '--clear-parameter=DBUsername ' + '--clear-parameter=DBPassword ' + '--clear-parameter=KeyPairName ' + '--clear-parameter=LinuxDistribution' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_update_with_patched_and_default_parameters(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + expected_data = { + 'files': {}, + 'environment': {}, + 'template': jsonutils.loads(template_data), + 'parameters': {'"KeyPairName': 'updated_key"'}, + 'clear_parameters': ['InstanceType', 'DBUsername', + 'DBPassword', 'KeyPairName', + 'LinuxDistribution'], + 'disable_rollback': False} + if self.client is http.HTTPClient: + headers = {'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + else: + headers = {} + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/2', 'PATCH', + data=expected_data, + headers=headers + ).AndReturn(resp) + else: + self.client.json_request( + 'PATCH', '/stacks/teststack2/2', + data=expected_data, + headers=headers + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + update_text = self.shell( + 'stack-update teststack2/2 ' + '--template-file=%s ' + '--enable-rollback ' + '--existing ' + '--parameters="KeyPairName=updated_key" ' + '--clear-parameter=InstanceType ' + '--clear-parameter=DBUsername ' + '--clear-parameter=DBPassword ' + '--clear-parameter=KeyPairName ' + '--clear-parameter=LinuxDistribution' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_update_with_tags(self): + self.register_keystone_auth_fixture() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + expected_data = { + 'files': {}, + 'environment': {}, + 'template': jsonutils.loads(template_data), + 'parameters': {'"KeyPairName': 'updated_key"'}, + 'disable_rollback': False, + 'tags': 'tag1,tag2'} + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/2', 'PATCH', + data=expected_data, headers={}).AndReturn(resp) + else: + http.HTTPClient.json_request( + 'PATCH', '/stacks/teststack2/2', + data=expected_data, + headers={'X-Auth-Key': 'password', 'X-Auth-User': 'username'} + ).AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + update_text = self.shell( + 'stack-update teststack2/2 ' + '--template-file=%s ' + '--enable-rollback ' + '--existing ' + '--parameters="KeyPairName=updated_key" ' + '--tags=tag1,tag2 ' % template_file) + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_delete(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 204, + 'No Content', + {}, + None) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/2', 'DELETE').AndReturn(resp) + else: + self.client.raw_request( + 'DELETE', '/stacks/teststack2/2').AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + delete_text = self.shell('stack-delete teststack2/2') + + required = [ + 'stack_name', + 'id', + 'teststack', + '1' + ] + for r in required: + self.assertRegexpMatches(delete_text, r) + + def test_stack_delete_multiple(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 204, + 'No Content', + {}, + None) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack1/1', 'DELETE').AndReturn(resp) + self.client.request( + '/stacks/teststack2/2', 'DELETE').AndReturn(resp) + else: + self.client.raw_request( + 'DELETE', '/stacks/teststack1/1').AndReturn((resp, None)) + self.client.raw_request( + 'DELETE', '/stacks/teststack2/2').AndReturn((resp, None)) + fakes.script_heat_list(client=self.client) + + self.m.ReplayAll() + + delete_text = self.shell('stack-delete teststack1/1 teststack2/2') + + required = [ + 'stack_name', + 'id', + 'teststack', + '1' + ] + for r in required: + self.assertRegexpMatches(delete_text, r) + + def test_build_info(self): + self.register_keystone_auth_fixture() + resp_dict = { + 'build_info': { + 'api': {'revision': 'api_revision'}, + 'engine': {'revision': 'engine_revision'} + } + } + resp_string = jsonutils.dumps(resp_dict) + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + response = (http_resp, resp_dict) + if self.client == http.SessionClient: + self.client.request('/build_info', 'GET').AndReturn(http_resp) + else: + self.client.json_request('GET', '/build_info').AndReturn(response) + + self.m.ReplayAll() + + build_info_text = self.shell('build-info') + + required = [ + 'api', + 'engine', + 'revision', + 'api_revision', + 'engine_revision', + ] + for r in required: + self.assertRegexpMatches(build_info_text, r) + + def test_stack_snapshot(self): + self.register_keystone_auth_fixture() + + stack_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + + resp_dict = {"snapshot": { + "id": "1", + "creation_time": "2012-10-25T01:58:47Z" + }} + + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if self.client == http.SessionClient: + self.client.request('/stacks/teststack/1', 'GET').AndReturn( + fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(stack_dict))) + self.client.request( + '/stacks/teststack/1/snapshots', + 'POST', + data={}).AndReturn(resp) + else: + http.HTTPClient.json_request( + 'GET', '/stacks/teststack/1').AndReturn( + (fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(stack_dict)), stack_dict)) + http.HTTPClient.json_request( + 'POST', + '/stacks/teststack/1/snapshots', + data={}).AndReturn((resp, resp_dict)) + + self.m.ReplayAll() + resp = self.shell('stack-snapshot teststack/1') + self.assertEqual(resp_dict, jsonutils.loads(resp)) + + def test_snapshot_list(self): + self.register_keystone_auth_fixture() + + stack_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + + resp_dict = {"snapshots": [{ + "id": "2", + "name": "snap1", + "status": "COMPLETE", + "status_reason": "", + "data": {}, + "creation_time": "2014-12-05T01:25:52Z" + }]} + + stack_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(stack_dict)) + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/1', + 'GET').AndReturn(stack_resp) + self.client.request( + '/stacks/teststack/1/snapshots', + 'GET').AndReturn(resp) + else: + http.HTTPClient.json_request( + 'GET', '/stacks/teststack/1').AndReturn((stack_resp, + stack_dict)) + http.HTTPClient.json_request( + 'GET', + '/stacks/teststack/1/snapshots').AndReturn((resp, resp_dict)) + + self.m.ReplayAll() + list_text = self.shell('snapshot-list teststack/1') + + required = [ + 'id', + 'name', + 'status', + 'status_reason', + 'data', + 'creation_time', + '2', + 'COMPLETE', + '{}', + '2014-12-05T01:25:52Z', + ] + for r in required: + self.assertRegexpMatches(list_text, r) + + def test_snapshot_show(self): + self.register_keystone_auth_fixture() + + stack_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + + resp_dict = {"snapshot": { + "id": "2", + "creation_time": "2012-10-25T01:58:47Z" + }} + + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if self.client == http.SessionClient: + self.client.request('/stacks/teststack/1', 'GET').AndReturn( + fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(stack_dict))) + self.client.request( + '/stacks/teststack/1/snapshots/2', + 'GET').AndReturn(resp) + else: + http.HTTPClient.json_request( + 'GET', '/stacks/teststack/1').AndReturn(( + fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(stack_dict)), stack_dict)) + http.HTTPClient.json_request( + 'GET', + '/stacks/teststack/1/snapshots/2').AndReturn((resp, resp_dict)) + + self.m.ReplayAll() + resp = self.shell('snapshot-show teststack/1 2') + self.assertEqual(resp_dict, jsonutils.loads(resp)) + + def test_snapshot_delete(self): + self.register_keystone_auth_fixture() + + stack_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + + resp_dict = {"snapshot": { + "id": "2", + "creation_time": "2012-10-25T01:58:47Z" + }} + + resp = fakes.FakeHTTPResponse( + 204, + 'No Content', + {'content-type': 'application/json'}, + jsonutils.dumps(stack_dict)) + second_resp = fakes.FakeHTTPResponse( + 204, + 'No Content', + {}, + jsonutils.dumps(resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/1', 'GET').AndReturn(resp) + self.client.request( + '/stacks/teststack/1/snapshots/2', + 'DELETE').AndReturn(second_resp) + else: + http.HTTPClient.json_request( + 'GET', '/stacks/teststack/1').AndReturn((resp, stack_dict)) + http.HTTPClient.raw_request( + 'DELETE', + '/stacks/teststack/1/snapshots/2').AndReturn(second_resp) + + self.m.ReplayAll() + resp = self.shell('snapshot-delete teststack/1 2') + self.assertEqual("", resp) + + def test_stack_restore(self): + self.register_keystone_auth_fixture() + + stack_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }} + + stack_resp = fakes.FakeHTTPResponse( + 204, + 'No Content', + {'content-type': 'application/json'}, + jsonutils.dumps(stack_dict)) + no_resp = fakes.FakeHTTPResponse( + 204, + 'No Content', + {'content-type': 'application/json'}, + jsonutils.dumps({})) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/1', 'GET').AndReturn(stack_resp) + self.client.request( + '/stacks/teststack/1/snapshots/2/restore', + 'POST').AndReturn(no_resp) + else: + http.HTTPClient.json_request( + 'GET', '/stacks/teststack/1').AndReturn((stack_resp, + stack_dict)) + http.HTTPClient.json_request( + 'POST', + '/stacks/teststack/1/snapshots/2/restore').AndReturn((no_resp, + {})) + + self.m.ReplayAll() + resp = self.shell('stack-restore teststack/1 2') + self.assertEqual("", resp) + + +class ShellTestActions(ShellBase): + + def setUp(self): + super(ShellTestActions, self).setUp() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def test_stack_cancel_update(self): + self.register_keystone_auth_fixture() + expected_data = {'cancel_update': None} + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/actions', 'POST', + data=expected_data).AndReturn(resp) + fakes.script_heat_list(client=self.client) + else: + http.HTTPClient.json_request( + 'POST', '/stacks/teststack2/actions', + data=expected_data + ).AndReturn((resp, None)) + fakes.script_heat_list() + + self.m.ReplayAll() + + update_text = self.shell('stack-cancel-update teststack2') + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(update_text, r) + + def test_stack_check(self): + self.register_keystone_auth_fixture() + expected_data = {'check': None} + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/actions', 'POST', + data=expected_data).AndReturn(resp) + fakes.script_heat_list(client=self.client) + else: + http.HTTPClient.json_request( + 'POST', '/stacks/teststack2/actions', + data=expected_data + ).AndReturn((resp, None)) + fakes.script_heat_list() + + self.m.ReplayAll() + + check_text = self.shell('action-check teststack2') + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(check_text, r) + + def test_stack_suspend(self): + self.register_keystone_auth_fixture() + expected_data = {'suspend': None} + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/actions', 'POST', + data=expected_data + ).AndReturn(resp) + fakes.script_heat_list(client=self.client) + else: + http.HTTPClient.json_request( + 'POST', '/stacks/teststack2/actions', + data=expected_data + ).AndReturn((resp, None)) + fakes.script_heat_list() + + self.m.ReplayAll() + + suspend_text = self.shell('action-suspend teststack2') + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(suspend_text, r) + + def test_stack_resume(self): + self.register_keystone_auth_fixture() + expected_data = {'resume': None} + resp = fakes.FakeHTTPResponse( + 202, + 'Accepted', + {}, + 'The request is accepted for processing.') + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack2/actions', 'POST', + data=expected_data + ).AndReturn(resp) + fakes.script_heat_list(client=self.client) + else: + http.HTTPClient.json_request( + 'POST', '/stacks/teststack2/actions', + data=expected_data + ).AndReturn((resp, None)) + fakes.script_heat_list() + + self.m.ReplayAll() + + resume_text = self.shell('action-resume teststack2') + + required = [ + 'stack_name', + 'id', + 'teststack2', + '1' + ] + for r in required: + self.assertRegexpMatches(resume_text, r) + + +class ShellTestEvents(ShellBase): + + def setUp(self): + super(ShellTestEvents, self).setUp() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + scenarios = [ + ('integer_id', dict( + event_id_one='24', + event_id_two='42')), + ('uuid_id', dict( + event_id_one='3d68809e-c4aa-4dc9-a008-933823d2e44f', + event_id_two='43b68bae-ed5d-4aed-a99f-0b3d39c2418a'))] + + def test_event_list(self): + self.register_keystone_auth_fixture() + resp, resp_dict = fakes.mock_script_event_list( + resource_name="aResource", + rsrc_eventid1=self.event_id_one, + rsrc_eventid2=self.event_id_two + ) + stack_id = 'teststack/1' + resource_name = 'testresource/1' + http.SessionClient.request( + '/stacks/%s/resources/%s/events?sort_dir=asc' % ( + parse.quote(stack_id, ''), + parse.quote(encodeutils.safe_encode( + resource_name), '')), 'GET').AndReturn(resp) + + self.m.ReplayAll() + + event_list_text = self.shell('event-list {0} --resource {1}'.format( + stack_id, resource_name)) + + required = [ + 'resource_name', + 'id', + 'resource_status_reason', + 'resource_status', + 'event_time', + 'aResource', + self.event_id_one, + self.event_id_two, + 'state changed', + 'CREATE_IN_PROGRESS', + 'CREATE_COMPLETE', + '2013-12-05T14:14:31Z', + '2013-12-05T14:14:32Z', + ] + for r in required: + self.assertRegexpMatches(event_list_text, r) + + def test_stack_event_list_log(self): + self.register_keystone_auth_fixture() + resp, resp_dict = fakes.mock_script_event_list( + resource_name="aResource", + rsrc_eventid1=self.event_id_one, + rsrc_eventid2=self.event_id_two + ) + + stack_id = 'teststack/1' + if self.client == http.SessionClient: + self.client.request( + '/stacks/%s/events?sort_dir=asc' % stack_id, + 'GET').AndReturn(resp) + else: + http.HTTPClient.json_request( + 'GET', + '/stacks/%s/events?sort_dir=asc' % + stack_id).AndReturn((resp, resp_dict)) + + self.m.ReplayAll() + + event_list_text = self.shell('event-list {0} --format log'.format( + stack_id)) + + expected = '14:14:31 2013-12-05 %s [aResource]: ' \ + 'CREATE_IN_PROGRESS state changed\n' \ + '14:14:32 2013-12-05 %s [aResource]: CREATE_COMPLETE ' \ + 'state changed\n' % (self.event_id_one, self.event_id_two) + + self.assertEqual(expected, event_list_text) + + def test_event_show(self): + self.register_keystone_auth_fixture() + resp_dict = {"event": + {"event_time": "2013-12-05T14:14:30Z", + "id": self.event_id_one, + "links": [{"href": "http://heat.example.com:8004/foo", + "rel": "self"}, + {"href": "http://heat.example.com:8004/foo2", + "rel": "resource"}, + {"href": "http://heat.example.com:8004/foo3", + "rel": "stack"}], + "logical_resource_id": "aResource", + "physical_resource_id": None, + "resource_name": "aResource", + "resource_properties": {"admin_user": "im_powerful", + "availability_zone": "nova"}, + "resource_status": "CREATE_IN_PROGRESS", + "resource_status_reason": "state changed", + "resource_type": "OS::Nova::Server" + }} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + stack_id = 'teststack/1' + resource_name = 'testresource/1' + http.SessionClient.request( + '/stacks/%s/resources/%s/events/%s' % + ( + parse.quote(stack_id, ''), + parse.quote(encodeutils.safe_encode( + resource_name), ''), + parse.quote(self.event_id_one, '') + ), 'GET').AndReturn(resp) + + self.m.ReplayAll() + + event_list_text = self.shell('event-show {0} {1} {2}'.format( + stack_id, resource_name, + self.event_id_one)) + + required = [ + 'Property', + 'Value', + 'event_time', + '2013-12-05T14:14:30Z', + 'id', + self.event_id_one, + 'links', + 'http://heat.example.com:8004/foo[0-9]', + 'logical_resource_id', + 'physical_resource_id', + 'resource_name', + 'aResource', + 'resource_properties', + 'admin_user', + 'availability_zone', + 'resource_status', + 'CREATE_IN_PROGRESS', + 'resource_status_reason', + 'state changed', + 'resource_type', + 'OS::Nova::Server', + ] + for r in required: + self.assertRegexpMatches(event_list_text, r) + + +class ShellTestEventsNested(ShellBase): + def setUp(self): + super(ShellTestEventsNested, self).setUp() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def test_shell_nested_depth_invalid_xor(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + resource_name = 'aResource' + + self.m.ReplayAll() + + error = self.assertRaises( + exc.CommandError, self.shell, + 'event-list {0} --resource {1} --nested-depth 5'.format( + stack_id, resource_name)) + self.assertIn('--nested-depth cannot be specified with --resource', + str(error)) + + def test_shell_nested_depth_invalid_value(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + resource_name = 'aResource' + error = self.assertRaises( + exc.CommandError, self.shell, + 'event-list {0} --nested-depth Z'.format( + stack_id, resource_name)) + self.assertIn('--nested-depth invalid value Z', str(error)) + + def test_shell_nested_depth_zero(self): + self.register_keystone_auth_fixture() + resp_dict = {"events": [{"id": 'eventid1'}, + {"id": 'eventid2'}]} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + stack_id = 'teststack/1' + + if self.client == http.SessionClient: + self.client.request( + '/stacks/%s/events?sort_dir=asc' % stack_id, + 'GET' + ).AndReturn(resp) + else: + http.HTTPClient.json_request( + 'GET', + '/stacks/%s/events?sort_dir=asc' % stack_id + ).AndReturn((resp, resp_dict)) + self.m.ReplayAll() + list_text = self.shell('event-list %s --nested-depth 0' % stack_id) + required = ['id', 'eventid1', 'eventid2'] + for r in required: + self.assertRegexpMatches(list_text, r) + + def _stub_event_list_response(self, stack_id, nested_id, timestamps): + # Stub events for parent stack + ev_resp_dict = {"events": [{"id": "p_eventid1", + "event_time": timestamps[0]}, + {"id": "p_eventid2", + "event_time": timestamps[3]}]} + ev_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(ev_resp_dict)) + if self.client == http.SessionClient: + self.client.request('/stacks/%s/events?sort_dir=asc' % stack_id, + 'GET').AndReturn(ev_resp) + else: + http.HTTPClient.json_request( + 'GET', '/stacks/%s/events?sort_dir=asc' % ( + stack_id)).AndReturn((ev_resp, ev_resp_dict)) + + # Stub resources for parent, including one nested + res_resp_dict = {"resources": [ + {"links": [{"href": "http://heat/foo", "rel": "self"}, + {"href": "http://heat/foo2", + "rel": "resource"}, + {"href": "http://heat/%s" % nested_id, + "rel": "nested"}], + "resource_type": "OS::Nested::Foo"}]} + res_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(res_resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/%s/resources' % ( + stack_id), 'GET').AndReturn(res_resp) + + else: + http.HTTPClient.json_request( + 'GET', '/stacks/%s/resources' % ( + stack_id)).AndReturn((res_resp, res_resp_dict)) + + # Stub the events for the nested stack + nev_resp_dict = {"events": [{"id": 'n_eventid1', + "event_time": timestamps[1]}, + {"id": 'n_eventid2', + "event_time": timestamps[2]}]} + nev_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(nev_resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/%s/events?sort_dir=asc' % ( + nested_id), 'GET').AndReturn(nev_resp) + else: + http.HTTPClient.json_request( + 'GET', '/stacks/%s/events?sort_dir=asc' % ( + nested_id)).AndReturn((nev_resp, nev_resp_dict)) + + def test_shell_nested_depth(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + nested_id = 'nested/2' + timestamps = ("2014-01-06T16:14:00Z", # parent p_eventid1 + "2014-01-06T16:15:00Z", # nested n_eventid1 + "2014-01-06T16:16:00Z", # nested n_eventid2 + "2014-01-06T16:17:00Z") # parent p_eventid2 + self._stub_event_list_response(stack_id, nested_id, timestamps) + self.m.ReplayAll() + list_text = self.shell('event-list %s --nested-depth 1' % stack_id) + required = ['id', 'p_eventid1', 'p_eventid2', 'n_eventid1', + 'n_eventid2', 'stack_name', 'teststack', 'nested'] + for r in required: + self.assertRegexpMatches(list_text, r) + + # Check event time sort/ordering + self.assertRegexpMatches(list_text, + "%s.*\n.*%s.*\n.*%s.*\n.*%s" % timestamps) + + def test_shell_nested_depth_marker(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + nested_id = 'nested/2' + timestamps = ("2014-01-06T16:14:00Z", # parent p_eventid1 + "2014-01-06T16:15:00Z", # nested n_eventid1 + "2014-01-06T16:16:00Z", # nested n_eventid2 + "2014-01-06T16:17:00Z") # parent p_eventid2 + self._stub_event_list_response(stack_id, nested_id, timestamps) + self.m.ReplayAll() + list_text = self.shell( + 'event-list %s --nested-depth 1 --marker n_eventid1' % stack_id) + required = ['id', 'p_eventid2', 'n_eventid1', 'n_eventid2', + 'stack_name', 'teststack', 'nested'] + for r in required: + self.assertRegexpMatches(list_text, r) + + self.assertNotRegexpMatches(list_text, 'p_eventid1') + + self.assertRegexpMatches(list_text, + "%s.*\n.*%s.*\n.*%s.*" % timestamps[1:]) + + def test_shell_nested_depth_limit(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + nested_id = 'nested/2' + timestamps = ("2014-01-06T16:14:00Z", # parent p_eventid1 + "2014-01-06T16:15:00Z", # nested n_eventid1 + "2014-01-06T16:16:00Z", # nested n_eventid2 + "2014-01-06T16:17:00Z") # parent p_eventid2 + self._stub_event_list_response(stack_id, nested_id, timestamps) + self.m.ReplayAll() + list_text = self.shell( + 'event-list %s --nested-depth 1 --limit 2' % stack_id) + required = ['id', 'p_eventid1', 'n_eventid1', + 'stack_name', 'teststack', 'nested'] + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'p_eventid2') + self.assertNotRegexpMatches(list_text, 'n_eventid2') + + self.assertRegexpMatches(list_text, + "%s.*\n.*%s.*\n" % timestamps[:2]) + + +class ShellTestHookFunctions(ShellBase): + def setUp(self): + super(ShellTestHookFunctions, self).setUp() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def _stub_stack_response(self, stack_id, action='CREATE', + status='IN_PROGRESS'): + # Stub parent stack show for status + resp_dict = {"stack": { + "id": stack_id.split("/")[1], + "stack_name": stack_id.split("/")[0], + "stack_status": '%s_%s' % (action, status), + "creation_time": "2014-01-06T16:14:00Z", + }} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/teststack/1', 'GET').AndReturn(resp) + else: + self.client.json_request( + 'GET', '/stacks/teststack/1').AndReturn((resp, resp_dict)) + + def _stub_responses(self, stack_id, nested_id, action='CREATE'): + action_reason = 'Stack %s started' % action + hook_reason = ('%s paused until Hook pre-%s is cleared' % + (action, action.lower())) + hook_clear_reason = 'Hook pre-%s is cleared' % action.lower() + + self._stub_stack_response(stack_id, action) + + # Stub events for parent stack + ev_resp_dict = {"events": [{"id": "p_eventid1", + "event_time": "2014-01-06T16:14:00Z", + "resource_name": None, + "resource_status_reason": action_reason}, + {"id": "p_eventid2", + "event_time": "2014-01-06T16:17:00Z", + "resource_name": "p_res", + "resource_status_reason": hook_reason}]} + ev_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(ev_resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/%s/events?sort_dir=asc' % (stack_id), + 'GET').AndReturn(ev_resp) + else: + self.client.json_request( + 'GET', '/stacks/%s/events?sort_dir=asc' % ( + stack_id)).AndReturn((ev_resp, ev_resp_dict)) + + # Stub resources for parent, including one nested + res_resp_dict = {"resources": [ + {"links": [{"href": "http://heat/foo", "rel": "self"}, + {"href": "http://heat/foo2", + "rel": "resource"}, + {"href": "http://heat/%s" % nested_id, + "rel": "nested"}], + "resource_type": "OS::Nested::Foo"}]} + res_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(res_resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/%s/resources' % (stack_id), + 'GET').AndReturn(res_resp) + else: + self.client.json_request( + 'GET', '/stacks/%s/resources' % ( + stack_id)).AndReturn((res_resp, res_resp_dict)) + + # Stub the events for the nested stack + nev_resp_dict = {"events": [{"id": 'n_eventid1', + "event_time": "2014-01-06T16:15:00Z", + "resource_name": "n_res", + "resource_status_reason": hook_reason}, + {"id": 'n_eventid2', + "event_time": "2014-01-06T16:16:00Z", + "resource_name": "n_res", + "resource_status_reason": + hook_clear_reason}]} + nev_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(nev_resp_dict)) + if self.client == http.SessionClient: + self.client.request( + '/stacks/%s/events?sort_dir=asc' % (nested_id), + 'GET').AndReturn(nev_resp) + else: + self.client.json_request( + 'GET', '/stacks/%s/events?sort_dir=asc' % ( + nested_id)).AndReturn((nev_resp, nev_resp_dict)) + + def test_hook_poll_pre_create(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + nested_id = 'nested/2' + self._stub_responses(stack_id, nested_id, 'CREATE') + self.m.ReplayAll() + list_text = self.shell('hook-poll %s --nested-depth 1' % stack_id) + hook_reason = 'CREATE paused until Hook pre-create is cleared' + required = ['id', 'p_eventid2', 'stack_name', 'teststack', hook_reason] + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'p_eventid1') + self.assertNotRegexpMatches(list_text, 'n_eventid1') + self.assertNotRegexpMatches(list_text, 'n_eventid2') + + def test_hook_poll_pre_update(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + nested_id = 'nested/2' + self._stub_responses(stack_id, nested_id, 'UPDATE') + self.m.ReplayAll() + list_text = self.shell('hook-poll %s --nested-depth 1' % stack_id) + hook_reason = 'UPDATE paused until Hook pre-update is cleared' + required = ['id', 'p_eventid2', 'stack_name', 'teststack', hook_reason] + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'p_eventid1') + self.assertNotRegexpMatches(list_text, 'n_eventid1') + self.assertNotRegexpMatches(list_text, 'n_eventid2') + + def test_hook_poll_bad_status(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + self._stub_stack_response(stack_id, status='COMPLETE') + self.m.ReplayAll() + error = self.assertRaises( + exc.CommandError, self.shell, + 'hook-poll %s --nested-depth 1' % stack_id) + self.assertIn('Stack status CREATE_COMPLETE not IN_PROGRESS', + str(error)) + + def test_shell_nested_depth_invalid_value(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + self.m.ReplayAll() + error = self.assertRaises( + exc.CommandError, self.shell, + 'hook-poll %s --nested-depth Z' % stack_id) + self.assertIn('--nested-depth invalid value Z', str(error)) + + def test_hook_poll_clear_bad_status(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + self._stub_stack_response(stack_id, status='COMPLETE') + self.m.ReplayAll() + error = self.assertRaises( + exc.CommandError, self.shell, + 'hook-clear %s aresource' % stack_id) + self.assertIn('Stack status CREATE_COMPLETE not IN_PROGRESS', + str(error)) + + def test_hook_poll_clear_bad_action(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + self._stub_stack_response(stack_id, action='DELETE') + self.m.ReplayAll() + error = self.assertRaises( + exc.CommandError, self.shell, + 'hook-clear %s aresource' % stack_id) + self.assertIn('Unexpected stack status DELETE_IN_PROGRESS', + str(error)) + + +class ShellTestResources(ShellBase): + + def setUp(self): + super(ShellTestResources, self).setUp() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def _test_resource_list(self, with_resource_name): + self.register_keystone_auth_fixture() + resp_dict = {"resources": [ + {"links": [{"href": "http://heat.example.com:8004/foo", + "rel": "self"}, + {"href": "http://heat.example.com:8004/foo2", + "rel": "resource"}], + "logical_resource_id": "aLogicalResource", + "physical_resource_id": + "43b68bae-ed5d-4aed-a99f-0b3d39c2418a", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Nova::Server", + "updated_time": "2014-01-06T16:14:26Z"}]} + if with_resource_name: + resp_dict["resources"][0]["resource_name"] = "aResource" + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + stack_id = 'teststack/1' + http.SessionClient.request( + '/stacks/%s/resources' % ( + stack_id), 'GET').AndReturn(resp) + + self.m.ReplayAll() + + resource_list_text = self.shell('resource-list {0}'.format(stack_id)) + + required = [ + 'physical_resource_id', + 'resource_type', + 'resource_status', + 'updated_time', + '43b68bae-ed5d-4aed-a99f-0b3d39c2418a', + 'OS::Nova::Server', + 'CREATE_COMPLETE', + '2014-01-06T16:14:26Z' + ] + if with_resource_name: + required.append('resource_name') + required.append('aResource') + else: + required.append('logical_resource_id') + required.append("aLogicalResource") + + for r in required: + self.assertRegexpMatches(resource_list_text, r) + + def test_resource_list(self): + self._test_resource_list(True) + + def test_resource_list_no_resource_name(self): + self._test_resource_list(False) + + def test_resource_list_empty(self): + self.register_keystone_auth_fixture() + resp_dict = {"resources": []} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + stack_id = 'teststack/1' + http.SessionClient.request( + '/stacks/%s/resources' % ( + stack_id), 'GET').AndReturn(resp) + + self.m.ReplayAll() + + resource_list_text = self.shell('resource-list {0}'.format(stack_id)) + + self.assertEqual('''\ ++---------------+----------------------+---------------+-----------------+\ +--------------+ +| resource_name | physical_resource_id | resource_type | resource_status |\ + updated_time | ++---------------+----------------------+---------------+-----------------+\ +--------------+ ++---------------+----------------------+---------------+-----------------+\ +--------------+ +''', resource_list_text) + + def test_resource_list_nested(self): + self.register_keystone_auth_fixture() + resp_dict = {"resources": [{ + "resource_name": "foobar", + "links": [{ + "href": "http://heat.example.com:8004/foo/12/resources/foobar", + "rel": "self" + }, { + "href": "http://heat.example.com:8004/foo/12", + "rel": "stack" + }], + }]} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + stack_id = 'teststack/1' + http.SessionClient.request( + '/stacks/%s/resources?nested_depth=99' % ( + stack_id), 'GET').AndReturn(resp) + + self.m.ReplayAll() + + shell_cmd = 'resource-list {0} --nested-depth {1}'.format(stack_id, 99) + resource_list_text = self.shell(shell_cmd) + + required = [ + 'resource_name', 'foobar', + 'stack_name', 'foo', + ] + for field in required: + self.assertRegexpMatches(resource_list_text, field) + + def test_resource_list_detail(self): + self.register_keystone_auth_fixture() + resp_dict = {"resources": [{ + "resource_name": "foobar", + "links": [{ + "href": "http://heat.example.com:8004/foo/12/resources/foobar", + "rel": "self" + }, { + "href": "http://heat.example.com:8004/foo/12", + "rel": "stack" + }], + }]} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + stack_id = 'teststack/1' + http.SessionClient.request('/stacks/%s/resources?%s' % ( + stack_id, + parse.urlencode({'with_detail': True}, True) + ), 'GET').AndReturn(resp) + + self.m.ReplayAll() + + shell_cmd = 'resource-list {0} --with-detail'.format(stack_id) + resource_list_text = self.shell(shell_cmd) + + required = [ + 'resource_name', 'foobar', + 'stack_name', 'foo', + ] + for field in required: + self.assertRegexpMatches(resource_list_text, field) + + def test_resource_show_with_attrs(self): + self.register_keystone_auth_fixture() + resp_dict = {"resource": + {"description": "", + "links": [{"href": "http://heat.example.com:8004/foo", + "rel": "self"}, + {"href": "http://heat.example.com:8004/foo2", + "rel": "resource"}], + "logical_resource_id": "aResource", + "physical_resource_id": + "43b68bae-ed5d-4aed-a99f-0b3d39c2418a", + "required_by": [], + "resource_name": "aResource", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Nova::Server", + "updated_time": "2014-01-06T16:14:26Z", + "creation_time": "2014-01-06T16:14:26Z", + "attributes": { + "attr_a": "value_of_attr_a", + "attr_b": "value_of_attr_b"}}} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + stack_id = 'teststack/1' + resource_name = 'aResource' + http.SessionClient.request( + '/stacks/%s/resources/%s?with_attr=attr_a&with_attr=attr_b' % + ( + parse.quote(stack_id, ''), + parse.quote(encodeutils.safe_encode( + resource_name), '') + ), 'GET').AndReturn(resp) + + self.m.ReplayAll() + + resource_show_text = self.shell( + 'resource-show {0} {1} --with-attr attr_a ' + '--with-attr attr_b'.format( + stack_id, resource_name)) + + required = [ + 'description', + 'links', + 'http://heat.example.com:8004/foo[0-9]', + 'logical_resource_id', + 'aResource', + 'physical_resource_id', + '43b68bae-ed5d-4aed-a99f-0b3d39c2418a', + 'required_by', + 'resource_name', + 'aResource', + 'resource_status', + 'CREATE_COMPLETE', + 'resource_status_reason', + 'state changed', + 'resource_type', + 'OS::Nova::Server', + 'updated_time', + '2014-01-06T16:14:26Z', + ] + for r in required: + self.assertRegexpMatches(resource_show_text, r) + + def test_resource_signal(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {}, + '') + stack_id = 'teststack/1' + resource_name = 'aResource' + http.SessionClient.request( + '/stacks/%s/resources/%s/signal' % + ( + parse.quote(stack_id, ''), + parse.quote(encodeutils.safe_encode( + resource_name), '') + ), + 'POST', + data={'message': 'Content'}).AndReturn(resp) + + self.m.ReplayAll() + + text = self.shell( + 'resource-signal {0} {1} -D {{"message":"Content"}}'.format( + stack_id, resource_name)) + self.assertEqual("", text) + + def test_resource_signal_no_data(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {}, + '') + stack_id = 'teststack/1' + resource_name = 'aResource' + http.SessionClient.request( + '/stacks/%s/resources/%s/signal' % + ( + parse.quote(stack_id, ''), + parse.quote(encodeutils.safe_encode( + resource_name), '') + ), 'POST', data=None).AndReturn(resp) + + self.m.ReplayAll() + + text = self.shell( + 'resource-signal {0} {1}'.format(stack_id, resource_name)) + self.assertEqual("", text) + + def test_resource_signal_no_json(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + resource_name = 'aResource' + + self.m.ReplayAll() + + error = self.assertRaises( + exc.CommandError, self.shell, + 'resource-signal {0} {1} -D [2'.format( + stack_id, resource_name)) + self.assertIn('Data should be in JSON format', str(error)) + + def test_resource_signal_no_dict(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + resource_name = 'aResource' + + self.m.ReplayAll() + + error = self.assertRaises( + exc.CommandError, self.shell, + 'resource-signal {0} {1} -D "message"'.format( + stack_id, resource_name)) + self.assertEqual('Data should be a JSON dict', str(error)) + + def test_resource_signal_both_data(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + resource_name = 'aResource' + + self.m.ReplayAll() + + error = self.assertRaises( + exc.CommandError, self.shell, + 'resource-signal {0} {1} -D "message" -f foo'.format( + stack_id, resource_name)) + self.assertEqual('Can only specify one of data and data-file', + str(error)) + + def test_resource_signal_data_file(self): + self.register_keystone_auth_fixture() + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {}, + '') + stack_id = 'teststack/1' + resource_name = 'aResource' + http.SessionClient.request( + '/stacks/%s/resources/%s/signal' % + ( + parse.quote(stack_id, ''), + parse.quote(encodeutils.safe_encode( + resource_name), '') + ), + 'POST', + data={'message': 'Content'}).AndReturn(resp) + + self.m.ReplayAll() + + with tempfile.NamedTemporaryFile() as data_file: + data_file.write(b'{"message":"Content"}') + data_file.flush() + text = self.shell( + 'resource-signal {0} {1} -f {2}'.format( + stack_id, resource_name, data_file.name)) + self.assertEqual("", text) + + +class ShellTestResourceTypes(ShellBase): + def setUp(self): + super(ShellTestResourceTypes, self).setUp() + self.set_fake_env(FAKE_ENV_KEYSTONE_V3) + + def test_resource_type_template_yaml(self): + self.register_keystone_auth_fixture() + resp_dict = {"heat_template_version": "2013-05-23", + "parameters": {}, + "resources": {}, + "outputs": {}} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + + http.SessionClient.request( + '/resource_types/OS%3A%3ANova%3A%3AKeyPair/template' + '?template_type=hot', 'GET' + ).AndReturn(resp) + + self.m.ReplayAll() + + show_text = self.shell( + 'resource-type-template -F yaml -t hot OS::Nova::KeyPair') + required = [ + "heat_template_version: '2013-05-23'", + "outputs: {}", + "parameters: {}", + "resources: {}" + ] + for r in required: + self.assertRegexpMatches(show_text, r) + + def test_resource_type_template_json(self): + self.register_keystone_auth_fixture() + resp_dict = {"AWSTemplateFormatVersion": "2013-05-23", + "Parameters": {}, + "Resources": {}, + "Outputs": {}} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + + http.SessionClient.request( + '/resource_types/OS%3A%3ANova%3A%3AKeyPair/template' + '?template_type=cfn', 'GET' + ).AndReturn(resp) + + self.m.ReplayAll() + + show_text = self.shell( + 'resource-type-template -F json OS::Nova::KeyPair') + required = [ + '{', + ' "AWSTemplateFormatVersion": "2013-05-23"', + ' "Outputs": {}', + ' "Resources": {}', + ' "Parameters": {}', + '}' + ] + for r in required: + self.assertRegexpMatches(show_text, r) + + +class ShellTestConfig(ShellBase): + + def setUp(self): + super(ShellTestConfig, self).setUp() + self._set_fake_env() + + def _set_fake_env(self): + '''Patch os.environ to avoid required auth info.''' + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def test_config_create(self): + self.register_keystone_auth_fixture() + + definition = { + 'inputs': [ + {'name': 'foo'}, + {'name': 'bar'}, + ], + 'outputs': [ + {'name': 'result'} + ], + 'options': {'a': 'b'} + } + validate_template = {'template': { + 'heat_template_version': '2013-05-23', + 'resources': { + 'config_name': { + 'type': 'OS::Heat::SoftwareConfig', + 'properties': { + 'config': 'the config script', + 'group': 'script', + 'inputs': [ + {'name': 'foo'}, + {'name': 'bar'}, + ], + 'outputs': [ + {'name': 'result'} + ], + 'options': {'a': 'b'}, + 'config': 'the config script' + } + } + } + }} + + create_dict = { + 'group': 'script', + 'name': 'config_name', + 'inputs': [ + {'name': 'foo'}, + {'name': 'bar'}, + ], + 'outputs': [ + {'name': 'result'} + ], + 'options': {'a': 'b'}, + 'config': 'the config script' + } + + resp_dict = {'software_config': { + 'group': 'script', + 'name': 'config_name', + 'inputs': [ + {'name': 'foo'}, + {'name': 'bar'}, + ], + 'outputs': [ + {'name': 'result'} + ], + 'options': {'a': 'b'}, + 'config': 'the config script', + 'id': 'abcd' + }} + resp_string = jsonutils.dumps(resp_dict) + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + + self.m.StubOutWithMock(request, 'urlopen') + request.urlopen('file:///tmp/defn').AndReturn( + six.StringIO(yaml.safe_dump(definition, indent=2))) + request.urlopen('file:///tmp/config_script').AndReturn( + six.StringIO('the config script')) + + http.SessionClient.request( + '/validate', 'POST', data=validate_template).AndReturn(http_resp) + http.SessionClient.request( + '/software_configs', 'POST', data=create_dict).AndReturn(http_resp) + + self.m.ReplayAll() + + text = self.shell('config-create -c /tmp/config_script ' + '-g script -f /tmp/defn config_name') + + self.assertEqual(resp_dict['software_config'], jsonutils.loads(text)) + + def test_config_show(self): + self.register_keystone_auth_fixture() + resp_dict = {'software_config': { + 'inputs': [], + 'group': 'script', + 'name': 'config_name', + 'outputs': [], + 'options': {}, + 'config': 'the config script', + 'id': 'abcd'}} + resp_string = jsonutils.dumps(resp_dict) + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + http.SessionClient.request( + '/software_configs/abcd', 'GET').AndReturn(http_resp) + http.SessionClient.request( + '/software_configs/abcd', 'GET').AndReturn(http_resp) + http.SessionClient.request( + '/software_configs/abcde', 'GET').AndRaise(exc.HTTPNotFound()) + + self.m.ReplayAll() + + text = self.shell('config-show abcd') + + required = [ + 'inputs', + 'group', + 'name', + 'outputs', + 'options', + 'config', + 'id', + ] + for r in required: + self.assertRegexpMatches(text, r) + + self.assertEqual( + 'the config script\n', + self.shell('config-show --config-only abcd')) + self.assertRaises(exc.CommandError, self.shell, 'config-show abcde') + + def test_config_delete(self): + self.register_keystone_auth_fixture() + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(204, 'OK', headers, None) + http.SessionClient.request( + '/software_configs/abcd', 'DELETE').AndReturn(http_resp) + http.SessionClient.request( + '/software_configs/qwer', 'DELETE').AndReturn(http_resp) + http.SessionClient.request( + '/software_configs/abcd', 'DELETE').AndRaise(exc.HTTPNotFound()) + http.SessionClient.request( + '/software_configs/qwer', 'DELETE').AndRaise(exc.HTTPNotFound()) + + self.m.ReplayAll() + + self.assertEqual('', self.shell('config-delete abcd qwer')) + self.assertRaises( + exc.CommandError, self.shell, 'config-delete abcd qwer') + + +class ShellTestDeployment(ShellBase): + + def setUp(self): + super(ShellTestDeployment, self).setUp() + self.client = http.SessionClient + self._set_fake_env() + + def _set_fake_env(self): + '''Patch os.environ to avoid required auth info.''' + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def test_deploy_create(self): + self.register_keystone_auth_fixture() + self.patch( + 'heatclient.common.deployment_utils.build_derived_config_params') + self.patch( + 'heatclient.common.deployment_utils.build_signal_id') + resp_dict = {'software_deployment': { + 'status': 'INPROGRESS', + 'server_id': '700115e5-0100-4ecc-9ef7-9e05f27d8803', + 'config_id': '18c4fc03-f897-4a1d-aaad-2b7622e60257', + 'output_values': { + 'deploy_stdout': '', + 'deploy_stderr': '', + 'deploy_status_code': 0, + 'result': 'The result value' + }, + 'input_values': {}, + 'action': 'UPDATE', + 'status_reason': 'Outputs received', + 'id': 'abcd' + }} + + config_dict = {'software_config': { + 'inputs': [], + 'group': 'script', + 'name': 'config_name', + 'outputs': [], + 'options': {}, + 'config': 'the config script', + 'id': 'defg'}} + + derived_dict = {'software_config': { + 'inputs': [], + 'group': 'script', + 'name': 'config_name', + 'outputs': [], + 'options': {}, + 'config': 'the config script', + 'id': 'abcd'}} + + deploy_data = {'action': 'UPDATE', + 'config_id': u'abcd', + 'server_id': 'inst01', + 'status': 'IN_PROGRESS', + 'tenant_id': 'asdf'} + + config_string = jsonutils.dumps(config_dict) + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, config_string) + response = (http_resp, config_dict) + if self.client == http.SessionClient: + http.SessionClient.request( + '/software_configs/defg', 'GET').AndReturn(http_resp) + else: + self.client.json_request( + 'GET', '/software_configs/defg').AndReturn(response) + + derived_string = jsonutils.dumps(derived_dict) + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, derived_string) + response = (http_resp, derived_dict) + if self.client == http.SessionClient: + http.SessionClient.request( + '/software_configs', 'POST', data={}).AndReturn(http_resp) + else: + self.client.json_request( + 'POST', '/software_configs', data={}).AndReturn(response) + + resp_string = jsonutils.dumps(resp_dict) + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + response = (http_resp, resp_dict) + if self.client == http.SessionClient: + self.client.request( + '/software_deployments', 'POST', + data=deploy_data).AndReturn(http_resp) + else: + self.client.json_request( + 'POST', + '/software_deployments', data=deploy_data).AndReturn(response) + + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, derived_string) + response = (http_resp, derived_dict) + if self.client == http.SessionClient: + http.SessionClient.request( + '/software_configs', 'POST', data={}).AndReturn(http_resp) + else: + self.client.json_request( + 'POST', '/software_configs', data={}).AndReturn(response) + + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + response = (http_resp, resp_dict) + if self.client == http.SessionClient: + self.client.request( + '/software_deployments', 'POST', + data=deploy_data).AndReturn(http_resp) + self.client.request( + '/software_configs/defgh', 'GET').AndRaise( + exc.HTTPNotFound()) + else: + self.client.json_request( + 'POST', '/software_deployments').AndReturn(response) + self.client.json_request( + 'GET', '/software_configs/defgh').AndRaise( + exc.HTTPNotFound()) + + self.m.ReplayAll() + + text = self.shell('deployment-create -c defg -sinst01 xxx') + + required = [ + 'status', + 'server_id', + 'config_id', + 'output_values', + 'input_values', + 'action', + 'status_reason', + 'id', + ] + for r in required: + self.assertRegexpMatches(text, r) + + text = self.shell('deployment-create -sinst01 xxx') + for r in required: + self.assertRegexpMatches(text, r) + + self.assertRaises(exc.CommandError, self.shell, + 'deployment-create -c defgh -s inst01 yyy') + self.m.VerifyAll() + + def test_deploy_list(self): + self.register_keystone_auth_fixture() + + resp_dict = { + 'software_deployments': + [{'status': 'COMPLETE', + 'server_id': '123', + 'config_id': '18c4fc03-f897-4a1d-aaad-2b7622e60257', + 'output_values': { + 'deploy_stdout': '', + 'deploy_stderr': '', + 'deploy_status_code': 0, + 'result': 'The result value' + }, + 'input_values': {}, + 'action': 'CREATE', + 'status_reason': 'Outputs received', + 'id': 'defg'}, ] + } + resp_string = jsonutils.dumps(resp_dict) + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + response = (http_resp, resp_dict) + if self.client == http.SessionClient: + self.client.request( + '/software_deployments?', 'GET').AndReturn(http_resp) + self.client.request( + '/software_deployments?server_id=123', + 'GET').AndReturn(http_resp) + else: + self.client.json_request( + 'GET', '/software_deployments?').AndReturn(response) + self.client.json_request( + 'GET', + '/software_deployments?server_id=123').AndReturn(response) + + self.m.ReplayAll() + + list_text = self.shell('deployment-list') + + required = [ + 'id', + 'config_id', + 'server_id', + 'action', + 'status', + 'creation_time', + 'status_reason', + ] + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'parent') + + list_text = self.shell('deployment-list -s 123') + + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'parent') + + def test_deploy_show(self): + self.register_keystone_auth_fixture() + resp_dict = {'software_deployment': { + 'status': 'COMPLETE', + 'server_id': '700115e5-0100-4ecc-9ef7-9e05f27d8803', + 'config_id': '18c4fc03-f897-4a1d-aaad-2b7622e60257', + 'output_values': { + 'deploy_stdout': '', + 'deploy_stderr': '', + 'deploy_status_code': 0, + 'result': 'The result value' + }, + 'input_values': {}, + 'action': 'CREATE', + 'status_reason': 'Outputs received', + 'id': 'defg' + }} + + resp_string = jsonutils.dumps(resp_dict) + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + response = (http_resp, resp_dict) + if self.client == http.SessionClient: + self.client.request( + '/software_deployments/defg', 'GET').AndReturn(http_resp) + self.client.request( + '/software_deployments/defgh', 'GET').AndRaise( + exc.HTTPNotFound()) + else: + self.client.json_request( + 'GET', '/software_deployments/defg').AndReturn(response) + self.client.json_request( + 'GET', '/software_deployments/defgh').AndRaise( + exc.HTTPNotFound()) + + self.m.ReplayAll() + + text = self.shell('deployment-show defg') + + required = [ + 'status', + 'server_id', + 'config_id', + 'output_values', + 'input_values', + 'action', + 'status_reason', + 'id', + ] + for r in required: + self.assertRegexpMatches(text, r) + self.assertRaises(exc.CommandError, self.shell, + 'deployment-show defgh') + + def test_deploy_delete(self): + self.register_keystone_auth_fixture() + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(204, 'OK', headers, None) + response = (http_resp, '') + if self.client == http.SessionClient: + self.client.request( + '/software_deployments/defg', + 'DELETE').AndReturn(http_resp) + self.client.request( + '/software_deployments/qwer', + 'DELETE').AndReturn(http_resp) + self.client.request( + '/software_deployments/defg', + 'DELETE').AndRaise(exc.HTTPNotFound()) + self.client.request( + '/software_deployments/qwer', + 'DELETE').AndRaise(exc.HTTPNotFound()) + else: + self.client.raw_request( + 'DELETE', '/software_deployments/defg').AndReturn(response) + self.client.raw_request( + 'DELETE', '/software_deployments/qwer').AndReturn(response) + self.client.raw_request( + 'DELETE', + '/software_deployments/defg').AndRaise(exc.HTTPNotFound()) + self.client.raw_request( + 'DELETE', + '/software_deployments/qwer').AndRaise(exc.HTTPNotFound()) + + self.m.ReplayAll() + + self.assertEqual('', self.shell('deployment-delete defg qwer')) + self.assertRaises(exc.CommandError, self.shell, + 'deployment-delete defg qwer') + + def test_deploy_metadata(self): + self.register_keystone_auth_fixture() + resp_dict = {'metadata': [ + {'id': 'abcd'}, + {'id': 'defg'} + ]} + + resp_string = jsonutils.dumps(resp_dict) + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + response = (http_resp, resp_dict) + if self.client == http.SessionClient: + self.client.request( + '/software_deployments/metadata/aaaa', + 'GET').AndReturn(http_resp) + else: + self.client.json_request( + 'GET', '/software_deployments/metadata/aaaa').AndReturn( + response) + + self.m.ReplayAll() + + build_info_text = self.shell('deployment-metadata-show aaaa') + + required = [ + 'abcd', + 'defg', + 'id', + ] + for r in required: + self.assertRegexpMatches(build_info_text, r) + + def test_deploy_output_show(self): + self.register_keystone_auth_fixture() + resp_dict = {'software_deployment': { + 'status': 'COMPLETE', + 'server_id': '700115e5-0100-4ecc-9ef7-9e05f27d8803', + 'config_id': '18c4fc03-f897-4a1d-aaad-2b7622e60257', + 'output_values': { + 'deploy_stdout': '', + 'deploy_stderr': '', + 'deploy_status_code': 0, + 'result': 'The result value', + 'dict_output': {'foo': 'bar'}, + 'list_output': ['foo', 'bar'] + }, + 'input_values': {}, + 'action': 'CREATE', + 'status_reason': 'Outputs received', + 'id': 'defg' + }} + + resp_string = jsonutils.dumps(resp_dict) + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + response = (http_resp, resp_dict) + if self.client == http.SessionClient: + self.client.request( + '/software_deployments/defgh', 'GET').AndRaise( + exc.HTTPNotFound()) + self.client.request( + '/software_deployments/defg', 'GET').MultipleTimes().AndReturn( + http_resp) + else: + self.client.json_request( + 'GET', '/software_deployments/defgh').AndRaise( + exc.HTTPNotFound()) + self.client.json_request( + 'GET', '/software_deployments/defg').MultipleTimes().AndReturn( + response) + + self.m.ReplayAll() + + self.assertRaises(exc.CommandError, self.shell, + 'deployment-output-show defgh result') + self.assertEqual( + 'The result value\n', + self.shell('deployment-output-show defg result')) + self.assertEqual( + '"The result value"\n', + self.shell('deployment-output-show --format json defg result')) + + self.assertEqual( + '{\n "foo": "bar"\n}\n', + self.shell('deployment-output-show defg dict_output')) + self.assertEqual( + self.shell( + 'deployment-output-show --format raw defg dict_output'), + self.shell( + 'deployment-output-show --format json defg dict_output')) + + self.assertEqual( + '[\n "foo", \n "bar"\n]\n', + self.shell('deployment-output-show defg list_output')) + self.assertEqual( + self.shell( + 'deployment-output-show --format raw defg list_output'), + self.shell( + 'deployment-output-show --format json defg list_output')) + + self.assertEqual({ + 'deploy_stdout': '', + 'deploy_stderr': '', + 'deploy_status_code': 0, + 'result': 'The result value', + 'dict_output': {'foo': 'bar'}, + 'list_output': ['foo', 'bar']}, + jsonutils.loads(self.shell( + 'deployment-output-show --format json defg --all')) + ) + + +class ShellTestBuildInfo(ShellBase): + + def setUp(self): + super(ShellTestBuildInfo, self).setUp() + self._set_fake_env() + + def _set_fake_env(self): + '''Patch os.environ to avoid required auth info.''' + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def test_build_info(self): + self.register_keystone_auth_fixture() + resp_dict = { + 'build_info': { + 'api': {'revision': 'api_revision'}, + 'engine': {'revision': 'engine_revision'} + } + } + resp_string = jsonutils.dumps(resp_dict) + headers = {'content-type': 'application/json'} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + response = http_resp + http.SessionClient.request( + '/build_info', 'GET').AndReturn(response) + + self.m.ReplayAll() + + build_info_text = self.shell('build-info') + + required = [ + 'api', + 'engine', + 'revision', + 'api_revision', + 'engine_revision', + ] + for r in required: + self.assertRegexpMatches(build_info_text, r) + + +class ShellTestToken(ShellTestUserPass): + + # Rerun all ShellTestUserPass test with token auth + def setUp(self): + self.token = 'a_token' + super(ShellTestToken, self).setUp() + + def _set_fake_env(self): + fake_env = { + 'OS_AUTH_TOKEN': self.token, + 'OS_TENANT_ID': 'tenant_id', + 'OS_AUTH_URL': BASE_URL, + # Note we also set username/password, because create/update + # pass them even if we have a token to support storing credentials + # Hopefully at some point we can remove this and move to only + # storing trust id's in heat-engine instead.. + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password' + } + self.set_fake_env(fake_env) + + +class ShellTestUserPassKeystoneV3(ShellTestUserPass): + + def _set_fake_env(self): + self.set_fake_env(FAKE_ENV_KEYSTONE_V3) + + +class ShellTestStandaloneToken(ShellTestUserPass): + + # Rerun all ShellTestUserPass test in standalone mode, where we + # specify --os-no-client-auth, a token and Heat endpoint + def setUp(self): + self.token = 'a_token' + super(ShellTestStandaloneToken, self).setUp() + self.client = http.HTTPClient + + def _set_fake_env(self): + fake_env = { + 'OS_AUTH_TOKEN': self.token, + 'OS_NO_CLIENT_AUTH': 'True', + 'HEAT_URL': 'http://no.where', + 'OS_AUTH_URL': BASE_URL, + # Note we also set username/password, because create/update + # pass them even if we have a token to support storing credentials + # Hopefully at some point we can remove this and move to only + # storing trust id's in heat-engine instead.. + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password' + } + self.set_fake_env(fake_env) + + def test_bad_template_file(self): + self.register_keystone_auth_fixture() + failed_msg = 'Error parsing template ' + + with tempfile.NamedTemporaryFile() as bad_json_file: + bad_json_file.write(b"{foo:}") + bad_json_file.flush() + self.shell_error("stack-create ts -f %s" % bad_json_file.name, + failed_msg) + + with tempfile.NamedTemporaryFile() as bad_json_file: + bad_json_file.write(b'{"foo": None}') + bad_json_file.flush() + self.shell_error("stack-create ts -f %s" % bad_json_file.name, + failed_msg) + + def test_commandline_args_passed_to_requests(self): + """Check that we have sent the proper arguments to requests.""" + self.register_keystone_auth_fixture() + + # we need a mock for 'request' to check whether proper arguments + # sent to request in the form of HTTP headers. So unset + # stubs(json_request, raw_request) and create a new mock for request. + self.m.UnsetStubs() + self.m.StubOutWithMock(requests, 'request') + + # Record a 200 + mock_conn = http.requests.request( + 'GET', 'http://no.where/stacks?', + allow_redirects=False, + headers={'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Auth-Token': self.token, + 'X-Auth-Url': BASE_URL, + 'User-Agent': 'python-heatclient'}) + resp_dict = {"stacks": [ + { + "id": "1", + "stack_name": "teststack", + "stack_owner": "testowner", + "project": "testproject", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2014-10-15T01:58:47Z" + }]} + mock_conn.AndReturn( + fakes.FakeHTTPResponse( + 200, 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict))) + + # Replay, create client, assert + self.m.ReplayAll() + list_text = self.shell('stack-list') + required = [ + 'id', + 'stack_status', + 'creation_time', + 'teststack', + '1', + 'CREATE_COMPLETE', + ] + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'parent') + + +class MockShellBase(TestCase): + + def setUp(self): + super(MockShellBase, self).setUp() + self.jreq_mock = self.patch( + 'heatclient.common.http.HTTPClient.json_request') + self.session_jreq_mock = self.patch( + 'heatclient.common.http.SessionClient.request') + + # Some tests set exc.verbose = 1, so reset on cleanup + def unset_exc_verbose(): + exc.verbose = 0 + + self.addCleanup(unset_exc_verbose) + + def shell(self, argstr): + orig = sys.stdout + try: + sys.stdout = six.StringIO() + _shell = heatclient.shell.HeatShell() + _shell.main(argstr.split()) + self.subcommands = _shell.subcommands.keys() + except SystemExit: + exc_type, exc_value, exc_traceback = sys.exc_info() + self.assertEqual(0, exc_value.code) + finally: + out = sys.stdout.getvalue() + sys.stdout.close() + sys.stdout = orig + + return out + + +class MockShellTestUserPass(MockShellBase): + + def setUp(self): + super(MockShellTestUserPass, self).setUp() + self._set_fake_env() + + def _set_fake_env(self): + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def test_stack_list_with_args(self): + self.register_keystone_auth_fixture() + self.jreq_mock.return_value = fakes.mock_script_heat_list() + self.session_jreq_mock.return_value = fakes.mock_script_heat_list()[0] + + list_text = self.shell('stack-list' + ' --limit 2' + ' --marker fake_id' + ' --filters=status=COMPLETE' + ' --filters=status=FAILED' + ' --tags=tag1,tag2' + ' --tags-any=tag3,tag4' + ' --not-tags=tag5,tag6' + ' --not-tags-any=tag7,tag8' + ' --global-tenant' + ' --show-deleted' + ' --show-hidden') + + required = [ + 'stack_owner', + 'project', + 'testproject', + 'teststack', + 'teststack2', + ] + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'parent') + + if self.jreq_mock.call_args is None: + self.assertEqual(1, self.session_jreq_mock.call_count) + url, method = self.session_jreq_mock.call_args[0] + else: + self.assertEqual(1, self.jreq_mock.call_count) + method, url = self.jreq_mock.call_args[0] + self.assertEqual('GET', method) + base_url, query_params = utils.parse_query_url(url) + self.assertEqual('/stacks', base_url) + expected_query_dict = {'limit': ['2'], + 'status': ['COMPLETE', 'FAILED'], + 'marker': ['fake_id'], + 'tags': ['tag1,tag2'], + 'tags_any': ['tag3,tag4'], + 'not_tags': ['tag5,tag6'], + 'not_tags_any': ['tag7,tag8'], + 'global_tenant': ['True'], + 'show_deleted': ['True'], + 'show_hidden': ['True']} + self.assertEqual(expected_query_dict, query_params) + + +class MockShellTestToken(MockShellTestUserPass): + + # Rerun all ShellTestUserPass test with token auth + def setUp(self): + self.token = 'a_token' + super(MockShellTestToken, self).setUp() + + def _set_fake_env(self): + fake_env = { + 'OS_AUTH_TOKEN': self.token, + 'OS_TENANT_ID': 'tenant_id', + 'OS_AUTH_URL': BASE_URL, + # Note we also set username/password, because create/update + # pass them even if we have a token to support storing credentials + # Hopefully at some point we can remove this and move to only + # storing trust id's in heat-engine instead.. + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password' + } + self.set_fake_env(fake_env) + + +class MockShellTestUserPassKeystoneV3(MockShellTestUserPass): + + def _set_fake_env(self): + self.set_fake_env(FAKE_ENV_KEYSTONE_V3) + + +class MockShellTestStandaloneToken(MockShellTestUserPass): + + # Rerun all ShellTestUserPass test in standalone mode, where we + # specify --os-no-client-auth, a token and Heat endpoint + def setUp(self): + self.token = 'a_token' + super(MockShellTestStandaloneToken, self).setUp() + + def _set_fake_env(self): + fake_env = { + 'OS_AUTH_TOKEN': self.token, + 'OS_NO_CLIENT_AUTH': 'True', + 'HEAT_URL': 'http://no.where', + 'OS_AUTH_URL': BASE_URL, + # Note we also set username/password, because create/update + # pass them even if we have a token to support storing credentials + # Hopefully at some point we can remove this and move to only + # storing trust id's in heat-engine instead.. + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password' + } + self.set_fake_env(fake_env) + + +class ShellTestManageService(ShellBase): + + def setUp(self): + super(ShellTestManageService, self).setUp() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def _set_fake_env(self): + '''Patch os.environ to avoid required auth info.''' + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def _test_error_case(self, code, message): + self.register_keystone_auth_fixture() + + resp_dict = { + 'explanation': '', + 'code': code, + 'error': { + 'message': message, + 'type': '', + 'traceback': '', + }, + 'title': 'test title' + } + resp_string = jsonutils.dumps(resp_dict) + resp = fakes.FakeHTTPResponse( + code, + 'test reason', + {'content-type': 'application/json'}, + resp_string) + (http.SessionClient.request('/services', 'GET'). + AndRaise(exc.from_response(resp))) + + exc.verbose = 1 + + self.m.ReplayAll() + e = self.assertRaises(exc.HTTPException, + self.shell, "service-list") + self.assertIn(message, str(e)) + + def test_service_list(self): + self.register_keystone_auth_fixture() + resp_dict = { + 'services': [ + { + "status": "up", + "binary": "heat-engine", + "engine_id": "9d9242c3-4b9e-45e1-9e74-7615fbf20e5d", + "hostname": "mrkanag", + "updated_at": "2015-02-03T05:57:59.000000", + "topic": "engine", + "host": "engine-1" + } + ] + } + resp_string = jsonutils.dumps(resp_dict) + headers = {} + http_resp = fakes.FakeHTTPResponse(200, 'OK', headers, resp_string) + http.SessionClient.request('/services', 'GET').AndReturn(http_resp) + + self.m.ReplayAll() + services_text = self.shell('service-list') + + required = [ + 'hostname', 'binary', 'engine_id', 'host', + 'topic', 'updated_at', 'status' + ] + for r in required: + self.assertRegexpMatches(services_text, r) + + def test_service_list_503(self): + self._test_error_case( + message='All heat engines are down', + code=503) + + def test_service_list_403(self): + self._test_error_case( + message=('You are not authorized to ' + 'complete this action'), + code=403) diff --git a/stacktaskclient/tests/unit/test_software_configs.py b/stacktaskclient/tests/unit/test_software_configs.py new file mode 100644 index 0000000..936ed50 --- /dev/null +++ b/stacktaskclient/tests/unit/test_software_configs.py @@ -0,0 +1,99 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import testtools + +from heatclient.common import utils +from heatclient.v1 import software_configs + + +class SoftwareConfigTest(testtools.TestCase): + + def setUp(self): + super(SoftwareConfigTest, self).setUp() + config_id = 'bca6871d-86c0-4aff-b792-58a1f6947b57' + self.config = software_configs.SoftwareConfig(mock.MagicMock(), + info={'id': config_id}) + self.config_id = config_id + + def test_delete(self): + self.config.manager.delete.return_value = None + self.assertIsNone(self.config.delete()) + kwargs = self.config.manager.delete.call_args[1] + self.assertEqual(self.config_id, kwargs['config_id']) + + def test_data(self): + self.assertEqual( + "" % self.config_id, str(self.config)) + self.config.manager.data.return_value = None + self.config.data(name='config_mysql') + kwargs = self.config.manager.data.call_args[1] + self.assertEqual('config_mysql', kwargs['name']) + + +class SoftwareConfigManagerTest(testtools.TestCase): + + def setUp(self): + super(SoftwareConfigManagerTest, self).setUp() + self.manager = software_configs.SoftwareConfigManager(mock.MagicMock()) + + @mock.patch.object(utils, 'get_response_body') + def test_get(self, mock_body): + config_id = 'bca6871d-86c0-4aff-b792-58a1f6947b57' + data = { + 'id': config_id, + 'name': 'config_mysql', + 'group': 'Heat::Shell', + 'config': '#!/bin/bash', + 'inputs': [], + 'ouputs': [], + 'options': []} + + self.manager.client.json_request.return_value = ( + {}, {'software_config': data}) + mock_body.return_value = {'software_config': data} + result = self.manager.get(config_id=config_id) + self.assertEqual(software_configs.SoftwareConfig(self.manager, data), + result) + call_args = self.manager.client.get.call_args + self.assertEqual( + ('/software_configs/%s' % config_id,), *call_args) + + @mock.patch.object(utils, 'get_response_body') + def test_create(self, mock_body): + config_id = 'bca6871d-86c0-4aff-b792-58a1f6947b57' + body = { + 'name': 'config_mysql', + 'group': 'Heat::Shell', + 'config': '#!/bin/bash', + 'inputs': [], + 'ouputs': [], + 'options': []} + data = body.copy() + data['id'] = config_id + self.manager.client.json_request.return_value = ( + {}, {'software_config': data}) + mock_body.return_value = {'software_config': data} + result = self.manager.create(**body) + self.assertEqual(software_configs.SoftwareConfig(self.manager, data), + result) + args, kargs = self.manager.client.post.call_args + self.assertEqual('/software_configs', args[0]) + self.assertEqual({'data': body}, kargs) + + def test_delete(self): + config_id = 'bca6871d-86c0-4aff-b792-58a1f6947b57' + self.manager.delete(config_id) + call_args = self.manager.client.delete.call_args + self.assertEqual( + ('/software_configs/%s' % config_id,), *call_args) diff --git a/stacktaskclient/tests/unit/test_software_deployments.py b/stacktaskclient/tests/unit/test_software_deployments.py new file mode 100644 index 0000000..47a446d --- /dev/null +++ b/stacktaskclient/tests/unit/test_software_deployments.py @@ -0,0 +1,166 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import testtools + +from heatclient.common import utils +from heatclient.v1 import software_deployments + + +class SoftwareDeploymentTest(testtools.TestCase): + + def setUp(self): + super(SoftwareDeploymentTest, self).setUp() + deployment_id = 'bca6871d-86c0-4aff-b792-58a1f6947b57' + self.deployment = software_deployments.SoftwareDeployment( + mock.MagicMock(), info={'id': deployment_id}) + self.deployment_id = deployment_id + + def test_delete(self): + self.deployment.manager.delete.return_value = None + self.assertIsNone(self.deployment.delete()) + kwargs = self.deployment.manager.delete.call_args[1] + self.assertEqual(self.deployment_id, kwargs['deployment_id']) + + def test_update(self): + self.assertEqual( + "" % self.deployment_id, + str(self.deployment)) + self.deployment.manager.update.return_value = None + config_id = 'd00ba4aa-db33-42e1-92f4-2a6469260107' + self.assertIsNone(self.deployment.update(config_id=config_id)) + kwargs = self.deployment.manager.update.call_args[1] + self.assertEqual(self.deployment_id, kwargs['deployment_id']) + self.assertEqual(config_id, kwargs['config_id']) + + +class SoftwareDeploymentManagerTest(testtools.TestCase): + + def setUp(self): + super(SoftwareDeploymentManagerTest, self).setUp() + self.manager = software_deployments.SoftwareDeploymentManager( + mock.MagicMock()) + + def test_list(self): + server_id = 'fc01f89f-e151-4dc5-9c28-543c0d20ed6a' + self.manager.client.json_request.return_value = ( + {}, + {'software_deployments': []}) + result = self.manager.list(server_id=server_id) + self.assertEqual([], result) + call_args = self.manager.client.get.call_args + self.assertEqual( + ('/software_deployments?server_id=%s' % server_id,), + *call_args) + + @mock.patch.object(utils, 'get_response_body') + def test_metadata(self, mock_utils): + server_id = 'fc01f89f-e151-4dc5-9c28-543c0d20ed6a' + metadata = { + 'group1': [{'foo': 'bar'}], + 'group2': [{'foo': 'bar'}, {'bar': 'baz'}], + } + self.manager.client.get.return_value = {} + mock_utils.return_value = {'metadata': metadata} + result = self.manager.metadata(server_id=server_id) + self.assertEqual(metadata, result) + call_args = self.manager.client.get.call_args + self.assertEqual( + '/software_deployments/metadata/%s' % server_id, + call_args[0][0]) + + @mock.patch.object(utils, 'get_response_body') + def test_get(self, mock_utils): + deployment_id = 'bca6871d-86c0-4aff-b792-58a1f6947b57' + config_id = 'd00ba4aa-db33-42e1-92f4-2a6469260107' + server_id = 'fb322564-7927-473d-8aad-68ae7fbf2abf' + data = { + 'id': deployment_id, + 'server_id': server_id, + 'input_values': {}, + 'output_values': {}, + 'action': 'INIT', + 'status': 'COMPLETE', + 'status_reason': None, + 'signal_id': None, + 'config_id': config_id, + 'config': '#!/bin/bash', + 'name': 'config_mysql', + 'group': 'Heat::Shell', + 'inputs': [], + 'outputs': [], + 'options': []} + + self.manager.client.get.return_value = {} + mock_utils.return_value = {'software_deployment': data} + result = self.manager.get(deployment_id=deployment_id) + self.assertEqual(software_deployments.SoftwareDeployment( + self.manager, data), result) + call_args = self.manager.client.get.call_args + self.assertEqual( + ('/software_deployments/%s' % deployment_id,), *call_args) + + @mock.patch.object(utils, 'get_response_body') + def test_create(self, mock_utils): + deployment_id = 'bca6871d-86c0-4aff-b792-58a1f6947b57' + config_id = 'd00ba4aa-db33-42e1-92f4-2a6469260107' + server_id = 'fb322564-7927-473d-8aad-68ae7fbf2abf' + body = { + 'server_id': server_id, + 'input_values': {}, + 'action': 'INIT', + 'status': 'COMPLETE', + 'status_reason': None, + 'signal_id': None, + 'config_id': config_id} + data = body.copy() + data['id'] = deployment_id + self.manager.client.post.return_value = {} + mock_utils.return_value = {'software_deployment': data} + result = self.manager.create(**body) + self.assertEqual(software_deployments.SoftwareDeployment( + self.manager, data), result) + args, kwargs = self.manager.client.post.call_args + self.assertEqual('/software_deployments', args[0]) + self.assertEqual({'data': body}, kwargs) + + def test_delete(self): + deployment_id = 'bca6871d-86c0-4aff-b792-58a1f6947b57' + self.manager.delete(deployment_id) + call_args = self.manager.client.delete.call_args + self.assertEqual( + ('/software_deployments/%s' % deployment_id,), *call_args) + + @mock.patch.object(utils, 'get_response_body') + def test_update(self, mock_utils): + deployment_id = 'bca6871d-86c0-4aff-b792-58a1f6947b57' + config_id = 'd00ba4aa-db33-42e1-92f4-2a6469260107' + server_id = 'fb322564-7927-473d-8aad-68ae7fbf2abf' + body = { + 'server_id': server_id, + 'input_values': {}, + 'action': 'DEPLOYED', + 'status': 'COMPLETE', + 'status_reason': None, + 'signal_id': None, + 'config_id': config_id} + data = body.copy() + data['id'] = deployment_id + self.manager.client.put.return_value = {} + mock_utils.return_value = {'software_deployment': data} + result = self.manager.update(deployment_id, **body) + self.assertEqual(software_deployments.SoftwareDeployment( + self.manager, data), result) + args, kwargs = self.manager.client.put.call_args + self.assertEqual('/software_deployments/%s' % deployment_id, args[0]) + self.assertEqual({'data': body}, kwargs) diff --git a/stacktaskclient/tests/unit/test_stacks.py b/stacktaskclient/tests/unit/test_stacks.py new file mode 100644 index 0000000..12ebdc0 --- /dev/null +++ b/stacktaskclient/tests/unit/test_stacks.py @@ -0,0 +1,316 @@ +# 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 heatclient.v1 import stacks + +import mock +import testscenarios +from testscenarios import scenarios as scnrs +import testtools + +load_tests = testscenarios.load_tests_apply_scenarios + + +def mock_stack(manager, stack_name, stack_id): + return stacks.Stack(manager, { + "id": stack_id, + "stack_name": stack_name, + "links": [{ + "href": "http://192.0.2.1:8004/v1/1234/stacks/%s/%s" % ( + stack_name, stack_id), + "rel": "self"}], + "description": "No description", + "stack_status_reason": "Stack create completed successfully", + "creation_time": "2013-08-04T20:57:55Z", + "updated_time": "2013-08-04T20:57:55Z", + "stack_status": "CREATE_COMPLETE" + }) + + +class StackStatusActionTest(testtools.TestCase): + + scenarios = scnrs.multiply_scenarios([ + ('CREATE', dict(action='CREATE')), + ('DELETE', dict(action='DELETE')), + ('UPDATE', dict(action='UPDATE')), + ('ROLLBACK', dict(action='ROLLBACK')), + ('SUSPEND', dict(action='SUSPEND')), + ('RESUME', dict(action='RESUME')), + ('CHECK', dict(action='CHECK')) + ], [ + ('IN_PROGRESS', dict(status='IN_PROGRESS')), + ('FAILED', dict(status='FAILED')), + ('COMPLETE', dict(status='COMPLETE')) + ]) + + def test_status_action(self): + stack_status = '%s_%s' % (self.action, self.status) + stack = mock_stack(None, 'stack_1', 'abcd1234') + stack.stack_status = stack_status + self.assertEqual(self.action, stack.action) + self.assertEqual(self.status, stack.status) + + +class StackIdentifierTest(testtools.TestCase): + + def test_stack_identifier(self): + stack = mock_stack(None, 'the_stack', 'abcd1234') + self.assertEqual('the_stack/abcd1234', stack.identifier) + + +class StackOperationsTest(testtools.TestCase): + + def test_delete_stack(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack.delete() + manager.delete.assert_called_once_with('the_stack/abcd1234') + + def test_abandon_stack(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack.abandon() + manager.abandon.assert_called_once_with('the_stack/abcd1234') + + def test_get_stack(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack.get() + manager.get.assert_called_once_with('the_stack/abcd1234') + + def test_update_stack(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack.update() + manager.update.assert_called_once_with('the_stack/abcd1234') + + def test_create_stack(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack = stack.create() + manager.create.assert_called_once_with('the_stack/abcd1234') + + def test_preview_stack(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack = stack.preview() + manager.preview.assert_called_once_with() + + def test_snapshot(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack.snapshot('foo') + manager.snapshot.assert_called_once_with('the_stack/abcd1234', 'foo') + + def test_snapshot_show(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack.snapshot_show('snap1234') + manager.snapshot_show.assert_called_once_with( + 'the_stack/abcd1234', 'snap1234') + + def test_snapshot_delete(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack.snapshot_delete('snap1234') + manager.snapshot_delete.assert_called_once_with( + 'the_stack/abcd1234', 'snap1234') + + def test_restore(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack.restore('snap1234') + manager.restore.assert_called_once_with( + 'the_stack/abcd1234', 'snap1234') + + def test_snapshot_list(self): + manager = mock.MagicMock() + stack = mock_stack(manager, 'the_stack', 'abcd1234') + stack.snapshot_list() + manager.snapshot_list.assert_called_once_with('the_stack/abcd1234') + + +class StackManagerNoPaginationTest(testtools.TestCase): + + scenarios = [ + ('total_0', dict(total=0)), + ('total_1', dict(total=1)), + ('total_9', dict(total=9)), + ('total_10', dict(total=10)), + ('total_11', dict(total=11)), + ('total_19', dict(total=19)), + ('total_20', dict(total=20)), + ('total_21', dict(total=21)), + ('total_49', dict(total=49)), + ('total_50', dict(total=50)), + ('total_51', dict(total=51)), + ('total_95', dict(total=95)), + ] + + # absolute limit for results returned + limit = 50 + + def mock_manager(self): + manager = stacks.StackManager(None) + manager._list = mock.MagicMock() + + def mock_list(*args, **kwargs): + def results(): + for i in range(0, self.total): + stack_name = 'stack_%s' % (i + 1) + stack_id = 'abcd1234-%s' % (i + 1) + yield mock_stack(manager, stack_name, stack_id) + + return list(results()) + + manager._list.side_effect = mock_list + return manager + + def test_stack_list_no_pagination(self): + manager = self.mock_manager() + + results = list(manager.list()) + manager._list.assert_called_once_with( + '/stacks?', 'stacks') + + # paginate is not specified, so the total + # results is always returned + self.assertEqual(self.total, len(results)) + + if self.total > 0: + self.assertEqual('stack_1', results[0].stack_name) + self.assertEqual('stack_%s' % self.total, results[-1].stack_name) + + +class StackManagerPaginationTest(testtools.TestCase): + + scenarios = [ + ('0_offset_0', dict( + offset=0, + total=0, + results=((0, 0),) + )), + ('1_offset_0', dict( + offset=0, + total=1, + results=((0, 1),) + )), + ('9_offset_0', dict( + offset=0, + total=9, + results=((0, 9),) + )), + ('10_offset_0', dict( + offset=0, + total=10, + results=((0, 10), (10, 10)) + )), + ('11_offset_0', dict( + offset=0, + total=11, + results=((0, 10), (10, 11)) + )), + ('11_offset_10', dict( + offset=10, + total=11, + results=((10, 11),) + )), + ('19_offset_10', dict( + offset=10, + total=19, + results=((10, 19),) + )), + ('20_offset_10', dict( + offset=10, + total=20, + results=((10, 20), (20, 20)) + )), + ('21_offset_10', dict( + offset=10, + total=21, + results=((10, 20), (20, 21)) + )), + ('21_offset_0', dict( + offset=0, + total=21, + results=((0, 10), (10, 20), (20, 21)) + )), + ('21_offset_20', dict( + offset=20, + total=21, + results=((20, 21),) + )), + ('95_offset_90', dict( + offset=90, + total=95, + results=((90, 95),) + )), + ] + + # absolute limit for results returned + limit = 50 + + def mock_manager(self): + manager = stacks.StackManager(None) + manager._list = mock.MagicMock() + + def mock_list(arg_url, arg_response_key): + try: + result = self.results[self.result_index] + except IndexError: + return [] + self.result_index = self.result_index + 1 + + limit_string = 'limit=%s' % self.limit + self.assertIn(limit_string, arg_url) + + offset = result[0] + if offset > 0: + offset_string = 'marker=abcd1234-%s' % offset + self.assertIn(offset_string, arg_url) + + def results(): + + for i in range(*result): + self.limit -= 1 + stack_name = 'stack_%s' % (i + 1) + stack_id = 'abcd1234-%s' % (i + 1) + yield mock_stack(manager, stack_name, stack_id) + + return list(results()) + + manager._list.side_effect = mock_list + return manager + + def test_stack_list_pagination(self): + manager = self.mock_manager() + + list_params = {'limit': self.limit} + + if self.offset > 0: + marker = 'abcd1234-%s' % self.offset + list_params['marker'] = marker + + self.result_index = 0 + results = list(manager.list(**list_params)) + + # assert that the list method has been called enough times + self.assertEqual(len(self.results), self.result_index) + + last_result = min(self.limit, self.total - self.offset) + # one or more list calls have been recomposed into a single list + self.assertEqual(last_result, len(results)) + + if last_result > 0: + self.assertEqual('stack_%s' % (self.offset + 1), + results[0].stack_name) + self.assertEqual('stack_%s' % (self.offset + last_result), + results[-1].stack_name) diff --git a/stacktaskclient/tests/unit/test_template_format.py b/stacktaskclient/tests/unit/test_template_format.py new file mode 100644 index 0000000..ca8d1fc --- /dev/null +++ b/stacktaskclient/tests/unit/test_template_format.py @@ -0,0 +1,50 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import testscenarios +import testtools +import yaml + +from heatclient.common import template_format + + +load_tests = testscenarios.load_tests_apply_scenarios + + +class YamlParseExceptions(testtools.TestCase): + + scenarios = [ + ('scanner', dict(raised_exception=yaml.scanner.ScannerError())), + ('parser', dict(raised_exception=yaml.parser.ParserError())), + ('reader', + dict(raised_exception=yaml.reader.ReaderError('', '', '', '', ''))), + ] + + def test_parse_to_value_exception(self): + text = 'not important' + + with mock.patch.object(yaml, 'load') as yaml_loader: + yaml_loader.side_effect = self.raised_exception + + self.assertRaises(ValueError, + template_format.parse, text) + + def test_parse_no_version_format(self): + yaml = '' + self.assertRaises(ValueError, template_format.parse, yaml) + yaml2 = '''Parameters: {} +Mappings: {} +Resources: {} +Outputs: {} +''' + self.assertRaises(ValueError, template_format.parse, yaml2) diff --git a/stacktaskclient/tests/unit/test_template_utils.py b/stacktaskclient/tests/unit/test_template_utils.py new file mode 100644 index 0000000..9f917b3 --- /dev/null +++ b/stacktaskclient/tests/unit/test_template_utils.py @@ -0,0 +1,1017 @@ +# 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 base64 +import json +from mox3 import mox +import six +from six.moves.urllib import request +import tempfile +import testtools +from testtools import matchers +import yaml + +from heatclient.common import template_utils +from heatclient.common import utils +from heatclient import exc + + +class ShellEnvironmentTest(testtools.TestCase): + + template_a = b'{"heat_template_version": "2013-05-23"}' + + def setUp(self): + super(ShellEnvironmentTest, self).setUp() + self.m = mox.Mox() + + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def collect_links(self, env, content, url, env_base_url=''): + + jenv = yaml.safe_load(env) + files = {} + if url: + self.m.StubOutWithMock(request, 'urlopen') + request.urlopen(url).AndReturn(six.BytesIO(content)) + request.urlopen(url).AndReturn(six.BytesIO(content)) + self.m.ReplayAll() + + template_utils.resolve_environment_urls( + jenv.get('resource_registry'), files, env_base_url) + if url: + self.assertEqual(content.decode('utf-8'), files[url]) + + def test_process_environment_file(self): + + self.m.StubOutWithMock(request, 'urlopen') + env_file = '/home/my/dir/env.yaml' + env = b''' + resource_registry: + "OS::Thingy": "file:///home/b/a.yaml" + ''' + + request.urlopen('file://%s' % env_file).AndReturn( + six.BytesIO(env)) + request.urlopen('file:///home/b/a.yaml').AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/b/a.yaml').AndReturn( + six.BytesIO(self.template_a)) + self.m.ReplayAll() + + files, env_dict = template_utils.process_environment_and_files( + env_file) + self.assertEqual( + {'resource_registry': { + 'OS::Thingy': 'file:///home/b/a.yaml'}}, + env_dict) + self.assertEqual(self.template_a.decode('utf-8'), + files['file:///home/b/a.yaml']) + + def test_process_environment_relative_file(self): + + self.m.StubOutWithMock(request, 'urlopen') + env_file = '/home/my/dir/env.yaml' + env_url = 'file:///home/my/dir/env.yaml' + env = b''' + resource_registry: + "OS::Thingy": a.yaml + ''' + + request.urlopen(env_url).AndReturn( + six.BytesIO(env)) + request.urlopen('file:///home/my/dir/a.yaml').AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/my/dir/a.yaml').AndReturn( + six.BytesIO(self.template_a)) + self.m.ReplayAll() + + self.assertEqual( + env_url, + utils.normalise_file_path_to_url(env_file)) + self.assertEqual( + 'file:///home/my/dir', + utils.base_url_for_url(env_url)) + + files, env_dict = template_utils.process_environment_and_files( + env_file) + + self.assertEqual( + {'resource_registry': { + 'OS::Thingy': 'file:///home/my/dir/a.yaml'}}, + env_dict) + self.assertEqual(self.template_a.decode('utf-8'), + files['file:///home/my/dir/a.yaml']) + + def test_process_environment_relative_file_up(self): + + self.m.StubOutWithMock(request, 'urlopen') + env_file = '/home/my/dir/env.yaml' + env_url = 'file:///home/my/dir/env.yaml' + env = b''' + resource_registry: + "OS::Thingy": ../bar/a.yaml + ''' + + request.urlopen(env_url).AndReturn( + six.BytesIO(env)) + request.urlopen('file:///home/my/bar/a.yaml').AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/my/bar/a.yaml').AndReturn( + six.BytesIO(self.template_a)) + self.m.ReplayAll() + + env_url = 'file://%s' % env_file + self.assertEqual( + env_url, + utils.normalise_file_path_to_url(env_file)) + self.assertEqual( + 'file:///home/my/dir', + utils.base_url_for_url(env_url)) + + files, env_dict = template_utils.process_environment_and_files( + env_file) + + self.assertEqual( + {'resource_registry': { + 'OS::Thingy': 'file:///home/my/bar/a.yaml'}}, + env_dict) + self.assertEqual(self.template_a.decode('utf-8'), + files['file:///home/my/bar/a.yaml']) + + def test_process_environment_url(self): + env = b''' + resource_registry: + "OS::Thingy": "a.yaml" + ''' + url = 'http://no.where/some/path/to/file.yaml' + tmpl_url = 'http://no.where/some/path/to/a.yaml' + + self.m.StubOutWithMock(request, 'urlopen') + request.urlopen(url).AndReturn(six.BytesIO(env)) + request.urlopen(tmpl_url).AndReturn(six.BytesIO(self.template_a)) + request.urlopen(tmpl_url).AndReturn(six.BytesIO(self.template_a)) + self.m.ReplayAll() + + files, env_dict = template_utils.process_environment_and_files( + url) + + self.assertEqual({'resource_registry': {'OS::Thingy': tmpl_url}}, + env_dict) + self.assertEqual(self.template_a.decode('utf-8'), files[tmpl_url]) + + def test_process_environment_empty_file(self): + + self.m.StubOutWithMock(request, 'urlopen') + env_file = '/home/my/dir/env.yaml' + env = b'' + + request.urlopen('file://%s' % env_file).AndReturn(six.BytesIO(env)) + self.m.ReplayAll() + + files, env_dict = template_utils.process_environment_and_files( + env_file) + + self.assertEqual({}, env_dict) + self.assertEqual({}, files) + + def test_no_process_environment_and_files(self): + files, env = template_utils.process_environment_and_files() + self.assertEqual({}, env) + self.assertEqual({}, files) + + def test_process_multiple_environments_and_files(self): + + self.m.StubOutWithMock(request, 'urlopen') + env_file1 = '/home/my/dir/env1.yaml' + env_file2 = '/home/my/dir/env2.yaml' + + env1 = b''' + parameters: + "param1": "value1" + resource_registry: + "OS::Thingy1": "file:///home/b/a.yaml" + ''' + env2 = b''' + parameters: + "param2": "value2" + resource_registry: + "OS::Thingy2": "file:///home/b/b.yaml" + ''' + + request.urlopen('file://%s' % env_file1).AndReturn( + six.BytesIO(env1)) + request.urlopen('file:///home/b/a.yaml').AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/b/a.yaml').AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file://%s' % env_file2).AndReturn( + six.BytesIO(env2)) + request.urlopen('file:///home/b/b.yaml').AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/b/b.yaml').AndReturn( + six.BytesIO(self.template_a)) + self.m.ReplayAll() + + files, env = template_utils.process_multiple_environments_and_files( + [env_file1, env_file2]) + self.assertEqual( + { + 'resource_registry': { + 'OS::Thingy1': 'file:///home/b/a.yaml', + 'OS::Thingy2': 'file:///home/b/b.yaml'}, + 'parameters': { + 'param1': 'value1', + 'param2': 'value2'} + }, + env) + self.assertEqual(self.template_a.decode('utf-8'), + files['file:///home/b/a.yaml']) + self.assertEqual(self.template_a.decode('utf-8'), + files['file:///home/b/b.yaml']) + + def test_process_multiple_environments_default_resources(self): + + self.m.StubOutWithMock(request, 'urlopen') + env_file1 = '/home/my/dir/env1.yaml' + env_file2 = '/home/my/dir/env2.yaml' + + env1 = b''' + resource_registry: + resources: + resource1: + "OS::Thingy1": "file:///home/b/a.yaml" + resource2: + "OS::Thingy2": "file:///home/b/b.yaml" + ''' + env2 = b''' + resource_registry: + resources: + resource1: + "OS::Thingy3": "file:///home/b/a.yaml" + resource2: + "OS::Thingy4": "file:///home/b/b.yaml" + ''' + + request.urlopen('file://%s' % env_file1).InAnyOrder().AndReturn( + six.BytesIO(env1)) + request.urlopen('file:///home/b/a.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/b/b.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/b/a.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/b/b.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file://%s' % env_file2).InAnyOrder().AndReturn( + six.BytesIO(env2)) + request.urlopen('file:///home/b/a.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/b/b.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/b/a.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.template_a)) + request.urlopen('file:///home/b/b.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.template_a)) + self.m.ReplayAll() + + files, env = template_utils.process_multiple_environments_and_files( + [env_file1, env_file2]) + self.assertEqual( + { + 'resource_registry': { + 'resources': { + 'resource1': { + 'OS::Thingy1': 'file:///home/b/a.yaml', + 'OS::Thingy3': 'file:///home/b/a.yaml' + }, + 'resource2': { + 'OS::Thingy2': 'file:///home/b/b.yaml', + 'OS::Thingy4': 'file:///home/b/b.yaml' + } + } + } + }, + env) + self.assertEqual(self.template_a.decode('utf-8'), + files['file:///home/b/a.yaml']) + self.assertEqual(self.template_a.decode('utf-8'), + files['file:///home/b/b.yaml']) + + def test_no_process_multiple_environments_and_files(self): + files, env = template_utils.process_multiple_environments_and_files() + self.assertEqual({}, env) + self.assertEqual({}, files) + + def test_global_files(self): + url = 'file:///home/b/a.yaml' + env = ''' + resource_registry: + "OS::Thingy": "%s" + ''' % url + self.collect_links(env, self.template_a, url) + + def test_nested_files(self): + url = 'file:///home/b/a.yaml' + env = ''' + resource_registry: + resources: + freddy: + "OS::Thingy": "%s" + ''' % url + self.collect_links(env, self.template_a, url) + + def test_http_url(self): + url = 'http://no.where/container/a.yaml' + env = ''' + resource_registry: + "OS::Thingy": "%s" + ''' % url + self.collect_links(env, self.template_a, url) + + def test_with_base_url(self): + url = 'ftp://no.where/container/a.yaml' + env = ''' + resource_registry: + base_url: "ftp://no.where/container/" + resources: + server_for_me: + "OS::Thingy": a.yaml + ''' + self.collect_links(env, self.template_a, url) + + def test_with_built_in_provider(self): + env = ''' + resource_registry: + resources: + server_for_me: + "OS::Thingy": OS::Compute::Server + ''' + self.collect_links(env, self.template_a, None) + + def test_with_env_file_base_url_file(self): + url = 'file:///tmp/foo/a.yaml' + env = ''' + resource_registry: + resources: + server_for_me: + "OS::Thingy": a.yaml + ''' + env_base_url = 'file:///tmp/foo' + self.collect_links(env, self.template_a, url, env_base_url) + + def test_with_env_file_base_url_http(self): + url = 'http://no.where/path/to/a.yaml' + env = ''' + resource_registry: + resources: + server_for_me: + "OS::Thingy": to/a.yaml + ''' + env_base_url = 'http://no.where/path' + self.collect_links(env, self.template_a, url, env_base_url) + + def test_unsupported_protocol(self): + env = ''' + resource_registry: + "OS::Thingy": "sftp://no.where/dev/null/a.yaml" + ''' + jenv = yaml.safe_load(env) + fields = {'files': {}} + self.assertRaises(exc.CommandError, + template_utils.get_file_contents, + jenv['resource_registry'], + fields) + + +class TestGetTemplateContents(testtools.TestCase): + + def setUp(self): + super(TestGetTemplateContents, self).setUp() + self.m = mox.Mox() + + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def test_get_template_contents_file(self): + with tempfile.NamedTemporaryFile() as tmpl_file: + tmpl = b'{"AWSTemplateFormatVersion" : "2010-09-09",' \ + b' "foo": "bar"}' + tmpl_file.write(tmpl) + tmpl_file.flush() + + files, tmpl_parsed = template_utils.get_template_contents( + tmpl_file.name) + self.assertEqual({"AWSTemplateFormatVersion": "2010-09-09", + "foo": "bar"}, tmpl_parsed) + self.assertEqual({}, files) + + def test_get_template_contents_file_empty(self): + with tempfile.NamedTemporaryFile() as tmpl_file: + + ex = self.assertRaises( + exc.CommandError, + template_utils.get_template_contents, + tmpl_file.name) + self.assertEqual( + 'Could not fetch template from file://%s' % tmpl_file.name, + str(ex)) + + def test_get_template_contents_file_none(self): + ex = self.assertRaises( + exc.CommandError, + template_utils.get_template_contents) + self.assertEqual( + ('Need to specify exactly one of --template-file, ' + '--template-url or --template-object'), + str(ex)) + + def test_get_template_contents_parse_error(self): + with tempfile.NamedTemporaryFile() as tmpl_file: + + tmpl = b'{"foo": "bar"' + tmpl_file.write(tmpl) + tmpl_file.flush() + + ex = self.assertRaises( + exc.CommandError, + template_utils.get_template_contents, + tmpl_file.name) + self.assertThat( + str(ex), + matchers.MatchesRegex( + 'Error parsing template file://%s ' % tmpl_file.name)) + + def test_get_template_contents_url(self): + tmpl = b'{"AWSTemplateFormatVersion" : "2010-09-09", "foo": "bar"}' + url = 'http://no.where/path/to/a.yaml' + self.m.StubOutWithMock(request, 'urlopen') + request.urlopen(url).AndReturn(six.BytesIO(tmpl)) + self.m.ReplayAll() + + files, tmpl_parsed = template_utils.get_template_contents( + template_url=url) + self.assertEqual({"AWSTemplateFormatVersion": "2010-09-09", + "foo": "bar"}, tmpl_parsed) + self.assertEqual({}, files) + + def test_get_template_contents_object(self): + tmpl = '{"AWSTemplateFormatVersion" : "2010-09-09", "foo": "bar"}' + url = 'http://no.where/path/to/a.yaml' + self.m.ReplayAll() + + self.object_requested = False + + def object_request(method, object_url): + self.object_requested = True + self.assertEqual('GET', method) + self.assertEqual('http://no.where/path/to/a.yaml', object_url) + return tmpl + + files, tmpl_parsed = template_utils.get_template_contents( + template_object=url, + object_request=object_request) + + self.assertEqual({"AWSTemplateFormatVersion": "2010-09-09", + "foo": "bar"}, tmpl_parsed) + self.assertEqual({}, files) + self.assertTrue(self.object_requested) + + def check_non_utf8_content(self, filename, content): + base_url = 'file:///tmp' + url = '%s/%s' % (base_url, filename) + template = {'resources': + {'one_init': + {'type': 'OS::Heat::CloudConfig', + 'properties': + {'cloud_config': + {'write_files': + [{'path': '/tmp/%s' % filename, + 'content': {'get_file': url}, + 'encoding': 'b64'}]}}}}} + self.m.StubOutWithMock(request, 'urlopen') + raw_content = base64.decodestring(content) + response = six.BytesIO(raw_content) + request.urlopen(url).AndReturn(response) + self.m.ReplayAll() + files = {} + template_utils.resolve_template_get_files( + template, files, base_url) + self.assertEqual({url: content}, files) + + def test_get_zip_content(self): + filename = 'heat.zip' + content = b'''\ +UEsDBAoAAAAAAEZZWkRbOAuBBQAAAAUAAAAIABwAaGVhdC50eHRVVAkAAxRbDVNYh\ +t9SdXgLAAEE\n6AMAAATpAwAAaGVhdApQSwECHgMKAAAAAABGWVpEWzgLgQUAAAAF\ +AAAACAAYAAAAAAABAAAApIEA\nAAAAaGVhdC50eHRVVAUAAxRbDVN1eAsAAQToAwA\ +ABOkDAABQSwUGAAAAAAEAAQBOAAAARwAAAAAA\n''' + # zip has '\0' in stream + self.assertIn(b'\0', base64.decodestring(content)) + decoded_content = base64.decodestring(content) + if six.PY3: + self.assertRaises(UnicodeDecodeError, decoded_content.decode) + else: + self.assertRaises( + UnicodeDecodeError, + json.dumps, + {'content': decoded_content}) + self.check_non_utf8_content( + filename=filename, content=content) + + def test_get_utf16_content(self): + filename = 'heat.utf16' + content = b'//4tTkhTCgA=\n' + # utf6 has '\0' in stream + self.assertIn(b'\0', base64.decodestring(content)) + decoded_content = base64.decodestring(content) + if six.PY3: + self.assertRaises(UnicodeDecodeError, decoded_content.decode) + else: + self.assertRaises( + UnicodeDecodeError, + json.dumps, + {'content': decoded_content}) + self.check_non_utf8_content(filename=filename, content=content) + + def test_get_gb18030_content(self): + filename = 'heat.gb18030' + content = b'1tDO5wo=\n' + # gb18030 has no '\0' in stream + self.assertNotIn('\0', base64.decodestring(content)) + decoded_content = base64.decodestring(content) + if six.PY3: + self.assertRaises(UnicodeDecodeError, decoded_content.decode) + else: + self.assertRaises( + UnicodeDecodeError, + json.dumps, + {'content': decoded_content}) + self.check_non_utf8_content(filename=filename, content=content) + + +class TestTemplateGetFileFunctions(testtools.TestCase): + + hot_template = b'''heat_template_version: 2013-05-23 +resources: + resource1: + type: OS::type1 + properties: + foo: {get_file: foo.yaml} + bar: + get_file: + 'http://localhost/bar.yaml' + resource2: + type: OS::type1 + properties: + baz: + - {get_file: baz/baz1.yaml} + - {get_file: baz/baz2.yaml} + - {get_file: baz/baz3.yaml} + ignored_list: {get_file: [ignore, me]} + ignored_dict: {get_file: {ignore: me}} + ignored_none: {get_file: } + ''' + + def setUp(self): + super(TestTemplateGetFileFunctions, self).setUp() + self.m = mox.Mox() + + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def test_hot_template(self): + self.m.StubOutWithMock(request, 'urlopen') + + tmpl_file = '/home/my/dir/template.yaml' + url = 'file:///home/my/dir/template.yaml' + request.urlopen(url).AndReturn( + six.BytesIO(self.hot_template)) + request.urlopen( + 'http://localhost/bar.yaml').InAnyOrder().AndReturn( + six.BytesIO(b'bar contents')) + request.urlopen( + 'file:///home/my/dir/foo.yaml').InAnyOrder().AndReturn( + six.BytesIO(b'foo contents')) + request.urlopen( + 'file:///home/my/dir/baz/baz1.yaml').InAnyOrder().AndReturn( + six.BytesIO(b'baz1 contents')) + request.urlopen( + 'file:///home/my/dir/baz/baz2.yaml').InAnyOrder().AndReturn( + six.BytesIO(b'baz2 contents')) + request.urlopen( + 'file:///home/my/dir/baz/baz3.yaml').InAnyOrder().AndReturn( + six.BytesIO(b'baz3 contents')) + + self.m.ReplayAll() + + files, tmpl_parsed = template_utils.get_template_contents( + template_file=tmpl_file) + + self.assertEqual({ + 'http://localhost/bar.yaml': b'bar contents', + 'file:///home/my/dir/foo.yaml': b'foo contents', + 'file:///home/my/dir/baz/baz1.yaml': b'baz1 contents', + 'file:///home/my/dir/baz/baz2.yaml': b'baz2 contents', + 'file:///home/my/dir/baz/baz3.yaml': b'baz3 contents', + }, files) + self.assertEqual({ + 'heat_template_version': '2013-05-23', + 'resources': { + 'resource1': { + 'type': 'OS::type1', + 'properties': { + 'bar': {'get_file': 'http://localhost/bar.yaml'}, + 'foo': {'get_file': 'file:///home/my/dir/foo.yaml'}, + }, + }, + 'resource2': { + 'type': 'OS::type1', + 'properties': { + 'baz': [ + {'get_file': 'file:///home/my/dir/baz/baz1.yaml'}, + {'get_file': 'file:///home/my/dir/baz/baz2.yaml'}, + {'get_file': 'file:///home/my/dir/baz/baz3.yaml'}, + ], + 'ignored_list': {'get_file': ['ignore', 'me']}, + 'ignored_dict': {'get_file': {'ignore': 'me'}}, + 'ignored_none': {'get_file': None}, + }, + } + } + }, tmpl_parsed) + + def test_hot_template_outputs(self): + self.m.StubOutWithMock(request, 'urlopen') + tmpl_file = '/home/my/dir/template.yaml' + url = 'file://%s' % tmpl_file + foo_url = 'file:///home/my/dir/foo.yaml' + contents = b''' +heat_template_version: 2013-05-23\n\ +outputs:\n\ + contents:\n\ + value:\n\ + get_file: foo.yaml\n''' + request.urlopen(url).AndReturn(six.BytesIO(contents)) + request.urlopen(foo_url).AndReturn(six.BytesIO(b'foo contents')) + self.m.ReplayAll() + files = template_utils.get_template_contents( + template_file=tmpl_file)[0] + self.assertEqual({foo_url: b'foo contents'}, files) + + def test_hot_template_same_file(self): + self.m.StubOutWithMock(request, 'urlopen') + tmpl_file = '/home/my/dir/template.yaml' + url = 'file://%s' % tmpl_file + foo_url = 'file:///home/my/dir/foo.yaml' + contents = b''' +heat_template_version: 2013-05-23\n +outputs:\n\ + contents:\n\ + value:\n\ + get_file: foo.yaml\n\ + template:\n\ + value:\n\ + get_file: foo.yaml\n''' + request.urlopen(url).AndReturn(six.BytesIO(contents)) + # asserts that is fetched only once even though it is + # referenced in the template twice + request.urlopen(foo_url).AndReturn(six.BytesIO(b'foo contents')) + self.m.ReplayAll() + files = template_utils.get_template_contents( + template_file=tmpl_file)[0] + self.assertEqual({foo_url: b'foo contents'}, files) + + +class TestTemplateTypeFunctions(testtools.TestCase): + + hot_template = b'''heat_template_version: 2013-05-23 +parameters: + param1: + type: string +resources: + resource1: + type: foo.yaml + properties: + foo: bar + resource2: + type: OS::Heat::ResourceGroup + properties: + resource_def: + type: spam/egg.yaml + ''' + + foo_template = b'''heat_template_version: "2013-05-23" +parameters: + foo: + type: string + ''' + + egg_template = b'''heat_template_version: "2013-05-23" +parameters: + egg: + type: string + ''' + + def setUp(self): + super(TestTemplateTypeFunctions, self).setUp() + self.m = mox.Mox() + + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def test_hot_template(self): + self.m.StubOutWithMock(request, 'urlopen') + tmpl_file = '/home/my/dir/template.yaml' + url = 'file:///home/my/dir/template.yaml' + request.urlopen( + 'file:///home/my/dir/foo.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + request.urlopen( + 'file:///home/my/dir/foo.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + request.urlopen(url).InAnyOrder().AndReturn( + six.BytesIO(self.hot_template)) + request.urlopen( + 'file:///home/my/dir/spam/egg.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.egg_template)) + request.urlopen( + 'file:///home/my/dir/spam/egg.yaml').InAnyOrder().AndReturn( + six.BytesIO(self.egg_template)) + self.m.ReplayAll() + + files, tmpl_parsed = template_utils.get_template_contents( + template_file=tmpl_file) + + self.assertEqual(yaml.load(self.foo_template.decode('utf-8')), + json.loads(files.get('file:///home/my/dir/foo.yaml'))) + self.assertEqual( + yaml.load(self.egg_template.decode('utf-8')), + json.loads(files.get('file:///home/my/dir/spam/egg.yaml'))) + + self.assertEqual({ + u'heat_template_version': u'2013-05-23', + u'parameters': { + u'param1': { + u'type': u'string' + } + }, + u'resources': { + u'resource1': { + u'type': u'file:///home/my/dir/foo.yaml', + u'properties': {u'foo': u'bar'} + }, + u'resource2': { + u'type': u'OS::Heat::ResourceGroup', + u'properties': { + u'resource_def': { + u'type': u'file:///home/my/dir/spam/egg.yaml' + } + } + } + } + }, tmpl_parsed) + + +class TestTemplateInFileFunctions(testtools.TestCase): + + hot_template = b'''heat_template_version: 2013-05-23 +resources: + resource1: + type: OS::Heat::Stack + properties: + template: {get_file: foo.yaml} + ''' + + foo_template = b'''heat_template_version: "2013-05-23" +resources: + foo: + type: OS::Type1 + properties: + config: {get_file: bar.yaml} + ''' + + bar_template = b'''heat_template_version: "2013-05-23" +parameters: + bar: + type: string + ''' + + def setUp(self): + super(TestTemplateInFileFunctions, self).setUp() + self.m = mox.Mox() + + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def test_hot_template(self): + self.m.StubOutWithMock(request, 'urlopen') + tmpl_file = '/home/my/dir/template.yaml' + url = 'file:///home/my/dir/template.yaml' + foo_url = 'file:///home/my/dir/foo.yaml' + bar_url = 'file:///home/my/dir/bar.yaml' + request.urlopen(url).InAnyOrder().AndReturn( + six.BytesIO(self.hot_template)) + request.urlopen(foo_url).InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + request.urlopen(foo_url).InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + request.urlopen(bar_url).InAnyOrder().AndReturn( + six.BytesIO(self.bar_template)) + request.urlopen(bar_url).InAnyOrder().AndReturn( + six.BytesIO(self.bar_template)) + self.m.ReplayAll() + + files, tmpl_parsed = template_utils.get_template_contents( + template_file=tmpl_file) + + self.assertEqual(yaml.load(self.bar_template.decode('utf-8')), + json.loads(files.get('file:///home/my/dir/bar.yaml'))) + + self.assertEqual({ + u'heat_template_version': u'2013-05-23', + u'resources': { + u'foo': { + u'type': u'OS::Type1', + u'properties': { + u'config': { + u'get_file': u'file:///home/my/dir/bar.yaml' + } + } + } + } + }, json.loads(files.get('file:///home/my/dir/foo.yaml'))) + + self.assertEqual({ + u'heat_template_version': u'2013-05-23', + u'resources': { + u'resource1': { + u'type': u'OS::Heat::Stack', + u'properties': { + u'template': { + u'get_file': u'file:///home/my/dir/foo.yaml' + } + } + } + } + }, tmpl_parsed) + + +class TestNestedIncludes(testtools.TestCase): + + hot_template = b'''heat_template_version: 2013-05-23 +parameters: + param1: + type: string +resources: + resource1: + type: foo.yaml + properties: + foo: bar + resource2: + type: OS::Heat::ResourceGroup + properties: + resource_def: + type: spam/egg.yaml + with: {get_file: spam/ham.yaml} + ''' + + egg_template = b'''heat_template_version: 2013-05-23 +parameters: + param1: + type: string +resources: + resource1: + type: one.yaml + properties: + foo: bar + resource2: + type: OS::Heat::ResourceGroup + properties: + resource_def: + type: two.yaml + with: {get_file: three.yaml} + ''' + + foo_template = b'''heat_template_version: "2013-05-23" +parameters: + foo: + type: string + ''' + + def setUp(self): + super(TestNestedIncludes, self).setUp() + self.m = mox.Mox() + + self.addCleanup(self.m.VerifyAll) + self.addCleanup(self.m.UnsetStubs) + + def test_env_nested_includes(self): + self.m.StubOutWithMock(request, 'urlopen') + env_file = '/home/my/dir/env.yaml' + env_url = 'file:///home/my/dir/env.yaml' + env = b''' + resource_registry: + "OS::Thingy": template.yaml + ''' + template_url = u'file:///home/my/dir/template.yaml' + foo_url = u'file:///home/my/dir/foo.yaml' + egg_url = u'file:///home/my/dir/spam/egg.yaml' + ham_url = u'file:///home/my/dir/spam/ham.yaml' + one_url = u'file:///home/my/dir/spam/one.yaml' + two_url = u'file:///home/my/dir/spam/two.yaml' + three_url = u'file:///home/my/dir/spam/three.yaml' + + request.urlopen(env_url).AndReturn( + six.BytesIO(env)) + request.urlopen(template_url).AndReturn( + six.BytesIO(self.hot_template)) + request.urlopen(template_url).AndReturn( + six.BytesIO(self.hot_template)) + + request.urlopen(foo_url).InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + request.urlopen(egg_url).InAnyOrder().AndReturn( + six.BytesIO(self.egg_template)) + request.urlopen(ham_url).InAnyOrder().AndReturn( + six.BytesIO(b'ham contents')) + request.urlopen(one_url).InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + request.urlopen(two_url).InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + request.urlopen(three_url).InAnyOrder().AndReturn( + six.BytesIO(b'three contents')) + request.urlopen(foo_url).InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + request.urlopen(egg_url).InAnyOrder().AndReturn( + six.BytesIO(self.egg_template)) + request.urlopen(one_url).InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + request.urlopen(two_url).InAnyOrder().AndReturn( + six.BytesIO(self.foo_template)) + + self.m.ReplayAll() + + files, env_dict = template_utils.process_environment_and_files( + env_file) + + self.assertEqual( + {'resource_registry': { + 'OS::Thingy': template_url}}, + env_dict) + + self.assertEqual({ + u'heat_template_version': u'2013-05-23', + u'parameters': {u'param1': {u'type': u'string'}}, + u'resources': { + u'resource1': { + u'properties': {u'foo': u'bar'}, + u'type': foo_url + }, + u'resource2': { + u'type': u'OS::Heat::ResourceGroup', + u'properties': { + u'resource_def': { + u'type': egg_url}, + u'with': {u'get_file': ham_url} + } + } + } + }, json.loads(files.get(template_url))) + + self.assertEqual(yaml.load(self.foo_template.decode('utf-8')), + json.loads(files.get(foo_url))) + self.assertEqual({ + u'heat_template_version': u'2013-05-23', + u'parameters': {u'param1': {u'type': u'string'}}, + u'resources': { + u'resource1': { + u'properties': {u'foo': u'bar'}, + u'type': one_url}, + u'resource2': { + u'type': u'OS::Heat::ResourceGroup', + u'properties': { + u'resource_def': {u'type': two_url}, + u'with': {u'get_file': three_url} + } + } + } + }, json.loads(files.get(egg_url))) + self.assertEqual(b'ham contents', + files.get(ham_url)) + self.assertEqual(yaml.load(self.foo_template.decode('utf-8')), + json.loads(files.get(one_url))) + self.assertEqual(yaml.load(self.foo_template.decode('utf-8')), + json.loads(files.get(two_url))) + self.assertEqual(b'three contents', + files.get(three_url)) diff --git a/stacktaskclient/tests/unit/test_template_versions.py b/stacktaskclient/tests/unit/test_template_versions.py new file mode 100644 index 0000000..fe3cd09 --- /dev/null +++ b/stacktaskclient/tests/unit/test_template_versions.py @@ -0,0 +1,57 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import testtools + +from heatclient.v1 import template_versions + + +class TemplateVersionManagerTest(testtools.TestCase): + + def setUp(self): + super(TemplateVersionManagerTest, self).setUp() + + def test_list_versions(self): + expect = ('GET', '/template_versions') + + class FakeResponse(object): + def json(self): + return {'template_versions': [{'version': '2013-05-23', + 'type': 'hot'}]} + + class FakeClient(object): + def get(self, *args, **kwargs): + assert ('GET', args[0]) == expect + return FakeResponse() + + manager = template_versions.TemplateVersionManager(FakeClient()) + versions = manager.list() + self.assertEqual('2013-05-23', getattr(versions[0], 'version')) + self.assertEqual('hot', getattr(versions[0], 'type')) + + def test_get(self): + expect = ('GET', '/template_versions/heat_template_version.2015-04-30' + '/functions') + + class FakeResponse(object): + def json(self): + return {'template_functions': [{'function': 'get_attr'}]} + + class FakeClient(object): + def get(self, *args, **kwargs): + assert ('GET', args[0]) == expect + return FakeResponse() + + manager = template_versions.TemplateVersionManager(FakeClient()) + functions = manager.get('heat_template_version.2015-04-30') + self.assertEqual('get_attr', getattr(functions[0], 'function')) diff --git a/stacktaskclient/tests/unit/test_utils.py b/stacktaskclient/tests/unit/test_utils.py new file mode 100644 index 0000000..10946eb --- /dev/null +++ b/stacktaskclient/tests/unit/test_utils.py @@ -0,0 +1,278 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from heatclient.common import utils +from heatclient import exc +from heatclient.v1 import resources as hc_res +import mock +import os +import testtools + + +class ShellTest(testtools.TestCase): + + def test_format_parameter_none(self): + self.assertEqual({}, utils.format_parameters(None)) + + def test_format_parameters(self): + p = utils.format_parameters([ + 'InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17']) + self.assertEqual({'InstanceType': 'm1.large', + 'DBUsername': 'wp', + 'DBPassword': 'verybadpassword', + 'KeyName': 'heat_key', + 'LinuxDistribution': 'F17' + }, p) + + def test_format_parameters_split(self): + p = utils.format_parameters([ + 'KeyName=heat_key;' + 'DnsSecKey=hsgx1m31PbamNF4WEcHlwjIlCGgifOdoB58/wwC7a4oAONQ/fDV5ct' + 'qrYBoLlKHhTfkyQEw9iVScKYZbbMtMNg==;' + 'UpstreamDNS=8.8.8.8']) + self.assertEqual({'KeyName': 'heat_key', + 'DnsSecKey': 'hsgx1m31PbamNF4WEcHlwjIlCGgifOdoB58/ww' + 'C7a4oAONQ/fDV5ctqrYBoLlKHhTfkyQEw9iVScKYZbbMtMNg==', + 'UpstreamDNS': '8.8.8.8'}, p) + + def test_format_parameters_multiple(self): + p = utils.format_parameters([ + 'KeyName=heat_key', + 'DnsSecKey=hsgx1m31PbamNF4WEcHlwjIlCGgifOdoB58/wwC7a4oAONQ/fDV5ct' + 'qrYBoLlKHhTfkyQEw9iVScKYZbbMtMNg==', + 'UpstreamDNS=8.8.8.8']) + self.assertEqual({'KeyName': 'heat_key', + 'DnsSecKey': 'hsgx1m31PbamNF4WEcHlwjIlCGgifOdoB58/ww' + 'C7a4oAONQ/fDV5ctqrYBoLlKHhTfkyQEw9iVScKYZbbMtMNg==', + 'UpstreamDNS': '8.8.8.8'}, p) + + def test_format_parameters_multiple_semicolon_values(self): + p = utils.format_parameters([ + 'KeyName=heat_key', + 'DnsSecKey=hsgx1m31;PbaNF4WEcHlwj;IlCGgfOdoB;58/ww7a4oAO;NQ/fD==', + 'UpstreamDNS=8.8.8.8']) + self.assertEqual({'KeyName': 'heat_key', + 'DnsSecKey': 'hsgx1m31;PbaNF4WEcHlwj;IlCGgfOdoB;58/' + 'ww7a4oAO;NQ/fD==', + 'UpstreamDNS': '8.8.8.8'}, p) + + def test_format_parameters_parse_semicolon_false(self): + p = utils.format_parameters( + ['KeyName=heat_key;UpstreamDNS=8.8.8.8;a=b'], + parse_semicolon=False) + self.assertEqual({'KeyName': 'heat_key;UpstreamDNS=8.8.8.8;a=b'}, p) + + def test_format_parameters_multiple_values_per_pamaters(self): + p = utils.format_parameters([ + 'status=COMPLETE', + 'status=FAILED']) + self.assertIn('status', p) + self.assertIn('COMPLETE', p['status']) + self.assertIn('FAILED', p['status']) + + def test_format_parameter_bad_parameter(self): + params = ['KeyName=heat_key;UpstreamDNS8.8.8.8'] + ex = self.assertRaises(exc.CommandError, + utils.format_parameters, params) + self.assertEqual('Malformed parameter(UpstreamDNS8.8.8.8). ' + 'Use the key=value format.', str(ex)) + + def test_format_multiple_bad_parameter(self): + params = ['KeyName=heat_key', 'UpstreamDNS8.8.8.8'] + ex = self.assertRaises(exc.CommandError, + utils.format_parameters, params) + self.assertEqual('Malformed parameter(UpstreamDNS8.8.8.8). ' + 'Use the key=value format.', str(ex)) + + def test_link_formatter(self): + self.assertEqual('', utils.link_formatter(None)) + self.assertEqual('', utils.link_formatter([])) + self.assertEqual( + 'http://foo.example.com\nhttp://bar.example.com', + utils.link_formatter([ + {'href': 'http://foo.example.com'}, + {'href': 'http://bar.example.com'}])) + self.assertEqual( + 'http://foo.example.com (a)\nhttp://bar.example.com (b)', + utils.link_formatter([ + {'href': 'http://foo.example.com', 'rel': 'a'}, + {'href': 'http://bar.example.com', 'rel': 'b'}])) + self.assertEqual( + '\n', + utils.link_formatter([ + {'hrf': 'http://foo.example.com'}, + {}])) + + def test_resource_nested_identifier(self): + rsrc_info = {'resource_name': 'aresource', + 'links': [{'href': u'http://foo/name/id/resources/0', + 'rel': u'self'}, + {'href': u'http://foo/name/id', + 'rel': u'stack'}, + {'href': u'http://foo/n_name/n_id', + 'rel': u'nested'}]} + rsrc = hc_res.Resource(manager=None, info=rsrc_info) + self.assertEqual('n_name/n_id', utils.resource_nested_identifier(rsrc)) + + def test_resource_nested_identifier_none(self): + rsrc_info = {'resource_name': 'aresource', + 'links': [{'href': u'http://foo/name/id/resources/0', + 'rel': u'self'}, + {'href': u'http://foo/name/id', + 'rel': u'stack'}]} + rsrc = hc_res.Resource(manager=None, info=rsrc_info) + self.assertIsNone(utils.resource_nested_identifier(rsrc)) + + def test_json_formatter(self): + self.assertEqual('null', utils.json_formatter(None)) + self.assertEqual('{}', utils.json_formatter({})) + self.assertEqual('{\n "foo": "bar"\n}', + utils.json_formatter({"foo": "bar"})) + self.assertEqual(u'{\n "Uni": "test\u2665"\n}', + utils.json_formatter({"Uni": u"test\u2665"})) + + def test_text_wrap_formatter(self): + self.assertEqual('', utils.text_wrap_formatter(None)) + self.assertEqual('', utils.text_wrap_formatter('')) + self.assertEqual('one two three', + utils.text_wrap_formatter('one two three')) + self.assertEqual( + 'one two three four five six seven eight nine ten eleven\ntwelve', + utils.text_wrap_formatter( + ('one two three four five six seven ' + 'eight nine ten eleven twelve'))) + + def test_newline_list_formatter(self): + self.assertEqual('', utils.newline_list_formatter(None)) + self.assertEqual('', utils.newline_list_formatter([])) + self.assertEqual('one\ntwo', + utils.newline_list_formatter(['one', 'two'])) + + +class ShellTestParameterFiles(testtools.TestCase): + + def test_format_parameter_file_none(self): + self.assertEqual({}, utils.format_parameter_file(None)) + + def test_format_parameter_file(self): + tmpl_file = '/opt/stack/template.yaml' + contents = 'DBUsername=wp\nDBPassword=verybadpassword' + utils.read_url_content = mock.MagicMock() + utils.read_url_content.return_value = 'DBUsername=wp\n' \ + 'DBPassword=verybadpassword' + + p = utils.format_parameter_file([ + 'env_file1=test_file1'], tmpl_file) + self.assertEqual({'env_file1': contents + }, p) + + def test_format_parameter_file_no_template(self): + tmpl_file = None + contents = 'DBUsername=wp\nDBPassword=verybadpassword' + utils.read_url_content = mock.MagicMock() + utils.read_url_content.return_value = 'DBUsername=wp\n' \ + 'DBPassword=verybadpassword' + p = utils.format_parameter_file([ + 'env_file1=test_file1'], tmpl_file) + self.assertEqual({'env_file1': contents + }, p) + + def test_format_all_parameters(self): + tmpl_file = '/opt/stack/template.yaml' + contents = 'DBUsername=wp\nDBPassword=verybadpassword' + params = ['KeyName=heat_key;UpstreamDNS=8.8.8.8'] + utils.read_url_content = mock.MagicMock() + utils.read_url_content.return_value = 'DBUsername=wp\n' \ + 'DBPassword=verybadpassword' + p = utils.format_all_parameters(params, [ + 'env_file1=test_file1'], template_file=tmpl_file) + self.assertEqual({'KeyName': 'heat_key', + 'UpstreamDNS': '8.8.8.8', + 'env_file1': contents}, p) + + +class TestURLFunctions(testtools.TestCase): + + def setUp(self): + super(TestURLFunctions, self).setUp() + self.m = mock.MagicMock() + + self.addCleanup(self.m.UnsetStubs) + + def test_normalise_file_path_to_url_relative(self): + self.assertEqual( + 'file://%s/foo' % os.getcwd(), + utils.normalise_file_path_to_url( + 'foo')) + + def test_normalise_file_path_to_url_absolute(self): + self.assertEqual( + 'file:///tmp/foo', + utils.normalise_file_path_to_url( + '/tmp/foo')) + + def test_normalise_file_path_to_url_file(self): + self.assertEqual( + 'file:///tmp/foo', + utils.normalise_file_path_to_url( + 'file:///tmp/foo')) + + def test_normalise_file_path_to_url_http(self): + self.assertEqual( + 'http://localhost/foo', + utils.normalise_file_path_to_url( + 'http://localhost/foo')) + + def test_get_template_url(self): + tmpl_file = '/opt/stack/template.yaml' + tmpl_url = 'file:///opt/stack/template.yaml' + self.assertEqual(utils.get_template_url(tmpl_file, None), + tmpl_url) + self.assertEqual(utils.get_template_url(None, tmpl_url), + tmpl_url) + self.assertEqual(utils.get_template_url(None, None), + None) + + def test_base_url_for_url(self): + self.assertEqual( + 'file:///foo/bar', + utils.base_url_for_url( + 'file:///foo/bar/baz')) + self.assertEqual( + 'file:///foo/bar', + utils.base_url_for_url( + 'file:///foo/bar/baz.txt')) + self.assertEqual( + 'file:///foo/bar', + utils.base_url_for_url( + 'file:///foo/bar/')) + self.assertEqual( + 'file:///', + utils.base_url_for_url( + 'file:///')) + self.assertEqual( + 'file:///', + utils.base_url_for_url( + 'file:///foo')) + + self.assertEqual( + 'http://foo/bar', + utils.base_url_for_url( + 'http://foo/bar/')) + self.assertEqual( + 'http://foo/bar', + utils.base_url_for_url( + 'http://foo/bar/baz.template')) diff --git a/stacktaskclient/tests/unit/v1/__init__.py b/stacktaskclient/tests/unit/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stacktaskclient/tests/unit/v1/test_hooks.py b/stacktaskclient/tests/unit/v1/test_hooks.py new file mode 100644 index 0000000..2b0724c --- /dev/null +++ b/stacktaskclient/tests/unit/v1/test_hooks.py @@ -0,0 +1,318 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mock +import testtools + +import heatclient.v1.shell as shell + + +class TestHooks(testtools.TestCase): + def setUp(self): + super(TestHooks, self).setUp() + self.client = mock.Mock() + nested_stack = mock.Mock() + self.client.resources.get = mock.Mock(name='thingy', + return_value=nested_stack) + type(nested_stack).physical_resource_id = mock.PropertyMock( + return_value='nested_id') + self.args = mock.Mock() + stack_name_p = mock.PropertyMock(return_value="mystack") + type(self.args).name = stack_name_p + type(self.args).id = stack_name_p + shell.template_utils.get_template_contents = mock.Mock( + return_value=({}, "")) + shell.template_utils.process_multiple_environments_and_files = \ + mock.Mock(return_value=({}, {})) + shell.utils.format_all_parameters = mock.Mock(return_value=[]) + shell.do_stack_list = mock.Mock() + shell.logger = mock.Mock() + type(self.args).clear_parameter = mock.PropertyMock(return_value=[]) + type(self.args).rollback = mock.PropertyMock(return_value=None) + type(self.args).pre_create = mock.PropertyMock(return_value=False) + type(self.args).pre_update = mock.PropertyMock(return_value=False) + type(self.args).poll = mock.PropertyMock(return_value=None) + + def test_create_hooks_in_args(self): + type(self.args).pre_create = mock.PropertyMock( + return_value=['bp', 'another_bp']) + + shell.do_stack_create(self.client, self.args) + self.assertEqual(1, self.client.stacks.create.call_count) + expected_hooks = { + 'bp': {'hooks': 'pre-create'}, + 'another_bp': {'hooks': 'pre-create'} + } + actual_hooks = self.client.stacks.create.call_args[1][ + 'environment']['resource_registry']['resources'] + self.assertEqual(expected_hooks, actual_hooks) + + def test_create_nested_hooks_in_args(self): + type(self.args).pre_create = mock.PropertyMock( + return_value=['nested/bp', 'super/nested/bp']) + + shell.do_stack_create(self.client, self.args) + self.assertEqual(1, self.client.stacks.create.call_count) + expected_hooks = { + 'nested': { + 'bp': {'hooks': 'pre-create'}, + }, + 'super': { + 'nested': { + 'bp': {'hooks': 'pre-create'}, + } + } + } + actual_hooks = self.client.stacks.create.call_args[1][ + 'environment']['resource_registry']['resources'] + self.assertEqual(expected_hooks, actual_hooks) + + def test_create_hooks_in_env_and_args(self): + type(self.args).pre_create = mock.PropertyMock(return_value=[ + 'nested_a/bp', + 'bp_a', + 'another_bp_a', + 'super_a/nested/bp', + ]) + env = { + 'resource_registry': { + 'resources': { + 'bp_e': {'hooks': 'pre-create'}, + 'another_bp_e': {'hooks': 'pre-create'}, + 'nested_e': { + 'bp': {'hooks': 'pre-create'} + }, + 'super_e': { + 'nested': { + 'bp': {'hooks': 'pre-create'} + } + } + } + } + } + shell.template_utils.process_multiple_environments_and_files = \ + mock.Mock(return_value=({}, env)) + + shell.do_stack_create(self.client, self.args) + self.assertEqual(1, self.client.stacks.create.call_count) + actual_hooks = self.client.stacks.create.call_args[1][ + 'environment']['resource_registry']['resources'] + expected_hooks = { + 'bp_e': {'hooks': 'pre-create'}, + 'another_bp_e': {'hooks': 'pre-create'}, + 'nested_e': { + 'bp': {'hooks': 'pre-create'} + }, + 'super_e': { + 'nested': { + 'bp': {'hooks': 'pre-create'} + } + }, + 'bp_a': {'hooks': 'pre-create'}, + 'another_bp_a': {'hooks': 'pre-create'}, + 'nested_a': { + 'bp': {'hooks': 'pre-create'} + }, + 'super_a': { + 'nested': { + 'bp': {'hooks': 'pre-create'} + } + }, + } + self.assertEqual(expected_hooks, actual_hooks) + + def test_update_hooks_in_args(self): + type(self.args).pre_update = mock.PropertyMock( + return_value=['bp', 'another_bp']) + + shell.do_stack_update(self.client, self.args) + self.assertEqual(1, self.client.stacks.update.call_count) + expected_hooks = { + 'bp': {'hooks': 'pre-update'}, + 'another_bp': {'hooks': 'pre-update'}, + } + actual_hooks = self.client.stacks.update.call_args[1][ + 'environment']['resource_registry']['resources'] + self.assertEqual(expected_hooks, actual_hooks) + + def test_update_nested_hooks_in_args(self): + type(self.args).pre_update = mock.PropertyMock( + return_value=['nested/bp', 'super/nested/bp']) + + shell.do_stack_update(self.client, self.args) + self.assertEqual(1, self.client.stacks.update.call_count) + expected_hooks = { + 'nested': { + 'bp': {'hooks': 'pre-update'} + }, + 'super': { + 'nested': { + 'bp': {'hooks': 'pre-update'} + } + } + } + actual_hooks = self.client.stacks.update.call_args[1][ + 'environment']['resource_registry']['resources'] + self.assertEqual(expected_hooks, actual_hooks) + + def test_update_hooks_in_env_and_args(self): + type(self.args).pre_update = mock.PropertyMock(return_value=[ + 'nested_a/bp', + 'bp_a', + 'another_bp_a', + 'super_a/nested/bp', + ]) + env = { + 'resource_registry': { + 'resources': { + 'bp_e': {'hooks': 'pre-update'}, + 'another_bp_e': {'hooks': 'pre-update'}, + 'nested_e': { + 'bp': {'hooks': 'pre-update'} + }, + 'super_e': { + 'nested': { + 'bp': {'hooks': 'pre-update'} + } + } + } + } + } + shell.template_utils.process_multiple_environments_and_files = \ + mock.Mock(return_value=({}, env)) + + shell.do_stack_update(self.client, self.args) + self.assertEqual(1, self.client.stacks.update.call_count) + actual_hooks = self.client.stacks.update.call_args[1][ + 'environment']['resource_registry']['resources'] + expected_hooks = { + 'bp_e': {'hooks': 'pre-update'}, + 'another_bp_e': {'hooks': 'pre-update'}, + 'nested_e': { + 'bp': {'hooks': 'pre-update'} + }, + 'super_e': { + 'nested': { + 'bp': {'hooks': 'pre-update'} + } + }, + 'bp_a': {'hooks': 'pre-update'}, + 'another_bp_a': {'hooks': 'pre-update'}, + 'nested_a': { + 'bp': {'hooks': 'pre-update'} + }, + 'super_a': { + 'nested': { + 'bp': {'hooks': 'pre-update'} + } + }, + } + self.assertEqual(expected_hooks, actual_hooks) + + def test_clear_all_hooks(self): + shell._get_hook_type_via_status =\ + mock.Mock(return_value='pre-create') + type(self.args).hook = mock.PropertyMock( + return_value=['bp']) + type(self.args).pre_create = mock.PropertyMock(return_value=True) + bp = mock.Mock() + type(bp).resource_name = 'bp' + self.client.resources.list = mock.Mock(return_value=[bp]) + + shell.do_hook_clear(self.client, self.args) + self.assertEqual(1, self.client.resources.signal.call_count) + payload_pre_create = self.client.resources.signal.call_args_list[0][1] + self.assertEqual({'unset_hook': 'pre-create'}, + payload_pre_create['data']) + self.assertEqual('bp', payload_pre_create['resource_name']) + self.assertEqual('mystack', payload_pre_create['stack_id']) + + def test_clear_pre_create_hooks(self): + type(self.args).hook = mock.PropertyMock( + return_value=['bp']) + type(self.args).pre_create = mock.PropertyMock(return_value=True) + bp = mock.Mock() + type(bp).resource_name = 'bp' + self.client.resources.list = mock.Mock(return_value=[bp]) + + shell.do_hook_clear(self.client, self.args) + self.assertEqual(1, self.client.resources.signal.call_count) + payload = self.client.resources.signal.call_args_list[0][1] + self.assertEqual({'unset_hook': 'pre-create'}, payload['data']) + self.assertEqual('bp', payload['resource_name']) + self.assertEqual('mystack', payload['stack_id']) + + def test_clear_pre_update_hooks(self): + type(self.args).hook = mock.PropertyMock( + return_value=['bp']) + type(self.args).pre_update = mock.PropertyMock(return_value=True) + bp = mock.Mock() + type(bp).resource_name = 'bp' + self.client.resources.list = mock.Mock(return_value=[bp]) + + shell.do_hook_clear(self.client, self.args) + self.assertEqual(1, self.client.resources.signal.call_count) + payload = self.client.resources.signal.call_args_list[0][1] + self.assertEqual({'unset_hook': 'pre-update'}, payload['data']) + self.assertEqual('bp', payload['resource_name']) + self.assertEqual('mystack', payload['stack_id']) + + def test_clear_nested_hook(self): + type(self.args).hook = mock.PropertyMock( + return_value=['a/b/bp']) + type(self.args).pre_create = mock.PropertyMock(return_value=True) + + a = mock.Mock() + type(a).resource_name = 'a' + b = mock.Mock() + type(b).resource_name = 'b' + bp = mock.Mock() + type(bp).resource_name = 'bp' + self.client.resources.list = mock.Mock( + side_effect=[[a], [b], [bp]]) + m1 = mock.Mock() + m2 = mock.Mock() + type(m2).physical_resource_id = 'nested_id' + self.client.resources.get = mock.Mock( + side_effect=[m1, m2]) + + shell.do_hook_clear(self.client, self.args) + payload = self.client.resources.signal.call_args_list[0][1] + self.assertEqual({'unset_hook': 'pre-create'}, payload['data']) + self.assertEqual('bp', payload['resource_name']) + self.assertEqual('nested_id', payload['stack_id']) + + def test_clear_wildcard_hooks(self): + type(self.args).hook = mock.PropertyMock( + return_value=['a/*b/bp*']) + type(self.args).pre_create = mock.PropertyMock(return_value=True) + a = mock.Mock() + type(a).resource_name = 'a' + b = mock.Mock() + type(b).resource_name = 'matcthis_b' + bp = mock.Mock() + type(bp).resource_name = 'bp_matchthis' + self.client.resources.list = mock.Mock( + side_effect=[[a], [b], [bp]]) + m1 = mock.Mock() + m2 = mock.Mock() + type(m2).physical_resource_id = 'nested_id' + self.client.resources.get = mock.Mock( + side_effect=[m1, m2]) + + shell.do_hook_clear(self.client, self.args) + payload = self.client.resources.signal.call_args_list[0][1] + self.assertEqual({'unset_hook': 'pre-create'}, + payload['data']) + self.assertEqual('bp_matchthis', payload['resource_name']) + self.assertEqual('nested_id', payload['stack_id']) diff --git a/stacktaskclient/tests/unit/var/adopt_stack_data.json b/stacktaskclient/tests/unit/var/adopt_stack_data.json new file mode 100644 index 0000000..efb998a --- /dev/null +++ b/stacktaskclient/tests/unit/var/adopt_stack_data.json @@ -0,0 +1,6 @@ +{ + "action": "CREATE", + "status": "COMPLETE", + "name": "teststack", + "resources": {} +} \ No newline at end of file diff --git a/stacktaskclient/tests/unit/var/minimal.template b/stacktaskclient/tests/unit/var/minimal.template new file mode 100644 index 0000000..67331ac --- /dev/null +++ b/stacktaskclient/tests/unit/var/minimal.template @@ -0,0 +1,9 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters" : { + }, + "Resources" : { + }, + "Outputs" : { + } +} diff --git a/stacktaskclient/v1/__init__.py b/stacktaskclient/v1/__init__.py new file mode 100644 index 0000000..507adf6 --- /dev/null +++ b/stacktaskclient/v1/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +__all__ = ['Client'] + +from stacktaskclient.v1.client import Client # noqa diff --git a/stacktaskclient/v1/build_info.py b/stacktaskclient/v1/build_info.py new file mode 100644 index 0000000..75c6b61 --- /dev/null +++ b/stacktaskclient/v1/build_info.py @@ -0,0 +1,34 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from stacktaskclient.common import utils +from stacktaskclient.openstack.common.apiclient import base + + +class BuildInfo(base.Resource): + def __repr__(self): + return "" % self._info + + def build_info(self): + return self.manager.build_info() + + +class BuildInfoManager(base.BaseManager): + resource_class = BuildInfo + + def build_info(self): + resp = self.client.get('/build_info') + body = utils.get_response_body(resp) + return body diff --git a/stacktaskclient/v1/client.py b/stacktaskclient/v1/client.py new file mode 100644 index 0000000..94f6316 --- /dev/null +++ b/stacktaskclient/v1/client.py @@ -0,0 +1,51 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from stacktaskclient.common import http +#from stacktaskclient.v1 import stacks +from stacktaskclient.v1 import users +from stacktaskclient.v1 import roles + + +class Client(object): + """Client for the Stacktask v1 API. + + :param string endpoint: A user-supplied endpoint URL for the stacktask + service. + :param string token: Token for authentication. + :param integer timeout: Allows customization of the timeout for client + http requests. (optional) + """ + + def __init__(self, *args, **kwargs): + """Initialize a new client for the Stacktask v1 API.""" + self.http_client = http._construct_http_client(*args, **kwargs) + #self.stacks = stacks.StackManager(self.http_client) + self.users = users.UsersManager(self.http_client) + self.roles = roles.RolesManager(self.http_client) + #self.resources = resources.ResourceManager(self.http_client) + #self.resource_types = resource_types.ResourceTypeManager( + # self.http_client) + #self.events = events.EventManager(self.http_client) + #self.actions = actions.ActionManager(self.http_client) + #self.build_info = build_info.BuildInfoManager(self.http_client) + #self.software_deployments = \ + # software_deployments.SoftwareDeploymentManager( + # self.http_client) + #self.software_configs = software_configs.SoftwareConfigManager( + # self.http_client) + #self.services = services.ServiceManager(self.http_client) + #self.template_versions = template_versions.TemplateVersionManager( + # self.http_client) diff --git a/stacktaskclient/v1/roles.py b/stacktaskclient/v1/roles.py new file mode 100644 index 0000000..b8dbb23 --- /dev/null +++ b/stacktaskclient/v1/roles.py @@ -0,0 +1,75 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from stacktaskclient.common import utils + +import six +from six.moves.urllib import parse + +from stacktaskclient.openstack.common.apiclient import base + + +class Roles(base.Resource): + def __repr__(self): + return "" % self._info + + def create(self, **fields): + return self.manager.create(self.identifier, **fields) + + def get(self): + # set_loaded() first ... so if we have to bail, we know we tried. + self._loaded = True + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.identifier) + if new: + self._add_details(new._info) + + @property + def action(self): + s = self.stack_status + # Return everything before the first underscore + return s[:s.index('_')] + + @property + def status(self): + s = self.stack_status + # Return everything after the first underscore + return s[s.index('_') + 1:] + + @property + def identifier(self): + return '%s/%s' % (self.stack_name, self.id) + + +class RolesManager(base.BaseManager): + resource_class = Roles + + def list(self, **kwargs): + """Get a list of roles that can be managed. + + :param limit: maximum number of stacks to return + :param marker: begin returning stacks that appear later in the stack + list than that represented by this stack id + :param filters: dict of direct comparison filters that mimics the + structure of a stack object + :rtype: list of :class:`Users` + """ + params = {} + #import pdb; pdb.set_trace() + url = '/roles?%(params)s' % {'params': parse.urlencode(params, True)} + roles = self._list(url, 'roles') + for role in roles: + yield role diff --git a/stacktaskclient/v1/shell.py b/stacktaskclient/v1/shell.py new file mode 100644 index 0000000..696e8ff --- /dev/null +++ b/stacktaskclient/v1/shell.py @@ -0,0 +1,170 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import fnmatch +import logging + +from oslo_serialization import jsonutils +from oslo_utils import strutils +import six +from six.moves.urllib import request +import time +import yaml + +from stacktaskclient.common import deployment_utils +from stacktaskclient.common import event_utils +from stacktaskclient.common import http +from stacktaskclient.common import template_format +from stacktaskclient.common import template_utils +from stacktaskclient.common import utils + +from stacktaskclient.openstack.common._i18n import _ +from stacktaskclient.openstack.common._i18n import _LE +from stacktaskclient.openstack.common._i18n import _LW + +import stacktaskclient.exc as exc + +logger = logging.getLogger(__name__) + + +def _authenticated_fetcher(hc): + """A wrapper around the stacktask client object to fetch a template. + """ + def _do(*args, **kwargs): + if isinstance(hc.http_client, http.SessionClient): + method, url = args + return hc.http_client.request(url, method, **kwargs).content + else: + return hc.http_client.raw_request(*args, **kwargs).content + + return _do + + +def do_user_tenant_list(hc, args): + """List all users in tenant""" + kwargs = {} + fields = ['id', 'username', 'email', 'roles'] + + tenant_users = hc.users.list(**kwargs) + utils.print_list(tenant_users, fields, sortby_index=1) + + +@utils.arg('--tenant-id', metavar='', + help=_('Invite to a particular tenant')) +@utils.arg('--user-email', metavar='', + help=_('Email address of user to invite')) +def do_user_tenant_invite(hc, args): + """ + Invites a user to become a member of a tenant. + User does not need to have an existing openstack account. + """ + print("do_user_tenant_invite") + pass + + +@utils.arg('--user', '--user-id', metavar='', + help=_('Name or ID of user.')) +@utils.arg('--tenant', '--tenant-id', metavar='', + help=_('Name or ID of tenant.')) +def do_user_role_add(hc, args): + """Add a role to user""" + print("do_user_role_add") + pass + + +@utils.arg('--user', '--user-id', metavar='', + help=_('Name or ID of user.')) +@utils.arg('--tenant', '--tenant-id', metavar='', + help=_('Name or ID of tenant.')) +#@utils.arg('--role', keystone ) +def do_user_role_remove(hc, args): + """Remove a role from a user""" + print("do_user_role_remove") + pass + + +@utils.arg('--tenant', metavar='', + help=_('Name or ID of tenant.')) +def do_managed_role_list(rc, args): + """List roles that may be managed in a given tenant""" + fields = ['id', 'name'] + kwargs = {} + #import pdb; pdb.set_trace() + roles = rc.roles.list(**kwargs) + utils.print_list(roles, fields, sortby_index=1) + + +#----- ------------ OLD HEAT SHELL COMMANDS ------------------- + + +@utils.arg('-s', '--show-deleted', default=False, action="store_true", + help=_('Include soft-deleted stacks in the stack listing.')) +@utils.arg('-n', '--show-nested', default=False, action="store_true", + help=_('Include nested stacks in the stack listing.')) +@utils.arg('-a', '--show-hidden', default=False, action="store_true", + help=_('Include hidden stacks in the stack listing.')) +@utils.arg('-f', '--filters', metavar='', + help=_('Filter parameters to apply on returned stacks. ' + 'This can be specified multiple times, or once with parameters ' + 'separated by a semicolon.'), + action='append') +@utils.arg('-t', '--tags', metavar='', + help=_('Show stacks containing these tags, combine multiple tags ' + 'using the boolean AND expression')) +@utils.arg('--tags-any', metavar='', + help=_('Show stacks containing these tags, combine multiple tags ' + 'using the boolean OR expression')) +@utils.arg('--not-tags', metavar='', + help=_('Show stacks not containing these tags, combine multiple ' + 'tags using the boolean AND expression')) +@utils.arg('--not-tags-any', metavar='', + help=_('Show stacks not containing these tags, combine multiple ' + 'tags using the boolean OR expression')) +@utils.arg('-l', '--limit', metavar='', + help=_('Limit the number of stacks returned.')) +@utils.arg('-m', '--marker', metavar='', + help=_('Only return stacks that appear after the given stack ID.')) +@utils.arg('-g', '--global-tenant', action='store_true', default=False, + help=_('Display stacks from all tenants. Operation only authorized ' + 'for users who match the policy in heat\'s policy.json.')) +@utils.arg('-o', '--show-owner', action='store_true', default=False, + help=_('Display stack owner information. This is automatically ' + 'enabled when using %(arg)s.') % {'arg': '--global-tenant'}) +def do_stack_list(hc, args=None): + '''List the user's stacks.''' + kwargs = {} + fields = ['id', 'username', 'email', 'roles'] + if args: + kwargs = {'limit': args.limit, + 'marker': args.marker, + 'filters': utils.format_parameters(args.filters), + 'tags': args.tags, + 'tags_any': args.tags_any, + 'not_tags': args.not_tags, + 'not_tags_any': args.not_tags_any, + 'global_tenant': args.global_tenant, + 'show_deleted': args.show_deleted, + 'show_hidden': args.show_hidden} + if args.show_nested: + fields.append('parent') + kwargs['show_nested'] = True + + if args.global_tenant or args.show_owner: + fields.insert(2, 'stack_owner') + if args.global_tenant: + fields.insert(2, 'project') + + stacks = hc.stacks.list(**kwargs) + utils.print_list(stacks, fields, sortby_index=3) diff --git a/stacktaskclient/v1/users.py b/stacktaskclient/v1/users.py new file mode 100644 index 0000000..4844830 --- /dev/null +++ b/stacktaskclient/v1/users.py @@ -0,0 +1,243 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from stacktaskclient.common import utils + +import six +from six.moves.urllib import parse + +from stacktaskclient.openstack.common.apiclient import base + + +class Users(base.Resource): + def __repr__(self): + return "" % self._info + + def preview(self, **fields): + return self.manager.preview(**fields) + + def create(self, **fields): + return self.manager.create(self.identifier, **fields) + + def update(self, **fields): + self.manager.update(self.identifier, **fields) + + def delete(self): + return self.manager.delete(self.identifier) + + def abandon(self): + return self.manager.abandon(self.identifier) + + def snapshot(self, name=None): + return self.manager.snapshot(self.identifier, name) + + def snapshot_show(self, snapshot_id): + return self.manager.snapshot_show(self.identifier, snapshot_id) + + def snapshot_delete(self, snapshot_id): + return self.manager.snapshot_delete(self.identifier, snapshot_id) + + def restore(self, snapshot_id): + return self.manager.restore(self.identifier, snapshot_id) + + def snapshot_list(self): + return self.manager.snapshot_list(self.identifier) + + def get(self): + # set_loaded() first ... so if we have to bail, we know we tried. + self._loaded = True + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.identifier) + if new: + self._add_details(new._info) + + @property + def action(self): + s = self.stack_status + # Return everything before the first underscore + return s[:s.index('_')] + + @property + def status(self): + s = self.stack_status + # Return everything after the first underscore + return s[s.index('_') + 1:] + + @property + def identifier(self): + return '%s/%s' % (self.stack_name, self.id) + + +class UsersManager(base.BaseManager): + resource_class = Users + + def list(self, **kwargs): + """Get a list of stacks. + + :param limit: maximum number of stacks to return + :param marker: begin returning stacks that appear later in the stack + list than that represented by this stack id + :param filters: dict of direct comparison filters that mimics the + structure of a stack object + :rtype: list of :class:`Users` + """ + def paginate(params): + '''Paginate stacks, even if more than API limit.''' + current_limit = int(params.get('limit') or 0) + url = '/users?%s' % parse.urlencode(params, True) + #import pdb; pdb.set_trace() + stacks = self._list(url, 'users') + for stack in stacks: + yield stack + + num_stacks = len(stacks) + remaining_limit = current_limit - num_stacks + if remaining_limit > 0 and num_stacks > 0: + params['limit'] = remaining_limit + params['marker'] = stack.id + for stack in paginate(params): + yield stack + + params = {} + if 'filters' in kwargs: + filters = kwargs.pop('filters') + params.update(filters) + + for key, value in six.iteritems(kwargs): + if value: + params[key] = value + + return paginate(params) + + def preview(self, **kwargs): + """Preview a stack.""" + headers = self.client.credentials_headers() + resp = self.client.post('/stacks/preview', + data=kwargs, headers=headers) + body = utils.get_response_body(resp) + return Stack(self, body.get('stack')) + + def create(self, **kwargs): + """Create a stack.""" + headers = self.client.credentials_headers() + resp = self.client.post('/stacks', + data=kwargs, headers=headers) + body = utils.get_response_body(resp) + return body + + def update(self, stack_id, **kwargs): + """Update a stack.""" + headers = self.client.credentials_headers() + if kwargs.pop('existing', None): + self.client.patch('/stacks/%s' % stack_id, data=kwargs, + headers=headers) + else: + self.client.put('/stacks/%s' % stack_id, data=kwargs, + headers=headers) + + def delete(self, stack_id): + """Delete a stack.""" + self._delete("/stacks/%s" % stack_id) + + def abandon(self, stack_id): + """Abandon a stack.""" + stack = self.get(stack_id) + resp = self.client.delete('/stacks/%s/abandon' % stack.identifier) + body = utils.get_response_body(resp) + return body + + def snapshot(self, stack_id, name=None): + """Snapshot a stack.""" + stack = self.get(stack_id) + data = {} + if name: + data['name'] = name + resp = self.client.post('/stacks/%s/snapshots' % stack.identifier, + data=data) + body = utils.get_response_body(resp) + return body + + def snapshot_show(self, stack_id, snapshot_id): + stack = self.get(stack_id) + resp = self.client.get('/stacks/%s/snapshots/%s' % (stack.identifier, + snapshot_id)) + body = utils.get_response_body(resp) + return body + + def snapshot_delete(self, stack_id, snapshot_id): + stack = self.get(stack_id) + resp = self.client.delete('/stacks/%s/snapshots/%s' % + (stack.identifier, snapshot_id)) + body = utils.get_response_body(resp) + return body + + def restore(self, stack_id, snapshot_id): + stack = self.get(stack_id) + resp = self.client.post('/stacks/%s/snapshots/%s/restore' % + (stack.identifier, snapshot_id)) + body = utils.get_response_body(resp) + return body + + def snapshot_list(self, stack_id): + stack = self.get(stack_id) + resp = self.client.get('/stacks/%s/snapshots' % stack.identifier) + body = utils.get_response_body(resp) + return body + + def get(self, stack_id): + """Get the metadata for a specific stack. + + :param stack_id: Stack ID to lookup + """ + resp = self.client.get('/stacks/%s' % stack_id) + body = utils.get_response_body(resp) + return Stack(self, body.get('stack')) + + def template(self, stack_id): + """Get the template content for a specific stack as a parsed JSON + object. + + :param stack_id: Stack ID to get the template for + """ + resp = self.client.get('/stacks/%s/template' % stack_id) + body = utils.get_response_body(resp) + return body + + def validate(self, **kwargs): + """Validate a stack template.""" + resp = self.client.post('/validate', data=kwargs) + body = utils.get_response_body(resp) + return body + + +class StackChildManager(base.BaseManager): + @property + def api(self): + return self.client + + def _resolve_stack_id(self, stack_id): + # if the id already has a slash in it, + # then it is already {stack_name}/{stack_id} + if stack_id.find('/') > 0: + return stack_id + # We want to capture the redirect, not actually get the stack, + # since all we want is the stacks:lookup response to get the + # fully qualified ID, and not all users are allowed to do the + # redirected stacks:show, so pass redirect=False + resp = self.client.get('/stacks/%s' % stack_id, redirect=False) + location = resp.headers.get('location') + path = self.client.strip_endpoint(location) + return path[len('/stacks/'):] diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..49e38ee --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,19 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. + +# Hacking already pins down pep8, pyflakes and flake8 +hacking<0.11,>=0.10.0 +coverage>=3.6 +discover +fixtures>=1.3.1 +requests-mock>=0.6.0 # Apache-2.0 +mock>=1.2 +mox3>=0.7.0 +oslosphinx>=2.5.0 # Apache-2.0 +oslotest>=1.10.0 # Apache-2.0 +sphinx!=1.2.0,!=1.3b1,<1.3,>=1.1.2 +tempest-lib>=0.6.1 +testrepository>=0.0.18 +testscenarios>=0.4 +testtools>=1.4.0 diff --git a/tools/heat.bash_completion b/tools/heat.bash_completion new file mode 100644 index 0000000..a04de1a --- /dev/null +++ b/tools/heat.bash_completion @@ -0,0 +1,27 @@ +# bash completion for openstack heat + +_heat_opts="" # lazy init +_heat_flags="" # lazy init +_heat_opts_exp="" # lazy init +_heat() +{ + local cur prev kbc + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + if [ "x$_heat_opts" == "x" ] ; then + kbc="`heat bash-completion | sed -e "s/ -h / /"`" + _heat_opts="`echo "$kbc" | sed -e "s/--[a-z0-9_-]*//g" -e "s/[ ][ ]*/ /g"`" + _heat_flags="`echo " $kbc" | sed -e "s/ [^-][^-][a-z0-9_-]*//g" -e "s/[ ][ ]*/ /g"`" + _heat_opts_exp="`echo $_heat_opts | sed -e "s/[ ]/|/g"`" + fi + + if [[ " ${COMP_WORDS[@]} " =~ " "($_heat_opts_exp)" " && "$prev" != "help" ]] ; then + COMPREPLY=($(compgen -W "${_heat_flags}" -- ${cur})) + else + COMPREPLY=($(compgen -W "${_heat_opts}" -- ${cur})) + fi + return 0 +} +complete -o default -F _heat heat diff --git a/tools/install_venv.py b/tools/install_venv.py new file mode 100644 index 0000000..cc21843 --- /dev/null +++ b/tools/install_venv.py @@ -0,0 +1,74 @@ +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2010 OpenStack Foundation +# Copyright 2013 IBM Corp. +# 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. + +import ConfigParser +import os +import sys + +import install_venv_common as install_venv # flake8: noqa + + +def print_help(project, venv, root): + help = """ + %(project)s development environment setup is complete. + + %(project)s development uses virtualenv to track and manage Python + dependencies while in development and testing. + + To activate the %(project)s virtualenv for the extent of your current + shell session you can run: + + $ source %(venv)s/bin/activate + + Or, if you prefer, you can run commands in the virtualenv on a case by + case basis by running: + + $ %(root)s/tools/with_venv.sh + """ + print help % dict(project=project, venv=venv, root=root) + + +def main(argv): + root = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + if os.environ.get('tools_path'): + root = os.environ['tools_path'] + venv = os.path.join(root, '.venv') + if os.environ.get('venv'): + venv = os.environ['venv'] + + pip_requires = os.path.join(root, 'requirements.txt') + test_requires = os.path.join(root, 'test-requirements.txt') + py_version = "python%s.%s" % (sys.version_info[0], sys.version_info[1]) + setup_cfg = ConfigParser.ConfigParser() + setup_cfg.read('setup.cfg') + project = setup_cfg.get('metadata', 'name') + + install = install_venv.InstallVenv( + root, venv, pip_requires, test_requires, py_version, project) + options = install.parse_args(argv) + install.check_python_version() + install.check_dependencies() + install.create_virtualenv(no_site_packages=options.no_site_packages) + install.install_dependencies() + print_help(project, venv, root) + +if __name__ == '__main__': + main(sys.argv) diff --git a/tools/install_venv_common.py b/tools/install_venv_common.py new file mode 100644 index 0000000..46822e3 --- /dev/null +++ b/tools/install_venv_common.py @@ -0,0 +1,172 @@ +# Copyright 2013 OpenStack Foundation +# Copyright 2013 IBM Corp. +# +# 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. + +"""Provides methods needed by installation script for OpenStack development +virtual environments. + +Since this script is used to bootstrap a virtualenv from the system's Python +environment, it should be kept strictly compatible with Python 2.6. + +Synced in from openstack-common +""" + +from __future__ import print_function + +import optparse +import os +import subprocess +import sys + + +class InstallVenv(object): + + def __init__(self, root, venv, requirements, + test_requirements, py_version, + project): + self.root = root + self.venv = venv + self.requirements = requirements + self.test_requirements = test_requirements + self.py_version = py_version + self.project = project + + def die(self, message, *args): + print(message % args, file=sys.stderr) + sys.exit(1) + + def check_python_version(self): + if sys.version_info < (2, 6): + self.die("Need Python Version >= 2.6") + + def run_command_with_code(self, cmd, redirect_output=True, + check_exit_code=True): + """Runs a command in an out-of-process shell. + + Returns the output of that command. Working directory is self.root. + """ + if redirect_output: + stdout = subprocess.PIPE + else: + stdout = None + + proc = subprocess.Popen(cmd, cwd=self.root, stdout=stdout) + output = proc.communicate()[0] + if check_exit_code and proc.returncode != 0: + self.die('Command "%s" failed.\n%s', ' '.join(cmd), output) + return (output, proc.returncode) + + def run_command(self, cmd, redirect_output=True, check_exit_code=True): + return self.run_command_with_code(cmd, redirect_output, + check_exit_code)[0] + + def get_distro(self): + if (os.path.exists('/etc/fedora-release') or + os.path.exists('/etc/redhat-release')): + return Fedora( + self.root, self.venv, self.requirements, + self.test_requirements, self.py_version, self.project) + else: + return Distro( + self.root, self.venv, self.requirements, + self.test_requirements, self.py_version, self.project) + + def check_dependencies(self): + self.get_distro().install_virtualenv() + + def create_virtualenv(self, no_site_packages=True): + """Creates the virtual environment and installs PIP. + + Creates the virtual environment and installs PIP only into the + virtual environment. + """ + if not os.path.isdir(self.venv): + print('Creating venv...', end=' ') + if no_site_packages: + self.run_command(['virtualenv', '-q', '--no-site-packages', + self.venv]) + else: + self.run_command(['virtualenv', '-q', self.venv]) + print('done.') + else: + print("venv already exists...") + pass + + def pip_install(self, *args): + self.run_command(['tools/with_venv.sh', + 'pip', 'install', '--upgrade'] + list(args), + redirect_output=False) + + def install_dependencies(self): + print('Installing dependencies with pip (this can take a while)...') + + # First things first, make sure our venv has the latest pip and + # setuptools and pbr + self.pip_install('pip>=1.4') + self.pip_install('setuptools') + self.pip_install('pbr') + + self.pip_install('-r', self.requirements, '-r', self.test_requirements) + + def parse_args(self, argv): + """Parses command-line arguments.""" + parser = optparse.OptionParser() + parser.add_option('-n', '--no-site-packages', + action='store_true', + help="Do not inherit packages from global Python " + "install") + return parser.parse_args(argv[1:])[0] + + +class Distro(InstallVenv): + + def check_cmd(self, cmd): + return bool(self.run_command(['which', cmd], + check_exit_code=False).strip()) + + def install_virtualenv(self): + if self.check_cmd('virtualenv'): + return + + if self.check_cmd('easy_install'): + print('Installing virtualenv via easy_install...', end=' ') + if self.run_command(['easy_install', 'virtualenv']): + print('Succeeded') + return + else: + print('Failed') + + self.die('ERROR: virtualenv not found.\n\n%s development' + ' requires virtualenv, please install it using your' + ' favorite package management tool' % self.project) + + +class Fedora(Distro): + """This covers all Fedora-based distributions. + + Includes: Fedora, RHEL, CentOS, Scientific Linux + """ + + def check_pkg(self, pkg): + return self.run_command_with_code(['rpm', '-q', pkg], + check_exit_code=False)[1] == 0 + + def install_virtualenv(self): + if self.check_cmd('virtualenv'): + return + + if not self.check_pkg('python-virtualenv'): + self.die("Please install 'python-virtualenv'.") + + super(Fedora, self).install_virtualenv() diff --git a/tools/with_venv.sh b/tools/with_venv.sh new file mode 100755 index 0000000..1b09ad7 --- /dev/null +++ b/tools/with_venv.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +command -v tox > /dev/null 2>&1 +if [ $? -ne 0 ]; then + echo 'This script requires "tox" to run.' + echo 'You can install it with "pip install tox".' + exit 1; +fi + +tox -evenv -- $@ diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..7674f25 --- /dev/null +++ b/tox.ini @@ -0,0 +1,51 @@ +[tox] +envlist = py26,py27,pypy,pep8 +minversion = 1.6 +skipsdist = True + +[testenv] +setenv = VIRTUAL_ENV={envdir} +usedevelop = True +install_command = pip install -U {opts} {packages} +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = python setup.py testr --slowest --testr-args='{posargs}' + +[testenv:pypy] +deps = setuptools<3.2 + -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:pep8] +commands = + flake8 + # Check that .po and .pot files are valid: + bash -c "find python-registrationclient -type f -regex '.*\.pot?' -print0|xargs -0 -n 1 msgfmt --check-format -o /dev/null" +whitelist_externals = bash + +[testenv:venv] +commands = {posargs} + +[testenv:functional] +setenv = + OS_TEST_PATH = ./registrationclient/tests/functional +passenv = OS_* + +[testenv:cover] +commands = python setup.py testr --coverage --testr-args='{posargs}' + +[testenv:docs] +commands= + python setup.py build_sphinx + +[tox:jenkins] +downloadcache = ~/cache/pip + +[flake8] +ignore = E123,E126,E128,E241,E265,E713,H202,H405,H238 +show-source = True +exclude=.venv,.git,.tox,dist,*openstack/common*,*lib/python*,*egg,build +max-complexity=20 + +[hacking] +import_exceptions = registrationclient.openstack.common._i18n