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