diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..50618ff6 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +branch = True +source = heatclient +omit = heatclient/tests/* + +[report] +ignore_errors = True +precision = 2 diff --git a/.gitignore b/.gitignore index ea9789f3..73321c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.coverage +.coverage* .venv *,cover cover @@ -12,7 +12,11 @@ run_tests.err.log doc/source/api doc/build *.egg +*.eggs heatclient/versioninfo *.egg-info *.log -.testrepository +.stestr/ + +# Files created by releasenotes build +releasenotes/build diff --git a/.gitreview b/.gitreview index 054320ab..341941b8 100644 --- a/.gitreview +++ b/.gitreview @@ -1,4 +1,4 @@ [gerrit] -host=review.openstack.org +host=review.opendev.org port=29418 project=openstack/python-heatclient.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..9e5ca7b8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + # Replaces or checks mixed line ending + - id: mixed-line-ending + args: ['--fix', 'lf'] + exclude: '.*\.(svg)$' + # Forbid files which have a UTF-8 byte-order marker + - id: fix-byte-order-marker + # Checks that non-binary executables have a proper shebang + - id: check-executables-have-shebangs + # Check for files that contain merge conflict strings. + - id: check-merge-conflict + # Check for debugger imports and py37+ breakpoint() + # calls in python source + - id: debug-statements + - id: check-yaml + files: .*\.(yaml|yml)$ + - repo: https://opendev.org/openstack/hacking + rev: 8.0.0 + hooks: + - id: hacking + additional_dependencies: [] + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.2 + hooks: + - id: pyupgrade + args: [--py310-plus] diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 00000000..eb18d61c --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=${OS_TEST_PATH:-./heatclient/tests/unit} +top_dir=./ diff --git a/.testr.conf b/.testr.conf deleted file mode 100644 index a2ff14b2..00000000 --- a/.testr.conf +++ /dev/null @@ -1,4 +0,0 @@ -[DEFAULT] -test_command=${PYTHON:-python} -m subunit.run discover -t ./ . $LISTOPT $IDOPTION -test_id_option=--load-list $IDFILE -test_list_option=--list diff --git a/.zuul.yaml b/.zuul.yaml new file mode 100644 index 00000000..43072ced --- /dev/null +++ b/.zuul.yaml @@ -0,0 +1,36 @@ +- job: + name: heatclient-functional + parent: devstack-tox-functional + timeout: 4200 + required-projects: + - openstack/heat + - openstack/python-heatclient + vars: + openrc_enable_export: true + devstack_plugins: + heat: https://opendev.org/openstack/heat + irrelevant-files: + - ^\.gitignore$ + - ^\.gitreview$ + - ^\.pre-commit-config\.yaml$ + - ^(test-|)requirements.txt$ + - ^setup.cfg$ + - ^doc/.*$ + - ^.*\.rst$ + - ^releasenotes/.*$ + - ^heatclient/tests/unit/.*$ + +- project: + templates: + - openstack-cover-jobs + - openstack-python3-jobs + - check-requirements + - openstackclient-plugin-jobs + - publish-openstack-docs-pti + - release-notes-jobs-python3 + check: + jobs: + - heatclient-functional + gate: + jobs: + - heatclient-functional diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 24c5cc72..f2fda38b 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,16 +1,20 @@ -If you would like to contribute to the development of OpenStack, -you must follow the steps documented at: +The source repository for this project can be found at: - http://wiki.openstack.org/HowToContribute#If_you.27re_a_developer + https://opendev.org/openstack/python-heatclient -Once those steps have been completed, changes to OpenStack -should be submitted for review via the Gerrit tool, following -the workflow documented at: +Pull requests submitted through GitHub are not monitored. - http://wiki.openstack.org/GerritWorkflow +To start contributing to OpenStack, follow the steps in the contribution guide +to set up and use Gerrit: -Pull requests submitted through GitHub will be ignored. + https://docs.openstack.org/contributors/code-and-documentation/quick-start.html -Bugs should be filed on Launchpad, not GitHub: +Bugs should be filed on Storyboard,: + + https://storyboard.openstack.org/#!/project/openstack/python-heatclient + +For more specific information about contributing to this repository, see the +python-heatclient contributor guide: + + https://docs.openstack.org/python-heatclient/latest/contributor/contributing.html - https://bugs.launchpad.net/python-heatclient diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 3af1445f..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,8 +0,0 @@ -include AUTHORS -include LICENSE -include README.rst -include ChangeLog -include tox.ini -include .testr.conf -recursive-include doc * -recursive-include tools * diff --git a/README.rst b/README.rst index bfbde8e8..4f921d72 100644 --- a/README.rst +++ b/README.rst @@ -1,12 +1,39 @@ -Python bindings to the Heat orchestration API -============================================= +================= +python-heatclient +================= + +.. image:: https://governance.openstack.org/tc/badges/python-heatclient.svg + +.. Change things from this point on + +.. image:: https://img.shields.io/pypi/v/python-heatclient.svg + :target: https://pypi.org/project/python-heatclient/ + :alt: Latest Version + + +OpenStack Orchestration API Client Library This is a client library for Heat built on the Heat orchestration API. It provides a Python API (the ``heatclient`` module) and a command-line tool (``heat``). -Development takes place via the usual OpenStack processes as outlined in the -`OpenStack wiki `_. The master -repository is on `GitHub `_. +* Free software: Apache license +* `PyPi`_ - package installation +* `Online Documentation`_ +* `Launchpad project`_ - release management +* `Blueprints`_ - feature specifications +* `Bugs`_ - issue tracking +* `Source`_ +* `Specs`_ +* `Template`_ +* `How to Contribute`_ -See release notes and more at ``_. +.. _PyPi: https://pypi.org/project/python-heatclient +.. _Online Documentation: https://docs.openstack.org/python-heatclient/latest +.. _Launchpad project: https://launchpad.net/python-heatclient +.. _Blueprints: https://blueprints.launchpad.net/python-heatclient +.. _Bugs: https://storyboard.openstack.org/#!/project/openstack/python-heatclient +.. _Source: https://opendev.org/openstack/python-heatclient +.. _How to Contribute: https://docs.openstack.org/infra/manual/developers.html +.. _Specs: https://specs.openstack.org/openstack/heat-specs/ +.. _Template: https://opendev.org/openstack/heat-templates/ diff --git a/doc/.gitignore b/doc/.gitignore deleted file mode 100644 index 8e0be80f..00000000 --- a/doc/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -build/ -source/ref/ diff --git a/doc/Makefile b/doc/Makefile deleted file mode 100644 index 6239df39..00000000 --- a/doc/Makefile +++ /dev/null @@ -1,90 +0,0 @@ -# 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/requirements.txt b/doc/requirements.txt new file mode 100644 index 00000000..875f9897 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,4 @@ +openstackdocstheme>=2.2.1 # Apache-2.0 +reno>=3.1.0 # Apache-2.0 +sphinx>=2.0.0 # BSD +python-openstackclient>=3.12.0 # Apache-2.0 diff --git a/doc/source/cli/index.rst b/doc/source/cli/index.rst new file mode 100644 index 00000000..d58a3202 --- /dev/null +++ b/doc/source/cli/index.rst @@ -0,0 +1,9 @@ +============ +Command List +============ + +.. toctree:: + :glob: + :maxdepth: 3 + + * diff --git a/doc/source/cli/orchestration.rst b/doc/source/cli/orchestration.rst new file mode 100644 index 00000000..0aac0ef7 --- /dev/null +++ b/doc/source/cli/orchestration.rst @@ -0,0 +1,24 @@ +============= +orchestration +============= + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: orchestration template validate + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: orchestration template version list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: orchestration template function list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: orchestration resource type list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: orchestration resource type show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: orchestration build info + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: orchestration service list diff --git a/doc/source/cli/software_config.rst b/doc/source/cli/software_config.rst new file mode 100644 index 00000000..b7543080 --- /dev/null +++ b/doc/source/cli/software_config.rst @@ -0,0 +1,16 @@ +=============== +software config +=============== + + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software config list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software config create + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software config show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software config delete diff --git a/doc/source/cli/software_deployment.rst b/doc/source/cli/software_deployment.rst new file mode 100644 index 00000000..b8ccbe14 --- /dev/null +++ b/doc/source/cli/software_deployment.rst @@ -0,0 +1,22 @@ +=================== +software deployment +=================== + + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software deployment list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software deployment create + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software deployment show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software deployment output show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software deployment delete + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: software deployment metadata show diff --git a/doc/source/cli/stack.rst b/doc/source/cli/stack.rst new file mode 100644 index 00000000..175867e9 --- /dev/null +++ b/doc/source/cli/stack.rst @@ -0,0 +1,123 @@ +===== +stack +===== + +Basic actions +------------- + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack create + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack update + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack cancel + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack delete + +Outputs +------- + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack output list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack output show + +Resources +--------- + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack resource list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack resource show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack resource metadata + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack resource mark unhealthy + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack resource signal + +Events +------ + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack event list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack event show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack failures list + +Input data +---------- + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack template show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack environment show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack file list + +User hooks +---------- + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack hook poll + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack hook clear + +Other actions +------------- + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack check + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack suspend + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack resume + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack export + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack abandon + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack adopt + +Snapshots +--------- + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack snapshot list + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack snapshot create + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack snapshot restore + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack snapshot show + +.. autoprogram-cliff:: openstack.orchestration.v1 + :command: stack snapshot delete diff --git a/doc/source/conf.py b/doc/source/conf.py index e611dbe6..75d5cd80 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,4 +1,3 @@ -# -*- 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 @@ -28,7 +27,7 @@ # 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('.')) +# sys.path.append(os.path.abspath('.')) exec(open(os.path.join("ext", "gen_ref.py")).read()) # -- General configuration ---------------------------------------------------- @@ -36,45 +35,32 @@ # 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'] +extensions = ['sphinx.ext.autodoc', 'openstackdocstheme', 'cliff.sphinxext'] # 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'] +templates_path = [] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. -#source_encoding = 'utf-8' +# 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 +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -82,24 +68,24 @@ # The reST default role (used for this markup: `text`) # to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# 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 +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = 'native' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] primary_domain = 'py' nitpicky = False @@ -110,7 +96,7 @@ # 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' +html_theme = 'openstackdocs' # 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 @@ -120,115 +106,108 @@ } # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# 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 +# 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 +# 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 +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = 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 = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# 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', + # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', + # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. - #'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'), + '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 +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output ------------------------------------------- @@ -237,12 +216,12 @@ # (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), + 'Command line access to the heat project.', + ['Heat Developers'], 1), ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ----------------------------------------------- @@ -251,16 +230,20 @@ # (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.', + ('index', 'Heat', 'Heat Documentation', + 'Heat Developers', 'Heat', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' + +# -- Options for openstackdocstheme ------------------------------------------- +openstackdocs_repo_name = 'openstack/python-heatclient' +openstackdocs_use_storyboard = True diff --git a/doc/source/contributor/contributing.rst b/doc/source/contributor/contributing.rst new file mode 100644 index 00000000..46c80d58 --- /dev/null +++ b/doc/source/contributor/contributing.rst @@ -0,0 +1,48 @@ +============================ +So You Want to Contribute... +============================ +For general information on contributing to OpenStack, please check out the +`contributor guide `_ to get started. +It covers all the basics that are common to all OpenStack projects: the accounts +you need, the basics of interacting with our Gerrit review system, how we +communicate as a community, etc. +Below will cover the more project specific information you need to get started +with python-heatclient. + +Communication +~~~~~~~~~~~~~ +* IRC channel #heat at OFTC +* Mailing list (prefix subjects with ``[heat]`` for faster responses) + http://lists.openstack.org/cgi-bin/mailman/listinfo/openstack-discuss + +Contacting the Core Team +~~~~~~~~~~~~~~~~~~~~~~~~ +Please refer the `python-heatclient Core Team +`_ contacts. + +New Feature Planning +~~~~~~~~~~~~~~~~~~~~ +python-heatclient features are tracked on `Storyboard `_. +Please specify the reported story with `python-heatclient`. + +Task Tracking +~~~~~~~~~~~~~ +We track our tasks in `Storyboard `_. +Please specify the reported story with `python-heatclient`. + +Reporting a Bug +~~~~~~~~~~~~~~~ +You found an issue and want to make sure we are aware of it? You can do so on +`Storyboard `_. +Please specify the reported story with `python-heatclient`. + +Getting Your Patch Merged +~~~~~~~~~~~~~~~~~~~~~~~~~ +All changes proposed to the python-heatclient project require one or two +2 votes +from python-heatclient core reviewers before one of the core reviewers can approve +patch by giving ``Workflow +1`` vote. + +Project Team Lead Duties +~~~~~~~~~~~~~~~~~~~~~~~~ +All common PTL duties are enumerated in the `PTL guide +`_. diff --git a/doc/source/ext/gen_ref.py b/doc/source/ext/gen_ref.py index 15ff8182..88bfc856 100644 --- a/doc/source/ext/gen_ref.py +++ b/doc/source/ext/gen_ref.py @@ -1,4 +1,3 @@ -# -*- 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 @@ -25,7 +24,7 @@ def gen_ref(ver, title, names): refdir = os.path.join(BASE_DIR, "ref") pkg = "heatclient" if ver: - pkg = "%s.%s" % (pkg, ver) + pkg = f"{pkg}.{ver}" refdir = os.path.join(refdir, ver) if not os.path.exists(refdir): os.makedirs(refdir) @@ -37,7 +36,7 @@ def gen_ref(ver, title, names): ".. toctree::\n" " :maxdepth: 1\n" "\n") % {"title": title, "signs": "=" * len(title)}) - for name in names: + for name, sec_title in names.items(): idx.write(" %s\n" % name) rstpath = os.path.join(refdir, "%s.rst" % name) with open(rstpath, "w") as rst: @@ -49,11 +48,16 @@ def gen_ref(ver, title, names): " :undoc-members:\n" " :show-inheritance:\n" " :noindex:\n") - % {"title": name.capitalize(), - "signs": "=" * len(name), + % {"title": sec_title, + "signs": "=" * len(sec_title), "pkg": pkg, "name": name}) -gen_ref("", "Client Reference", ["client", "exc"]) + +gen_ref("", "Client Reference", {"client": "Client", "exc": "Exceptions"}) gen_ref("v1", "Version 1 API Reference", - ["stacks", "resources", "events", "actions", - "software_configs", "software_deployments"]) + {"stacks": "Stacks", + "resources": "Resources", + "events": "Events", + "actions": "Actions", + "software_configs": "Software Configs", + "software_deployments": "Software Deployments"}) diff --git a/doc/source/index.rst b/doc/source/index.rst index 02fa7af3..44f3a33f 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,10 +1,11 @@ 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`). +This is a client for OpenStack Heat API. There's a Python API (the +:mod:`heatclient` module), a `python-openstackclient`_ plugin for command-line +use, and a legacy command-line script (installed as :program:`heat`). +========== Python API ========== @@ -20,6 +21,29 @@ 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) +Alternatively, you can create a client instance using the keystoneauth session API:: + + >>> from keystoneauth1 import loading + >>> from keystoneauth1 import session + >>> from heatclient import client + >>> loader = loading.get_plugin_loader('password') + >>> auth = loader.load_from_options(auth_url=AUTH_URL, + ... username=USERNAME, + ... password=PASSWORD, + ... project_id=PROJECT_ID) + >>> sess = session.Session(auth=auth) + >>> heat = client.Client('1', session=sess) + >>> heat.stacks.list() + +If you have PROJECT_NAME instead of a PROJECT_ID, use the project_name +parameter. Similarly, if your cloud uses keystone v3 and you have a DOMAIN_NAME +or DOMAIN_ID, provide it as `user_domain_(name|id)` and if you are using a +PROJECT_NAME also provide the domain information as `project_domain_(name|id)`. + +For more information on keystoneauth API, see `Using Sessions`_. + +.. _Using Sessions: https://docs.openstack.org/keystoneauth/latest/using-sessions.html + Reference --------- @@ -29,8 +53,28 @@ Reference ref/index ref/v1/index -Command-line Tool -================= +============================ +OpenStackClient Command Line +============================ + +The preferred way of accessing Heat via the command line is using the +python-heatclient's plugin for `python-openstackclient`_. Heat commands are +available through the ``openstack`` CLI command when both python-heatclient and +python-openstackclient are installed. + +.. toctree:: + :maxdepth: 2 + + cli/index + +.. _python-openstackclient: https://docs.openstack.org/python-openstackclient + +======================== +Legacy Command-line Tool +======================== + +The ``heat`` command is provided as a legacy CLI option. Users should prefer +using the python-openstackclient plugin via the ``openstack`` command instead. In order to use the CLI, you must provide your OpenStack username, password, tenant, and auth endpoint. Use the corresponding @@ -56,20 +100,20 @@ 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`_. +For Contributors +================ + +* If you are a new contributor to python-heatclient please refer: :doc:`contributor/contributing` + + .. toctree:: + :hidden: -.. _on GitHub: https://github.com/openstack/python-heatclient -.. _Launchpad: https://launchpad.net/python-heatclient -.. _Gerrit: http://wiki.openstack.org/GerritWorkflow + contributor/contributing diff --git a/doc/source/man/heat.rst b/doc/source/man/heat.rst index 5da86af6..d181b39e 100644 --- a/doc/source/man/heat.rst +++ b/doc/source/man/heat.rst @@ -81,7 +81,19 @@ 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/. +Heat client bugs are managed throught StoryBoard +`OpenStack Heat Client Stories `__ diff --git a/heatclient/_i18n.py b/heatclient/_i18n.py new file mode 100644 index 00000000..4abe87e5 --- /dev/null +++ b/heatclient/_i18n.py @@ -0,0 +1,37 @@ +# 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 https://docs.openstack.org/oslo.i18n/latest/user/index.html + +""" + +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='heatclient') + +# The primary translation function using the well-known name "_" +_ = _translators.primary + + +def get_available_languages(): + return oslo_i18n.get_available_languages('heatclient') + + +def enable_lazy(): + return oslo_i18n.enable_lazy() diff --git a/heatclient/client.py b/heatclient/client.py index 8237b8e1..77444f43 100644 --- a/heatclient/client.py +++ b/heatclient/client.py @@ -10,10 +10,11 @@ # License for the specific language governing permissions and limitations # under the License. -from heatclient.common import utils +from oslo_utils import importutils def Client(version, *args, **kwargs): - module = utils.import_versioned_module(version, 'client') + module = importutils.import_versioned_module('heatclient', + version, 'client') client_class = getattr(module, 'Client') return client_class(*args, **kwargs) diff --git a/heatclient/openstack/common/apiclient/base.py b/heatclient/common/base.py similarity index 79% rename from heatclient/openstack/common/apiclient/base.py rename to heatclient/common/base.py index 14b57669..b19c8052 100644 --- a/heatclient/openstack/common/apiclient/base.py +++ b/heatclient/common/base.py @@ -19,18 +19,15 @@ """ Base utilities to build API operation managers and objects on top of. """ - -# E1102: %s is not callable -# pylint: disable=E1102 - import abc import copy -import six -from six.moves.urllib import parse +from oslo_utils import reflection +from oslo_utils import strutils +from urllib import parse -from heatclient.openstack.common.apiclient import exceptions -from heatclient.openstack.common import strutils +from heatclient._i18n import _ +from heatclient import exc as exceptions def getid(obj): @@ -51,7 +48,7 @@ def getid(obj): # TODO(aababilov): call run_hooks() in HookableMixin's child classes -class HookableMixin(object): +class HookableMixin: """Mixin so classes can register and run hooks.""" _hooks_map = {} @@ -74,8 +71,8 @@ def run_hooks(cls, hook_type, *args, **kwargs): :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 + :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: @@ -95,15 +92,16 @@ def __init__(self, client): :param client: instance of BaseClient descendant for HTTP requests """ - super(BaseManager, self).__init__() + super().__init__() self.client = client - def _list(self, url, response_key, obj_class=None, json=None): + 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' + 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 @@ -117,7 +115,7 @@ def _list(self, url, response_key, obj_class=None, json=None): if obj_class is None: obj_class = self.resource_class - data = body[response_key] + 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: @@ -127,15 +125,17 @@ def _list(self, url, response_key, obj_class=None, json=None): return [obj_class(self, res, loaded=True) for res in data if res] - def _get(self, url, response_key): + 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' + e.g., 'server'. If response_key is None - all response body + will be used. """ body = self.client.get(url).json() - return self.resource_class(self, body[response_key], loaded=True) + 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. @@ -145,21 +145,23 @@ def _head(self, url): resp = self.client.head(url) return resp.status_code == 204 - def _post(self, url, json, response_key, return_raw=False): + 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., 'servers' + 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 body[response_key] - return self.resource_class(self, body[response_key]) + return data + return self.resource_class(self, data) def _put(self, url, json=None, response_key=None): """Update an object with PUT method. @@ -168,7 +170,8 @@ def _put(self, url, json=None, response_key=None): :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' + 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 @@ -186,7 +189,8 @@ def _patch(self, url, json=None, response_key=None): :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' + 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: @@ -202,8 +206,7 @@ def _delete(self, url): return self.client.delete(url) -@six.add_metaclass(abc.ABCMeta) -class ManagerWithFind(BaseManager): +class ManagerWithFind(BaseManager, metaclass=abc.ABCMeta): """Manager with additional `find()`/`findall()` methods.""" @abc.abstractmethod @@ -219,7 +222,10 @@ def find(self, **kwargs): matches = self.findall(**kwargs) num_matches = len(matches) if num_matches == 0: - msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + 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() @@ -250,14 +256,12 @@ 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 @@ -267,38 +271,32 @@ def build_url(self, base_url=None, **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 + url = '/'.join([url, self.collection_key]) # do we have a specific entity? - entity_id = kwargs.get('%s_id' % self.key) + entity_id = kwargs.get(f'{self.key}_id') if entity_id is not None: - url += '/%s' % entity_id + url = '/'.join([url, entity_id]) return url def _filter_kwargs(self, kwargs): """Drop null values and handle ids.""" - for key, ref in six.iteritems(kwargs.copy()): + for key, ref in kwargs.copy().items(): if ref is None: kwargs.pop(key) else: if isinstance(ref, Resource): kwargs.pop(key) - kwargs['%s_id' % key] = getid(ref) + kwargs['{}_id'.firmat(key)] = getid(ref) return kwargs def create(self, **kwargs): @@ -324,12 +322,13 @@ def list(self, base_url=None, **kwargs): :param base_url: if provided, the generated URL will be appended to it """ kwargs = self._filter_kwargs(kwargs) + query = f'?{parse.urlencode(kwargs)}' if kwargs else '' 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 '', - }, + '{base_url}{query}'.format( + base_url=self.build_url(base_url=base_url, **kwargs), + query=query, + ), self.collection_key) def put(self, base_url=None, **kwargs): @@ -344,7 +343,7 @@ def put(self, base_url=None, **kwargs): def update(self, **kwargs): kwargs = self._filter_kwargs(kwargs) params = kwargs.copy() - params.pop('%s_id' % self.key) + params.pop(f'{self.key}_id') return self._patch( self.build_url(**kwargs), @@ -363,18 +362,22 @@ def find(self, base_url=None, **kwargs): :param base_url: if provided, the generated URL will be appended to it """ kwargs = self._filter_kwargs(kwargs) + query = f'?{parse.urlencode(kwargs)}' if kwargs else '' 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 '', - }, + '{base_url}{query}'.format( + base_url=self.build_url(base_url=base_url, **kwargs), + query=query, + ), self.collection_key) num = len(rl) if num == 0: - msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) - raise exceptions.NotFound(404, msg) + 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: @@ -388,7 +391,7 @@ class Extension(HookableMixin): manager_class = None def __init__(self, name, module): - super(Extension, self).__init__() + super().__init__() self.name = name self.module = module self._parse_extension_module() @@ -406,15 +409,14 @@ def _parse_extension_module(self): pass def __repr__(self): - return "" % self.name + return f"" -class Resource(object): +class Resource: """Base class for OpenStack resources (tenant, user, etc.). This is pretty much just a bag for attributes. """ - HUMAN_ID = False NAME_ATTR = 'name' @@ -432,21 +434,23 @@ def __init__(self, manager, info, loaded=False): def __repr__(self): reprkeys = sorted(k - for k in self.__dict__.keys() + for k in self.__dict__ 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) + info = ", ".join(f"{k}={getattr(self, k)}" for k in reprkeys) + class_name = reflection.get_class_name(self, fully_qualified=False) + return f"<{class_name} {info}>" @property def human_id(self): - """Human-readable ID which can be used for bash completion. - """ - if self.NAME_ATTR in self.__dict__ and self.HUMAN_ID: - return strutils.to_slug(getattr(self, self.NAME_ATTR)) + """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): + for (k, v) in info.items(): try: setattr(self, k, v) self._info[k] = v @@ -456,7 +460,7 @@ def _add_details(self, info): def __getattr__(self, k): if k not in self.__dict__: - #NOTE(bcwaldon): disallow lazy-loading if already loaded once + # NOTE(bcwaldon): disallow lazy-loading if already loaded once if not self.is_loaded(): self.get() return self.__getattr__(k) @@ -479,6 +483,8 @@ def get(self): 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): @@ -486,10 +492,18 @@ def __eq__(self, other): # 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 __ne__(self, other): + return not self.__eq__(other) + + def is_same_obj(self, other): + """Identify the two objects are same one with same id.""" + if isinstance(other, self.__class__): + if hasattr(self, 'id') and hasattr(other, 'id'): + return self.id == other.id + return False + def is_loaded(self): return self._loaded diff --git a/heatclient/common/deployment_utils.py b/heatclient/common/deployment_utils.py new file mode 100644 index 00000000..8546c5a5 --- /dev/null +++ b/heatclient/common/deployment_utils.py @@ -0,0 +1,152 @@ +# 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 uuid + +from swiftclient import client as sc +from swiftclient import utils as swiftclient_utils +from urllib import parse as urlparse + +from heatclient._i18n import _ +from heatclient import exc +from heatclient.v1 import software_configs + + +def build_derived_config_params(action, source, name, input_values, + server_id, signal_transport, signal_id=None): + + if isinstance(source, software_configs.SoftwareConfig): + source = source.to_dict() + 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 heat 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}-{uuid}'.format( + 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: str(uuid.uuid4())[:32]}) + + key = swift_client.head_account()[key_header] + project_path = swift_client.url.split('/')[-1] + path = f'/v1/{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 = f'{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 + + # NOTE(pas-ha) only heatclient has os-no-client-auth arg, + # osc plugin does not have it + if getattr(args, 'os_no_client_auth', False): + 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/heatclient/common/environment_format.py b/heatclient/common/environment_format.py index 82d6f1a6..201ff60b 100644 --- a/heatclient/common/environment_format.py +++ b/heatclient/common/environment_format.py @@ -10,42 +10,58 @@ # License for the specific language governing permissions and limitations # under the License. -from heatclient.common.template_format import yaml_loader - import yaml +from heatclient._i18n import _ +from heatclient.common import template_format + -SECTIONS = (PARAMETERS, RESOURCE_REGISTRY) = \ - ('parameters', 'resource_registry') +SECTIONS = ( + PARAMETER_DEFAULTS, PARAMETERS, RESOURCE_REGISTRY, + ENCRYPTED_PARAM_NAMES, EVENT_SINKS, + PARAMETER_MERGE_STRATEGIES +) = ( + 'parameter_defaults', 'parameters', 'resource_registry', + 'encrypted_param_names', 'event_sinks', + 'parameter_merge_strategies' +) def parse(env_str): - '''Takes a string and returns a dict containing the parsed structure. + """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=yaml_loader) - except yaml.YAMLError as yea: - raise ValueError(yea) + env = yaml.load(env_str, Loader=template_format.yaml_loader) + except yaml.YAMLError: + # NOTE(prazumovsky): we need to return more informative error for + # user, so use SafeLoader, which return error message with template + # snippet where error has been occurred. + try: + env = yaml.load(env_str, Loader=yaml.SafeLoader) + 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.') + 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) + raise ValueError(_('environment has wrong section "%s"') % param) return env def default_for_missing(env): - '''Checks a parsed environment for missing sections. - ''' + """Checks a parsed environment for missing sections.""" for param in SECTIONS: - if param not in env: - env[param] = {} + if param not in env and param != PARAMETER_MERGE_STRATEGIES: + if param in (ENCRYPTED_PARAM_NAMES, EVENT_SINKS): + env[param] = [] + else: + env[param] = {} diff --git a/heatclient/common/event_utils.py b/heatclient/common/event_utils.py new file mode 100644 index 00000000..ca213257 --- /dev/null +++ b/heatclient/common/event_utils.py @@ -0,0 +1,264 @@ +# 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. + +import sys +import time + +from heatclient._i18n import _ +from heatclient.common import utils +import heatclient.exc as exc +from heatclient.v1 import events as events_mod + + +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' + elif hook_type == 'pre-delete': + stack_action_reason = 'Stack DELETE started' + hook_event_reason = 'DELETE paused until Hook pre-delete is cleared' + hook_clear_event_reason = 'Hook pre-delete 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): + event_args = dict(event_args) + if marker: + event_args['marker'] = marker + if limit: + event_args['limit'] = limit + if not nested_depth: + # simple call with no nested_depth + return _get_stack_events(hc, stack_id, event_args) + + # assume an API which supports nested_depth + event_args['nested_depth'] = nested_depth + events = _get_stack_events(hc, stack_id, event_args) + + if not events: + return events + + first_links = getattr(events[0], 'links', []) + root_stack_links = [link for link in first_links + if link.get('rel') == 'root_stack'] + if root_stack_links: + # response has a root_stack link, indicating this is an API which + # supports nested_depth + return events + + # API doesn't support nested_depth, do client-side paging and recursive + # event fetch + marker = event_args.pop('marker', None) + limit = event_args.pop('limit', None) + event_args.pop('nested_depth', None) + events = _get_stack_events(hc, stack_id, event_args) + 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: + try: + marker_index = [e.id for e in events].index(marker) + events = events[marker_index:] + except ValueError: + pass + + # 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 heat version, but this will be required + # until kilo heat 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_name_from_links(event): + links = {link.get('rel'): link.get('href') + for link in getattr(event, 'links', [])} + href = links.get('stack') + if not href: + return + return href.split('/stacks/', 1)[-1].split('/')[0] + + +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: + stack_name = stack_id.split("/")[0] + # Show which stack the event comes from (for nested events) + for e in events: + e.stack_name = _get_stack_name_from_links(e) or stack_name + return events + + +def poll_for_events(hc, stack_name, action=None, poll_period=5, marker=None, + out=None, nested_depth=0): + """Continuously poll events and logs for performed action on stack.""" + + if action: + stop_status = ('%s_FAILED' % action, '%s_COMPLETE' % action) + stop_check = lambda a: a in stop_status # noqa: E731 + else: + stop_check = lambda a: a.endswith('_COMPLETE') or a.endswith('_FAILED') # noqa E731 + + no_event_polls = 0 + msg_template = _("\n Stack %(name)s %(status)s \n") + if not out: + out = sys.stdout + event_log_context = utils.EventLogContext() + + def is_stack_event(event): + if getattr(event, 'resource_name', '') != stack_name: + return False + + phys_id = getattr(event, 'physical_resource_id', '') + links = {link.get('rel'): link.get('href') + for link in getattr(event, 'links', [])} + stack_id = links.get('stack', phys_id).rsplit('/', 1)[-1] + return stack_id == phys_id + + while True: + events = get_events(hc, stack_id=stack_name, nested_depth=nested_depth, + event_args={'sort_dir': 'asc', + 'marker': marker}) + + if len(events) == 0: + no_event_polls += 1 + else: + no_event_polls = 0 + # set marker to last event that was received. + marker = getattr(events[-1], 'id', None) + events_log = utils.event_log_formatter(events, event_log_context) + out.write(events_log) + out.write('\n') + + for event in events: + # check if stack event was also received + if is_stack_event(event): + stack_status = getattr(event, 'resource_status', '') + msg = msg_template % dict( + name=stack_name, status=stack_status) + if stop_check(stack_status): + return stack_status, msg + + if no_event_polls >= 2: + # after 2 polls with no events, fall back to a stack get + stack = hc.stacks.get(stack_name, resolve_outputs=False) + stack_status = stack.stack_status + msg = msg_template % dict( + name=stack_name, status=stack_status) + if stop_check(stack_status): + return stack_status, msg + # go back to event polling again + no_event_polls = 0 + + time.sleep(poll_period) + + +def wait_for_events(ws, stack_name, out=None): + """Receive events over the passed websocket and wait for final status.""" + msg_template = _("\n Stack %(name)s %(status)s \n") + if not out: + out = sys.stdout + event_log_context = utils.EventLogContext() + while True: + data = ws.recv()['body'] + event = events_mod.Event(None, data['payload'], True) + # Keep compatibility with the HTTP API + event.event_time = data['timestamp'] + event.resource_status = '{}_{}'.format(event.resource_action, + event.resource_status) + events_log = utils.event_log_formatter([event], event_log_context) + out.write(events_log) + out.write('\n') + if data['payload']['resource_name'] == stack_name: + stack_status = data['payload']['resource_status'] + if stack_status in ('COMPLETE', 'FAILED'): + msg = msg_template % dict( + name=stack_name, status=event.resource_status) + return f'{event.resource_action}_{stack_status}', msg diff --git a/heatclient/common/format_utils.py b/heatclient/common/format_utils.py new file mode 100644 index 00000000..4151c80e --- /dev/null +++ b/heatclient/common/format_utils.py @@ -0,0 +1,95 @@ +# 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. +# +# Copyright 2015 IBM Corp. + +import sys + +from osc_lib.command import command + + +class RawFormat(command.ShowOne): + + def produce_output(self, parsed_args, column_names, data): + if data is None: + return + + self.formatter.emit_one(column_names, data, + self.app.stdout, parsed_args) + + +class JsonFormat(RawFormat): + + @property + def formatter_default(self): + return 'json' + + +class YamlFormat(RawFormat): + + @property + def formatter_default(self): + return 'yaml' + + +class ShellFormat(RawFormat): + + @property + def formatter_default(self): + return 'shell' + + +class ValueFormat(RawFormat): + + @property + def formatter_default(self): + return 'value' + + +def indent_and_truncate(txt, spaces=0, truncate=False, truncate_limit=10, + truncate_prefix=None, truncate_postfix=None): + """Indents supplied multiline text by the specified number of spaces + + """ + if txt is None: + return + lines = str(txt).splitlines() + if truncate and len(lines) > truncate_limit: + lines = lines[-truncate_limit:] + if truncate_prefix is not None: + lines.insert(0, truncate_prefix) + if truncate_postfix is not None: + lines.append(truncate_postfix) + + if spaces > 0: + lines = [" " * spaces + line for line in lines] + return '\n'.join(lines) + + +def print_software_deployment_output(data, name, out=sys.stdout, long=False): + """Prints details of the software deployment for user consumption + + The format attempts to be valid yaml, but is primarily aimed at showing + useful information to the user in a helpful layout. + """ + if data is None: + data = {} + if name in ('deploy_stdout', 'deploy_stderr'): + output = indent_and_truncate( + data.get(name), + spaces=4, + truncate=not long, + truncate_prefix='...', + truncate_postfix='(truncated, view all with --long)') + out.write(f' {name}: |\n{output}\n') + else: + out.write(f' {name}: {data.get(name)}\n') diff --git a/heatclient/common/hook_utils.py b/heatclient/common/hook_utils.py new file mode 100644 index 00000000..3e91ae3b --- /dev/null +++ b/heatclient/common/hook_utils.py @@ -0,0 +1,79 @@ +# 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 heatclient._i18n import _ +from heatclient import exc + +logger = logging.getLogger(__name__) + + +def clear_hook(hc, stack_id, resource_name, hook_type): + try: + hc.resources.signal( + stack_id=stack_id, + resource_name=resource_name, + data={'unset_hook': hook_type}) + except exc.HTTPNotFound: + logger.error( + "Stack %(stack)s or resource %(resource)s " + "not found for hook %(hook_type)s", + {'resource': resource_name, 'stack': stack_id, + 'hook_type': hook_type}) + + +def clear_wildcard_hooks(hc, stack_id, stack_patterns, hook_type, + resource_pattern): + if stack_patterns: + for resource in hc.resources.list(stack_id): + res_name = resource.resource_name + if fnmatch.fnmatchcase(res_name, stack_patterns[0]): + nested_stack = hc.resources.get( + stack_id=stack_id, + resource_name=res_name) + clear_wildcard_hooks( + hc, + nested_stack.physical_resource_id, + stack_patterns[1:], hook_type, resource_pattern) + else: + for resource in hc.resources.list(stack_id): + res_name = resource.resource_name + if fnmatch.fnmatchcase(res_name, resource_pattern): + clear_hook(hc, stack_id, res_name, hook_type) + + +def get_hook_type_via_status(hc, stack_id): + # Figure out if the hook should be pre-create, pre-update or + # pre-delete based on the stack status, also sanity assertions + # that we're in-progress. + try: + stack = hc.stacks.get(stack_id=stack_id) + except exc.HTTPNotFound: + raise exc.CommandError(_('Stack not found: %s') % stack_id) + else: + if 'IN_PROGRESS' not in stack.stack_status: + raise exc.CommandError(_('Stack status %s not IN_PROGRESS') % + stack.stack_status) + + if 'CREATE' in stack.stack_status: + hook_type = 'pre-create' + elif 'UPDATE' in stack.stack_status: + hook_type = 'pre-update' + elif 'DELETE' in stack.stack_status: + hook_type = 'pre-delete' + else: + raise exc.CommandError(_('Unexpected stack status %s, ' + 'only create, update and delete supported') + % stack.stack_status) + return hook_type diff --git a/heatclient/common/http.py b/heatclient/common/http.py index 33267959..8d7a548a 100644 --- a/heatclient/common/http.py +++ b/heatclient/common/http.py @@ -19,14 +19,16 @@ import os import socket +from keystoneauth1 import adapter +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 urllib import parse +from heatclient._i18n import _ +from heatclient.common import utils from heatclient import exc -from heatclient.openstack.common import importutils -from heatclient.openstack.common import jsonutils -from heatclient.openstack.common import strutils LOG = logging.getLogger(__name__) USER_AGENT = 'python-heatclient' @@ -35,6 +37,19 @@ osprofiler_web = importutils.try_import("osprofiler.web") +def authenticated_fetcher(hc): + """A wrapper around the heat client object to fetch a template.""" + + def _do(*args, **kwargs): + if isinstance(hc.http_client, 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 get_system_ca_file(): """Return path to system default CA file.""" # Standard CA file locations for Debian/Ubuntu, RedHat/Fedora, @@ -50,10 +65,10 @@ def get_system_ca_file(): if os.path.exists(ca): LOG.debug("Using ca file %s", ca) return ca - LOG.warn("System ca file could not be found.") + LOG.warning("System ca file could not be found.") -class HTTPClient(object): +class HTTPClient: def __init__(self, endpoint, **kwargs): self.endpoint = endpoint @@ -83,18 +98,23 @@ def __init__(self, endpoint, **kwargs): 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 strutils.safe_decode(name), "{SHA1}%s" % d + return encodeutils.safe_decode(name), "{SHA1}%s" % d else: - return strutils.safe_decode(name), strutils.safe_decode(value) + return (encodeutils.safe_decode(name), + encodeutils.safe_decode(value)) def log_curl_request(self, method, url, kwargs): - curl = ['curl -i -X %s' % method] + curl = ['curl -g -i -X %s' % method] for (key, value) in kwargs['headers'].items(): header = '-H \'%s: %s\'' % self.safe_header(key, value) @@ -116,18 +136,21 @@ def log_curl_request(self, method, url, kwargs): if 'data' in kwargs: curl.append('-d \'%s\'' % kwargs['data']) - curl.append('%s%s' % (self.endpoint, url)) + if not parse.urlparse(url).netloc: + url = self.endpoint + url + + curl.append(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.extend([f'{k}: {v}' for k, v in resp.headers.items()]) dump.append('') if resp.content: content = resp.content - if isinstance(content, six.binary_type): + if isinstance(content, bytes): content = content.decode() dump.extend([content, '']) LOG.debug('\n'.join(dump)) @@ -149,7 +172,7 @@ def _http_request(self, url, method, **kwargs): 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']: + if self.include_pass and 'X-Auth-Key' not in kwargs['headers']: kwargs['headers'].update(self.credentials_headers()) if osprofiler_web: kwargs['headers'].update(osprofiler_web.get_trace_id_headers()) @@ -167,7 +190,7 @@ def _http_request(self, url, method, **kwargs): # Allow caller to specify not to follow redirects, in which case we # just return the redirect response. Useful for using stacks:lookup. - follow_redirects = kwargs.pop('follow_redirects', True) + 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 @@ -179,56 +202,49 @@ def _http_request(self, url, method, **kwargs): # See issue: https://github.com/kennethreitz/requests/issues/1704 allow_redirects = False + # Use fully qualified URL from response header for redirects + if not parse.urlparse(url).netloc: + url = self.endpoint_url + url + try: resp = requests.request( method, - self.endpoint_url + url, + url, allow_redirects=allow_redirects, **kwargs) except socket.gaierror as e: - message = ("Error finding address for %(url)s: %(e)s" % + 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: + except (OSError, TimeoutError) as e: endpoint = self.endpoint - message = ("Error communicating with %(endpoint)s %(e)s" % + 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 " - "--include-password or export " - "HEAT_INCLUDE_PASSWORD=1\n%s" + txt_content = encodeutils.safe_decode(resp.content, 'utf-8') + if not ('X-Auth-Key' in kwargs['headers']) and ( + resp.status_code == 401 or + (resp.status_code == 500 and "(HTTP 401)" in txt_content)): + raise exc.HTTPUnauthorized(_("Authentication failed: %s") % 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 follow_redirects=False - if follow_redirects: + # unless caller specified redirect=False + if redirect: location = resp.headers.get('location') - path = self.strip_endpoint(location) - resp = self._http_request(path, method, **kwargs) + if not location: + message = _("Location not returned with redirect") + raise exc.InvalidEndpoint(message=message) + resp = self._http_request(location, 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 @@ -252,15 +268,7 @@ def json_request(self, method, url, **kwargs): kwargs['data'] = jsonutils.dumps(kwargs['data']) resp = self._http_request(url, method, **kwargs) - body = resp.content - if 'application/json' in resp.headers.get('content-type', ''): - try: - body = resp.json() - except ValueError: - LOG.error('Could not decode response body as JSON') - else: - body = None - + body = utils.get_response_body(resp) return resp, body def raw_request(self, method, url, **kwargs): @@ -292,74 +300,73 @@ def patch(self, url, **kwargs): return self.client_request("PATCH", url, **kwargs) -class SessionClient(HTTPClient): +class SessionClient(adapter.LegacyJsonAdapter): """HTTP client based on Keystone client session.""" - # NOTE(dhu): Will eventually move to a common session client. - # https://bugs.launchpad.net/python-keystoneclient/+bug/1332337 - def __init__(self, session, auth, endpoint, **kwargs): - self.session = session - self.auth = auth - self.endpoint = endpoint - - self.auth_url = kwargs.get('auth_url') - self.region_name = kwargs.get('region_name') - self.interface = kwargs.get('interface', - kwargs.get('endpoint_type', 'public')) - self.service_type = kwargs.get('service_type') - - self.include_pass = kwargs.get('include_pass') - self.username = kwargs.get('username') - self.password = kwargs.get('password') - # see if we can get the auth_url from auth plugin if one is not - # provided from kwargs - if not self.auth_url and hasattr(self.auth, 'auth_url'): - self.auth_url = self.auth.auth_url - - def _http_request(self, url, method, **kwargs): + def request(self, url, method, **kwargs): + redirect = kwargs.get('redirect') kwargs.setdefault('user_agent', USER_AGENT) - kwargs.setdefault('auth', self.auth) - endpoint_filter = kwargs.setdefault('endpoint_filter', {}) - endpoint_filter.setdefault('interface', self.interface) - endpoint_filter.setdefault('service_type', self.service_type) - endpoint_filter.setdefault('region_name', self.region_name) + headers = kwargs.setdefault('headers', {}) + headers.setdefault('Content-Type', 'application/json') - # TODO(gyee): what are these headers for? - 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 'X-Auth-Key' not in kwargs['headers']: - kwargs['headers'].update(self.credentials_headers()) - - # Allow caller to specify not to follow redirects, in which case we - # just return the redirect response. Useful for using stacks:lookup. - follow_redirects = kwargs.pop('follow_redirects', True) + if 'data' in kwargs: + kwargs['data'] = jsonutils.dumps(kwargs['data']) - resp = self.session.request(url, method, redirect=follow_redirects, - raise_exc=False, **kwargs) + resp, body = super().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): - # Redirected. Reissue the request to the new location, - # unless caller specified follow_redirects=False - if follow_redirects: + if redirect: location = resp.headers.get('location') path = self.strip_endpoint(location) - resp = self._http_request(path, method, **kwargs) + resp = self.request(path, method, **kwargs) elif resp.status_code == 300: raise exc.from_response(resp) return resp + def credentials_headers(self): + return {} -def _construct_http_client(*args, **kwargs): + 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: - return SessionClient(session, auth, *args, **kwargs) + if 'endpoint_override' not in kwargs and endpoint: + kwargs['endpoint_override'] = endpoint + + if 'service_type' not in kwargs: + kwargs['service_type'] = 'orchestration' + + if 'interface' not in kwargs and endpoint_type: + kwargs['interface'] = endpoint_type + + if 'region_name' in kwargs: + kwargs['additional_headers'] = { + 'X-Region-Name': kwargs['region_name']} + + return SessionClient(session, auth=auth, **kwargs) else: - return HTTPClient(*args, **kwargs) + return HTTPClient(endpoint=endpoint, username=username, + password=password, include_pass=include_pass, + endpoint_type=endpoint_type, auth_url=auth_url, + **kwargs) diff --git a/heatclient/common/resource_formatter.py b/heatclient/common/resource_formatter.py new file mode 100644 index 00000000..3cfb63f1 --- /dev/null +++ b/heatclient/common/resource_formatter.py @@ -0,0 +1,150 @@ +# 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 +import hashlib + +from cliff.formatters import base + + +class ResourceDotInfo: + + def __init__(self, res): + self.resource = res + links = {link['rel']: link['href'] for link in res.links} + self.nested_dot_id = self.dot_id(links.get('nested'), 'stack') + self.stack_dot_id = self.dot_id(links.get('stack'), 'stack') + self.res_dot_id = self.dot_id(links.get('self')) + + @staticmethod + def dot_id(url, prefix=None): + """Build an id with a prefix and a truncated hash of the URL""" + if not url: + return None + if not prefix: + prefix = 'r' + hash_object = hashlib.sha256(url.encode('utf-8')) + return f'{prefix}_{hash_object.hexdigest()[:20]}' + + +class ResourceDotFormatter(base.ListFormatter): + def add_argument_group(self, parser): + pass + + def emit_list(self, column_names, data, stdout, parsed_args): + writer = ResourceDotWriter(data, stdout) + writer.write() + + +class ResourceDotWriter: + + def __init__(self, data, stdout): + self.resources_by_stack = collections.defaultdict( + collections.OrderedDict) + self.resources_by_dot_id = collections.OrderedDict() + self.nested_stack_ids = [] + self.stdout = stdout + + for r in data: + rinfo = ResourceDotInfo(r) + if rinfo.stack_dot_id: + self.resources_by_stack[ + rinfo.stack_dot_id][r.resource_name] = rinfo + if rinfo.res_dot_id: + self.resources_by_dot_id[rinfo.res_dot_id] = rinfo + if rinfo.nested_dot_id: + self.nested_stack_ids.append(rinfo.nested_dot_id) + + def write(self): + stdout = self.stdout + + stdout.write('digraph G {\n') + stdout.write(' graph [\n' + ' fontsize=10 fontname="Verdana" ' + 'compound=true rankdir=LR\n' + ' ]\n') + + self.write_root_nodes() + self.write_subgraphs() + self.write_nested_stack_edges() + self.write_required_by_edges() + stdout.write('}\n') + + def write_root_nodes(self): + for stack_dot_id in set(self.resources_by_stack.keys()).difference( + self.nested_stack_ids): + resources = self.resources_by_stack[stack_dot_id] + self.write_nodes(resources, 2) + + def write_subgraphs(self): + for dot_id, rinfo in self.resources_by_dot_id.items(): + if rinfo.nested_dot_id: + resources = self.resources_by_stack[rinfo.nested_dot_id] + if resources: + self.write_subgraph(resources, rinfo) + + def write_nodes(self, resources, indent): + stdout = self.stdout + spaces = ' ' * indent + for rinfo in resources.values(): + r = rinfo.resource + dot_id = rinfo.res_dot_id + if r.resource_status.endswith('FAILED'): + style = 'style=filled color=red' + else: + style = '' + stdout.write('%s%s [label="%s\n%s" %s];\n' + % (spaces, dot_id, r.resource_name, + r.resource_type, style)) + stdout.write('\n') + + def write_subgraph(self, resources, nested_resource): + stdout = self.stdout + stack_dot_id = nested_resource.nested_dot_id + nested_name = nested_resource.resource.resource_name + stdout.write(' subgraph cluster_%s {\n' % stack_dot_id) + stdout.write(' label="%s";\n' % nested_name) + self.write_nodes(resources, 4) + stdout.write(' }\n\n') + + def write_required_by_edges(self): + stdout = self.stdout + for dot_id, rinfo in self.resources_by_dot_id.items(): + r = rinfo.resource + + required_by = r.required_by + stack_dot_id = rinfo.stack_dot_id + if not required_by or not stack_dot_id: + continue + + stack_resources = self.resources_by_stack.get(stack_dot_id, {}) + for req in required_by: + other_rinfo = stack_resources.get(req) + if other_rinfo: + stdout.write(' %s -> %s;\n' + % (rinfo.res_dot_id, other_rinfo.res_dot_id)) + stdout.write('\n') + + def write_nested_stack_edges(self): + stdout = self.stdout + for dot_id, rinfo in self.resources_by_dot_id.items(): + if rinfo.nested_dot_id: + nested_resources = self.resources_by_stack[rinfo.nested_dot_id] + if nested_resources: + first_resource = list(nested_resources.values())[0] + stdout.write( + ' %s -> %s [\n color=dimgray lhead=cluster_%s ' + 'arrowhead=none\n ];\n' + % (dot_id, first_resource.res_dot_id, + rinfo.nested_dot_id)) + stdout.write('\n') diff --git a/heatclient/common/template_format.py b/heatclient/common/template_format.py index b29817b0..ff2fdf98 100644 --- a/heatclient/common/template_format.py +++ b/heatclient/common/template_format.py @@ -13,43 +13,64 @@ import json import yaml +from heatclient._i18n import _ + if hasattr(yaml, 'CSafeLoader'): - yaml_loader = yaml.CSafeLoader + yaml_loader_base = yaml.CSafeLoader else: - yaml_loader = yaml.SafeLoader + yaml_loader_base = yaml.SafeLoader if hasattr(yaml, 'CSafeDumper'): - yaml_dumper = yaml.CSafeDumper + yaml_dumper_base = yaml.CSafeDumper else: - yaml_dumper = yaml.SafeDumper + yaml_dumper_base = yaml.SafeDumper + + +# We create custom class to not overriden the default yaml behavior +class yaml_loader(yaml_loader_base): + pass + + +class yaml_dumper(yaml_dumper_base): + pass 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) + + +yaml_loader.add_constructor('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', +yaml_loader.add_constructor('tag:yaml.org,2002:timestamp', _construct_yaml_str) def parse(tmpl_str): - '''Takes a string and returns a dict containing the parsed structure. + """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. - ''' + """ + # strip any whitespace before the check + tmpl_str = tmpl_str.strip() 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) + except yaml.YAMLError: + # NOTE(prazumovsky): we need to return more informative error for + # user, so use SafeLoader, which return error message with template + # snippet where error has been occurred. + try: + tpl = yaml.load(tmpl_str, Loader=yaml.SafeLoader) + except yaml.YAMLError as yea: + raise ValueError(yea) else: if tpl is None: tpl = {} @@ -57,5 +78,5 @@ def parse(tmpl_str): if not ('HeatTemplateFormatVersion' in tpl or 'heat_template_version' in tpl or 'AWSTemplateFormatVersion' in tpl): - raise ValueError("Template format version not found.") + raise ValueError(_("Template format version not found.")) return tpl diff --git a/heatclient/common/template_utils.py b/heatclient/common/template_utils.py index b7a6a703..cc814356 100644 --- a/heatclient/common/template_utils.py +++ b/heatclient/common/template_utils.py @@ -13,107 +13,145 @@ # License for the specific language governing permissions and limitations # under the License. -import base64 -import collections -import os -import six -from six.moves.urllib import error -from six.moves.urllib import parse -from six.moves.urllib import request +from collections import abc +from oslo_serialization import jsonutils +from urllib import error +from urllib import parse +from urllib import request +from heatclient._i18n import _ from heatclient.common import environment_format from heatclient.common import template_format +from heatclient.common import utils from heatclient import exc -from heatclient.openstack.common import jsonutils + + +def process_template_path(template_path, object_request=None, + existing=False, fetch_child=True): + """Read template from template path. + + Attempt to read template first as a file or url. If that is unsuccessful, + try again assuming path is to a template object. + + :param template_path: local or uri path to template + :param object_request: custom object request function used to get template + if local or uri path fails + :param existing: if the current stack's template should be used + :param fetch_child: Whether to fetch the child templates + :returns: get_file dict and template contents + :raises: error.URLError + """ + try: + return get_template_contents(template_file=template_path, + existing=existing, + fetch_child=fetch_child) + except error.URLError as template_file_exc: + try: + return get_template_contents(template_object=template_path, + object_request=object_request, + existing=existing, + fetch_child=fetch_child) + except exc.HTTPNotFound: + # The initial exception gives the user better failure context. + raise template_file_exc def get_template_contents(template_file=None, template_url=None, template_object=None, object_request=None, - files=None): + files=None, existing=False, + fetch_child=True): + is_object = False # Transform a bare file path to a file:// URL. if template_file: - template_url = normalise_file_path_to_url(template_file) + template_url = utils.normalise_file_path_to_url(template_file) if template_url: tpl = request.urlopen(template_url).read() elif template_object: + is_object = True 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 ' - '--template-file, --template-url ' - 'or --template-object') + raise exc.CommandError(_('Need to specify exactly one of ' + '[%(arg1)s, %(arg2)s or %(arg3)s]' + ' or %(arg4)s') % + { + 'arg1': '--template-file', + 'arg2': '--template-url', + 'arg3': '--template-object', + 'arg4': '--existing'}) if not tpl: - raise exc.CommandError('Could not fetch template from %s' + raise exc.CommandError(_('Could not fetch template from %s') % template_url) try: - if isinstance(tpl, six.binary_type): + if isinstance(tpl, bytes): tpl = tpl.decode('utf-8') template = template_format.parse(tpl) except ValueError as e: - raise exc.CommandError( - 'Error parsing template %s %s' % (template_url, e)) - - tmpl_base_url = base_url_for_url(template_url) + raise exc.CommandError(_('Error parsing template %(url)s %(error)s') % + {'url': template_url, 'error': e}) if files is None: files = {} - resolve_template_get_files(template, files, tmpl_base_url) - resolve_template_type(template, files, tmpl_base_url) + + if fetch_child: + tmpl_base_url = utils.base_url_for_url(template_url) + resolve_template_get_files(template, files, tmpl_base_url, is_object, + object_request) return files, template -def resolve_template_get_files(template, files, template_base_url): +def resolve_template_get_files(template, files, template_base_url, + is_object=False, object_request=None): def ignore_if(key, value): - if key != 'get_file': + if key != 'get_file' and key != 'type': + return True + if not isinstance(value, str): return True - if not isinstance(value, six.string_types): + 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) - + ignore_if, recurse_if, is_object, object_request) -def resolve_template_type(template, files, template_base_url): - def ignore_if(key, value): - if key != 'type': - return True - if not isinstance(value, six.string_types): - return True - if not value.endswith(('.yaml', '.template')): - return True +def is_template(file_content): + try: + if isinstance(file_content, bytes): + file_content = file_content.decode('utf-8') + template_format.parse(file_content) + except (ValueError, TypeError): return False - - def recurse_if(value): - return isinstance(value, (dict, list)) - - get_file_contents(template, files, template_base_url, - ignore_if, recurse_if, file_is_template=True) + return True def get_file_contents(from_data, files, base_url=None, - ignore_if=None, recurse_if=None, file_is_template=False): + ignore_if=None, recurse_if=None, + is_object=False, object_request=None): if recurse_if and recurse_if(from_data): if isinstance(from_data, dict): - recurse_data = six.itervalues(from_data) + recurse_data = from_data.values() else: recurse_data = from_data for value in recurse_data: get_file_contents(value, files, base_url, ignore_if, recurse_if, - file_is_template=file_is_template) + is_object, object_request) if isinstance(from_data, dict): - for key, value in iter(from_data.items()): + for key, value in from_data.items(): if ignore_if and ignore_if(key, value): continue @@ -122,85 +160,168 @@ def get_file_contents(from_data, files, base_url=None, str_url = parse.urljoin(base_url, value) if str_url not in files: - if file_is_template: - template = get_template_contents( - template_url=str_url, files=files)[1] - file_content = jsonutils.dumps(template) + if is_object and object_request: + file_content = object_request('GET', str_url) else: - file_content = read_url_content(str_url) + file_content = utils.read_url_content(str_url) + if is_template(file_content): + if is_object: + template = get_template_contents( + template_object=str_url, files=files, + object_request=object_request)[1] + else: + 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): - 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 + '''DEPRECATED! Use 'utils.read_url_content' instead.''' + return utils.read_url_content(url) def base_url_for_url(url): - parsed = parse.urlparse(url) - parsed_dir = os.path.dirname(parsed.path) - return parse.urljoin(url, parsed_dir) + '''DEPRECATED! Use 'utils.base_url_for_url' instead.''' + return utils.base_url_for_url(url) 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)) + '''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.''' + + # Prevents an error if in a previous iteration + # old[k] = None but v[k] = {...}, + if old is None: + old = {} + for k, v in new.items(): - if isinstance(v, collections.Mapping): + if isinstance(v, abc.Mapping): r = deep_update(old.get(k, {}), v) old[k] = r + elif v is None and isinstance(old.get(k), abc.Mapping): + # Don't override empty data, to work around yaml syntax issue + pass else: old[k] = new[k] return old def process_multiple_environments_and_files(env_paths=None, template=None, - template_url=None): + template_url=None, + env_path_is_object=None, + object_request=None, + env_list_tracker=None, + fetch_env_files=True): + """Reads one or more environment files. + + Reads in each specified environment file and returns a dictionary + of the filenames->contents (suitable for the files dict) + and the consolidated environment (after having applied the correct + overrides based on order). + + If a list is provided in the env_list_tracker parameter, the behavior + is altered to take advantage of server-side environment resolution. + Specifically, this means: + + * Populating env_list_tracker with an ordered list of environment file + URLs to be passed to the server + * Including the contents of each environment file in the returned + files dict, keyed by one of the URLs in env_list_tracker + + :param env_paths: list of paths to the environment files to load; if + None, empty results will be returned + :type env_paths: list or None + :param template: unused; only included for API compatibility + :param template_url: unused; only included for API compatibility + :param env_list_tracker: if specified, environment filenames will be + stored within + :type env_list_tracker: list or None + :return: tuple of files dict and a dict of the consolidated environment + :rtype: tuple + :param fetch_env_files: fetch env_files or leave it to server + """ merged_files = {} merged_env = {} + # If we're keeping a list of environment files separately, include the + # contents of the files in the files dict + include_env_in_files = env_list_tracker is not None + 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) + if fetch_env_files: + files, env = process_environment_and_files( + env_path=env_path, + template=template, + template_url=template_url, + env_path_is_object=env_path_is_object, + object_request=object_request, + include_env_in_files=include_env_in_files) + + # '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) + env_url = utils.normalise_file_path_to_url(env_path) + else: + env_url = env_path + + if env_list_tracker is not None: + env_list_tracker.append(env_url) return merged_files, merged_env -def process_environment_and_files(env_path=None, template=None, - template_url=None): +def process_environment_and_files(env_path=None, + template=None, + template_url=None, + env_path_is_object=None, + object_request=None, + include_env_in_files=False): + """Loads a single environment file. + + Returns an entry suitable for the files dict which maps the environment + filename to its contents. + + :param env_path: full path to the file to load + :type env_path: str or None + :param include_env_in_files: if specified, the raw environment file itself + will be included in the returned files dict + :type include_env_in_files: bool + :return: tuple of files dict and the loaded environment as a dict + :rtype: (dict, dict) + """ files = {} env = {} - if env_path: - env_url = normalise_file_path_to_url(env_path) - env_base_url = base_url_for_url(env_url) + is_object = env_path_is_object and env_path_is_object(env_path) + + if is_object: + raw_env = object_request and object_request('GET', env_path) + env = environment_format.parse(raw_env) + env_base_url = utils.base_url_for_url(env_path) + + resolve_environment_urls( + env.get('resource_registry'), + files, + env_base_url, is_object=True, object_request=object_request) + + elif 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( @@ -208,10 +329,23 @@ def process_environment_and_files(env_path=None, template=None, files, env_base_url) + if include_env_in_files: + files[env_url] = jsonutils.dumps(env) + return files, env -def resolve_environment_urls(resource_registry, files, env_base_url): +def resolve_environment_urls(resource_registry, files, env_base_url, + is_object=False, object_request=None): + """Handles any resource URLs specified in an environment. + + :param resource_registry: mapping of type name to template filename + :type resource_registry: dict + :param files: dict to store loaded file contents into + :type files: dict + :param env_base_url: base URL to look in when loading files + :type env_base_url: str or None + """ if resource_registry is None: return @@ -227,10 +361,38 @@ def ignore_if(key, value): # Built in providers like: "X::Compute::Server" # don't need downloading. return True + if key in ['hooks', 'restricted_actions']: + return True - get_file_contents(rr, files, base_url, ignore_if, file_is_template=True) + get_file_contents(rr, files, base_url, ignore_if, + is_object=is_object, object_request=object_request) - for res_name, res_dict in iter(rr.get('resources', {}).items()): + for res_name, res_dict in 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, file_is_template=True) + res_dict, files, res_base_url, ignore_if, + is_object=is_object, object_request=object_request) + + +def hooks_to_env(env, arg_hooks, hook): + """Add hooks from args to environment's resource_registry section. + + Hooks are either "resource_name" (if it's a top-level resource) or + "nested_stack/resource_name" (if the resource is in a nested stack). + + The environment expects each hook to be associated with the resource + within `resource_registry/resources` using the `hooks: pre-create` format. + """ + if 'resource_registry' not in env: + env['resource_registry'] = {} + if 'resources' not in env['resource_registry']: + env['resource_registry']['resources'] = {} + for hook_declaration in arg_hooks: + hook_path = [r for r in hook_declaration.split('/') if r] + resources = env['resource_registry']['resources'] + for nested_stack in hook_path: + if nested_stack not in resources: + resources[nested_stack] = {} + resources = resources[nested_stack] + else: + resources['hooks'] = hook diff --git a/heatclient/common/utils.py b/heatclient/common/utils.py index a86593a5..8d104c7a 100644 --- a/heatclient/common/utils.py +++ b/heatclient/common/utils.py @@ -12,41 +12,145 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -from __future__ import print_function -import prettytable -import sys +import base64 +import logging +import os +from pathlib import Path import textwrap import uuid + +from oslo_serialization import jsonutils +from oslo_utils import encodeutils +import prettytable +from urllib import error +from urllib import parse +from urllib import request import yaml +from heatclient._i18n import _ from heatclient import exc -from heatclient.openstack.common import cliutils -from heatclient.openstack.common import importutils -from heatclient.openstack.common import jsonutils + +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 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 print_list(objs, fields, formatters=None, sortby_index=0, + mixed_case_fields=None, field_labels=None): + """Print a list of 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) + + print(encodeutils.safe_encode(pt.get_string(**kwargs)).decode()) def link_formatter(links): - def format_link(l): - if 'rel' in l: - return "%s (%s)" % (l.get('href', ''), l.get('rel', '')) + def format_link(link): + if 'rel' in link: + return "{} ({})".format(link.get('href', ''), link.get('rel', '')) else: - return "%s" % (l.get('href', '')) - return '\n'.join(format_link(l) for l in links or []) + return "%s" % (link.get('href', '')) + return '\n'.join(format_link(link) for link in links or []) + + +def resource_nested_identifier(rsrc): + nested_link = [link for link in rsrc.links or [] + if link.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) + return jsonutils.dumps(js, indent=2, ensure_ascii=False, + separators=(', ', ': ')) + + +def yaml_formatter(js): + return yaml.safe_dump(js, default_flow_style=False) def text_wrap_formatter(d): @@ -63,7 +167,7 @@ def print_dict(d, formatters=None): caching=False, print_empty=False) pt.align = 'l' - for field in d.keys(): + for field in d: if field in formatters: pt.add_row([field, formatters[field](d[field])]) else: @@ -71,6 +175,120 @@ def print_dict(d, formatters=None): print(pt.get_string(sortby='Property')) +class EventLogContext: + + def __init__(self): + # key is a stack id or the name of the nested stack, value is a tuple + # of the parent stack id, and the name of the resource in the parent + # stack + self.id_to_res_info = {} + + def prepend_paths(self, resource_path, stack_id): + if stack_id not in self.id_to_res_info: + return + stack_id, res_name = self.id_to_res_info.get(stack_id) + if res_name in self.id_to_res_info: + # do a double lookup to skip the ugly stack name that doesn't + # correspond to an actual resource name + n_stack_id, res_name = self.id_to_res_info.get(res_name) + resource_path.insert(0, res_name) + self.prepend_paths(resource_path, n_stack_id) + elif res_name: + resource_path.insert(0, res_name) + + def build_resource_name(self, event): + res_name = getattr(event, 'resource_name') + + # Contribute this event to self.id_to_res_info to assist with + # future calls to build_resource_name + + def get_stack_id(): + if getattr(event, 'stack_id', None) is not None: + return event.stack_id + for link in getattr(event, 'links', []): + if link.get('rel') == 'stack': + if 'href' not in link: + return None + stack_link = link['href'] + return stack_link.split('/')[-1] + + stack_id = get_stack_id() + if not stack_id: + return res_name + phys_id = getattr(event, 'physical_resource_id', None) + status = getattr(event, 'resource_status', None) + + is_stack_event = stack_id == phys_id + if is_stack_event: + # this is an event for a stack + self.id_to_res_info[stack_id] = (stack_id, res_name) + elif phys_id and status == 'CREATE_IN_PROGRESS': + # this might be an event for a resource which creates a stack + self.id_to_res_info[phys_id] = (stack_id, res_name) + + # Now build this resource path based on previous calls to + # build_resource_name + resource_path = [] + if res_name and not is_stack_event: + resource_path.append(res_name) + self.prepend_paths(resource_path, stack_id) + + return '.'.join(resource_path) + + +def event_log_formatter(events, event_log_context=None): + """Return the events in log format.""" + event_log = [] + log_format = ("%(event_time)s %(event_id)s [%(rsrc_name)s]: " + "%(rsrc_status)s %(rsrc_status_reason)s") + + # It is preferable for a context to be passed in, but there might be enough + # events in this call to build a better resource name, so create a context + # anyway + if event_log_context is None: + event_log_context = EventLogContext() + + for event in events: + rsrc_name = event_log_context.build_resource_name(event) + + event_time = getattr(event, 'event_time', '') + log_data = { + 'event_time': event_time.replace('T', ' '), + 'rsrc_name': rsrc_name, + 'rsrc_status': getattr(event, 'resource_status', ''), + 'rsrc_status_reason': getattr(event, 'resource_status_reason', ''), + 'event_id': getattr(event, 'id', '') + } + + log = log_format % log_data + event_log.append(log) + + return "\n".join(event_log) + + +def print_update_list(lst, fields, formatters=None): + """Print the stack-update --dry-run output as a table. + + This function is necessary to print the stack-update --dry-run + output, which contains additional information about the update. + """ + formatters = formatters or {} + pt = prettytable.PrettyTable(fields, caching=False, print_empty=False) + pt.align = 'l' + + for change in lst: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](change.get(field, None))) + else: + row.append(change.get(field, None)) + + pt.add_row(row) + + print(encodeutils.safe_encode(pt.get_string()).decode()) + + def find_resource(manager, name_or_id): """Helper for the _find_* methods.""" # first try to get entity as integer id @@ -91,42 +309,34 @@ def find_resource(manager, name_or_id): try: return manager.find(name=name_or_id) except exc.NotFound: - msg = "No %s with a name or ID of '%s' exists." % \ - (manager.resource_class.__name__.lower(), name_or_id) + 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 = 'heatclient.v%s' % version - if submodule: - module = '.'.join((module, submodule)) - return importutils.import_module(module) - - -def exit(msg=''): - if msg: - print(msg, file=sys.stderr) - sys.exit(1) - - -def format_parameters(params): +def format_parameters(params, parse_semicolon=True): '''Reformat parameters into dict of format expected by the API.''' if not params: return {} - # expect multiple invocations of --parameters but fall back - # to ; delimited if only one --parameters is specified - if len(params) == 1: - params = params[0].split(';') + 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 = '%s(%s). %s.' % ('Malformed parameter', p, - 'Use the key=value format') + msg = _('Malformed parameter(%s). Use the key=value format.') % p raise exc.CommandError(msg) if n not in parameters: @@ -139,11 +349,97 @@ def format_parameters(params): 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 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." + 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.encodebytes(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 + return Path(path).resolve().as_uri() + + +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('Could not decode response body as JSON') + else: + body = None + return body diff --git a/heatclient/exc.py b/heatclient/exc.py index fd2f334b..5cbd915b 100644 --- a/heatclient/exc.py +++ b/heatclient/exc.py @@ -12,7 +12,10 @@ import sys -from heatclient.openstack.common import jsonutils +from oslo_serialization import jsonutils +from oslo_utils import reflection + +from heatclient._i18n import _ verbose = 0 @@ -42,12 +45,12 @@ class HTTPException(BaseException): """Base exception for all HTTP-derived exceptions.""" code = 'N/A' - def __init__(self, message=None): - super(HTTPException, self).__init__(message) + def __init__(self, message=None, code=None): + super().__init__(message) try: self.error = jsonutils.loads(message) if 'error' not in self.error: - raise KeyError('Key "error" not exists') + 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 @@ -57,24 +60,29 @@ def __init__(self, message=None): except Exception: self.error = {'error': {'message': self.message or self.__class__.__doc__}} + if self.code == "N/A" and code is not None: + self.code = code def __str__(self): message = self.error['error'].get('message', 'Internal Error') if verbose: traceback = self.error['error'].get('traceback', '') - return 'ERROR: %s\n%s' % (message, traceback) + return (_('ERROR: %(message)s\n%(traceback)s') % + {'message': message, 'traceback': traceback}) else: - return 'ERROR: %s' % message + return _('ERROR: %s') % message class HTTPMultipleChoices(HTTPException): code = 300 def __str__(self): - self.details = ("Requested version of Heat API is not" - "available.") - return "%s (HTTP %s) %s" % (self.__class__.__name__, self.code, - self.details) + self.details = _("Requested version of Heat API is not available.") + return (_("%(name)s (HTTP %(code)s) %(details)s") % + { + 'name': reflection.get_class_name(self, fully_qualified=False), + 'code': self.code, + 'details': self.details}) class BadRequest(HTTPException): @@ -113,6 +121,10 @@ class HTTPNotFound(NotFound): pass +class NoUniqueMatch(HTTPException): + pass + + class HTTPMethodNotAllowed(HTTPException): code = 405 @@ -160,7 +172,7 @@ class HTTPServiceUnavailable(ServiceUnavailable): pass -#NOTE(bcwaldon): Build a mapping of HTTP codes to corresponding exception +# NOTE(bcwaldon): Build a mapping of HTTP codes to corresponding exception # classes _code_map = {} for obj_name in dir(sys.modules[__name__]): @@ -172,7 +184,7 @@ class HTTPServiceUnavailable(ServiceUnavailable): 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) + return cls(response.content, response.status_code) class NoTokenLookupException(Exception): @@ -183,3 +195,7 @@ class NoTokenLookupException(Exception): class EndpointNotFound(Exception): """DEPRECATED.""" pass + + +class StackFailure(Exception): + pass diff --git a/heatclient/openstack/common/apiclient/auth.py b/heatclient/openstack/common/apiclient/auth.py deleted file mode 100644 index 0535748e..00000000 --- a/heatclient/openstack/common/apiclient/auth.py +++ /dev/null @@ -1,221 +0,0 @@ -# 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 - -import abc -import argparse -import os - -import six -from stevedore import extension - -from heatclient.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 = "heatclient.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/heatclient/openstack/common/apiclient/client.py b/heatclient/openstack/common/apiclient/client.py deleted file mode 100644 index 8f671855..00000000 --- a/heatclient/openstack/common/apiclient/client.py +++ /dev/null @@ -1,358 +0,0 @@ -# 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 logging -import time - -try: - import simplejson as json -except ImportError: - import json - -import requests - -from heatclient.openstack.common.apiclient import exceptions -from heatclient.openstack.common import importutils - - -_logger = logging.getLogger(__name__) - - -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 = "heatclient.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 - - def _http_log_req(self, method, url, kwargs): - if not self.debug: - return - - string_parts = [ - "curl -i", - "-X '%s'" % method, - "'%s'" % url, - ] - - for element in kwargs['headers']: - header = "-H '%s: %s'" % (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.get("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) - - 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 - 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="iframe.php?url=http%3A%2F%2Fauth%3A5000%2Fv2.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) - - 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 %s client version '%s'. must be one of: %s" % ( - (api_name, version, ', '.join(version_map.keys()))) - raise exceptions.UnsupportedVersion(msg) - - return importutils.import_class(client_path) diff --git a/heatclient/openstack/common/apiclient/exceptions.py b/heatclient/openstack/common/apiclient/exceptions.py deleted file mode 100644 index ada1344f..00000000 --- a/heatclient/openstack/common/apiclient/exceptions.py +++ /dev/null @@ -1,459 +0,0 @@ -# Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 Nebula, Inc. -# Copyright 2013 Alessio Ababilov -# Copyright 2013 OpenStack Foundation -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -""" -Exception definitions. -""" - -import inspect -import sys - -import six - - -class ClientException(Exception): - """The base exception class for all exceptions this library raises. - """ - pass - - -class MissingArgs(ClientException): - """Supplied arguments are not sufficient for calling a function.""" - def __init__(self, missing): - self.missing = missing - msg = "Missing argument(s): %s" % ", ".join(missing) - super(MissingArgs, self).__init__(msg) - - -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 ConnectionRefused(ClientException): - """Cannot 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 a AuthSystem that is not installed.""" - def __init__(self, auth_system): - super(AuthSystemNotFound, self).__init__( - "AuthSystemNotFound: %s" % repr(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: %s" % repr(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 - """ - kwargs = { - "http_status": response.status_code, - "response": response, - "method": method, - "url": url, - "request_id": response.headers.get("x-compute-request-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 = list(body.values())[0] - kwargs["message"] = error.get("message") - kwargs["details"] = error.get("details") - elif content_type.startswith("text/"): - kwargs["details"] = 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/heatclient/openstack/common/apiclient/fake_client.py b/heatclient/openstack/common/apiclient/fake_client.py deleted file mode 100644 index eb10e0fe..00000000 --- a/heatclient/openstack/common/apiclient/fake_client.py +++ /dev/null @@ -1,173 +0,0 @@ -# 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. -""" - -# W0102: Dangerous default value %s as argument -# pylint: disable=W0102 - -import json - -import requests -import six -from six.moves.urllib import parse - -from heatclient.openstack.common.apiclient import client - - -def assert_has_keys(dct, required=[], optional=[]): - 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 not "auth_plugin" 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 = {} - return TestResponse({ - "status_code": status, - "text": body, - "headers": headers, - }) diff --git a/heatclient/openstack/common/cliutils.py b/heatclient/openstack/common/cliutils.py deleted file mode 100644 index e493fd8d..00000000 --- a/heatclient/openstack/common/cliutils.py +++ /dev/null @@ -1,309 +0,0 @@ -# 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 - -import prettytable -import six -from six import moves - -from heatclient.openstack.common.apiclient import exceptions -from heatclient.openstack.common.gettextutils import _ -from heatclient.openstack.common import strutils -from heatclient.openstack.common import uuidutils - - -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, 'im_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 exceptions.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): - """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') - """ - formatters = formatters or {} - mixed_case_fields = mixed_case_fields or [] - if sortby_index is None: - kwargs = {} - else: - kwargs = {'sortby': fields[sortby_index]} - pt = prettytable.PrettyTable(fields, caching=False) - 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) - - print(strutils.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'], caching=False) - 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]) - print(strutils.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 find_resource(manager, name_or_id, **find_args): - """Look for resource in a given manager. - - Used as a helper for the _find_* methods. - Example: - - 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: - tmp_id = strutils.safe_encode(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) - - -def service_type(stype): - """Adds 'service_type' attribute to decorated function. - - Usage: - @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/heatclient/openstack/common/gettextutils.py b/heatclient/openstack/common/gettextutils.py deleted file mode 100644 index 07805a40..00000000 --- a/heatclient/openstack/common/gettextutils.py +++ /dev/null @@ -1,448 +0,0 @@ -# Copyright 2012 Red Hat, Inc. -# Copyright 2013 IBM Corp. -# 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. - -""" -gettext for openstack-common modules. - -Usual usage in an openstack.common module: - - from heatclient.openstack.common.gettextutils import _ -""" - -import copy -import functools -import gettext -import locale -from logging import handlers -import os - -from babel import localedata -import six - -_localedir = os.environ.get('heatclient'.upper() + '_LOCALEDIR') -_t = gettext.translation('heatclient', localedir=_localedir, fallback=True) - -# We use separate translation catalogs for each log level, so set up a -# mapping between the log level name and the translator. The domain -# for the log level is project_name + "-log-" + log_level so messages -# for each level end up in their own catalog. -_t_log_levels = dict( - (level, gettext.translation('heatclient' + '-log-' + level, - localedir=_localedir, - fallback=True)) - for level in ['info', 'warning', 'error', 'critical'] -) - -_AVAILABLE_LANGUAGES = {} -USE_LAZY = False - - -def enable_lazy(): - """Convenience function for configuring _() to use lazy gettext - - Call this at the start of execution to enable the gettextutils._ - function to use lazy gettext functionality. This is useful if - your project is importing _ directly instead of using the - gettextutils.install() way of importing the _ function. - """ - global USE_LAZY - USE_LAZY = True - - -def _(msg): - if USE_LAZY: - return Message(msg, domain='heatclient') - else: - if six.PY3: - return _t.gettext(msg) - return _t.ugettext(msg) - - -def _log_translation(msg, level): - """Build a single translation of a log message - """ - if USE_LAZY: - return Message(msg, domain='heatclient' + '-log-' + level) - else: - translator = _t_log_levels[level] - if six.PY3: - return translator.gettext(msg) - return translator.ugettext(msg) - -# 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 = functools.partial(_log_translation, level='info') -_LW = functools.partial(_log_translation, level='warning') -_LE = functools.partial(_log_translation, level='error') -_LC = functools.partial(_log_translation, level='critical') - - -def install(domain, lazy=False): - """Install a _() function using the given translation domain. - - Given a translation domain, install a _() function using gettext's - install() function. - - The main difference from gettext.install() is that we allow - overriding the default localedir (e.g. /usr/share/locale) using - a translation-domain-specific environment variable (e.g. - NOVA_LOCALEDIR). - - :param domain: the translation domain - :param lazy: indicates whether or not to install the lazy _() function. - The lazy _() introduces a way to do deferred translation - of messages by installing a _ that builds Message objects, - instead of strings, which can then be lazily translated into - any available locale. - """ - if lazy: - # NOTE(mrodden): Lazy gettext functionality. - # - # The following introduces a deferred way to do translations on - # messages in OpenStack. We override the standard _() function - # and % (format string) operation to build Message objects that can - # later be translated when we have more information. - def _lazy_gettext(msg): - """Create and return a Message object. - - Lazy gettext function for a given domain, it is a factory method - for a project/module to get a lazy gettext function for its own - translation domain (i.e. nova, glance, cinder, etc.) - - Message encapsulates a string so that we can translate - it later when needed. - """ - return Message(msg, domain=domain) - - from six import moves - moves.builtins.__dict__['_'] = _lazy_gettext - else: - localedir = '%s_LOCALEDIR' % domain.upper() - if six.PY3: - gettext.install(domain, - localedir=os.environ.get(localedir)) - else: - gettext.install(domain, - localedir=os.environ.get(localedir), - unicode=True) - - -class Message(six.text_type): - """A Message object is a unicode object that can be translated. - - Translation of Message is done explicitly using the translate() method. - For all non-translation intents and purposes, a Message is simply unicode, - and can be treated as such. - """ - - def __new__(cls, msgid, msgtext=None, params=None, - domain='heatclient', *args): - """Create a new Message object. - - In order for translation to work gettext requires a message ID, this - msgid will be used as the base unicode text. It is also possible - for the msgid and the base unicode text to be different by passing - the msgtext parameter. - """ - # If the base msgtext is not given, we use the default translation - # of the msgid (which is in English) just in case the system locale is - # not English, so that the base text will be in that locale by default. - if not msgtext: - msgtext = Message._translate_msgid(msgid, domain) - # We want to initialize the parent unicode with the actual object that - # would have been plain unicode if 'Message' was not enabled. - msg = super(Message, cls).__new__(cls, msgtext) - msg.msgid = msgid - msg.domain = domain - msg.params = params - return msg - - def translate(self, desired_locale=None): - """Translate this message to the desired locale. - - :param desired_locale: The desired locale to translate the message to, - if no locale is provided the message will be - translated to the system's default locale. - - :returns: the translated message in unicode - """ - - translated_message = Message._translate_msgid(self.msgid, - self.domain, - desired_locale) - if self.params is None: - # No need for more translation - return translated_message - - # This Message object may have been formatted with one or more - # Message objects as substitution arguments, given either as a single - # argument, part of a tuple, or as one or more values in a dictionary. - # When translating this Message we need to translate those Messages too - translated_params = _translate_args(self.params, desired_locale) - - translated_message = translated_message % translated_params - - return translated_message - - @staticmethod - def _translate_msgid(msgid, domain, desired_locale=None): - if not desired_locale: - system_locale = locale.getdefaultlocale() - # If the system locale is not available to the runtime use English - if not system_locale[0]: - desired_locale = 'en_US' - else: - desired_locale = system_locale[0] - - locale_dir = os.environ.get(domain.upper() + '_LOCALEDIR') - lang = gettext.translation(domain, - localedir=locale_dir, - languages=[desired_locale], - fallback=True) - if six.PY3: - translator = lang.gettext - else: - translator = lang.ugettext - - translated_message = translator(msgid) - return translated_message - - def __mod__(self, other): - # When we mod a Message we want the actual operation to be performed - # by the parent class (i.e. unicode()), the only thing we do here is - # save the original msgid and the parameters in case of a translation - params = self._sanitize_mod_params(other) - unicode_mod = super(Message, self).__mod__(params) - modded = Message(self.msgid, - msgtext=unicode_mod, - params=params, - domain=self.domain) - return modded - - def _sanitize_mod_params(self, other): - """Sanitize the object being modded with this Message. - - - Add support for modding 'None' so translation supports it - - Trim the modded object, which can be a large dictionary, to only - those keys that would actually be used in a translation - - Snapshot the object being modded, in case the message is - translated, it will be used as it was when the Message was created - """ - if other is None: - params = (other,) - elif isinstance(other, dict): - # Merge the dictionaries - # Copy each item in case one does not support deep copy. - params = {} - if isinstance(self.params, dict): - for key, val in self.params.items(): - params[key] = self._copy_param(val) - for key, val in other.items(): - params[key] = self._copy_param(val) - else: - params = self._copy_param(other) - return params - - def _copy_param(self, param): - try: - return copy.deepcopy(param) - except Exception: - # Fallback to casting to unicode this will handle the - # python code-like objects that can't be deep-copied - return six.text_type(param) - - def __add__(self, other): - msg = _('Message objects do not support addition.') - raise TypeError(msg) - - def __radd__(self, other): - return self.__add__(other) - - def __str__(self): - # NOTE(luisg): Logging in python 2.6 tries to str() log records, - # and it expects specifically a UnicodeError in order to proceed. - msg = _('Message objects do not support str() because they may ' - 'contain non-ascii characters. ' - 'Please use unicode() or translate() instead.') - raise UnicodeError(msg) - - -def get_available_languages(domain): - """Lists the available languages for the given translation domain. - - :param domain: the domain to get languages for - """ - if domain in _AVAILABLE_LANGUAGES: - return copy.copy(_AVAILABLE_LANGUAGES[domain]) - - localedir = '%s_LOCALEDIR' % domain.upper() - find = lambda x: gettext.find(domain, - localedir=os.environ.get(localedir), - languages=[x]) - - # NOTE(mrodden): en_US should always be available (and first in case - # order matters) since our in-line message strings are en_US - language_list = ['en_US'] - # NOTE(luisg): Babel <1.0 used a function called list(), which was - # renamed to locale_identifiers() in >=1.0, the requirements master list - # requires >=0.9.6, uncapped, so defensively work with both. We can remove - # this check when the master list updates to >=1.0, and update all projects - list_identifiers = (getattr(localedata, 'list', None) or - getattr(localedata, 'locale_identifiers')) - locale_identifiers = list_identifiers() - - for i in locale_identifiers: - if find(i) is not None: - language_list.append(i) - - # NOTE(luisg): Babel>=1.0,<1.3 has a bug where some OpenStack supported - # locales (e.g. 'zh_CN', and 'zh_TW') aren't supported even though they - # are perfectly legitimate locales: - # https://github.com/mitsuhiko/babel/issues/37 - # In Babel 1.3 they fixed the bug and they support these locales, but - # they are still not explicitly "listed" by locale_identifiers(). - # That is why we add the locales here explicitly if necessary so that - # they are listed as supported. - aliases = {'zh': 'zh_CN', - 'zh_Hant_HK': 'zh_HK', - 'zh_Hant': 'zh_TW', - 'fil': 'tl_PH'} - for (locale, alias) in six.iteritems(aliases): - if locale in language_list and alias not in language_list: - language_list.append(alias) - - _AVAILABLE_LANGUAGES[domain] = language_list - return copy.copy(language_list) - - -def translate(obj, desired_locale=None): - """Gets the translated unicode representation of the given object. - - If the object is not translatable it is returned as-is. - If the locale is None the object is translated to the system locale. - - :param obj: the object to translate - :param desired_locale: the locale to translate the message to, if None the - default system locale will be used - :returns: the translated object in unicode, or the original object if - it could not be translated - """ - message = obj - if not isinstance(message, Message): - # If the object to translate is not already translatable, - # let's first get its unicode representation - message = six.text_type(obj) - if isinstance(message, Message): - # Even after unicoding() we still need to check if we are - # running with translatable unicode before translating - return message.translate(desired_locale) - return obj - - -def _translate_args(args, desired_locale=None): - """Translates all the translatable elements of the given arguments object. - - This method is used for translating the translatable values in method - arguments which include values of tuples or dictionaries. - If the object is not a tuple or a dictionary the object itself is - translated if it is translatable. - - If the locale is None the object is translated to the system locale. - - :param args: the args to translate - :param desired_locale: the locale to translate the args to, if None the - default system locale will be used - :returns: a new args object with the translated contents of the original - """ - if isinstance(args, tuple): - return tuple(translate(v, desired_locale) for v in args) - if isinstance(args, dict): - translated_dict = {} - for (k, v) in six.iteritems(args): - translated_v = translate(v, desired_locale) - translated_dict[k] = translated_v - return translated_dict - return translate(args, desired_locale) - - -class TranslationHandler(handlers.MemoryHandler): - """Handler that translates records before logging them. - - The TranslationHandler takes a locale and a target logging.Handler object - to forward LogRecord objects to after translating them. This handler - depends on Message objects being logged, instead of regular strings. - - The handler can be configured declaratively in the logging.conf as follows: - - [handlers] - keys = translatedlog, translator - - [handler_translatedlog] - class = handlers.WatchedFileHandler - args = ('/var/log/api-localized.log',) - formatter = context - - [handler_translator] - class = openstack.common.log.TranslationHandler - target = translatedlog - args = ('zh_CN',) - - If the specified locale is not available in the system, the handler will - log in the default locale. - """ - - def __init__(self, locale=None, target=None): - """Initialize a TranslationHandler - - :param locale: locale to use for translating messages - :param target: logging.Handler object to forward - LogRecord objects to after translation - """ - # NOTE(luisg): In order to allow this handler to be a wrapper for - # other handlers, such as a FileHandler, and still be able to - # configure it using logging.conf, this handler has to extend - # MemoryHandler because only the MemoryHandlers' logging.conf - # parsing is implemented such that it accepts a target handler. - handlers.MemoryHandler.__init__(self, capacity=0, target=target) - self.locale = locale - - def setFormatter(self, fmt): - self.target.setFormatter(fmt) - - def emit(self, record): - # We save the message from the original record to restore it - # after translation, so other handlers are not affected by this - original_msg = record.msg - original_args = record.args - - try: - self._translate_and_log_record(record) - finally: - record.msg = original_msg - record.args = original_args - - def _translate_and_log_record(self, record): - record.msg = translate(record.msg, self.locale) - - # In addition to translating the message, we also need to translate - # arguments that were passed to the log method that were not part - # of the main message e.g., log.info(_('Some message %s'), this_one)) - record.args = _translate_args(record.args, self.locale) - - self.target.emit(record) diff --git a/heatclient/openstack/common/importutils.py b/heatclient/openstack/common/importutils.py deleted file mode 100644 index 7b4b09af..00000000 --- a/heatclient/openstack/common/importutils.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright 2011 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 related utilities and helper functions. -""" - -import sys -import traceback - - -def import_class(import_str): - """Returns a class from a string including module and class.""" - mod_str, _sep, class_str = import_str.rpartition('.') - try: - __import__(mod_str) - return getattr(sys.modules[mod_str], class_str) - except (ValueError, AttributeError): - raise ImportError('Class %s cannot be found (%s)' % - (class_str, - traceback.format_exception(*sys.exc_info()))) - - -def import_object(import_str, *args, **kwargs): - """Import a class and return an instance of it.""" - return import_class(import_str)(*args, **kwargs) - - -def import_object_ns(name_space, import_str, *args, **kwargs): - """Tries to import object from default namespace. - - Imports a class and return an instance of it, first by trying - to find the class in a default namespace, then failing back to - a full path if not found in the default namespace. - """ - import_value = "%s.%s" % (name_space, import_str) - try: - return import_class(import_value)(*args, **kwargs) - except ImportError: - return import_class(import_str)(*args, **kwargs) - - -def import_module(import_str): - """Import a module.""" - __import__(import_str) - return sys.modules[import_str] - - -def import_versioned_module(version, submodule=None): - module = 'heatclient.v%s' % version - if submodule: - module = '.'.join((module, submodule)) - return import_module(module) - - -def try_import(import_str, default=None): - """Try to import a module and if it fails return default.""" - try: - return import_module(import_str) - except ImportError: - return default diff --git a/heatclient/openstack/common/jsonutils.py b/heatclient/openstack/common/jsonutils.py deleted file mode 100644 index 18915c18..00000000 --- a/heatclient/openstack/common/jsonutils.py +++ /dev/null @@ -1,182 +0,0 @@ -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# Copyright 2011 Justin Santa Barbara -# 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. - -''' -JSON related utilities. - -This module provides a few things: - - 1) A handy function for getting an object down to something that can be - JSON serialized. See to_primitive(). - - 2) Wrappers around loads() and dumps(). The dumps() wrapper will - automatically use to_primitive() for you if needed. - - 3) This sets up anyjson to use the loads() and dumps() wrappers if anyjson - is available. -''' - - -import datetime -import functools -import inspect -import itertools -import json -try: - import xmlrpclib -except ImportError: - # NOTE(jaypipes): xmlrpclib was renamed to xmlrpc.client in Python3 - # however the function and object call signatures - # remained the same. This whole try/except block should - # be removed and replaced with a call to six.moves once - # six 1.4.2 is released. See http://bit.ly/1bqrVzu - import xmlrpc.client as xmlrpclib - -import six - -from heatclient.openstack.common import gettextutils -from heatclient.openstack.common import importutils -from heatclient.openstack.common import timeutils - -netaddr = importutils.try_import("netaddr") - -_nasty_type_tests = [inspect.ismodule, inspect.isclass, inspect.ismethod, - inspect.isfunction, inspect.isgeneratorfunction, - inspect.isgenerator, inspect.istraceback, inspect.isframe, - inspect.iscode, inspect.isbuiltin, inspect.isroutine, - inspect.isabstract] - -_simple_types = (six.string_types + six.integer_types - + (type(None), bool, float)) - - -def to_primitive(value, convert_instances=False, convert_datetime=True, - level=0, max_depth=3): - """Convert a complex object into primitives. - - Handy for JSON serialization. We can optionally handle instances, - but since this is a recursive function, we could have cyclical - data structures. - - To handle cyclical data structures we could track the actual objects - visited in a set, but not all objects are hashable. Instead we just - track the depth of the object inspections and don't go too deep. - - Therefore, convert_instances=True is lossy ... be aware. - - """ - # handle obvious types first - order of basic types determined by running - # full tests on nova project, resulting in the following counts: - # 572754 - # 460353 - # 379632 - # 274610 - # 199918 - # 114200 - # 51817 - # 26164 - # 6491 - # 283 - # 19 - if isinstance(value, _simple_types): - return value - - if isinstance(value, datetime.datetime): - if convert_datetime: - return timeutils.strtime(value) - else: - return value - - # value of itertools.count doesn't get caught by nasty_type_tests - # and results in infinite loop when list(value) is called. - if type(value) == itertools.count: - return six.text_type(value) - - # FIXME(vish): Workaround for LP bug 852095. Without this workaround, - # tests that raise an exception in a mocked method that - # has a @wrap_exception with a notifier will fail. If - # we up the dependency to 0.5.4 (when it is released) we - # can remove this workaround. - if getattr(value, '__module__', None) == 'mox': - return 'mock' - - if level > max_depth: - return '?' - - # The try block may not be necessary after the class check above, - # but just in case ... - try: - recursive = functools.partial(to_primitive, - convert_instances=convert_instances, - convert_datetime=convert_datetime, - level=level, - max_depth=max_depth) - if isinstance(value, dict): - return dict((k, recursive(v)) for k, v in six.iteritems(value)) - elif isinstance(value, (list, tuple)): - return [recursive(lv) for lv in value] - - # It's not clear why xmlrpclib created their own DateTime type, but - # for our purposes, make it a datetime type which is explicitly - # handled - if isinstance(value, xmlrpclib.DateTime): - value = datetime.datetime(*tuple(value.timetuple())[:6]) - - if convert_datetime and isinstance(value, datetime.datetime): - return timeutils.strtime(value) - elif isinstance(value, gettextutils.Message): - return value.data - elif hasattr(value, 'iteritems'): - return recursive(dict(value.iteritems()), level=level + 1) - elif hasattr(value, '__iter__'): - return recursive(list(value)) - elif convert_instances and hasattr(value, '__dict__'): - # Likely an instance of something. Watch for cycles. - # Ignore class member vars. - return recursive(value.__dict__, level=level + 1) - elif netaddr and isinstance(value, netaddr.IPAddress): - return six.text_type(value) - else: - if any(test(value) for test in _nasty_type_tests): - return six.text_type(value) - return value - except TypeError: - # Class objects are tricky since they may define something like - # __iter__ defined but it isn't callable as list(). - return six.text_type(value) - - -def dumps(value, default=to_primitive, **kwargs): - return json.dumps(value, default=default, **kwargs) - - -def loads(s): - return json.loads(s) - - -def load(s): - return json.load(s) - - -try: - import anyjson -except ImportError: - pass -else: - anyjson._modules.append((__name__, 'dumps', TypeError, - 'loads', ValueError, 'load')) - anyjson.force_implementation(__name__) diff --git a/heatclient/openstack/common/strutils.py b/heatclient/openstack/common/strutils.py deleted file mode 100644 index d6aef268..00000000 --- a/heatclient/openstack/common/strutils.py +++ /dev/null @@ -1,245 +0,0 @@ -# Copyright 2011 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. - -""" -System-level utilities and helper functions. -""" - -import math -import re -import sys -import unicodedata - -import six - -from heatclient.openstack.common.gettextutils import _ - - -UNIT_PREFIX_EXPONENT = { - 'k': 1, - 'K': 1, - 'Ki': 1, - 'M': 2, - 'Mi': 2, - 'G': 3, - 'Gi': 3, - 'T': 4, - 'Ti': 4, -} -UNIT_SYSTEM_INFO = { - 'IEC': (1024, re.compile(r'(^[-+]?\d*\.?\d+)([KMGT]i?)?(b|bit|B)$')), - 'SI': (1000, re.compile(r'(^[-+]?\d*\.?\d+)([kMGT])?(b|bit|B)$')), -} - -TRUE_STRINGS = ('1', 't', 'true', 'on', 'y', 'yes') -FALSE_STRINGS = ('0', 'f', 'false', 'off', 'n', 'no') - -SLUGIFY_STRIP_RE = re.compile(r"[^\w\s-]") -SLUGIFY_HYPHENATE_RE = re.compile(r"[-\s]+") - - -def int_from_bool_as_string(subject): - """Interpret a string as a boolean and return either 1 or 0. - - Any string value in: - - ('True', 'true', 'On', 'on', '1') - - is interpreted as a boolean True. - - Useful for JSON-decoded stuff and config file parsing - """ - return bool_from_string(subject) and 1 or 0 - - -def bool_from_string(subject, strict=False, default=False): - """Interpret a string as a boolean. - - A case-insensitive match is performed such that strings matching 't', - 'true', 'on', 'y', 'yes', or '1' are considered True and, when - `strict=False`, anything else returns the value specified by 'default'. - - Useful for JSON-decoded stuff and config file parsing. - - If `strict=True`, unrecognized values, including None, will raise a - ValueError which is useful when parsing values passed in from an API call. - Strings yielding False are 'f', 'false', 'off', 'n', 'no', or '0'. - """ - if not isinstance(subject, six.string_types): - subject = str(subject) - - lowered = subject.strip().lower() - - if lowered in TRUE_STRINGS: - return True - elif lowered in FALSE_STRINGS: - return False - elif strict: - acceptable = ', '.join( - "'%s'" % s for s in sorted(TRUE_STRINGS + FALSE_STRINGS)) - msg = _("Unrecognized value '%(val)s', acceptable values are:" - " %(acceptable)s") % {'val': subject, - 'acceptable': acceptable} - raise ValueError(msg) - else: - return default - - -def safe_decode(text, incoming=None, errors='strict'): - """Decodes incoming text/bytes string using `incoming` if they're not - already unicode. - - :param incoming: Text's current encoding - :param errors: Errors handling policy. See here for valid - values http://docs.python.org/2/library/codecs.html - :returns: text or a unicode `incoming` encoded - representation of it. - :raises TypeError: If text is not an instance of str - """ - if not isinstance(text, (six.string_types, six.binary_type)): - raise TypeError("%s can't be decoded" % type(text)) - - if isinstance(text, six.text_type): - return text - - if not incoming: - incoming = (sys.stdin.encoding or - sys.getdefaultencoding()) - - try: - return text.decode(incoming, errors) - except UnicodeDecodeError: - # Note(flaper87) If we get here, it means that - # sys.stdin.encoding / sys.getdefaultencoding - # didn't return a suitable encoding to decode - # text. This happens mostly when global LANG - # var is not set correctly and there's no - # default encoding. In this case, most likely - # python will use ASCII or ANSI encoders as - # default encodings but they won't be capable - # of decoding non-ASCII characters. - # - # Also, UTF-8 is being used since it's an ASCII - # extension. - return text.decode('utf-8', errors) - - -def safe_encode(text, incoming=None, - encoding='utf-8', errors='strict'): - """Encodes incoming text/bytes string using `encoding`. - - If incoming is not specified, text is expected to be encoded with - current python's default encoding. (`sys.getdefaultencoding`) - - :param incoming: Text's current encoding - :param encoding: Expected encoding for text (Default UTF-8) - :param errors: Errors handling policy. See here for valid - values http://docs.python.org/2/library/codecs.html - :returns: text or a bytestring `encoding` encoded - representation of it. - :raises TypeError: If text is not an instance of str - """ - if not isinstance(text, (six.string_types, six.binary_type)): - raise TypeError("%s can't be encoded" % type(text)) - - if not incoming: - incoming = (sys.stdin.encoding or - sys.getdefaultencoding()) - - if isinstance(text, six.text_type): - if six.PY3: - return text.encode(encoding, errors).decode(incoming) - else: - return text.encode(encoding, errors) - elif text and encoding != incoming: - # Decode text before encoding it with `encoding` - text = safe_decode(text, incoming, errors) - if six.PY3: - return text.encode(encoding, errors).decode(incoming) - else: - return text.encode(encoding, errors) - - return text - - -def string_to_bytes(text, unit_system='IEC', return_int=False): - """Converts a string into an float representation of bytes. - - The units supported for IEC :: - - Kb(it), Kib(it), Mb(it), Mib(it), Gb(it), Gib(it), Tb(it), Tib(it) - KB, KiB, MB, MiB, GB, GiB, TB, TiB - - The units supported for SI :: - - kb(it), Mb(it), Gb(it), Tb(it) - kB, MB, GB, TB - - Note that the SI unit system does not support capital letter 'K' - - :param text: String input for bytes size conversion. - :param unit_system: Unit system for byte size conversion. - :param return_int: If True, returns integer representation of text - in bytes. (default: decimal) - :returns: Numerical representation of text in bytes. - :raises ValueError: If text has an invalid value. - - """ - try: - base, reg_ex = UNIT_SYSTEM_INFO[unit_system] - except KeyError: - msg = _('Invalid unit system: "%s"') % unit_system - raise ValueError(msg) - match = reg_ex.match(text) - if match: - magnitude = float(match.group(1)) - unit_prefix = match.group(2) - if match.group(3) in ['b', 'bit']: - magnitude /= 8 - else: - msg = _('Invalid string format: %s') % text - raise ValueError(msg) - if not unit_prefix: - res = magnitude - else: - res = magnitude * pow(base, UNIT_PREFIX_EXPONENT[unit_prefix]) - if return_int: - return int(math.ceil(res)) - return res - - -def to_slug(value, incoming=None, errors="strict"): - """Normalize string. - - Convert to lowercase, remove non-word characters, and convert spaces - to hyphens. - - Inspired by Django's `slugify` filter. - - :param value: Text to slugify - :param incoming: Text's current encoding - :param errors: Errors handling policy. See here for valid - values http://docs.python.org/2/library/codecs.html - :returns: slugified unicode representation of `value` - :raises TypeError: If text is not an instance of str - """ - value = safe_decode(value, incoming, errors) - # NOTE(aababilov): no need to use safe_(encode|decode) here: - # encodings are always "ascii", error handling is always "ignore" - # and types are always known (first: unicode; second: str) - value = unicodedata.normalize("NFKD", value).encode( - "ascii", "ignore").decode("ascii") - value = SLUGIFY_STRIP_RE.sub("", value).strip().lower() - return SLUGIFY_HYPHENATE_RE.sub("-", value) diff --git a/heatclient/openstack/common/timeutils.py b/heatclient/openstack/common/timeutils.py deleted file mode 100644 index c8b0b153..00000000 --- a/heatclient/openstack/common/timeutils.py +++ /dev/null @@ -1,204 +0,0 @@ -# Copyright 2011 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. - -""" -Time related utilities and helper functions. -""" - -import calendar -import datetime -import time - -import iso8601 -import six - - -# ISO 8601 extended time format with microseconds -_ISO8601_TIME_FORMAT_SUBSECOND = '%Y-%m-%dT%H:%M:%S.%f' -_ISO8601_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S' -PERFECT_TIME_FORMAT = _ISO8601_TIME_FORMAT_SUBSECOND - - -def isotime(at=None, subsecond=False): - """Stringify time in ISO 8601 format.""" - if not at: - at = utcnow() - st = at.strftime(_ISO8601_TIME_FORMAT - if not subsecond - else _ISO8601_TIME_FORMAT_SUBSECOND) - tz = at.tzinfo.tzname(None) if at.tzinfo else 'UTC' - st += ('Z' if tz == 'UTC' else tz) - return st - - -def parse_isotime(timestr): - """Parse time from ISO 8601 format.""" - try: - return iso8601.parse_date(timestr) - except iso8601.ParseError as e: - raise ValueError(six.text_type(e)) - except TypeError as e: - raise ValueError(six.text_type(e)) - - -def strtime(at=None, fmt=PERFECT_TIME_FORMAT): - """Returns formatted utcnow.""" - if not at: - at = utcnow() - return at.strftime(fmt) - - -def parse_strtime(timestr, fmt=PERFECT_TIME_FORMAT): - """Turn a formatted time back into a datetime.""" - return datetime.datetime.strptime(timestr, fmt) - - -def normalize_time(timestamp): - """Normalize time in arbitrary timezone to UTC naive object.""" - offset = timestamp.utcoffset() - if offset is None: - return timestamp - return timestamp.replace(tzinfo=None) - offset - - -def is_older_than(before, seconds): - """Return True if before is older than seconds.""" - if isinstance(before, six.string_types): - before = parse_strtime(before).replace(tzinfo=None) - return utcnow() - before > datetime.timedelta(seconds=seconds) - - -def is_newer_than(after, seconds): - """Return True if after is newer than seconds.""" - if isinstance(after, six.string_types): - after = parse_strtime(after).replace(tzinfo=None) - return after - utcnow() > datetime.timedelta(seconds=seconds) - - -def utcnow_ts(): - """Timestamp version of our utcnow function.""" - if utcnow.override_time is None: - # NOTE(kgriffs): This is several times faster - # than going through calendar.timegm(...) - return int(time.time()) - - return calendar.timegm(utcnow().timetuple()) - - -def utcnow(): - """Overridable version of utils.utcnow.""" - if utcnow.override_time: - try: - return utcnow.override_time.pop(0) - except AttributeError: - return utcnow.override_time - return datetime.datetime.utcnow() - - -def iso8601_from_timestamp(timestamp): - """Returns a iso8601 formated date from timestamp.""" - return isotime(datetime.datetime.utcfromtimestamp(timestamp)) - - -utcnow.override_time = None - - -def set_time_override(override_time=None): - """Overrides utils.utcnow. - - Make it return a constant time or a list thereof, one at a time. - - :param override_time: datetime instance or list thereof. If not - given, defaults to the current UTC time. - """ - utcnow.override_time = override_time or datetime.datetime.utcnow() - - -def advance_time_delta(timedelta): - """Advance overridden time using a datetime.timedelta.""" - assert(not utcnow.override_time is None) - try: - for dt in utcnow.override_time: - dt += timedelta - except TypeError: - utcnow.override_time += timedelta - - -def advance_time_seconds(seconds): - """Advance overridden time by seconds.""" - advance_time_delta(datetime.timedelta(0, seconds)) - - -def clear_time_override(): - """Remove the overridden time.""" - utcnow.override_time = None - - -def marshall_now(now=None): - """Make an rpc-safe datetime with microseconds. - - Note: tzinfo is stripped, but not required for relative times. - """ - if not now: - now = utcnow() - return dict(day=now.day, month=now.month, year=now.year, hour=now.hour, - minute=now.minute, second=now.second, - microsecond=now.microsecond) - - -def unmarshall_time(tyme): - """Unmarshall a datetime dict.""" - return datetime.datetime(day=tyme['day'], - month=tyme['month'], - year=tyme['year'], - hour=tyme['hour'], - minute=tyme['minute'], - second=tyme['second'], - microsecond=tyme['microsecond']) - - -def delta_seconds(before, after): - """Return the difference between two timing objects. - - Compute the difference in seconds between two date, time, or - datetime objects (as a float, to microsecond resolution). - """ - delta = after - before - return total_seconds(delta) - - -def total_seconds(delta): - """Return the total seconds of datetime.timedelta object. - - Compute total seconds of datetime.timedelta, datetime.timedelta - doesn't have method total_seconds in Python2.6, calculate it manually. - """ - try: - return delta.total_seconds() - except AttributeError: - return ((delta.days * 24 * 3600) + delta.seconds + - float(delta.microseconds) / (10 ** 6)) - - -def is_soon(dt, window): - """Determines if time is going to happen in the next window seconds. - - :params dt: the time - :params window: minimum seconds to remain to consider the time not soon - - :return: True if expiration is within the given duration - """ - soon = (utcnow() + datetime.timedelta(seconds=window)) - return normalize_time(dt) <= soon diff --git a/heatclient/openstack/common/uuidutils.py b/heatclient/openstack/common/uuidutils.py deleted file mode 100644 index 234b880c..00000000 --- a/heatclient/openstack/common/uuidutils.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) 2012 Intel Corporation. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -""" -UUID related utilities and helper functions. -""" - -import uuid - - -def generate_uuid(): - return str(uuid.uuid4()) - - -def is_uuid_like(val): - """Returns validation of a value as a UUID. - - For our purposes, a UUID is a canonical form string: - aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa - - """ - try: - return str(uuid.UUID(val)) == val - except (TypeError, ValueError, AttributeError): - return False diff --git a/heatclient/openstack/__init__.py b/heatclient/osc/__init__.py similarity index 100% rename from heatclient/openstack/__init__.py rename to heatclient/osc/__init__.py diff --git a/heatclient/osc/plugin.py b/heatclient/osc/plugin.py new file mode 100644 index 00000000..859a13e8 --- /dev/null +++ b/heatclient/osc/plugin.py @@ -0,0 +1,74 @@ +# 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. +# + +"""OpenStackClient plugin for Orchestration service.""" + +import logging + +from osc_lib import utils + +LOG = logging.getLogger(__name__) + +DEFAULT_ORCHESTRATION_API_VERSION = '1' +API_VERSION_OPTION = 'os_orchestration_api_version' +API_NAME = 'orchestration' +API_VERSIONS = { + '1': 'heatclient.v1.client.Client', +} + + +def make_client(instance): + """Returns an orchestration service client""" + heat_client = utils.get_client_class( + API_NAME, + instance._api_version[API_NAME], + API_VERSIONS) + LOG.debug('Instantiating orchestration client: %s', heat_client) + + kwargs = {'region_name': instance.region_name, + 'interface': instance.interface} + + if instance.session: + kwargs.update({'session': instance.session, + 'service_type': API_NAME}) + elif instance.auth_plugin_name == 'token_endpoint': + kwargs.update({'endpoint': instance.auth.url, + 'token': instance.auth.token}) + else: + endpoint = instance.get_endpoint_for_service_type( + API_NAME, + region_name=instance.region_name, + interface=instance.interface, + ) + kwargs.update({'endpoint': endpoint, + 'auth_url': instance.auth.auth_url, + 'username': instance.auth_ref.username, + 'token': instance.auth_ref.auth_token}) + + client = heat_client(**kwargs) + + return client + + +def build_option_parser(parser): + """Hook to add global options""" + parser.add_argument( + '--os-orchestration-api-version', + metavar='', + default=utils.env( + 'OS_ORCHESTRATION_API_VERSION', + default=DEFAULT_ORCHESTRATION_API_VERSION), + help='Orchestration API version, default=' + + DEFAULT_ORCHESTRATION_API_VERSION + + ' (Env: OS_ORCHESTRATION_API_VERSION)') + return parser diff --git a/heatclient/openstack/common/apiclient/__init__.py b/heatclient/osc/v1/__init__.py similarity index 100% rename from heatclient/openstack/common/apiclient/__init__.py rename to heatclient/osc/v1/__init__.py diff --git a/heatclient/osc/v1/build_info.py b/heatclient/osc/v1/build_info.py new file mode 100644 index 00000000..6428fe42 --- /dev/null +++ b/heatclient/osc/v1/build_info.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. +# + +"""Orchestration v1 build info action implementations""" + +import logging + +from osc_lib.command import command +from osc_lib import utils + +from heatclient.osc.v1 import common + + +class BuildInfo(command.ShowOne): + """Retrieve build information.""" + + log = logging.getLogger(__name__ + ".BuildInfo") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + result = heat_client.build_info.build_info() + + formatters = { + 'api': common.JsonColumn, + 'engine': common.JsonColumn, + } + columns = sorted(list(result.keys())) + return columns, utils.get_dict_properties(result, columns, + formatters=formatters) diff --git a/heatclient/osc/v1/common.py b/heatclient/osc/v1/common.py new file mode 100644 index 00000000..1b5ac701 --- /dev/null +++ b/heatclient/osc/v1/common.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. +# + +from cliff import columns + +from heatclient.common import utils as heat_utils + + +class LinkColumn(columns.FormattableColumn): + def human_readable(self): + return heat_utils.link_formatter(self._value) + + +class JsonColumn(columns.FormattableColumn): + def human_readable(self): + return heat_utils.json_formatter(self._value) + + +class YamlColumn(columns.FormattableColumn): + def human_readable(self): + return heat_utils.yaml_formatter(self._value) + + +class TextWrapColumn(columns.FormattableColumn): + def human_readable(self): + return heat_utils.text_wrap_formatter(self._value) + + +class NewlineListColumn(columns.FormattableColumn): + def human_readable(self): + return heat_utils.newline_list_formatter(self._value) diff --git a/heatclient/osc/v1/event.py b/heatclient/osc/v1/event.py new file mode 100644 index 00000000..ea5ff16d --- /dev/null +++ b/heatclient/osc/v1/event.py @@ -0,0 +1,251 @@ +# 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. +# +# Copyright 2015 IBM Corp. + +import logging +import time + +from cliff.formatters import base +from osc_lib.command import command +from osc_lib import utils + +from heatclient._i18n import _ +from heatclient.common import event_utils +from heatclient.common import utils as heat_utils +from heatclient import exc +from heatclient.osc.v1 import common + + +class ShowEvent(command.ShowOne): + """Show event details.""" + + log = logging.getLogger(__name__ + '.ShowEvent') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack to show events for') + ) + parser.add_argument( + 'resource', + metavar='', + help=_('Name of the resource event belongs to') + ) + parser.add_argument( + 'event', + metavar='', + help=_('ID of event to display details for') + ) + + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + + client = self.app.client_manager.orchestration + + fields = { + 'stack_id': parsed_args.stack, + 'resource_name': parsed_args.resource, + 'event_id': parsed_args.event + } + + try: + client.stacks.get(parsed_args.stack) + client.resources.get(parsed_args.stack, parsed_args.resource) + event = client.events.get(**fields) + except exc.HTTPNotFound as ex: + raise exc.CommandError(str(ex)) + + formatters = { + 'links': common.LinkColumn, + 'resource_properties': common.JsonColumn + } + + columns = [] + for key in event.to_dict(): + columns.append(key) + + return columns, utils.get_item_properties(event, columns, + formatters=formatters) + + +class ListEvent(command.Lister): + """List events.""" + + log = logging.getLogger(__name__ + '.ListEvent') + + @property + def formatter_default(self): + return 'log' + + @property + def formatter_namespace(self): + return 'heatclient.event.formatter.list' + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack to show events for') + ) + parser.add_argument( + '--resource', + metavar='', + help=_('Name of resource to show events for. Note: this cannot ' + 'be specified with --nested-depth') + ) + parser.add_argument( + '--filter', + metavar='', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend", + help=_('Filter parameters to apply on returned events') + ) + parser.add_argument( + '--limit', + metavar='', + type=int, + help=_('Limit the number of events returned') + ) + parser.add_argument( + '--marker', + metavar='', + help=_('Only return events that appear after the given ID') + ) + parser.add_argument( + '--nested-depth', + metavar='', + type=int, + help=_('Depth of nested stacks from which to display events. ' + 'Note: this cannot be specified with --resource') + ) + parser.add_argument( + '--sort', + metavar='[:]', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend", + help=_('Sort output by selected keys and directions (asc or desc) ' + '(default: asc). Specify multiple times to sort on ' + 'multiple keys. Sort key can be: ' + '"event_time" (default), "resource_name", "links", ' + '"logical_resource_id", "resource_status", ' + '"resource_status_reason", "physical_resource_id", or ' + '"id". You can leave the key empty and specify ":desc" ' + 'for sorting by reverse time.') + ) + parser.add_argument( + '--follow', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Print events until process is halted') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + + client = self.app.client_manager.orchestration + + columns = ['id', 'resource_status', 'resource_status_reason', + 'event_time', 'physical_resource_id'] + + kwargs = { + 'resource_name': parsed_args.resource, + 'filters': heat_utils.format_parameters(parsed_args.filter), + 'sort_dir': 'asc' + } + + if parsed_args.resource and parsed_args.nested_depth: + msg = _('--nested-depth cannot be specified with --resource') + raise exc.CommandError(msg) + + if parsed_args.nested_depth: + columns.append('stack_name') + nested_depth = parsed_args.nested_depth + else: + nested_depth = 0 + + if parsed_args.sort: + sorts = [] + sort_keys = [] + for sort in parsed_args.sort: + if sort.startswith(":"): + sorts.append(":".join(["event_time", sort.lstrip(":")])) + else: + sorts.append(sort) + sort_keys.append(sort.split(":")[0]) + kwargs['sort_keys'] = sort_keys + + if ":" in parsed_args.sort[0]: + kwargs['sort_dir'] = parsed_args.sort[0].split(":")[1] + + if parsed_args.follow: + if parsed_args.formatter != 'log': + msg = _('--follow can only be specified with --format log') + raise exc.CommandError(msg) + + marker = parsed_args.marker + try: + event_log_context = heat_utils.EventLogContext() + while True: + events = event_utils.get_events( + client, + stack_id=parsed_args.stack, + event_args=kwargs, + nested_depth=nested_depth, + marker=marker) + if events: + marker = getattr(events[-1], 'id', None) + events_log = heat_utils.event_log_formatter( + events, event_log_context) + self.app.stdout.write(events_log) + self.app.stdout.write('\n') + time.sleep(5) + # this loop never exits + except (KeyboardInterrupt, EOFError): # ctrl-c, ctrl-d + return [], [] + + events = event_utils.get_events( + client, stack_id=parsed_args.stack, event_args=kwargs, + nested_depth=nested_depth, marker=parsed_args.marker, + limit=parsed_args.limit) + + if parsed_args.sort: + events = utils.sort_items(events, ','.join(sorts)) + + if parsed_args.formatter == 'log': + return [], events + + if len(events): + if hasattr(events[0], 'resource_name'): + columns.insert(0, 'resource_name') + columns.append('logical_resource_id') + else: + columns.insert(0, 'logical_resource_id') + + return ( + columns, + (utils.get_item_properties(s, columns) for s in events) + ) + + +class LogFormatter(base.ListFormatter): + """A formatter which prints event objects in a log style""" + + def add_argument_group(self, parser): + pass + + def emit_list(self, column_names, data, stdout, parsed_args): + stdout.write(heat_utils.event_log_formatter(data)) + stdout.write('\n') diff --git a/heatclient/osc/v1/resource.py b/heatclient/osc/v1/resource.py new file mode 100644 index 00000000..b2d893f0 --- /dev/null +++ b/heatclient/osc/v1/resource.py @@ -0,0 +1,303 @@ +# 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. +# + +"""Orchestration v1 Stack action implementations""" + +import logging + +from osc_lib.command import command +from osc_lib import exceptions as exc +from osc_lib.i18n import _ +from osc_lib import utils +from oslo_serialization import jsonutils +from urllib import request + +from heatclient.common import format_utils +from heatclient.common import utils as heat_utils +from heatclient import exc as heat_exc + + +class ResourceShow(command.ShowOne): + """Display stack resource.""" + + log = logging.getLogger(__name__ + '.ResourceShowStack') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack to query') + ) + parser.add_argument( + 'resource', + metavar='', + help=_('Name of resource') + ) + parser.add_argument( + '--with-attr', + metavar='', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend", + help=_('Attribute to show, can be specified multiple times') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + + client = self.app.client_manager.orchestration + + try: + resource = client.resources.get(parsed_args.stack, + parsed_args.resource, + with_attr=parsed_args.with_attr) + except heat_exc.HTTPNotFound: + msg = (_('Stack or resource not found: %(stack)s %(resource)s') % + {'stack': parsed_args.stack, + 'resource': parsed_args.resource}) + raise exc.CommandError(msg) + + return self.dict2columns(resource.to_dict()) + + +class ResourceList(command.Lister): + """List stack resources.""" + + log = logging.getLogger(__name__ + '.ResourceListStack') + + @property + def formatter_namespace(self): + return 'heatclient.resource.formatter.list' + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack to query') + ) + parser.add_argument( + '--long', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Enable detailed information presented for each resource ' + 'in resource list') + ) + parser.add_argument( + '-n', '--nested-depth', + metavar='', + type=int, + help=_('Depth of nested stacks from which to display resources') + ) + + parser.add_argument( + '--filter', + metavar='', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend", + help=_('Filter parameters to apply on returned resources based on ' + 'their name, status, type, action, id and ' + 'physical_resource_id') + ) + + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + + client = self.app.client_manager.orchestration + + fields = { + 'nested_depth': parsed_args.nested_depth, + 'with_detail': parsed_args.long, + 'filters': heat_utils.format_parameters(parsed_args.filter), + } + + try: + resources = client.resources.list(parsed_args.stack, **fields) + except heat_exc.HTTPNotFound: + msg = _('Stack not found: %s') % parsed_args.stack + raise exc.CommandError(msg) + + if parsed_args.formatter == 'dot': + return [], resources + + columns = ['physical_resource_id', 'resource_type', 'resource_status', + 'updated_time'] + + if len(resources) >= 1 and not hasattr(resources[0], 'resource_name'): + columns.insert(0, 'logical_resource_id') + else: + columns.insert(0, 'resource_name') + + if parsed_args.nested_depth or parsed_args.long: + columns.append('stack_name') + + return ( + columns, + (utils.get_item_properties(r, columns) for r in resources) + ) + + +class ResourceMetadata(format_utils.JsonFormat): + """Show resource metadata""" + + log = logging.getLogger(__name__ + ".ResourceMetadata") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Stack to display (name or ID)'), + ) + parser.add_argument( + 'resource', + metavar='', + help=_('Name of the resource to show the metadata for')) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + return _resource_metadata(heat_client, parsed_args) + + +def _resource_metadata(heat_client, args): + fields = {'stack_id': args.stack, + 'resource_name': args.resource} + try: + metadata = heat_client.resources.metadata(**fields) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Stack %(stack)s or resource %(resource)s ' + 'not found.') % + {'stack': args.stack, + 'resource': args.resource}) + + data = list(metadata.values()) + columns = list(metadata.keys()) + return columns, data + + +class ResourceSignal(command.Command): + """Signal a resource with optional data.""" + + log = logging.getLogger(__name__ + ".ResourceSignal") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack the resource belongs to'), + ) + parser.add_argument( + 'resource', + metavar='', + help=_('Name of the resoure to signal'), + ) + parser.add_argument( + '--data', + metavar='', + help=_('JSON Data to send to the signal handler') + ) + parser.add_argument( + '--data-file', + metavar='', + help=_('File containing JSON data to send to the signal handler') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + return _resource_signal(heat_client, parsed_args) + + +def _resource_signal(heat_client, args): + fields = {'stack_id': args.stack, + 'resource_name': args.resource} + data = args.data + data_file = args.data_file + if data and data_file: + raise exc.CommandError(_('Should only specify one of data or ' + 'data-file')) + + if data_file: + data_url = heat_utils.normalise_file_path_to_url(data_file) + data = request.urlopen(data_url).read() + + if data: + try: + data = jsonutils.loads(data) + except ValueError as ex: + raise exc.CommandError(_('Data should be in JSON format: %s') % ex) + if not isinstance(data, dict): + raise exc.CommandError(_('Data should be a JSON dict')) + + fields['data'] = data + try: + heat_client.resources.signal(**fields) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Stack %(stack)s or resource %(resource)s ' + 'not found.') % + {'stack': args.stack, + 'resource': args.resource}) + + +class ResourceMarkUnhealthy(command.Command): + """Set resource's health.""" + + log = logging.getLogger(__name__ + ".ResourceMarkUnhealthy") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack the resource belongs to') + ) + parser.add_argument( + 'resource', + metavar='', + help=_('Name of the resource') + ) + parser.add_argument( + 'reason', + default="", + nargs='?', + help=_('Reason for state change') + ) + parser.add_argument( + '--reset', + default=False, + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Set the resource as healthy') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + fields = {'stack_id': parsed_args.stack, + 'resource_name': parsed_args.resource, + 'mark_unhealthy': not parsed_args.reset, + 'resource_status_reason': parsed_args.reason} + try: + heat_client.resources.mark_unhealthy(**fields) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Stack or resource not found: ' + '%(id)s %(resource)s') % + {'id': parsed_args.stack, + 'resource': parsed_args.resource}) diff --git a/heatclient/osc/v1/resource_type.py b/heatclient/osc/v1/resource_type.py new file mode 100644 index 00000000..6e5cf634 --- /dev/null +++ b/heatclient/osc/v1/resource_type.py @@ -0,0 +1,129 @@ +# 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. +# + +"""Orchestration v1 resource type implementations""" + +import logging + +from osc_lib.command import command +from osc_lib import exceptions as exc +from osc_lib.i18n import _ + +from heatclient.common import format_utils +from heatclient.common import utils as heat_utils +from heatclient import exc as heat_exc + + +class ResourceTypeShow(format_utils.YamlFormat): + """Show details and optionally generate a template for a resource type.""" + + log = logging.getLogger(__name__ + ".ResourceTypeShow") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'resource_type', + metavar='', + help=_('Resource type to show details for'), + ) + parser.add_argument( + '--template-type', + metavar='', + help=_('Optional template type to generate, hot or cfn') + ) + parser.add_argument( + '--long', + default=False, + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Show resource type with corresponding description.') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + if parsed_args.template_type is not None and parsed_args.long: + msg = _('Cannot use --template-type and --long in one time.') + raise exc.CommandError(msg) + + heat_client = self.app.client_manager.orchestration + return _show_resourcetype(heat_client, parsed_args) + + +def _show_resourcetype(heat_client, parsed_args): + try: + if parsed_args.template_type: + template_type = parsed_args.template_type.lower() + if template_type not in ('hot', 'cfn'): + raise exc.CommandError( + _('Template type invalid: %s') % parsed_args.template_type) + + fields = {'resource_type': parsed_args.resource_type, + 'template_type': template_type} + data = heat_client.resource_types.generate_template(**fields) + else: + data = heat_client.resource_types.get(parsed_args.resource_type, + parsed_args.long) + except heat_exc.HTTPNotFound: + raise exc.CommandError( + _('Resource type not found: %s') % parsed_args.resource_type) + + rows = list(data.values()) + columns = list(data.keys()) + return columns, rows + + +class ResourceTypeList(command.Lister): + """List resource types.""" + + log = logging.getLogger(__name__ + '.ResourceTypeList') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + '--filter', + dest='filter', + metavar='', + help=_('Filter parameters to apply on returned resource types. ' + 'This can be specified multiple times. It can be any of ' + 'name, version or support_status'), + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend" + ) + parser.add_argument( + '--long', + default=False, + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Show resource types with corresponding description of ' + 'each resource type.') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + return _list_resourcetypes(heat_client, parsed_args) + + +def _list_resourcetypes(heat_client, parsed_args): + resource_types = heat_client.resource_types.list( + filters=heat_utils.format_parameters(parsed_args.filter), + with_description=parsed_args.long + ) + if parsed_args.long: + columns = ['Resource Type', 'Description'] + rows = sorted([r.resource_type, r.description] for r in resource_types) + else: + columns = ['Resource Type'] + rows = sorted([r.resource_type] for r in resource_types) + return columns, rows diff --git a/heatclient/osc/v1/service.py b/heatclient/osc/v1/service.py new file mode 100644 index 00000000..3ca042ca --- /dev/null +++ b/heatclient/osc/v1/service.py @@ -0,0 +1,41 @@ +# 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. +# + +"""Orchestration v1 Service action implementations""" + +import logging + +from osc_lib.command import command +from osc_lib import utils + + +class ListService(command.Lister): + """List the Heat engines.""" + + log = logging.getLogger(__name__ + ".ListService") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + columns = ['Hostname', 'Binary', 'Engine ID', 'Host', + 'Topic', 'Updated At', 'Status'] + services = heat_client.services.list() + return ( + columns, + (utils.get_item_properties(s, columns) for s in services) + ) diff --git a/heatclient/osc/v1/snapshot.py b/heatclient/osc/v1/snapshot.py new file mode 100644 index 00000000..79eea08d --- /dev/null +++ b/heatclient/osc/v1/snapshot.py @@ -0,0 +1,238 @@ +# 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. +# + +"""Orchestration v1 Stack Snapshot implementations.""" + +import logging +import sys + +from osc_lib.command import command +from osc_lib import exceptions as exc +from osc_lib.i18n import _ +from osc_lib import utils + +from heatclient.common import format_utils +from heatclient import exc as heat_exc + + +class ListSnapshot(command.Lister): + """List stack snapshots.""" + + log = logging.getLogger(__name__ + ".ListSnapshot") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack containing the snapshots') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + heat_client = self.app.client_manager.orchestration + return self._list_snapshot(heat_client, parsed_args) + + def _list_snapshot(self, heat_client, parsed_args): + fields = {'stack_id': parsed_args.stack} + try: + snapshots = heat_client.stacks.snapshot_list(**fields) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Stack not found: %s') % + parsed_args.stack) + + columns = [ + 'id', + 'name', + 'action', + 'status', + 'status_reason', + 'creation_time' + ] + return ( + columns, + (utils.get_dict_properties(s, columns) + for s in snapshots['snapshots']) + ) + + +class ShowSnapshot(format_utils.YamlFormat): + """Show stack snapshot.""" + + log = logging.getLogger(__name__ + ".ShowSnapshot") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack containing the snapshot') + ) + parser.add_argument( + 'snapshot', + metavar='', + help=_('ID of the snapshot to show') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + heat_client = self.app.client_manager.orchestration + return self._show_snapshot(heat_client, parsed_args.stack, + parsed_args.snapshot) + + def _show_snapshot(self, heat_client, stack_id, snapshot_id): + try: + data = heat_client.stacks.snapshot_show(stack_id, snapshot_id) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Snapshot ID <%(snapshot_id)s> not found ' + 'for stack <%(stack_id)s>') + % {'snapshot_id': snapshot_id, + 'stack_id': stack_id}) + + rows = list(data.values()) + columns = list(data.keys()) + return columns, rows + + +class RestoreSnapshot(command.Command): + """Restore stack snapshot""" + + log = logging.getLogger(__name__ + ".RestoreSnapshot") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack containing the snapshot') + ) + parser.add_argument( + 'snapshot', + metavar='', + help=_('ID of the snapshot to restore') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + heat_client = self.app.client_manager.orchestration + return self._restore_snapshot(heat_client, parsed_args) + + def _restore_snapshot(self, heat_client, parsed_args): + fields = {'stack_id': parsed_args.stack, + 'snapshot_id': parsed_args.snapshot} + try: + heat_client.stacks.restore(**fields) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Stack %(stack)s or ' + 'snapshot %(snapshot)s not found.') % + {'stack': parsed_args.stack, + 'snapshot': parsed_args.snapshot}) + + +class CreateSnapshot(command.ShowOne): + """Create stack snapshot.""" + + log = logging.getLogger(__name__ + ".CreateSnapshot") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack') + ) + parser.add_argument( + '--name', + metavar='', + help=_('Name of snapshot') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + heat_client = self.app.client_manager.orchestration + + try: + data = heat_client.stacks.snapshot(parsed_args.stack, + parsed_args.name) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Stack not found: %s') + % parsed_args.stack) + + columns = [ + 'ID', + 'name', + 'action', + 'status', + 'status_reason', + 'data', + 'creation_time' + ] + return (columns, utils.get_dict_properties(data, columns)) + + +class DeleteSnapshot(command.Command): + """Delete stack snapshot.""" + log = logging.getLogger(__name__ + ".DeleteSnapshot") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Name or ID of stack') + ) + parser.add_argument( + 'snapshot', + metavar='', + help=_('ID of stack snapshot') + ) + parser.add_argument( + '-y', '--yes', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Skip yes/no prompt (assume yes)') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + heat_client = self.app.client_manager.orchestration + msg = ('User did not confirm snapshot delete ' + '%sso taking no action.') + try: + if not parsed_args.yes and sys.stdin.isatty(): + sys.stdout.write( + _('Are you sure you want to delete the snapshot of this ' + 'stack [Y/N]?')) + prompt_response = sys.stdin.readline().lower() + if not prompt_response.startswith('y'): + self.log.info(msg, '') + return + except KeyboardInterrupt: # ctrl-c + self.log.info(msg, '(ctrl-c) ') + return + except EOFError: # ctrl-d + self.log.info(msg, '(ctrl-d) ') + return + + try: + heat_client.stacks.snapshot_delete(parsed_args.stack, + parsed_args.snapshot) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Snapshot ID <%(snapshot_id)s> not found ' + 'for stack <%(stack_id)s>') + % {'snapshot_id': parsed_args.snapshot, + 'stack_id': parsed_args.stack}) diff --git a/heatclient/osc/v1/software_config.py b/heatclient/osc/v1/software_config.py new file mode 100644 index 00000000..89378310 --- /dev/null +++ b/heatclient/osc/v1/software_config.py @@ -0,0 +1,240 @@ +# 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. +# + +"""Orchestration v1 software config action implementations""" + +import logging + +from osc_lib.command import command +from osc_lib import exceptions as exc +from osc_lib import utils +from urllib import request +import yaml + +from heatclient._i18n import _ +from heatclient.common import format_utils +from heatclient.common import template_format +from heatclient.common import utils as heat_utils +from heatclient import exc as heat_exc + + +class DeleteConfig(command.Command): + """Delete software configs""" + + log = logging.getLogger(__name__ + ".DeleteConfig") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'config', + metavar='', + nargs='+', + help=_('IDs of the software configs to delete') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + return _delete_config(heat_client, parsed_args) + + +def _delete_config(heat_client, args): + failure_count = 0 + + for config_id in args.config: + try: + heat_client.software_configs.delete( + config_id=config_id) + except Exception as e: + if isinstance(e, heat_exc.HTTPNotFound): + print(_('Software config with ID %s not found') % config_id) + failure_count += 1 + continue + + if failure_count: + raise exc.CommandError(_('Unable to delete %(count)s of the ' + '%(total)s software configs.') % + {'count': failure_count, + 'total': len(args.config)}) + + +class ListConfig(command.Lister): + """List software configs""" + + log = logging.getLogger(__name__ + ".ListConfig") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + '--limit', + metavar='', + type=int, + help=_('Limit the number of configs returned') + ) + parser.add_argument( + '--marker', + metavar='', + help=_('Return configs that appear after the given config ID') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + heat_client = self.app.client_manager.orchestration + return _list_config(heat_client, parsed_args) + + +def _list_config(heat_client, args): + kwargs = {} + if args.limit: + kwargs['limit'] = args.limit + if args.marker: + kwargs['marker'] = args.marker + scs = heat_client.software_configs.list(**kwargs) + + columns = ['id', 'name', 'group', 'creation_time'] + return (columns, (utils.get_item_properties(s, columns) for s in scs)) + + +class CreateConfig(format_utils.JsonFormat): + """Create software config""" + + log = logging.getLogger(__name__ + ".CreateConfig") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'name', + metavar='', + help=_('Name of the software config to create') + ) + parser.add_argument( + '--config-file', + metavar='', + help=_('Path to JSON/YAML containing map defining ' + ', , and ') + ) + parser.add_argument( + '--definition-file', + metavar='', + help=_('Path to software config script/data') + ) + parser.add_argument( + '--group', + metavar='', + default='Heat::Ungrouped', + help=_('Group name of tool expected by the software config') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + heat_client = self.app.client_manager.orchestration + return _create_config(heat_client, parsed_args) + + +def _create_config(heat_client, args): + config = { + 'group': args.group, + 'config': '' + } + + defn = {} + if args.definition_file: + defn_url = heat_utils.normalise_file_path_to_url( + args.definition_file) + defn_raw = request.urlopen(defn_url).read() or '{}' + defn = yaml.load(defn_raw, Loader=template_format.yaml_loader) + + config['inputs'] = defn.get('inputs', []) + config['outputs'] = defn.get('outputs', []) + config['options'] = defn.get('options', {}) + + if args.config_file: + config_url = heat_utils.normalise_file_path_to_url( + args.config_file) + config['config'] = request.urlopen(config_url).read() + + # build a mini-template with a config resource and validate it + validate_template = { + 'heat_template_version': '2013-05-23', + 'resources': { + args.name: { + 'type': 'OS::Heat::SoftwareConfig', + 'properties': config + } + } + } + heat_client.stacks.validate(template=validate_template) + + config['name'] = args.name + sc = heat_client.software_configs.create(**config).to_dict() + rows = list(sc.values()) + columns = list(sc.keys()) + return columns, rows + + +class ShowConfig(format_utils.YamlFormat): + """Show software config details""" + + log = logging.getLogger(__name__ + ".ShowConfig") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'config', + metavar='', + help=_('ID of the config') + ) + parser.add_argument( + '--config-only', + default=False, + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Only display the value of the property.') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + heat_client = self.app.client_manager.orchestration + return _show_config(heat_client, config_id=parsed_args.config, + config_only=parsed_args.config_only) + + +def _show_config(heat_client, config_id, config_only): + try: + sc = heat_client.software_configs.get(config_id=config_id) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Configuration not found: %s') % config_id) + + columns = None + rows = None + + if config_only: + print(sc.config) + else: + columns = ( + 'id', + 'name', + 'group', + 'config', + 'inputs', + 'outputs', + 'options', + 'creation_time', + ) + rows = utils.get_dict_properties(sc.to_dict(), columns) + + return columns, rows diff --git a/heatclient/osc/v1/software_deployment.py b/heatclient/osc/v1/software_deployment.py new file mode 100644 index 00000000..6c5cfd92 --- /dev/null +++ b/heatclient/osc/v1/software_deployment.py @@ -0,0 +1,356 @@ +# 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. +# + +"""Orchestration v1 Software Deployment action implementations""" + +import logging + +from osc_lib.command import command +from osc_lib import exceptions as exc +from osc_lib import utils +from oslo_serialization import jsonutils + +from heatclient._i18n import _ +from heatclient.common import deployment_utils +from heatclient.common import format_utils +from heatclient.common import utils as heat_utils +from heatclient import exc as heat_exc + + +class CreateDeployment(format_utils.YamlFormat): + """Create a software deployment.""" + + log = logging.getLogger(__name__ + '.CreateDeployment') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'name', + metavar='', + help=_('Name of the derived config associated with this ' + 'deployment. This is used to apply a sort order to the ' + 'list of configurations currently deployed to the server.') + ) + parser.add_argument( + '--input-value', + metavar='', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend", + help=_('Input value to set on the deployment. This can be ' + 'specified multiple times.') + ) + parser.add_argument( + '--action', + metavar='', + default='UPDATE', + help=_('Name of an action for this deployment. This can be a ' + 'custom action, or one of CREATE, UPDATE, DELETE, SUSPEND, ' + 'RESUME. Default is UPDATE') + ) + parser.add_argument( + '--config', + metavar='', + help=_('ID of the configuration to deploy') + ) + parser.add_argument( + '--signal-transport', + metavar='', + default='TEMP_URL_SIGNAL', + help=_('How the server should signal to heat with the deployment ' + 'output values. TEMP_URL_SIGNAL will create a Swift ' + 'TempURL to be signaled via HTTP PUT. ZAQAR_SIGNAL will ' + 'create a dedicated zaqar queue to be signaled using the ' + 'provided keystone credentials.NO_SIGNAL will result in ' + 'the resource going to the COMPLETE state without waiting ' + 'for any signal') + ) + parser.add_argument( + '--container', + metavar='', + help=_('Optional name of container to store TEMP_URL_SIGNAL ' + 'objects in. If not specified a container will be created ' + 'with a name derived from the DEPLOY_NAME') + ) + parser.add_argument( + '--timeout', + metavar='', + type=int, + default=60, + help=_('Deployment timeout in minutes') + ) + parser.add_argument( + '--server', + metavar='', + required=True, + help=_('ID of the server being deployed to') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + + client = self.app.client_manager.orchestration + + config = {} + if parsed_args.config: + try: + config = client.software_configs.get(parsed_args.config) + except heat_exc.HTTPNotFound: + msg = (_('Software configuration not found: %s') % + parsed_args.config) + raise exc.CommandError(msg) + + derived_params = deployment_utils.build_derived_config_params( + parsed_args.action, + config, + parsed_args.name, + heat_utils.format_parameters(parsed_args.input_value, False), + parsed_args.server, + parsed_args.signal_transport, + signal_id=deployment_utils.build_signal_id(client, parsed_args) + ) + derived_config = client.software_configs.create(**derived_params) + + sd = client.software_deployments.create( + config_id=derived_config.id, + server_id=parsed_args.server, + action=parsed_args.action, + status='IN_PROGRESS' + ) + + return zip(*sorted(sd.to_dict().items())) + + +class DeleteDeployment(command.Command): + """Delete software deployment(s) and correlative config(s).""" + + log = logging.getLogger(__name__ + '.DeleteDeployment') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'deployment', + metavar='', + nargs='+', + help=_('ID of the deployment(s) to delete.') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + hc = self.app.client_manager.orchestration + failure_count = 0 + + for deploy_id in parsed_args.deployment: + try: + sd = hc.software_deployments.get(deployment_id=deploy_id) + hc.software_deployments.delete( + deployment_id=deploy_id) + except Exception as e: + if isinstance(e, heat_exc.HTTPNotFound): + print(_('Deployment with ID %s not found') % deploy_id) + else: + print(_('Deployment with ID %s failed to delete') + % deploy_id) + failure_count += 1 + continue + # just try best to delete the corresponding config + try: + config_id = getattr(sd, 'config_id') + hc.software_configs.delete(config_id=config_id) + except Exception: + print(_('Failed to delete the correlative config' + ' %(config_id)s of deployment %(deploy_id)s') % + {'config_id': config_id, 'deploy_id': deploy_id}) + + if failure_count: + raise exc.CommandError(_('Unable to delete %(count)s of the ' + '%(total)s deployments.') % + {'count': failure_count, + 'total': len(parsed_args.deployment)}) + + +class ListDeployment(command.Lister): + """List software deployments.""" + + log = logging.getLogger(__name__ + '.ListDeployment') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + '--server', + metavar='', + help=_('ID of the server to fetch deployments for') + ) + parser.add_argument( + '--long', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('List more fields in output') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + return _list_deployment(heat_client, args=parsed_args) + + +def _list_deployment(heat_client, args=None): + kwargs = {'server_id': args.server} if args.server else {} + columns = ['id', 'config_id', 'server_id', 'action', 'status'] + if args.long: + columns.append('creation_time') + columns.append('status_reason') + + deployments = heat_client.software_deployments.list(**kwargs) + return ( + columns, + (utils.get_item_properties(s, columns) for s in deployments) + ) + + +class ShowDeployment(command.ShowOne): + """Show SoftwareDeployment Details.""" + + log = logging.getLogger(__name__ + ".ShowSoftwareDeployment") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'deployment', + metavar='', + help=_('ID of the deployment') + ) + parser.add_argument( + '--long', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Show more fields in output') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + try: + data = heat_client.software_deployments.get( + deployment_id=parsed_args.deployment) + except heat_exc.HTTPNotFound: + raise exc.CommandError( + _('Software Deployment not found: %s') + % parsed_args.deployment) + else: + columns = [ + 'id', + 'server_id', + 'config_id', + 'creation_time', + 'updated_time', + 'status', + 'status_reason', + 'input_values', + 'action', + ] + if parsed_args.long: + columns.append('output_values') + return columns, utils.get_item_properties(data, columns) + + +class ShowMetadataDeployment(command.Command): + """Get deployment configuration metadata for the specified server.""" + + log = logging.getLogger(__name__ + '.ShowMetadataDeployment') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'server', + metavar='', + help=_('ID of the server to fetch deployments for') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + heat_client = self.app.client_manager.orchestration + md = heat_client.software_deployments.metadata( + server_id=parsed_args.server) + print(jsonutils.dumps(md, indent=2)) + + +class ShowOutputDeployment(command.Command): + """Show a specific deployment output.""" + + log = logging.getLogger(__name__ + '.ShowOutputDeployment') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'deployment', + metavar='', + help=_('ID of deployment to show the output for') + ) + parser.add_argument( + 'output', + metavar='', + nargs='?', + default=None, + help=_('Name of an output to display') + ) + parser.add_argument( + '--all', + default=False, + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Display all deployment outputs') + ) + parser.add_argument( + '--long', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + default=False, + help='Show full deployment logs in output', + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + if (not parsed_args.all and parsed_args.output is None or + parsed_args.all and parsed_args.output is not None): + raise exc.CommandError( + _('Error: either %(output)s or %(all)s argument is needed.') + % {'output': '', 'all': '--all'}) + try: + sd = heat_client.software_deployments.get( + deployment_id=parsed_args.deployment) + except heat_exc.HTTPNotFound: + raise exc.CommandError(_('Deployment not found: %s') + % parsed_args.deployment) + outputs = sd.output_values + if outputs: + if parsed_args.all: + print('output_values:\n') + for k in outputs: + format_utils.print_software_deployment_output( + data=outputs, name=k, long=parsed_args.long) + else: + if parsed_args.output not in outputs: + msg = (_('Output %(output)s does not exist in deployment' + ' %(deployment)s') + % {'output': parsed_args.output, + 'deployment': parsed_args.deployment}) + raise exc.CommandError(msg) + else: + print('output_value:\n') + format_utils.print_software_deployment_output( + data=outputs, name=parsed_args.output) diff --git a/heatclient/osc/v1/stack.py b/heatclient/osc/v1/stack.py new file mode 100644 index 00000000..92010a8c --- /dev/null +++ b/heatclient/osc/v1/stack.py @@ -0,0 +1,1470 @@ +# 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. +# + +"""Orchestration v1 Stack action implementations""" + +import logging +import sys + +from openstackclient.identity import common as identity_common +from osc_lib.command import command +from osc_lib import exceptions as exc +from osc_lib import utils +from oslo_serialization import jsonutils +from urllib import request +import yaml + +from heatclient._i18n import _ +from heatclient.common import event_utils +from heatclient.common import format_utils +from heatclient.common import hook_utils +from heatclient.common import http +from heatclient.common import template_utils +from heatclient.common import utils as heat_utils +from heatclient import exc as heat_exc +from heatclient.osc.v1 import common + + +class CreateStack(command.ShowOne): + """Create a stack.""" + + log = logging.getLogger(__name__ + '.CreateStack') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + '-e', '--environment', + metavar='', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend", + help=_('Path to the environment. Can be specified multiple times') + ) + parser.add_argument( + '-s', '--files-container', + metavar='', + help=_('Swift files container name. Local files other than ' + 'root template would be ignored. If other files are not ' + 'found in swift, heat engine would raise an error.') + ) + parser.add_argument( + '--timeout', + metavar='', + type=int, + help=_('Stack creating timeout in minutes') + ) + parser.add_argument( + '--pre-create', + metavar='', + default=None, + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend", + help=_('Name of a resource to set a pre-create hook to. Resources ' + 'in nested stacks can be set using slash as a separator: ' + '``nested_stack/another/my_resource``. You can use ' + 'wildcards to match multiple stacks or resources: ' + '``nested_stack/an*/*_resource``. This can be specified ' + 'multiple times') + ) + parser.add_argument( + '--enable-rollback', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Enable rollback on create/update failure') + ) + parser.add_argument( + '--parameter', + metavar='', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend", + help=_('Parameter values used to create the stack. This can be ' + 'specified multiple times') + ) + parser.add_argument( + '--parameter-file', + metavar='', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fappend", + help=_('Parameter values from file used to create the stack. ' + 'This can be specified multiple times. Parameter values ' + 'would be the content of the file') + ) + parser.add_argument( + '--wait', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Wait until stack goes to CREATE_COMPLETE or CREATE_FAILED') + ) + parser.add_argument( + '--poll', + metavar='SECONDS', + type=int, + default=5, + help=_('Poll interval in seconds for use with --wait, ' + 'defaults to 5.') + ) + parser.add_argument( + '--tags', + metavar='', + help=_('A list of tags to associate with the stack') + ) + parser.add_argument( + '--dry-run', + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", + help=_('Do not actually perform the stack create, but show what ' + 'would be created') + ) + parser.add_argument( + 'name', + metavar='', + help=_('Name of the stack to create') + ) + parser.add_argument( + '-t', '--template', + metavar='