diff --git a/.coveragerc b/.coveragerc index 4248611c..50618ff6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,7 @@ [run] branch = True source = heatclient -omit = heatclient/tests/*,heatclient/openstack/* +omit = heatclient/tests/* [report] ignore_errors = True diff --git a/.gitignore b/.gitignore index 42bff162..73321c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ doc/build 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..c0ad41ca --- /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.1.0 + hooks: + - id: hacking + additional_dependencies: [] + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.2 + hooks: + - id: pyupgrade + args: [--py311-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 6e77f6be..00000000 --- a/.testr.conf +++ /dev/null @@ -1,4 +0,0 @@ -[DEFAULT] -test_command=${PYTHON:-python} -m subunit.run discover -t ./ ${OS_TEST_PATH:-./heatclient/tests/unit} $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..ddff6521 --- /dev/null +++ b/.zuul.yaml @@ -0,0 +1,37 @@ +- 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 + - openstack-python3-next-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 d7f2b8c8..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://docs.openstack.org/infra/manual/developers.html#development-workflow + 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://docs.openstack.org/infra/manual/developers.html#development-workflow +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/README.rst b/README.rst index 4a953f47..4f921d72 100644 --- a/README.rst +++ b/README.rst @@ -2,14 +2,14 @@ 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.python.org/pypi/python-heatclient/ + :target: https://pypi.org/project/python-heatclient/ :alt: Latest Version -.. image:: https://img.shields.io/pypi/dm/python-heatclient.svg - :target: https://pypi.python.org/pypi/python-heatclient/ - :alt: Downloads - OpenStack Orchestration API Client Library @@ -28,12 +28,12 @@ provides a Python API (the ``heatclient`` module) and a command-line tool * `Template`_ * `How to Contribute`_ -.. _PyPi: https://pypi.python.org/pypi/python-heatclient -.. _Online Documentation: http://docs.openstack.org/developer/python-heatclient +.. _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://bugs.launchpad.net/python-heatclient -.. _Source: https://git.openstack.org/cgit/openstack/python-heatclient -.. _How to Contribute: http://docs.openstack.org/infra/manual/developers.html -.. _Specs: http://specs.openstack.org/openstack/heat-specs/ -.. _Template: https://git.openstack.org/cgit/openstack/heat-templates/ +.. _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/babel.cfg b/babel.cfg deleted file mode 100644 index efceab81..00000000 --- a/babel.cfg +++ /dev/null @@ -1 +0,0 @@ -[python: **.py] 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 e483b53c..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 @@ -24,7 +23,6 @@ # serve to show the default. import os -import subprocess # 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 @@ -37,13 +35,10 @@ # 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' @@ -55,18 +50,8 @@ 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 @@ -97,7 +82,7 @@ # 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 = [] @@ -111,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 @@ -144,14 +129,6 @@ # 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 = subprocess.Popen( - git_cmd, stdout=subprocess.PIPE).communicate()[0] - # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True @@ -192,7 +169,6 @@ # Output file base name for HTML help builder. htmlhelp_basename = 'python-heatclientdoc' - # -- Options for LaTeX output ------------------------------------------------- latex_elements = { @@ -210,7 +186,7 @@ # (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 @@ -240,8 +216,8 @@ # (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. @@ -254,8 +230,8 @@ # (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'), ] @@ -267,3 +243,7 @@ # How to display URL addresses: 'footnote', 'no', or 'inline'. # 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 ec0bdc5e..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://docs.openstack.org/infra/manual/developers.html#development-workflow + contributor/contributing diff --git a/doc/source/man/heat.rst b/doc/source/man/heat.rst index 9355a80e..d181b39e 100644 --- a/doc/source/man/heat.rst +++ b/doc/source/man/heat.rst @@ -95,4 +95,5 @@ 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 88% rename from heatclient/openstack/common/apiclient/base.py rename to heatclient/common/base.py index 8251e4fe..b19c8052 100644 --- a/heatclient/openstack/common/apiclient/base.py +++ b/heatclient/common/base.py @@ -19,37 +19,15 @@ """ Base utilities to build API operation managers and objects on top of. """ - -######################################################################## -# -# THIS MODULE IS DEPRECATED -# -# Please refer to -# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for -# the discussion leading to this deprecation. -# -# We recommend checking out the python-openstacksdk project -# (https://launchpad.net/python-openstacksdk) instead. -# -######################################################################## - - -# E1102: %s is not callable -# pylint: disable=E1102 - import abc import copy -import logging from oslo_utils import reflection from oslo_utils import strutils -import six -from six.moves.urllib import parse +from urllib import parse -from heatclient.openstack.common._i18n import _, _LW -from heatclient.openstack.common.apiclient import exceptions - -LOG = logging.getLogger(__name__) +from heatclient._i18n import _ +from heatclient import exc as exceptions def getid(obj): @@ -70,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 = {} @@ -114,7 +92,7 @@ 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=None, obj_class=None, json=None): @@ -228,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 @@ -279,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 @@ -296,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): @@ -353,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): @@ -373,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), @@ -392,12 +362,13 @@ 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) @@ -420,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() @@ -438,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' @@ -464,16 +434,15 @@ 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) + info = ", ".join(f"{k}={getattr(self, k)}" for k in reprkeys) class_name = reflection.get_class_name(self, fully_qualified=False) - return "<%s %s>" % (class_name, info) + return f"<{class_name} {info}>" @property def human_id(self): - """Human-readable ID which can be used for bash completion. - """ + """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: @@ -481,7 +450,7 @@ def human_id(self): 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 @@ -523,12 +492,11 @@ def __eq__(self, other): # two resources of different types are not equal if not isinstance(other, self.__class__): return False - LOG.warning(_LW("Two objects are equal when all of the attributes are " - "equal, if you want to identify whether two objects " - "are same one with same id, please use is_same_obj() " - "function.")) 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__): diff --git a/heatclient/common/deployment_utils.py b/heatclient/common/deployment_utils.py index e6d68f89..1a86e136 100644 --- a/heatclient/common/deployment_utils.py +++ b/heatclient/common/deployment_utils.py @@ -11,21 +11,21 @@ # under the License. import copy -import six -from six.moves.urllib import parse as urlparse +import uuid + from swiftclient import client as sc from swiftclient import utils as swiftclient_utils -import uuid +from urllib import parse as urlparse +from heatclient._i18n import _ from heatclient import exc -from heatclient.openstack.common._i18n import _ -from heatclient.v1.software_configs import SoftwareConfig +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, SoftwareConfig): + if isinstance(source, software_configs.SoftwareConfig): source = source.to_dict() input_values = input_values or {} inputs = copy.deepcopy(source.get('inputs')) or [] @@ -93,24 +93,24 @@ def build_derived_config_params(action, source, name, input_values, def create_temp_url(swift_client, name, timeout, container=None): - container = container or '%(name)s-%(uuid)s' % { - 'name': name, 'uuid': uuid.uuid4()} + 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: six.text_type(uuid.uuid4())[:32]}) + key_header: str(uuid.uuid4())[:32]}) key = swift_client.head_account()[key_header] project_path = swift_client.url.split('/')[-1] - path = '/v1/%s/%s/%s' % (project_path, container, object_name) + 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 = '%s://%s%s' % (sw_url.scheme, sw_url.netloc, tempurl) + put_url = f'{sw_url.scheme}://{sw_url.netloc}{tempurl}' swift_client.put_object(container, object_name, '') return put_url @@ -119,7 +119,9 @@ def build_signal_id(hc, args): if args.signal_transport != 'TEMP_URL_SIGNAL': return - if args.os_no_client_auth: + # 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.')) @@ -136,7 +138,7 @@ def create_swift_client(auth, session, args): region_name=args.os_region_name) project_name = args.os_project_name or args.os_tenant_name swift_args = { - 'auth_version': '2.0', + 'auth_version': '3', 'tenant_name': project_name, 'user': args.os_username, 'key': None, diff --git a/heatclient/common/environment_format.py b/heatclient/common/environment_format.py index 179a2055..201ff60b 100644 --- a/heatclient/common/environment_format.py +++ b/heatclient/common/environment_format.py @@ -10,16 +10,20 @@ # License for the specific language governing permissions and limitations # under the License. -from heatclient.common import template_format -from heatclient.openstack.common._i18n import _ - import yaml +from heatclient._i18n import _ +from heatclient.common import template_format + SECTIONS = ( - PARAMETER_DEFAULTS, PARAMETERS, RESOURCE_REGISTRY, EVENT_SINKS + PARAMETER_DEFAULTS, PARAMETERS, RESOURCE_REGISTRY, + ENCRYPTED_PARAM_NAMES, EVENT_SINKS, + PARAMETER_MERGE_STRATEGIES ) = ( - 'parameter_defaults', 'parameters', 'resource_registry', 'event_sinks' + 'parameter_defaults', 'parameters', 'resource_registry', + 'encrypted_param_names', 'event_sinks', + 'parameter_merge_strategies' ) @@ -55,7 +59,9 @@ def parse(env_str): def default_for_missing(env): """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 index 1b8b1794..ca213257 100644 --- a/heatclient/common/event_utils.py +++ b/heatclient/common/event_utils.py @@ -12,12 +12,13 @@ # 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.openstack.common._i18n import _ +from heatclient.v1 import events as events_mod def get_hook_events(hc, stack_id, event_args, nested_depth=0, @@ -30,6 +31,10 @@ def get_hook_events(hc, stack_id, event_args, nested_depth=0, 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) @@ -56,35 +61,63 @@ def get_hook_events(hc, stack_id, event_args, nested_depth=0, 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)]) + 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) - if nested_depth > 0: - events.extend(_get_nested_events(hc, nested_depth, - stack_id, event_args)) - # Because there have been multiple stacks events mangled into - # one list, we need to sort before passing to print_list - # Note we can't use the prettytable sortby_index here, because - # the "start" option doesn't allow post-sort slicing, which - # will be needed to make "--marker" work for nested_depth lists - events.sort(key=lambda x: x.event_time) - - # Slice the list if marker is specified - if marker: - 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] + 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 @@ -118,6 +151,15 @@ def _get_nested_events(hc, nested_depth, stack_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: @@ -127,25 +169,41 @@ def _get_stack_events(hc, stack_id, event_args): # 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 = stack_id.split("/")[0] + 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): +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 + stop_check = lambda a: a in stop_status # noqa: E731 else: - stop_check = lambda a: a.endswith('_COMPLETE') or a.endswith('_FAILED') + 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, + events = get_events(hc, stack_id=stack_name, nested_depth=nested_depth, event_args={'sort_dir': 'asc', 'marker': marker}) @@ -155,12 +213,13 @@ def poll_for_events(hc, stack_name, action=None, poll_period=5, marker=None): 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) - print(events_log) + 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 getattr(event, 'resource_name', '') == stack_name: + if is_stack_event(event): stack_status = getattr(event, 'resource_status', '') msg = msg_template % dict( name=stack_name, status=stack_status) @@ -169,7 +228,7 @@ def poll_for_events(hc, stack_name, action=None, poll_period=5, marker=None): if no_event_polls >= 2: # after 2 polls with no events, fall back to a stack get - stack = hc.stacks.get(stack_name) + stack = hc.stacks.get(stack_name, resolve_outputs=False) stack_status = stack.stack_status msg = msg_template % dict( name=stack_name, status=stack_status) @@ -179,3 +238,27 @@ def poll_for_events(hc, stack_name, action=None, poll_period=5, marker=None): 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 index 5cb691ab..4151c80e 100644 --- a/heatclient/common/format_utils.py +++ b/heatclient/common/format_utils.py @@ -12,12 +12,12 @@ # # Copyright 2015 IBM Corp. -from cliff import show -import six import sys +from osc_lib.command import command -class RawFormat(show.ShowOne): + +class RawFormat(command.ShowOne): def produce_output(self, parsed_args, column_names, data): if data is None: @@ -62,7 +62,7 @@ def indent_and_truncate(txt, spaces=0, truncate=False, truncate_limit=10, """ if txt is None: return - lines = six.text_type(txt).splitlines() + lines = str(txt).splitlines() if truncate and len(lines) > truncate_limit: lines = lines[-truncate_limit:] if truncate_prefix is not None: @@ -81,6 +81,8 @@ def print_software_deployment_output(data, name, out=sys.stdout, long=False): 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), @@ -88,6 +90,6 @@ def print_software_deployment_output(data, name, out=sys.stdout, long=False): truncate=not long, truncate_prefix='...', truncate_postfix='(truncated, view all with --long)') - out.write(' %s: |\n%s\n' % (name, output)) + out.write(f' {name}: |\n{output}\n') else: - out.write(' %s: %s\n' % (name, data.get(name))) + out.write(f' {name}: {data.get(name)}\n') diff --git a/heatclient/common/hook_utils.py b/heatclient/common/hook_utils.py index 5cef4342..3e91ae3b 100644 --- a/heatclient/common/hook_utils.py +++ b/heatclient/common/hook_utils.py @@ -13,10 +13,8 @@ import fnmatch import logging -import heatclient.exc as exc - -from heatclient.openstack.common._i18n import _ -from heatclient.openstack.common._i18n import _LE +from heatclient._i18n import _ +from heatclient import exc logger = logging.getLogger(__name__) @@ -29,8 +27,8 @@ def clear_hook(hc, stack_id, resource_name, hook_type): data={'unset_hook': hook_type}) except exc.HTTPNotFound: logger.error( - _LE("Stack %(stack)s or resource %(resource)s" - "not found for hook %(hook_type)"), + "Stack %(stack)s or resource %(resource)s " + "not found for hook %(hook_type)s", {'resource': resource_name, 'stack': stack_id, 'hook_type': hook_type}) @@ -56,8 +54,9 @@ def clear_wildcard_hooks(hc, stack_id, stack_patterns, hook_type, def get_hook_type_via_status(hc, stack_id): - # Figure out if the hook should be pre-create or pre-update based - # on the stack status, also sanity assertions that we're in-progress. + # 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: @@ -71,8 +70,10 @@ def get_hook_type_via_status(hc, stack_id): 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 supported') + '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 4b46c71d..8d7a548a 100644 --- a/heatclient/common/http.py +++ b/heatclient/common/http.py @@ -19,18 +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._i18n import _ -from heatclient.openstack.common._i18n import _LW -from keystoneclient import adapter LOG = logging.getLogger(__name__) USER_AGENT = 'python-heatclient' @@ -39,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, @@ -54,10 +65,10 @@ def get_system_ca_file(): if os.path.exists(ca): LOG.debug("Using ca file %s", ca) return ca - LOG.warning(_LW("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 @@ -125,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)) @@ -188,27 +202,31 @@ 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") % {'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") % {'endpoint': endpoint, 'e': e}) raise exc.CommunicationError(message=message) self.log_http_response(resp) - + 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 resp.content)): + (resp.status_code == 500 and "(HTTP 401)" in txt_content)): raise exc.HTTPUnauthorized(_("Authentication failed: %s") % resp.content) elif 400 <= resp.status_code < 600: @@ -218,23 +236,15 @@ def _http_request(self, url, method, **kwargs): # 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 @@ -297,12 +307,13 @@ def request(self, url, method, **kwargs): redirect = kwargs.get('redirect') kwargs.setdefault('user_agent', USER_AGENT) - try: - kwargs.setdefault('json', kwargs.pop('data')) - except KeyError: - pass + headers = kwargs.setdefault('headers', {}) + headers.setdefault('Content-Type', 'application/json') + + if 'data' in kwargs: + kwargs['data'] = jsonutils.dumps(kwargs['data']) - resp, body = super(SessionClient, self).request( + resp, body = super().request( url, method, raise_exc=False, **kwargs) @@ -328,7 +339,7 @@ def strip_endpoint(self, location): 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):] + return location[len(self.endpoint_override):] else: return location @@ -340,7 +351,19 @@ def _construct_http_client(endpoint=None, username=None, password=None, auth = kwargs.pop('auth', None) if session: - kwargs['endpoint_override'] = endpoint + 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(endpoint=endpoint, username=username, 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 9ba4d641..ff2fdf98 100644 --- a/heatclient/common/template_format.py +++ b/heatclient/common/template_format.py @@ -13,29 +13,40 @@ import json import yaml -from heatclient.openstack.common._i18n import _ +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) diff --git a/heatclient/common/template_utils.py b/heatclient/common/template_utils.py index fae54efe..cc814356 100644 --- a/heatclient/common/template_utils.py +++ b/heatclient/common/template_utils.py @@ -13,38 +13,53 @@ # License for the specific language governing permissions and limitations # under the License. -import collections +from collections import abc from oslo_serialization import jsonutils -import six -from six.moves.urllib import error -from six.moves.urllib import parse -from six.moves.urllib import request +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._i18n import _ -def process_template_path(template_path, object_request=None, existing=False): +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 to assuming path is to a template object. + 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) - except error.URLError: - return get_template_contents(template_object=template_path, - object_request=object_request, - existing=existing) + 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, existing=False): + files=None, existing=False, + fetch_child=True): is_object = False # Transform a bare file path to a file:// URL. @@ -63,29 +78,32 @@ def get_template_contents(template_file=None, template_url=None, return {}, None else: raise exc.CommandError(_('Need to specify exactly one of ' - '%(arg1)s, %(arg2)s or %(arg3)s') % + '[%(arg1)s, %(arg2)s or %(arg3)s]' + ' or %(arg4)s') % { 'arg1': '--template-file', 'arg2': '--template-url', - 'arg3': '--template-object'}) + 'arg3': '--template-object', + 'arg4': '--existing'}) if not tpl: 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 %(url)s %(error)s') % {'url': template_url, 'error': e}) - - tmpl_base_url = utils.base_url_for_url(template_url) if files is None: files = {} - resolve_template_get_files(template, files, tmpl_base_url, is_object, - object_request) + + 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 @@ -95,7 +113,7 @@ def resolve_template_get_files(template, files, template_base_url, def ignore_if(key, value): if key != 'get_file' and key != 'type': return True - if not isinstance(value, six.string_types): + if not isinstance(value, str): return True if (key == 'type' and not value.endswith(('.yaml', '.template'))): @@ -111,7 +129,7 @@ def recurse_if(value): def is_template(file_content): try: - if isinstance(file_content, six.binary_type): + if isinstance(file_content, bytes): file_content = file_content.decode('utf-8') template_format.parse(file_content) except (ValueError, TypeError): @@ -125,7 +143,7 @@ def get_file_contents(from_data, files, base_url=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: @@ -133,7 +151,7 @@ def get_file_contents(from_data, files, base_url=None, is_object, object_request) if isinstance(from_data, dict): - for key, value in six.iteritems(from_data): + for key, value in from_data.items(): if ignore_if and ignore_if(key, value): continue @@ -177,10 +195,19 @@ def 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 @@ -190,7 +217,8 @@ def process_multiple_environments_and_files(env_paths=None, template=None, template_url=None, env_path_is_object=None, object_request=None, - env_list_tracker=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 @@ -217,6 +245,7 @@ def process_multiple_environments_and_files(env_paths=None, template=None, :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 = {} @@ -227,24 +256,28 @@ def process_multiple_environments_and_files(env_paths=None, template=None, if env_paths: for env_path in env_paths: - 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) + 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_url = utils.normalise_file_path_to_url(env_path) env_list_tracker.append(env_url) return merged_files, merged_env @@ -289,9 +322,6 @@ def process_environment_and_files(env_path=None, env_base_url = utils.base_url_for_url(env_url) raw_env = request.urlopen(env_url).read() - if include_env_in_files: - files[env_url] = raw_env - env = environment_format.parse(raw_env) resolve_environment_urls( @@ -299,6 +329,9 @@ def process_environment_and_files(env_path=None, files, env_base_url) + if include_env_in_files: + files[env_url] = jsonutils.dumps(env) + return files, env @@ -334,7 +367,7 @@ def ignore_if(key, value): get_file_contents(rr, files, base_url, ignore_if, is_object=is_object, object_request=object_request) - for res_name, res_dict in six.iteritems(rr.get('resources', {})): + 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, diff --git a/heatclient/common/utils.py b/heatclient/common/utils.py index a36ab38c..8d104c7a 100644 --- a/heatclient/common/utils.py +++ b/heatclient/common/utils.py @@ -16,23 +16,20 @@ import base64 import logging import os +from pathlib import Path import textwrap import uuid from oslo_serialization import jsonutils from oslo_utils import encodeutils -from oslo_utils import importutils import prettytable -import six -from six.moves.urllib import error -from six.moves.urllib import parse -from six.moves.urllib import request +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._i18n import _ -from heatclient.openstack.common._i18n import _LE -from heatclient.openstack.common import cliutils LOG = logging.getLogger(__name__) @@ -42,24 +39,105 @@ "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 = [l for l in rsrc.links or [] - if l.get('rel') == 'nested'] + 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:] @@ -89,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: @@ -97,19 +175,92 @@ def print_dict(d, formatters=None): print(pt.get_string(sortby='Property')) -def event_log_formatter(events): +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 " - "[%(rsrc_name)s]: %(rsrc_status)s %(rsrc_status_reason)s") + 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 = log_format % { + log_data = { 'event_time': event_time.replace('T', ' '), - 'rsrc_name': getattr(event, 'resource_name', ''), + 'rsrc_name': rsrc_name, 'rsrc_status': getattr(event, 'resource_status', ''), - 'rsrc_status_reason': getattr(event, 'resource_status_reason', '') + '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) @@ -135,10 +286,7 @@ def print_update_list(lst, fields, formatters=None): pt.add_row(row) - if six.PY3: - print(encodeutils.safe_encode(pt.get_string()).decode()) - else: - print(encodeutils.safe_encode(pt.get_string())) + print(encodeutils.safe_encode(pt.get_string()).decode()) def find_resource(manager, name_or_id): @@ -171,13 +319,6 @@ def find_resource(manager, 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 format_parameters(params, parse_semicolon=True): '''Reformat parameters into dict of format expected by the API.''' @@ -232,9 +373,9 @@ def format_parameter_file(param_files, template_file=None, template_file, template_url)) param_file = {} - for key, value in six.iteritems(params): - param_file[key] = resolve_param_get_file(value, - template_base_url) + for key, value in params.items(): + param_file[key] = resolve_param_get_file(value, + template_base_url) return param_file @@ -276,7 +417,7 @@ def read_url_content(url): try: content.decode('utf-8') except ValueError: - content = base64.encodestring(content) + content = base64.encodebytes(content) return content @@ -289,8 +430,7 @@ def 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)) + return Path(path).resolve().as_uri() def get_response_body(resp): @@ -299,7 +439,7 @@ def get_response_body(resp): try: body = resp.json() except ValueError: - LOG.error(_LE('Could not decode response body as JSON')) + 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 7d4968ce..5cbd915b 100644 --- a/heatclient/exc.py +++ b/heatclient/exc.py @@ -15,7 +15,7 @@ from oslo_serialization import jsonutils from oslo_utils import reflection -from heatclient.openstack.common._i18n import _ +from heatclient._i18n import _ verbose = 0 @@ -45,8 +45,8 @@ 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: @@ -60,6 +60,8 @@ 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') @@ -75,8 +77,7 @@ class HTTPMultipleChoices(HTTPException): code = 300 def __str__(self): - self.details = _("Requested version of Heat API is not" - "available.") + 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), @@ -120,6 +121,10 @@ class HTTPNotFound(NotFound): pass +class NoUniqueMatch(HTTPException): + pass + + class HTTPMethodNotAllowed(HTTPException): code = 405 @@ -179,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): diff --git a/heatclient/openstack/__init__.py b/heatclient/openstack/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/heatclient/openstack/common/__init__.py b/heatclient/openstack/common/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/heatclient/openstack/common/_i18n.py b/heatclient/openstack/common/_i18n.py deleted file mode 100644 index 3a8b7286..00000000 --- a/heatclient/openstack/common/_i18n.py +++ /dev/null @@ -1,45 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -"""oslo.i18n integration module. - -See http://docs.openstack.org/developer/oslo.i18n/usage.html - -""" - -try: - import oslo_i18n - - # NOTE(dhellmann): This reference to o-s-l-o will be replaced by the - # application name when this module is synced into the separate - # repository. It is OK to have more than one translation function - # using the same domain, since there will still only be one message - # catalog. - _translators = oslo_i18n.TranslatorFactory(domain='heatclient') - - # The primary translation function using the well-known name "_" - _ = _translators.primary - - # Translators for log levels. - # - # The abbreviated names are meant to reflect the usual use of a short - # name like '_'. The "L" is for "log" and the other letter comes from - # the level. - _LI = _translators.log_info - _LW = _translators.log_warning - _LE = _translators.log_error - _LC = _translators.log_critical -except ImportError: - # NOTE(dims): Support for cases where a project wants to use - # code from oslo-incubator, but is not ready to be internationalized - # (like tempest) - _ = _LI = _LW = _LE = _LC = lambda x: x diff --git a/heatclient/openstack/common/apiclient/__init__.py b/heatclient/openstack/common/apiclient/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/heatclient/openstack/common/apiclient/auth.py b/heatclient/openstack/common/apiclient/auth.py deleted file mode 100644 index 38048608..00000000 --- a/heatclient/openstack/common/apiclient/auth.py +++ /dev/null @@ -1,234 +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 - -######################################################################## -# -# THIS MODULE IS DEPRECATED -# -# Please refer to -# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for -# the discussion leading to this deprecation. -# -# We recommend checking out the python-openstacksdk project -# (https://launchpad.net/python-openstacksdk) instead. -# -######################################################################## - -import abc -import argparse -import os - -import six -from stevedore import extension - -from 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 3ddaa108..00000000 --- a/heatclient/openstack/common/apiclient/client.py +++ /dev/null @@ -1,388 +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 hashlib -import logging -import time - -try: - import simplejson as json -except ImportError: - import json - -from oslo_utils import encodeutils -from oslo_utils import importutils -import requests - -from heatclient.openstack.common._i18n import _ -from heatclient.openstack.common.apiclient import exceptions - -_logger = logging.getLogger(__name__) -SENSITIVE_HEADERS = ('X-Auth-Token', 'X-Subject-Token',) - - -class HTTPClient(object): - """This client handles sending HTTP requests to OpenStack servers. - - Features: - - - share authentication information between several clients to different - services (e.g., for compute and image clients); - - reissue authentication request for expired tokens; - - encode/decode JSON bodies; - - raise exceptions on HTTP errors; - - pluggable authentication; - - store authentication information in a keyring; - - store time spent for requests; - - register clients for particular services, so one can use - `http_client.identity` or `http_client.compute`; - - log requests and responses in a format that is easy to copy-and-paste - into terminal and send the same request with curl. - """ - - user_agent = "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 - self.last_request_id = None - - def _safe_header(self, name, value): - if name in SENSITIVE_HEADERS: - # because in python3 byte string handling is ... ug - v = value.encode('utf-8') - h = hashlib.sha1(v) - d = h.hexdigest() - return encodeutils.safe_decode(name), "{SHA1}%s" % d - else: - return (encodeutils.safe_decode(name), - encodeutils.safe_decode(value)) - - def _http_log_req(self, method, url, kwargs): - if not self.debug: - return - - string_parts = [ - "curl -g -i", - "-X '%s'" % method, - "'%s'" % url, - ] - - for element in kwargs['headers']: - header = ("-H '%s: %s'" % - self._safe_header(element, kwargs['headers'][element])) - string_parts.append(header) - - _logger.debug("REQ: %s" % " ".join(string_parts)) - if 'data' in kwargs: - _logger.debug("REQ BODY: %s\n" % (kwargs['data'])) - - def _http_log_resp(self, resp): - if not self.debug: - return - _logger.debug( - "RESP: [%s] %s\n", - resp.status_code, - resp.headers) - if resp._content_consumed: - _logger.debug( - "RESP BODY: %s\n", - resp.text) - - def serialize(self, kwargs): - if kwargs.get('json') is not None: - kwargs['headers']['Content-Type'] = 'application/json' - kwargs['data'] = json.dumps(kwargs['json']) - try: - del kwargs['json'] - except KeyError: - pass - - def get_timings(self): - return self.times - - def reset_timings(self): - self.times = [] - - def request(self, method, url, **kwargs): - """Send an http request with the specified characteristics. - - Wrapper around `requests.Session.request` to handle tasks such as - setting headers, JSON encoding/decoding, and error handling. - - :param method: method of HTTP request - :param url: URL of HTTP request - :param kwargs: any other parameter that can be passed to - requests.Session.request (such as `headers`) or `json` - that will be encoded as JSON and used as `data` argument - """ - kwargs.setdefault("headers", {}) - kwargs["headers"]["User-Agent"] = self.user_agent - if self.original_ip: - kwargs["headers"]["Forwarded"] = "for=%s;by=%s" % ( - self.original_ip, self.user_agent) - if self.timeout is not None: - kwargs.setdefault("timeout", self.timeout) - kwargs.setdefault("verify", self.verify) - if self.cert is not None: - kwargs.setdefault("cert", self.cert) - self.serialize(kwargs) - - self._http_log_req(method, url, kwargs) - if self.timings: - start_time = time.time() - resp = self.http.request(method, url, **kwargs) - if self.timings: - self.times.append(("%s %s" % (method, url), - start_time, time.time())) - self._http_log_resp(resp) - - self.last_request_id = resp.headers.get('x-openstack-request-id') - - if resp.status_code >= 400: - _logger.debug( - "Request returned failure status: %s", - resp.status_code) - raise exceptions.from_response(resp, method, url) - - return resp - - @staticmethod - def concat_url(endpoint, url): - """Concatenate endpoint and final URL. - - E.g., "http://keystone/v2.0/" and "/tokens" are concatenated to - "http://keystone/v2.0/tokens". - - :param endpoint: the base URL - :param url: the final URL - """ - return "%s/%s" % (endpoint.rstrip("/"), url.strip("/")) - - def client_request(self, client, method, url, **kwargs): - """Send an http request using `client`'s endpoint and specified `url`. - - If request was rejected as unauthorized (possibly because the token is - expired), issue one authorization attempt and send the request once - again. - - :param client: instance of BaseClient descendant - :param method: method of HTTP request - :param url: URL of HTTP request - :param kwargs: any other parameter that can be passed to - `HTTPClient.request` - """ - - filter_args = { - "endpoint_type": client.endpoint_type or self.endpoint_type, - "service_type": client.service_type, - } - token, endpoint = (self.cached_token, client.cached_endpoint) - just_authenticated = False - if not (token and endpoint): - try: - token, endpoint = self.auth_plugin.token_and_endpoint( - **filter_args) - except exceptions.EndpointException: - pass - if not (token and endpoint): - self.authenticate() - just_authenticated = True - token, endpoint = self.auth_plugin.token_and_endpoint( - **filter_args) - if not (token and endpoint): - raise exceptions.AuthorizationFailure( - _("Cannot find endpoint or token for request")) - - old_token_endpoint = (token, endpoint) - kwargs.setdefault("headers", {})["X-Auth-Token"] = token - self.cached_token = token - client.cached_endpoint = endpoint - # Perform the request once. If we get Unauthorized, then it - # might be because the auth token expired, so try to - # re-authenticate and try again. If it still fails, bail. - try: - return self.request( - method, self.concat_url(endpoint, url), **kwargs) - except exceptions.Unauthorized as unauth_ex: - if just_authenticated: - raise - self.cached_token = None - client.cached_endpoint = None - if self.auth_plugin.opts.get('token'): - self.auth_plugin.opts['token'] = None - if self.auth_plugin.opts.get('endpoint'): - self.auth_plugin.opts['endpoint'] = None - self.authenticate() - try: - token, endpoint = self.auth_plugin.token_and_endpoint( - **filter_args) - except exceptions.EndpointException: - raise unauth_ex - if (not (token and endpoint) or - old_token_endpoint == (token, endpoint)): - raise unauth_ex - self.cached_token = token - client.cached_endpoint = endpoint - kwargs["headers"]["X-Auth-Token"] = token - return self.request( - method, self.concat_url(endpoint, url), **kwargs) - - def add_client(self, base_client_instance): - """Add a new instance of :class:`BaseClient` descendant. - - `self` will store a reference to `base_client_instance`. - - Example: - - >>> def test_clients(): - ... from keystoneclient.auth import keystone - ... from openstack.common.apiclient import client - ... auth = keystone.KeystoneAuthPlugin( - ... username="user", password="pass", tenant_name="tenant", - ... auth_url="http://auth:5000/v2.0") - ... openstack_client = client.HTTPClient(auth) - ... # create nova client - ... from novaclient.v1_1 import client - ... client.Client(openstack_client) - ... # create keystone client - ... from keystoneclient.v2_0 import client - ... client.Client(openstack_client) - ... # use them - ... openstack_client.identity.tenants.list() - ... openstack_client.compute.servers.list() - """ - service_type = base_client_instance.service_type - if service_type and not hasattr(self, service_type): - setattr(self, service_type, base_client_instance) - - def authenticate(self): - self.auth_plugin.authenticate(self) - # Store the authentication results in the keyring for later requests - if self.keyring_saver: - self.keyring_saver.save(self) - - -class BaseClient(object): - """Top-level object to access the OpenStack API. - - This client uses :class:`HTTPClient` to send requests. :class:`HTTPClient` - will handle a bunch of issues such as authentication. - """ - - service_type = None - endpoint_type = None # "publicURL" will be used - cached_endpoint = None - - def __init__(self, http_client, extensions=None): - self.http_client = http_client - http_client.add_client(self) - - # Add in any extensions... - if extensions: - for extension in extensions: - if extension.manager_class: - setattr(self, extension.name, - extension.manager_class(self)) - - def client_request(self, method, url, **kwargs): - return self.http_client.client_request( - self, method, url, **kwargs) - - @property - def last_request_id(self): - return self.http_client.last_request_id - - def head(self, url, **kwargs): - return self.client_request("HEAD", url, **kwargs) - - def get(self, url, **kwargs): - return self.client_request("GET", url, **kwargs) - - def post(self, url, **kwargs): - return self.client_request("POST", url, **kwargs) - - def put(self, url, **kwargs): - return self.client_request("PUT", url, **kwargs) - - def delete(self, url, **kwargs): - return self.client_request("DELETE", url, **kwargs) - - def patch(self, url, **kwargs): - return self.client_request("PATCH", url, **kwargs) - - @staticmethod - def get_class(api_name, version, version_map): - """Returns the client class for the requested API version - - :param api_name: the name of the API, e.g. 'compute', 'image', etc - :param version: the requested API version - :param version_map: a dict of client classes keyed by version - :rtype: a client class for the requested API version - """ - try: - client_path = version_map[str(version)] - except (KeyError, ValueError): - msg = _("Invalid %(api_name)s client version '%(version)s'. " - "Must be one of: %(version_map)s") % { - 'api_name': api_name, - 'version': version, - 'version_map': ', '.join(version_map.keys())} - raise exceptions.UnsupportedVersion(msg) - - return importutils.import_class(client_path) diff --git a/heatclient/openstack/common/apiclient/exceptions.py b/heatclient/openstack/common/apiclient/exceptions.py deleted file mode 100644 index 247db55e..00000000 --- a/heatclient/openstack/common/apiclient/exceptions.py +++ /dev/null @@ -1,479 +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. -""" - -######################################################################## -# -# THIS MODULE IS DEPRECATED -# -# Please refer to -# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for -# the discussion leading to this deprecation. -# -# We recommend checking out the python-openstacksdk project -# (https://launchpad.net/python-openstacksdk) instead. -# -######################################################################## - -import inspect -import sys - -import six - -from heatclient.openstack.common._i18n import _ - - -class ClientException(Exception): - """The base exception class for all exceptions this library raises. - """ - pass - - -class ValidationError(ClientException): - """Error in validation on API client side.""" - pass - - -class UnsupportedVersion(ClientException): - """User is trying to use an unsupported version of the API.""" - pass - - -class CommandError(ClientException): - """Error in CLI tool.""" - pass - - -class AuthorizationFailure(ClientException): - """Cannot authorize API client.""" - pass - - -class ConnectionError(ClientException): - """Cannot connect to API service.""" - pass - - -class ConnectionRefused(ConnectionError): - """Connection refused while trying to connect to API service.""" - pass - - -class AuthPluginOptionsMissing(AuthorizationFailure): - """Auth plugin misses some options.""" - def __init__(self, opt_names): - super(AuthPluginOptionsMissing, self).__init__( - _("Authentication failed. Missing options: %s") % - ", ".join(opt_names)) - self.opt_names = opt_names - - -class AuthSystemNotFound(AuthorizationFailure): - """User has specified an AuthSystem that is not installed.""" - def __init__(self, auth_system): - super(AuthSystemNotFound, self).__init__( - _("AuthSystemNotFound: %r") % auth_system) - self.auth_system = auth_system - - -class NoUniqueMatch(ClientException): - """Multiple entities found instead of one.""" - pass - - -class EndpointException(ClientException): - """Something is rotten in Service Catalog.""" - pass - - -class EndpointNotFound(EndpointException): - """Could not find requested endpoint in Service Catalog.""" - pass - - -class AmbiguousEndpoints(EndpointException): - """Found more than one matching endpoint in Service Catalog.""" - def __init__(self, endpoints=None): - super(AmbiguousEndpoints, self).__init__( - _("AmbiguousEndpoints: %r") % endpoints) - self.endpoints = endpoints - - -class HttpError(ClientException): - """The base exception class for all HTTP exceptions. - """ - http_status = 0 - message = _("HTTP Error") - - def __init__(self, message=None, details=None, - response=None, request_id=None, - url=None, method=None, http_status=None): - self.http_status = http_status or self.http_status - self.message = message or self.message - self.details = details - self.request_id = request_id - self.response = response - self.url = url - self.method = method - formatted_string = "%s (HTTP %s)" % (self.message, self.http_status) - if request_id: - formatted_string += " (Request-ID: %s)" % request_id - super(HttpError, self).__init__(formatted_string) - - -class HTTPRedirection(HttpError): - """HTTP Redirection.""" - message = _("HTTP Redirection") - - -class HTTPClientError(HttpError): - """Client-side HTTP error. - - Exception for cases in which the client seems to have erred. - """ - message = _("HTTP Client Error") - - -class HttpServerError(HttpError): - """Server-side HTTP error. - - Exception for cases in which the server is aware that it has - erred or is incapable of performing the request. - """ - message = _("HTTP Server Error") - - -class MultipleChoices(HTTPRedirection): - """HTTP 300 - Multiple Choices. - - Indicates multiple options for the resource that the client may follow. - """ - - http_status = 300 - message = _("Multiple Choices") - - -class BadRequest(HTTPClientError): - """HTTP 400 - Bad Request. - - The request cannot be fulfilled due to bad syntax. - """ - http_status = 400 - message = _("Bad Request") - - -class Unauthorized(HTTPClientError): - """HTTP 401 - Unauthorized. - - Similar to 403 Forbidden, but specifically for use when authentication - is required and has failed or has not yet been provided. - """ - http_status = 401 - message = _("Unauthorized") - - -class PaymentRequired(HTTPClientError): - """HTTP 402 - Payment Required. - - Reserved for future use. - """ - http_status = 402 - message = _("Payment Required") - - -class Forbidden(HTTPClientError): - """HTTP 403 - Forbidden. - - The request was a valid request, but the server is refusing to respond - to it. - """ - http_status = 403 - message = _("Forbidden") - - -class NotFound(HTTPClientError): - """HTTP 404 - Not Found. - - The requested resource could not be found but may be available again - in the future. - """ - http_status = 404 - message = _("Not Found") - - -class MethodNotAllowed(HTTPClientError): - """HTTP 405 - Method Not Allowed. - - A request was made of a resource using a request method not supported - by that resource. - """ - http_status = 405 - message = _("Method Not Allowed") - - -class NotAcceptable(HTTPClientError): - """HTTP 406 - Not Acceptable. - - The requested resource is only capable of generating content not - acceptable according to the Accept headers sent in the request. - """ - http_status = 406 - message = _("Not Acceptable") - - -class ProxyAuthenticationRequired(HTTPClientError): - """HTTP 407 - Proxy Authentication Required. - - The client must first authenticate itself with the proxy. - """ - http_status = 407 - message = _("Proxy Authentication Required") - - -class RequestTimeout(HTTPClientError): - """HTTP 408 - Request Timeout. - - The server timed out waiting for the request. - """ - http_status = 408 - message = _("Request Timeout") - - -class Conflict(HTTPClientError): - """HTTP 409 - Conflict. - - Indicates that the request could not be processed because of conflict - in the request, such as an edit conflict. - """ - http_status = 409 - message = _("Conflict") - - -class Gone(HTTPClientError): - """HTTP 410 - Gone. - - Indicates that the resource requested is no longer available and will - not be available again. - """ - http_status = 410 - message = _("Gone") - - -class LengthRequired(HTTPClientError): - """HTTP 411 - Length Required. - - The request did not specify the length of its content, which is - required by the requested resource. - """ - http_status = 411 - message = _("Length Required") - - -class PreconditionFailed(HTTPClientError): - """HTTP 412 - Precondition Failed. - - The server does not meet one of the preconditions that the requester - put on the request. - """ - http_status = 412 - message = _("Precondition Failed") - - -class RequestEntityTooLarge(HTTPClientError): - """HTTP 413 - Request Entity Too Large. - - The request is larger than the server is willing or able to process. - """ - http_status = 413 - message = _("Request Entity Too Large") - - def __init__(self, *args, **kwargs): - try: - self.retry_after = int(kwargs.pop('retry_after')) - except (KeyError, ValueError): - self.retry_after = 0 - - super(RequestEntityTooLarge, self).__init__(*args, **kwargs) - - -class RequestUriTooLong(HTTPClientError): - """HTTP 414 - Request-URI Too Long. - - The URI provided was too long for the server to process. - """ - http_status = 414 - message = _("Request-URI Too Long") - - -class UnsupportedMediaType(HTTPClientError): - """HTTP 415 - Unsupported Media Type. - - The request entity has a media type which the server or resource does - not support. - """ - http_status = 415 - message = _("Unsupported Media Type") - - -class RequestedRangeNotSatisfiable(HTTPClientError): - """HTTP 416 - Requested Range Not Satisfiable. - - The client has asked for a portion of the file, but the server cannot - supply that portion. - """ - http_status = 416 - message = _("Requested Range Not Satisfiable") - - -class ExpectationFailed(HTTPClientError): - """HTTP 417 - Expectation Failed. - - The server cannot meet the requirements of the Expect request-header field. - """ - http_status = 417 - message = _("Expectation Failed") - - -class UnprocessableEntity(HTTPClientError): - """HTTP 422 - Unprocessable Entity. - - The request was well-formed but was unable to be followed due to semantic - errors. - """ - http_status = 422 - message = _("Unprocessable Entity") - - -class InternalServerError(HttpServerError): - """HTTP 500 - Internal Server Error. - - A generic error message, given when no more specific message is suitable. - """ - http_status = 500 - message = _("Internal Server Error") - - -# NotImplemented is a python keyword. -class HttpNotImplemented(HttpServerError): - """HTTP 501 - Not Implemented. - - The server either does not recognize the request method, or it lacks - the ability to fulfill the request. - """ - http_status = 501 - message = _("Not Implemented") - - -class BadGateway(HttpServerError): - """HTTP 502 - Bad Gateway. - - The server was acting as a gateway or proxy and received an invalid - response from the upstream server. - """ - http_status = 502 - message = _("Bad Gateway") - - -class ServiceUnavailable(HttpServerError): - """HTTP 503 - Service Unavailable. - - The server is currently unavailable. - """ - http_status = 503 - message = _("Service Unavailable") - - -class GatewayTimeout(HttpServerError): - """HTTP 504 - Gateway Timeout. - - The server was acting as a gateway or proxy and did not receive a timely - response from the upstream server. - """ - http_status = 504 - message = _("Gateway Timeout") - - -class HttpVersionNotSupported(HttpServerError): - """HTTP 505 - HttpVersion Not Supported. - - The server does not support the HTTP protocol version used in the request. - """ - http_status = 505 - message = _("HTTP Version Not Supported") - - -# _code_map contains all the classes that have http_status attribute. -_code_map = dict( - (getattr(obj, 'http_status', None), obj) - for name, obj in six.iteritems(vars(sys.modules[__name__])) - if inspect.isclass(obj) and getattr(obj, 'http_status', False) -) - - -def from_response(response, method, url): - """Returns an instance of :class:`HttpError` or subclass based on response. - - :param response: instance of `requests.Response` class - :param method: HTTP method used for request - :param url: URL used for request - """ - - req_id = response.headers.get("x-openstack-request-id") - # NOTE(hdd) true for older versions of nova and cinder - if not req_id: - req_id = response.headers.get("x-compute-request-id") - kwargs = { - "http_status": response.status_code, - "response": response, - "method": method, - "url": url, - "request_id": req_id, - } - if "retry-after" in response.headers: - kwargs["retry_after"] = response.headers["retry-after"] - - content_type = response.headers.get("Content-Type", "") - if content_type.startswith("application/json"): - try: - body = response.json() - except ValueError: - pass - else: - if isinstance(body, dict): - error = body.get(list(body)[0]) - if isinstance(error, dict): - kwargs["message"] = (error.get("message") or - error.get("faultstring")) - kwargs["details"] = (error.get("details") or - six.text_type(body)) - elif content_type.startswith("text/"): - kwargs["details"] = getattr(response, 'text', '') - - try: - cls = _code_map[response.status_code] - except KeyError: - if 500 <= response.status_code < 600: - cls = HttpServerError - elif 400 <= response.status_code < 500: - cls = HTTPClientError - else: - cls = HttpError - return cls(**kwargs) diff --git a/heatclient/openstack/common/apiclient/fake_client.py b/heatclient/openstack/common/apiclient/fake_client.py deleted file mode 100644 index 5fb13e96..00000000 --- a/heatclient/openstack/common/apiclient/fake_client.py +++ /dev/null @@ -1,190 +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. -""" - -######################################################################## -# -# THIS MODULE IS DEPRECATED -# -# Please refer to -# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for -# the discussion leading to this deprecation. -# -# We recommend checking out the python-openstacksdk project -# (https://launchpad.net/python-openstacksdk) instead. -# -######################################################################## - -# W0102: Dangerous default value %s as argument -# pylint: disable=W0102 - -import json - -import requests -import six -from six.moves.urllib import parse - -from heatclient.openstack.common.apiclient import client - - -def assert_has_keys(dct, required=None, optional=None): - required = required or [] - optional = optional or [] - for k in required: - try: - assert k in dct - except AssertionError: - extra_keys = set(dct.keys()).difference(set(required + optional)) - raise AssertionError("found unexpected keys: %s" % - list(extra_keys)) - - -class TestResponse(requests.Response): - """Wrap requests.Response and provide a convenient initialization. - """ - - def __init__(self, data): - super(TestResponse, self).__init__() - self._content_consumed = True - if isinstance(data, dict): - self.status_code = data.get('status_code', 200) - # Fake the text attribute to streamline Response creation - text = data.get('text', "") - if isinstance(text, (dict, list)): - self._content = json.dumps(text) - default_headers = { - "Content-Type": "application/json", - } - else: - self._content = text - default_headers = {} - if six.PY3 and isinstance(self._content, six.string_types): - self._content = self._content.encode('utf-8', 'strict') - self.headers = data.get('headers') or default_headers - else: - self.status_code = data - - def __eq__(self, other): - return (self.status_code == other.status_code and - self.headers == other.headers and - self._content == other._content) - - -class FakeHTTPClient(client.HTTPClient): - - def __init__(self, *args, **kwargs): - self.callstack = [] - self.fixtures = kwargs.pop("fixtures", None) or {} - if not args and "auth_plugin" not in kwargs: - args = (None, ) - super(FakeHTTPClient, self).__init__(*args, **kwargs) - - def assert_called(self, method, url, body=None, pos=-1): - """Assert than an API method was just called. - """ - expected = (method, url) - called = self.callstack[pos][0:2] - assert self.callstack, \ - "Expected %s %s but no calls were made." % expected - - assert expected == called, 'Expected %s %s; got %s %s' % \ - (expected + called) - - if body is not None: - if self.callstack[pos][3] != body: - raise AssertionError('%r != %r' % - (self.callstack[pos][3], body)) - - def assert_called_anytime(self, method, url, body=None): - """Assert than an API method was called anytime in the test. - """ - expected = (method, url) - - assert self.callstack, \ - "Expected %s %s but no calls were made." % expected - - found = False - entry = None - for entry in self.callstack: - if expected == entry[0:2]: - found = True - break - - assert found, 'Expected %s %s; got %s' % \ - (method, url, self.callstack) - if body is not None: - assert entry[3] == body, "%s != %s" % (entry[3], body) - - self.callstack = [] - - def clear_callstack(self): - self.callstack = [] - - def authenticate(self): - pass - - def client_request(self, client, method, url, **kwargs): - # Check that certain things are called correctly - if method in ["GET", "DELETE"]: - assert "json" not in kwargs - - # Note the call - self.callstack.append( - (method, - url, - kwargs.get("headers") or {}, - kwargs.get("json") or kwargs.get("data"))) - try: - fixture = self.fixtures[url][method] - except KeyError: - pass - else: - return TestResponse({"headers": fixture[0], - "text": fixture[1]}) - - # Call the method - args = parse.parse_qsl(parse.urlparse(url)[4]) - kwargs.update(args) - munged_url = url.rsplit('?', 1)[0] - munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') - munged_url = munged_url.replace('-', '_') - - callback = "%s_%s" % (method.lower(), munged_url) - - if not hasattr(self, callback): - raise AssertionError('Called unknown API method: %s %s, ' - 'expected fakes method name: %s' % - (method, url, callback)) - - resp = getattr(self, callback)(**kwargs) - if len(resp) == 3: - status, headers, body = resp - else: - status, body = resp - headers = {} - self.last_request_id = headers.get('x-openstack-request-id', - 'req-test') - return TestResponse({ - "status_code": status, - "text": body, - "headers": headers, - }) diff --git a/heatclient/openstack/common/apiclient/utils.py b/heatclient/openstack/common/apiclient/utils.py deleted file mode 100644 index 4307adc4..00000000 --- a/heatclient/openstack/common/apiclient/utils.py +++ /dev/null @@ -1,100 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -######################################################################## -# -# THIS MODULE IS DEPRECATED -# -# Please refer to -# https://etherpad.openstack.org/p/kilo-oslo-library-proposals for -# the discussion leading to this deprecation. -# -# We recommend checking out the python-openstacksdk project -# (https://launchpad.net/python-openstacksdk) instead. -# -######################################################################## - -from oslo_utils import encodeutils -from oslo_utils import uuidutils -import six - -from heatclient.openstack.common._i18n import _ -from heatclient.openstack.common.apiclient import exceptions - - -def find_resource(manager, name_or_id, **find_args): - """Look for resource in a given manager. - - Used as a helper for the _find_* methods. - Example: - - .. code-block:: python - - def _find_hypervisor(cs, hypervisor): - #Get a hypervisor by name or ID. - return cliutils.find_resource(cs.hypervisors, hypervisor) - """ - # first try to get entity as integer id - try: - return manager.get(int(name_or_id)) - except (TypeError, ValueError, exceptions.NotFound): - pass - - # now try to get entity as uuid - try: - if six.PY2: - tmp_id = encodeutils.safe_encode(name_or_id) - else: - tmp_id = encodeutils.safe_decode(name_or_id) - - if uuidutils.is_uuid_like(tmp_id): - return manager.get(tmp_id) - except (TypeError, ValueError, exceptions.NotFound): - pass - - # for str id which is not uuid - if getattr(manager, 'is_alphanum_id_allowed', False): - try: - return manager.get(name_or_id) - except exceptions.NotFound: - pass - - try: - try: - return manager.find(human_id=name_or_id, **find_args) - except exceptions.NotFound: - pass - - # finally try to find entity by name - try: - resource = getattr(manager, 'resource_class', None) - name_attr = resource.NAME_ATTR if resource else 'name' - kwargs = {name_attr: name_or_id} - kwargs.update(find_args) - return manager.find(**kwargs) - except exceptions.NotFound: - msg = _("No %(name)s with a name or " - "ID of '%(name_or_id)s' exists.") % \ - { - "name": manager.resource_class.__name__.lower(), - "name_or_id": name_or_id - } - raise exceptions.CommandError(msg) - except exceptions.NoUniqueMatch: - msg = _("Multiple %(name)s matches found for " - "'%(name_or_id)s', use an ID to be more specific.") % \ - { - "name": manager.resource_class.__name__.lower(), - "name_or_id": name_or_id - } - raise exceptions.CommandError(msg) diff --git a/heatclient/openstack/common/cliutils.py b/heatclient/openstack/common/cliutils.py deleted file mode 100644 index 5f1170f1..00000000 --- a/heatclient/openstack/common/cliutils.py +++ /dev/null @@ -1,272 +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 - -from oslo_utils import encodeutils -from oslo_utils import strutils -import prettytable -import six -from six import moves - -from heatclient.openstack.common._i18n import _ - - -class MissingArgs(Exception): - """Supplied arguments are not sufficient for calling a function.""" - def __init__(self, missing): - self.missing = missing - msg = _("Missing arguments: %s") % ", ".join(missing) - super(MissingArgs, self).__init__(msg) - - -def validate_args(fn, *args, **kwargs): - """Check that the supplied args are sufficient for calling a function. - - >>> validate_args(lambda a: None) - Traceback (most recent call last): - ... - MissingArgs: Missing argument(s): a - >>> validate_args(lambda a, b, c, d: None, 0, c=1) - Traceback (most recent call last): - ... - MissingArgs: Missing argument(s): b, d - - :param fn: the function to check - :param arg: the positional arguments supplied - :param kwargs: the keyword arguments supplied - """ - argspec = inspect.getargspec(fn) - - num_defaults = len(argspec.defaults or []) - required_args = argspec.args[:len(argspec.args) - num_defaults] - - def isbound(method): - return getattr(method, '__self__', None) is not None - - if isbound(fn): - required_args.pop(0) - - missing = [arg for arg in required_args if arg not in kwargs] - missing = missing[len(args):] - if missing: - raise MissingArgs(missing) - - -def arg(*args, **kwargs): - """Decorator for CLI args. - - Example: - - >>> @arg("name", help="Name of the new entity") - ... def entity_create(args): - ... pass - """ - def _decorator(func): - add_arg(func, *args, **kwargs) - return func - return _decorator - - -def env(*args, **kwargs): - """Returns the first environment variable set. - - If all are empty, defaults to '' or keyword arg `default`. - """ - for arg in args: - value = os.environ.get(arg) - if value: - return value - return kwargs.get('default', '') - - -def add_arg(func, *args, **kwargs): - """Bind CLI arguments to a shell.py `do_foo` function.""" - - if not hasattr(func, 'arguments'): - func.arguments = [] - - # NOTE(sirp): avoid dups that can occur when the module is shared across - # tests. - if (args, kwargs) not in func.arguments: - # Because of the semantics of decorator composition if we just append - # to the options list positional options will appear to be backwards. - func.arguments.insert(0, (args, kwargs)) - - -def unauthenticated(func): - """Adds 'unauthenticated' attribute to decorated function. - - Usage: - - >>> @unauthenticated - ... def mymethod(f): - ... pass - """ - func.unauthenticated = True - return func - - -def isunauthenticated(func): - """Checks if the function does not require authentication. - - Mark such functions with the `@unauthenticated` decorator. - - :returns: bool - """ - return getattr(func, 'unauthenticated', False) - - -def print_list(objs, fields, formatters=None, sortby_index=0, - mixed_case_fields=None, field_labels=None): - """Print a list 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) - - if six.PY3: - print(encodeutils.safe_encode(pt.get_string(**kwargs)).decode()) - else: - print(encodeutils.safe_encode(pt.get_string(**kwargs))) - - -def print_dict(dct, dict_property="Property", wrap=0, dict_value='Value'): - """Print a `dict` as a table of two columns. - - :param dct: `dict` to print - :param dict_property: name of the first column - :param wrap: wrapping for the second column - :param dict_value: header label for the value (second) column - """ - pt = prettytable.PrettyTable([dict_property, dict_value]) - pt.align = 'l' - for k, v in sorted(dct.items()): - # convert dict to str to check length - if isinstance(v, dict): - v = six.text_type(v) - if wrap > 0: - v = textwrap.fill(six.text_type(v), wrap) - # if value has a newline, add in multiple rows - # e.g. fault with stacktrace - if v and isinstance(v, six.string_types) and r'\n' in v: - lines = v.strip().split(r'\n') - col1 = k - for line in lines: - pt.add_row([col1, line]) - col1 = '' - else: - pt.add_row([k, v]) - - if six.PY3: - print(encodeutils.safe_encode(pt.get_string()).decode()) - else: - print(encodeutils.safe_encode(pt.get_string())) - - -def get_password(max_password_prompts=3): - """Read password from TTY.""" - verify = strutils.bool_from_string(env("OS_VERIFY_PASSWORD")) - pw = None - if hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): - # Check for Ctrl-D - try: - for __ in moves.range(max_password_prompts): - pw1 = getpass.getpass("OS Password: ") - if verify: - pw2 = getpass.getpass("Please verify: ") - else: - pw2 = pw1 - if pw1 == pw2 and pw1: - pw = pw1 - break - except EOFError: - pass - return pw - - -def service_type(stype): - """Adds 'service_type' attribute to decorated function. - - Usage: - - .. code-block:: python - - @service_type('volume') - def mymethod(f): - ... - """ - def inner(f): - f.service_type = stype - return f - return inner - - -def get_service_type(f): - """Retrieves service type from function.""" - return getattr(f, 'service_type', None) - - -def pretty_choice_list(l): - return ', '.join("'%s'" % i for i in l) - - -def exit(msg=''): - if msg: - print (msg, file=sys.stderr) - sys.exit(1) diff --git a/heatclient/osc/plugin.py b/heatclient/osc/plugin.py index dd980efc..859a13e8 100644 --- a/heatclient/osc/plugin.py +++ b/heatclient/osc/plugin.py @@ -11,9 +11,11 @@ # under the License. # +"""OpenStackClient plugin for Orchestration service.""" + import logging -from openstackclient.common import utils +from osc_lib import utils LOG = logging.getLogger(__name__) @@ -33,14 +35,27 @@ def make_client(instance): API_VERSIONS) LOG.debug('Instantiating orchestration client: %s', heat_client) - client = heat_client( - endpoint=instance.get_endpoint_for_service_type('orchestration'), - session=instance.session, - auth_url=instance._auth_url, - username=instance._username, - password=instance._password, - region_name=instance._region_name, - ) + 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 diff --git a/heatclient/osc/v1/build_info.py b/heatclient/osc/v1/build_info.py index 11dead55..6428fe42 100644 --- a/heatclient/osc/v1/build_info.py +++ b/heatclient/osc/v1/build_info.py @@ -14,21 +14,20 @@ """Orchestration v1 build info action implementations""" import logging -import six -from cliff import show -from openstackclient.common import utils +from osc_lib.command import command +from osc_lib import utils -from heatclient.common import utils as heat_utils +from heatclient.osc.v1 import common -class BuildInfo(show.ShowOne): +class BuildInfo(command.ShowOne): """Retrieve build information.""" log = logging.getLogger(__name__ + ".BuildInfo") def get_parser(self, prog_name): - parser = super(BuildInfo, self).get_parser(prog_name) + parser = super().get_parser(prog_name) return parser def take_action(self, parsed_args): @@ -38,9 +37,9 @@ def take_action(self, parsed_args): result = heat_client.build_info.build_info() formatters = { - 'api': heat_utils.json_formatter, - 'engine': heat_utils.json_formatter, + 'api': common.JsonColumn, + 'engine': common.JsonColumn, } - columns = sorted(list(six.iterkeys(result))) + 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 index feee81d5..ea5ff16d 100644 --- a/heatclient/osc/v1/event.py +++ b/heatclient/osc/v1/event.py @@ -15,23 +15,24 @@ import logging import time -from cliff import lister -from cliff import show -from openstackclient.common import utils +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.openstack.common._i18n import _ +from heatclient.osc.v1 import common -class ShowEvent(show.ShowOne): +class ShowEvent(command.ShowOne): """Show event details.""" log = logging.getLogger(__name__ + '.ShowEvent') def get_parser(self, prog_name): - parser = super(ShowEvent, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -69,8 +70,8 @@ def take_action(self, parsed_args): raise exc.CommandError(str(ex)) formatters = { - 'links': heat_utils.link_formatter, - 'resource_properties': heat_utils.json_formatter + 'links': common.LinkColumn, + 'resource_properties': common.JsonColumn } columns = [] @@ -81,17 +82,21 @@ def take_action(self, parsed_args): formatters=formatters) -class ListEvent(lister.Lister): +class ListEvent(command.Lister): """List events.""" log = logging.getLogger(__name__ + '.ListEvent') @property def formatter_default(self): - return 'value' + return 'log' + + @property + def formatter_namespace(self): + return 'heatclient.event.formatter.list' def get_parser(self, prog_name): - parser = super(ListEvent, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -133,7 +138,12 @@ def get_parser(self, prog_name): action='append', help=_('Sort output by selected keys and directions (asc or desc) ' '(default: asc). Specify multiple times to sort on ' - 'multiple keys') + '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', @@ -152,8 +162,6 @@ def take_action(self, parsed_args): kwargs = { 'resource_name': parsed_args.resource, - 'limit': parsed_args.limit, - 'marker': parsed_args.marker, 'filters': heat_utils.format_parameters(parsed_args.filter), 'sort_dir': 'asc' } @@ -163,24 +171,34 @@ def take_action(self, parsed_args): raise exc.CommandError(msg) if parsed_args.nested_depth: - # Until the API supports recursive event listing we'll have to do - # the marker/limit filtering client-side - del kwargs['marker'] - del kwargs['limit'] 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 != 'value': - msg = _('--follow can only be specified with --format value') + 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: - kwargs['marker'] = marker events = event_utils.get_events( client, stack_id=parsed_args.stack, @@ -189,7 +207,8 @@ def take_action(self, parsed_args): marker=marker) if events: marker = getattr(events[-1], 'id', None) - events_log = heat_utils.event_log_formatter(events) + 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) @@ -203,11 +222,10 @@ def take_action(self, parsed_args): limit=parsed_args.limit) if parsed_args.sort: - events = utils.sort_items(events, ','.join(parsed_args.sort)) + events = utils.sort_items(events, ','.join(sorts)) - if parsed_args.formatter == 'value': - events = heat_utils.event_log_formatter(events).split('\n') - return [], [e.split(' ') for e in events] + if parsed_args.formatter == 'log': + return [], events if len(events): if hasattr(events[0], 'resource_name'): @@ -220,3 +238,14 @@ def take_action(self, parsed_args): 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 index 7f9d1b2d..b2d893f0 100644 --- a/heatclient/osc/v1/resource.py +++ b/heatclient/osc/v1/resource.py @@ -13,30 +13,27 @@ """Orchestration v1 Stack action implementations""" -from cliff import command import logging -import six -from six.moves.urllib import request - -from cliff import lister -from cliff import show -from openstackclient.common import exceptions as exc -from openstackclient.common import utils -from openstackclient.i18n import _ + +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(show.ShowOne): +class ResourceShow(command.ShowOne): """Display stack resource.""" log = logging.getLogger(__name__ + '.ResourceShowStack') def get_parser(self, prog_name): - parser = super(ResourceShow, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -45,7 +42,7 @@ def get_parser(self, prog_name): parser.add_argument( 'resource', metavar='', - help=_('Name or ID of resource') + help=_('Name of resource') ) parser.add_argument( '--with-attr', @@ -73,13 +70,17 @@ def take_action(self, parsed_args): return self.dict2columns(resource.to_dict()) -class ResourceList(lister.Lister): +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(ResourceList, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -104,7 +105,7 @@ def get_parser(self, prog_name): action='append', help=_('Filter parameters to apply on returned resources based on ' 'their name, status, type, action, id and ' - 'physcial_resource_id') + 'physical_resource_id') ) return parser @@ -126,6 +127,9 @@ def take_action(self, parsed_args): 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'] @@ -149,7 +153,7 @@ class ResourceMetadata(format_utils.JsonFormat): log = logging.getLogger(__name__ + ".ResourceMetadata") def get_parser(self, prog_name): - parser = super(ResourceMetadata, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -179,8 +183,8 @@ def _resource_metadata(heat_client, args): {'stack': args.stack, 'resource': args.resource}) - data = list(six.itervalues(metadata)) - columns = list(six.iterkeys(metadata)) + data = list(metadata.values()) + columns = list(metadata.keys()) return columns, data @@ -190,7 +194,7 @@ class ResourceSignal(command.Command): log = logging.getLogger(__name__ + ".ResourceSignal") def get_parser(self, prog_name): - parser = super(ResourceSignal, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -257,7 +261,7 @@ class ResourceMarkUnhealthy(command.Command): log = logging.getLogger(__name__ + ".ResourceMarkUnhealthy") def get_parser(self, prog_name): - parser = super(ResourceMarkUnhealthy, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', diff --git a/heatclient/osc/v1/resource_type.py b/heatclient/osc/v1/resource_type.py index 28d966e8..6e5cf634 100644 --- a/heatclient/osc/v1/resource_type.py +++ b/heatclient/osc/v1/resource_type.py @@ -14,14 +14,14 @@ """Orchestration v1 resource type implementations""" import logging -import six -from cliff import lister +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 -from openstackclient.common import exceptions as exc -from openstackclient.i18n import _ class ResourceTypeShow(format_utils.YamlFormat): @@ -30,8 +30,7 @@ class ResourceTypeShow(format_utils.YamlFormat): log = logging.getLogger(__name__ + ".ResourceTypeShow") def get_parser(self, prog_name): - parser = super(ResourceTypeShow, - self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'resource_type', metavar='', @@ -42,11 +41,21 @@ def get_parser(self, prog_name): metavar='', help=_('Optional template type to generate, hot or cfn') ) + parser.add_argument( + '--long', + default=False, + action='store_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) @@ -63,24 +72,24 @@ def _show_resourcetype(heat_client, parsed_args): 'template_type': template_type} data = heat_client.resource_types.generate_template(**fields) else: - data = heat_client.resource_types.get(parsed_args.resource_type) + 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(six.itervalues(data)) - columns = list(six.iterkeys(data)) + rows = list(data.values()) + columns = list(data.keys()) return columns, rows -class ResourceTypeList(lister.Lister): +class ResourceTypeList(command.Lister): """List resource types.""" log = logging.getLogger(__name__ + '.ResourceTypeList') def get_parser(self, prog_name): - parser = super(ResourceTypeList, - self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( '--filter', dest='filter', @@ -90,6 +99,13 @@ def get_parser(self, prog_name): 'name, version or support_status'), action='append' ) + parser.add_argument( + '--long', + default=False, + action='store_true', + help=_('Show resource types with corresponding description of ' + 'each resource type.') + ) return parser def take_action(self, parsed_args): @@ -101,7 +117,13 @@ def take_action(self, parsed_args): def _list_resourcetypes(heat_client, parsed_args): resource_types = heat_client.resource_types.list( - filters=heat_utils.format_parameters(parsed_args.filter)) - columns = ['Resource Type'] - rows = sorted([r.resource_type] for r in resource_types) - return (columns, rows) + 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 index 57c8cf0b..3ca042ca 100644 --- a/heatclient/osc/v1/service.py +++ b/heatclient/osc/v1/service.py @@ -15,25 +15,25 @@ import logging -from cliff import lister -from openstackclient.common import utils +from osc_lib.command import command +from osc_lib import utils -class ListService(lister.Lister): +class ListService(command.Lister): """List the Heat engines.""" log = logging.getLogger(__name__ + ".ListService") def get_parser(self, prog_name): - parser = super(ListService, self).get_parser(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'] + columns = ['Hostname', 'Binary', 'Engine ID', 'Host', + 'Topic', 'Updated At', 'Status'] services = heat_client.services.list() return ( columns, diff --git a/heatclient/osc/v1/snapshot.py b/heatclient/osc/v1/snapshot.py index a46d31ad..79eea08d 100644 --- a/heatclient/osc/v1/snapshot.py +++ b/heatclient/osc/v1/snapshot.py @@ -14,26 +14,24 @@ """Orchestration v1 Stack Snapshot implementations.""" import logging -import six +import sys -from cliff import command -from cliff import lister -from cliff import show -from openstackclient.common import exceptions as exc -from openstackclient.common import utils +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 -from openstackclient.i18n import _ -class ListSnapshot(lister.Lister): +class ListSnapshot(command.Lister): """List stack snapshots.""" log = logging.getLogger(__name__ + ".ListSnapshot") def get_parser(self, prog_name): - parser = super(ListSnapshot, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -42,7 +40,7 @@ def get_parser(self, prog_name): return parser def take_action(self, parsed_args): - self.log.debug('take_action(%s)' % 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) @@ -54,7 +52,14 @@ def _list_snapshot(self, heat_client, parsed_args): raise exc.CommandError(_('Stack not found: %s') % parsed_args.stack) - columns = ['id', 'name', 'status', 'status_reason', 'creation_time'] + columns = [ + 'id', + 'name', + 'action', + 'status', + 'status_reason', + 'creation_time' + ] return ( columns, (utils.get_dict_properties(s, columns) @@ -68,7 +73,7 @@ class ShowSnapshot(format_utils.YamlFormat): log = logging.getLogger(__name__ + ".ShowSnapshot") def get_parser(self, prog_name): - parser = super(ShowSnapshot, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -82,7 +87,7 @@ def get_parser(self, prog_name): return parser def take_action(self, parsed_args): - self.log.debug('take_action(%s)' % 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) @@ -96,8 +101,8 @@ def _show_snapshot(self, heat_client, stack_id, snapshot_id): % {'snapshot_id': snapshot_id, 'stack_id': stack_id}) - rows = list(six.itervalues(data)) - columns = list(six.iterkeys(data)) + rows = list(data.values()) + columns = list(data.keys()) return columns, rows @@ -107,7 +112,7 @@ class RestoreSnapshot(command.Command): log = logging.getLogger(__name__ + ".RestoreSnapshot") def get_parser(self, prog_name): - parser = super(RestoreSnapshot, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -121,7 +126,7 @@ def get_parser(self, prog_name): return parser def take_action(self, parsed_args): - self.log.debug('take_action(%s)' % 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) @@ -137,13 +142,13 @@ def _restore_snapshot(self, heat_client, parsed_args): 'snapshot': parsed_args.snapshot}) -class CreateSnapshot(show.ShowOne): +class CreateSnapshot(command.ShowOne): """Create stack snapshot.""" log = logging.getLogger(__name__ + ".CreateSnapshot") def get_parser(self, prog_name): - parser = super(CreateSnapshot, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -157,7 +162,7 @@ def get_parser(self, prog_name): return parser def take_action(self, parsed_args): - self.log.debug('take_action(%s)' % parsed_args) + self.log.debug('take_action(%s)', parsed_args) heat_client = self.app.client_manager.orchestration try: @@ -170,6 +175,7 @@ def take_action(self, parsed_args): columns = [ 'ID', 'name', + 'action', 'status', 'status_reason', 'data', @@ -183,7 +189,7 @@ class DeleteSnapshot(command.Command): log = logging.getLogger(__name__ + ".DeleteSnapshot") def get_parser(self, prog_name): - parser = super(DeleteSnapshot, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'stack', metavar='', @@ -194,11 +200,34 @@ def get_parser(self, prog_name): metavar='', help=_('ID of stack snapshot') ) + parser.add_argument( + '-y', '--yes', + action='store_true', + help=_('Skip yes/no prompt (assume yes)') + ) return parser def take_action(self, parsed_args): - self.log.debug('take_action(%s)' % 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) diff --git a/heatclient/osc/v1/software_config.py b/heatclient/osc/v1/software_config.py index d3d31587..89378310 100644 --- a/heatclient/osc/v1/software_config.py +++ b/heatclient/osc/v1/software_config.py @@ -14,21 +14,18 @@ """Orchestration v1 software config action implementations""" import logging -import six -from six.moves.urllib import request +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 cliff import command -from cliff import lister -from openstackclient.common import exceptions as exc -from openstackclient.common import utils - +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 -from heatclient.openstack.common._i18n import _ class DeleteConfig(command.Command): @@ -37,7 +34,7 @@ class DeleteConfig(command.Command): log = logging.getLogger(__name__ + ".DeleteConfig") def get_parser(self, prog_name): - parser = super(DeleteConfig, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'config', metavar='', @@ -73,16 +70,17 @@ def _delete_config(heat_client, args): 'total': len(args.config)}) -class ListConfig(lister.Lister): +class ListConfig(command.Lister): """List software configs""" log = logging.getLogger(__name__ + ".ListConfig") def get_parser(self, prog_name): - parser = super(ListConfig, self).get_parser(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( @@ -116,7 +114,7 @@ class CreateConfig(format_utils.JsonFormat): log = logging.getLogger(__name__ + ".CreateConfig") def get_parser(self, prog_name): - parser = super(CreateConfig, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'name', metavar='', @@ -183,8 +181,8 @@ def _create_config(heat_client, args): config['name'] = args.name sc = heat_client.software_configs.create(**config).to_dict() - rows = list(six.itervalues(sc)) - columns = list(six.iterkeys(sc)) + rows = list(sc.values()) + columns = list(sc.keys()) return columns, rows @@ -194,7 +192,7 @@ class ShowConfig(format_utils.YamlFormat): log = logging.getLogger(__name__ + ".ShowConfig") def get_parser(self, prog_name): - parser = super(ShowConfig, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'config', metavar='', diff --git a/heatclient/osc/v1/software_deployment.py b/heatclient/osc/v1/software_deployment.py index 7e3a66ac..6c5cfd92 100644 --- a/heatclient/osc/v1/software_deployment.py +++ b/heatclient/osc/v1/software_deployment.py @@ -14,20 +14,17 @@ """Orchestration v1 Software Deployment action implementations""" import logging -from oslo_serialization import jsonutils - -from cliff import command -from cliff import lister -from cliff import show -from openstackclient.common import exceptions as exc -from openstackclient.common import utils +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 -from heatclient.openstack.common._i18n import _ class CreateDeployment(format_utils.YamlFormat): @@ -36,7 +33,7 @@ class CreateDeployment(format_utils.YamlFormat): log = logging.getLogger(__name__ + '.CreateDeployment') def get_parser(self, prog_name): - parser = super(CreateDeployment, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'name', metavar='', @@ -64,12 +61,6 @@ def get_parser(self, prog_name): metavar='', help=_('ID of the configuration to deploy') ) - parser.add_argument( - '--server', - metavar='', - required=True, - help=_('ID of the server being deployed to') - ) parser.add_argument( '--signal-transport', metavar='', @@ -96,6 +87,12 @@ def get_parser(self, prog_name): 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): @@ -139,7 +136,7 @@ class DeleteDeployment(command.Command): log = logging.getLogger(__name__ + '.DeleteDeployment') def get_parser(self, prog_name): - parser = super(DeleteDeployment, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'deployment', metavar='', @@ -182,13 +179,13 @@ def take_action(self, parsed_args): 'total': len(parsed_args.deployment)}) -class ListDeployment(lister.Lister): +class ListDeployment(command.Lister): """List software deployments.""" log = logging.getLogger(__name__ + '.ListDeployment') def get_parser(self, prog_name): - parser = super(ListDeployment, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( '--server', metavar='', @@ -222,13 +219,13 @@ def _list_deployment(heat_client, args=None): ) -class ShowDeployment(show.ShowOne): +class ShowDeployment(command.ShowOne): """Show SoftwareDeployment Details.""" log = logging.getLogger(__name__ + ".ShowSoftwareDeployment") def get_parser(self, prog_name): - parser = super(ShowDeployment, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'deployment', metavar='', @@ -249,7 +246,9 @@ def take_action(self, parsed_args): data = heat_client.software_deployments.get( deployment_id=parsed_args.deployment) except heat_exc.HTTPNotFound: - raise exc.CommandError(_('Software Deployment not found: %s') % id) + raise exc.CommandError( + _('Software Deployment not found: %s') + % parsed_args.deployment) else: columns = [ 'id', @@ -273,7 +272,7 @@ class ShowMetadataDeployment(command.Command): log = logging.getLogger(__name__ + '.ShowMetadataDeployment') def get_parser(self, prog_name): - parser = super(ShowMetadataDeployment, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'server', metavar='', @@ -295,7 +294,7 @@ class ShowOutputDeployment(command.Command): log = logging.getLogger(__name__ + '.ShowOutputDeployment') def get_parser(self, prog_name): - parser = super(ShowOutputDeployment, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( 'deployment', metavar='', @@ -341,7 +340,7 @@ def take_action(self, parsed_args): if outputs: if parsed_args.all: print('output_values:\n') - for k in outputs.keys(): + for k in outputs: format_utils.print_software_deployment_output( data=outputs, name=k, long=parsed_args.long) else: diff --git a/heatclient/osc/v1/stack.py b/heatclient/osc/v1/stack.py index e60b6e20..92010a8c 100644 --- a/heatclient/osc/v1/stack.py +++ b/heatclient/osc/v1/stack.py @@ -16,16 +16,15 @@ import logging import sys -from cliff import command -from cliff import lister -from cliff import show -from openstackclient.common import exceptions as exc -from openstackclient.common import parseractions -from openstackclient.common import utils +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 -import six -from six.moves.urllib import request +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 @@ -33,40 +32,29 @@ from heatclient.common import template_utils from heatclient.common import utils as heat_utils from heatclient import exc as heat_exc -from heatclient.openstack.common._i18n import _ -from heatclient.openstack.common._i18n import _LI +from heatclient.osc.v1 import common -def _authenticated_fetcher(client): - def _do(*args, **kwargs): - if isinstance(client.http_client, http.SessionClient): - method, url = args - return client.http_client.request(url, method, **kwargs).content - else: - return client.http_client.raw_request(*args, **kwargs).content - - return _do - - -class CreateStack(show.ShowOne): +class CreateStack(command.ShowOne): """Create a stack.""" log = logging.getLogger(__name__ + '.CreateStack') def get_parser(self, prog_name): - parser = super(CreateStack, self).get_parser(prog_name) - parser.add_argument( - '-t', '--template', - metavar='