diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..383d24e --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +*.egg +*.egg-info +*.pyc +*.retry +.idea +.mypy_cache +.test +.testrepository +.tox +.venv +.coverage +.stestr +AUTHORS +build/* +ChangeLog +doc/build/* +dist/ diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..2978dac --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=tests/unit +top_dir=./ diff --git a/.zuul.yaml b/.zuul.yaml new file mode 100644 index 0000000..e599517 --- /dev/null +++ b/.zuul.yaml @@ -0,0 +1,25 @@ +- project: + check: + jobs: + - opendev-tox-docs + - tox-linters: + vars: + tox_install_bindep: false + - tox-py36: + nodeset: ubuntu-bionic + timeout: 3600 + - tox-py38: + nodeset: ubuntu-bionic + timeout: 3600 + gate: + jobs: + - opendev-tox-docs + - tox-linters: + vars: + tox_install_bindep: false + - tox-py36: + nodeset: ubuntu-bionic + timeout: 3600 + - tox-py38: + nodeset: ubuntu-bionic + timeout: 3600 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..75b5248 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..74fc557 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include AUTHORS +include ChangeLog + +exclude .gitignore +exclude .gitreview + +global-exclude *.pyc diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..2971e41 --- /dev/null +++ b/README.rst @@ -0,0 +1,57 @@ +Zuul-client +=========== + +Zuul-client is a CLI tool that can be used to interact with Zuul, the project +gating system. + +The latest documentation for Zuul and Zuul-client can be found at: +https://zuul-ci.org/docs/zuul/ + +Getting Help +------------ + +There are two Zuul-related mailing lists: + +`zuul-announce `_ + A low-traffic announcement-only list to which every Zuul operator or + power-user should subscribe. + +`zuul-discuss `_ + General discussion about Zuul, including questions about how to use + it, and future development. + +You will also find Zuul developers in the `#zuul` channel on Freenode +IRC. + +Contributing +------------ + +To browse the latest code, see: https://opendev.org/zuul/zuul-client +To clone the latest code, use `git clone https://opendev.org/zuul/zuul-client` + +Bugs are handled at: https://storyboard.openstack.org/#!/project/zuul/zuul + +Suspected security vulnerabilities are most appreciated if first +reported privately following any of the supported mechanisms +described at https://zuul-ci.org/docs/zuul/user/vulnerabilities.html + +Code reviews are handled by gerrit at https://review.opendev.org + +After creating a Gerrit account, use `git review` to submit patches. +Example:: + + # Do your commits + $ git review + # Enter your username if prompted + +Join `#zuul` on Freenode to discuss development or usage. + +License +------- + +Zuul-client is free software, and licensed under the Apache License, version 2.0. + +Python Version Support +---------------------- + +Zuul-client requires Python 3. It does not support Python 2. diff --git a/TESTING.rst b/TESTING.rst new file mode 100644 index 0000000..8ac0d5b --- /dev/null +++ b/TESTING.rst @@ -0,0 +1,51 @@ +================= +Testing Your Code +================= +------------ +A Quickstart +------------ + +This is designed to be enough information for you to run your first tests. + +*Install pip*:: + + [apt-get | yum] install python-pip + +More information on pip here: http://www.pip-installer.org/en/latest/ + +*Use pip to install tox*:: + + pip install tox + +Run The Tests +------------- + +*Navigate to the project's root directory and execute*:: + + tox + +Information about tox can be found here: http://testrun.org/tox/latest/ + + +Run The Tests in One Environment +-------------------------------- + +Tox will run your entire test suite in the environments specified in the project tox.ini:: + + [tox] + + envlist = + +To run the test suite in just one of the environments in envlist execute:: + + tox -e +so for example, *run the test suite in py36*:: + + tox -e py36 + +Run One Test +------------ + +To run individual tests with tox:: + + tox -e -- path.to.module.Class.test diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..5957660 --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = -W +SPHINXBUILD = sphinx-build +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) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +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 " singlehtml to make a single large HTML file" + @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 " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @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." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +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/Zuul.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Zuul.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Zuul" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Zuul" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +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 0000000..75f8a14 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,5 @@ +sphinx>=1.6.1 +sphinxcontrib-programoutput +sphinx-autodoc-typehints +reno>=2.8.0 # Apache-2.0 +zuul-sphinx diff --git a/doc/source/commands.rst b/doc/source/commands.rst new file mode 100644 index 0000000..8f48ecc --- /dev/null +++ b/doc/source/commands.rst @@ -0,0 +1,167 @@ +:title: Commands + +Commands +======== + +Privileged commands +------------------- + +Some commands require a valid authentication token to be passed as the ``--auth-token`` +argument. Administrators can generate such a token for users as needed. + +Usage +----- +The general options that apply to all subcommands are: + +.. program-output:: zuul-client --help + +The following subcommands are supported: + +Autohold +^^^^^^^^ + +.. note:: This command is only available with a valid authentication token. + +.. program-output:: zuul-client autohold --help + +Example:: + + zuul-client autohold --tenant openstack --project example_project --job example_job --reason "reason text" --count 1 + +Autohold Delete +^^^^^^^^^^^^^^^ + +.. note:: This command is only available with a valid authentication token. + +.. program-output:: zuul-client autohold-delete --help + +Example:: + + zuul-client autohold-delete --tenant openstack --id 0000000123 + +Autohold Info +^^^^^^^^^^^^^ +.. program-output:: zuul-client autohold-info --help + +Example:: + + zuul-client autohold-info --tenant openstack --id 0000000123 + +Autohold List +^^^^^^^^^^^^^ +.. program-output:: zuul-client autohold-list --help + +Example:: + + zuul-client autohold-list --tenant openstack + +Dequeue +^^^^^^^ + +.. note:: This command is only available with a valid authentication token. + +.. program-output:: zuul-client dequeue --help + +Examples:: + + zuul-client dequeue --tenant openstack --pipeline check --project example_project --change 5,1 + zuul-client dequeue --tenant openstack --pipeline periodic --project example_project --ref refs/heads/master + +Enqueue +^^^^^^^ + +.. note:: This command is only available with a valid authentication token. + +.. program-output:: zuul-client enqueue --help + +Example:: + + zuul-client enqueue --tenant openstack --trigger gerrit --pipeline check --project example_project --change 12345,1 + +Note that the format of change id is ,. + +Enqueue-ref +^^^^^^^^^^^ + +.. note:: This command is only available with a valid authentication token. + +.. program-output:: zuul-client enqueue-ref --help + +This command is provided to manually simulate a trigger from an +external source. It can be useful for testing or replaying a trigger +that is difficult or impossible to recreate at the source. The +arguments to ``enqueue-ref`` will vary depending on the source and +type of trigger. Some familiarity with the arguments emitted by +``gerrit`` `update hooks +`__ +such as ``patchset-created`` and ``ref-updated`` is recommended. Some +examples of common operations are provided below. + +Manual enqueue examples +*********************** + +It is common to have a ``release`` pipeline that listens for new tags +coming from ``gerrit`` and performs a range of code packaging jobs. +If there is an unexpected issue in the release jobs, the same tag can +not be recreated in ``gerrit`` and the user must either tag a new +release or request a manual re-triggering of the jobs. To re-trigger +the jobs, pass the failed tag as the ``ref`` argument and set +``newrev`` to the change associated with the tag in the project +repository (i.e. what you see from ``git show X.Y.Z``):: + + zuul-client enqueue-ref --tenant openstack --trigger gerrit --pipeline release --project openstack/example_project --ref refs/tags/X.Y.Z --newrev abc123... + +The command can also be used asynchronosly trigger a job in a +``periodic`` pipeline that would usually be run at a specific time by +the ``timer`` driver. For example, the following command would +trigger the ``periodic`` jobs against the current ``master`` branch +top-of-tree for a project:: + + zuul-client enqueue-ref --tenant openstack --trigger timer --pipeline periodic --project openstack/example_project --ref refs/heads/master + +Another common pipeline is a ``post`` queue listening for ``gerrit`` +merge results. Triggering here is slightly more complicated as you +wish to recreate the full ``ref-updated`` event from ``gerrit``. For +a new commit on ``master``, the gerrit ``ref-updated`` trigger +expresses "reset ``refs/heads/master`` for the project from ``oldrev`` +to ``newrev``" (``newrev`` being the committed change). Thus to +replay the event, you could ``git log`` in the project and take the +current ``HEAD`` and the prior change, then enqueue the event:: + + NEW_REF=$(git rev-parse HEAD) + OLD_REF=$(git rev-parse HEAD~1) + + zuul-client enqueue-ref --tenant openstack --trigger gerrit --pipeline post --project openstack/example_project --ref refs/heads/master --newrev $NEW_REF --oldrev $OLD_REF + +Note that zero values for ``oldrev`` and ``newrev`` can indicate +branch creation and deletion; the source code of Zuul is the best reference +for these more advanced operations. + + +Promote +^^^^^^^ + +.. note:: This command is only available with a valid authentication token. + +.. program-output:: zuul-client promote --help + +This command will push the listed changes at the top of the chosen pipeline. + +Example:: + + zuul-client promote --tenant openstack --pipeline check --changes 12345,1 13336,3 + +Note that the format of changes id is ,. + +The promote action is used to reorder the change queue in a pipeline, by putting +the provided changes at the top of the queue; therefore this action makes the most +sense when performed against a dependent pipeline. + +The most common use case for the promote action is the need to merge an urgent fix +when the gate pipeline has already several patches queued ahead. This is especially +needed if there is concern that one or more changes ahead in the queue may fail, +thus increasing the time to land for the fix; or concern that the fix may not +pass validation if applied on top of the current patch queue in the gate. + +If the queue of a dependent pipeline is targeted by the promote, all the ongoing +jobs in that queue will be canceled and restarted on top of the promoted changes. diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 0000000..bf1738b --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,60 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'Zuul-client' +copyright = '2020, OpenStack' +author = 'OpenStack' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx_autodoc_typehints', + 'sphinxcontrib.programoutput', + 'zuul_sphinx', + # 'zuul.sphinx.zuul', + 'reno.sphinxext', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +#html_theme = 'alabaster' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst new file mode 100644 index 0000000..9effc51 --- /dev/null +++ b/doc/source/configuration.rst @@ -0,0 +1,12 @@ +:title: Configuration + +Configuration +============= + +The web client will look by default for a ``.zuul.conf`` file for its +configuration. The file should consist of a ``[webclient]`` section with at least +the ``url`` attribute set. The optional ``verify_ssl`` can be set to False to +disable SSL verifications when connecting to Zuul (defaults to True). + +It is also possible to run the web client without a configuration file, by using the +``--zuul-url`` option to specify the base URL of the Zuul web server. diff --git a/doc/source/examples/.zuul.conf b/doc/source/examples/.zuul.conf new file mode 100644 index 0000000..cc95625 --- /dev/null +++ b/doc/source/examples/.zuul.conf @@ -0,0 +1,7 @@ +[opendev] +url=https://zuul.opendev.org +verify_ssl=True + +[softwarefactory] +url=https://softwarefactory-project.io/zuul/ +verify_ssl=True diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..dbe9eea --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,16 @@ +Zuul-client - User CLI for the Zuul Project Gating System +========================================================= + +zuul-client is a simple command line client that may be used to query Zuul's +state or affect its behavior, granted the user is allowed to do so. It must be +run on a host with access to Zuul's web server. + +Documentation +------------- + +.. toctree:: + :maxdepth: 2 + + installation + configuration + commands diff --git a/doc/source/installation.rst b/doc/source/installation.rst new file mode 100644 index 0000000..44c5327 --- /dev/null +++ b/doc/source/installation.rst @@ -0,0 +1,18 @@ +:title: Installation + +Installation +============ + +*Install pip*:: + + [apt-get | yum] install python-pip + +More information on pip here: http://www.pip-installer.org/en/latest/ + +*Install dependencies*:: + + pip install -r requirements.txt + +*Install zuul-client*:: + + python setup.py install diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..502e94e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pbr>=1.1.0 +requests==2.24.0 +urllib3!=1.25.4,!=1.25.5 # https://github.com/urllib3/urllib3/pull/1684 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..fe33c3e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,32 @@ +[metadata] +name = zuul +summary = A Project Gating System +description-file = + README.rst +author = Zuul Team +author-email = zuul-discuss@lists.zuul-ci.org +home-page = https://zuul-ci.org/ +python-requires = >=3.6 +classifier = + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: Apache Software License + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + +[pbr] +warnerrors = True + +[entry_points] +console_scripts = + zuul-client = zuulclient.cmd:main + +[build_sphinx] +source-dir = doc/source +build-dir = doc/build +all_files = 1 +warning-is-error = 1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7539e57 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# Copyright (c) 2020 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import setuptools + +setuptools.setup( + setup_requires=['pbr'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..40d4294 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,3 @@ +coverage>=3.6 +stestr>=1.0.0 # Apache-2.0 +testtools>=0.9.32 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e40080c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2020 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. diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..45f27d7 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,38 @@ +# Copyright 2020 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import logging +import testtools + + +class BaseTestCase(testtools.TestCase): + log = logging.getLogger("zuulclient.test") + + def setUp(self): + super(BaseTestCase, self).setUp() + + +class FakeRequestResponse(object): + def __init__(self, status_code=None, json=None, exception_msg=None): + self._json = json + self.status_code = status_code + self.exception_msg = exception_msg or 'Error' + + def json(self): + return self._json + + def raise_for_status(self): + if self.status_code >= 400: + raise Exception(self.exception_msg) diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000..71e9caf --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,284 @@ +# Copyright 2020 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from tests.unit import BaseTestCase +from tests.unit import FakeRequestResponse + +from unittest.mock import MagicMock + +from zuulclient.api import ZuulRESTClient +from zuulclient.api import ZuulRESTException + + +class TestApi(BaseTestCase): + + def test_client_init(self): + """Test initialization of a client""" + client = ZuulRESTClient(url='https://fake.zuul/') + self.assertEqual('https://fake.zuul/', client.url) + self.assertEqual('https://fake.zuul/api/', client.base_url) + self.assertEqual(False, client.session.verify) + self.assertFalse('Authorization' in client.session.headers) + client = ZuulRESTClient(url='https://fake.zuul') + self.assertEqual('https://fake.zuul/', client.url) + self.assertEqual('https://fake.zuul/api/', client.base_url) + client = ZuulRESTClient(url='https://fake.zuul/with/path/') + self.assertEqual('https://fake.zuul/with/path/', client.url) + self.assertEqual('https://fake.zuul/with/path/api/', client.base_url) + token = 'aiaiaiai' + client = ZuulRESTClient(url='https://fake.zuul/', verify=True, + auth_token=token) + self.assertEqual('https://fake.zuul/', client.url) + self.assertEqual('https://fake.zuul/api/', client.base_url) + self.assertEqual(True, client.session.verify) + self.assertEqual('Bearer %s' % token, + client.session.headers.get('Authorization')) + + def _test_status_check(self, client, verb, func, *args, **kwargs): + # validate request errors + for error_code, regex in [(401, 'Unauthorized'), + (403, 'Insufficient privileges'), + (500, 'Unknown error')]: + with self.assertRaisesRegex(ZuulRESTException, + regex): + req = FakeRequestResponse(error_code) + if verb == 'post': + client.session.post = MagicMock(return_value=req) + elif verb == 'get': + client.session.get = MagicMock(return_value=req) + elif verb == 'delete': + client.session.delete = MagicMock(return_value=req) + else: + raise Exception('Unknown HTTP "verb" %s' % verb) + func(*args, **kwargs) + + def test_autohold(self): + """Test autohold""" + client = ZuulRESTClient(url='https://fake.zuul/') + # token required + with self.assertRaisesRegex(Exception, 'Auth Token required'): + client.autohold( + 'tenant', 'project', 'job', 1, None, 'reason', 1, 3600) + + client = ZuulRESTClient(url='https://fake.zuul/', + auth_token='aiaiaiai') + # test status checks + self._test_status_check( + client, 'post', client.autohold, + 'tenant', 'project', 'job', 1, None, 'reason', 1, 3600) + # test REST call + req = FakeRequestResponse(200, True) + client.session.post = MagicMock(return_value=req) + ah = client.autohold( + 'tenant', 'project', 'job', 1, None, 'reason', 1, 3600) + client.session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant/project/project/autohold', + json={'reason': 'reason', + 'count': 1, + 'job': 'job', + 'change': 1, + 'ref': None, + 'node_hold_expiration': 3600} + ) + self.assertEqual(True, ah) + + def test_autohold_list(self): + """Test autohold-list""" + client = ZuulRESTClient(url='https://fake.zuul/') + # test status checks + self._test_status_check( + client, 'get', client.autohold_list, 'tenant1') + + fakejson = [ + {'id': 123, + 'tenant': 'tenant1', + 'project': 'project1', + 'job': 'job1', + 'ref_filter': '.*', + 'max_count': 1, + 'current_count': 0, + 'reason': 'because', + 'nodes': ['node1', 'node2']} + ] + req = FakeRequestResponse(200, fakejson) + client.session.get = MagicMock(return_value=req) + ahl = client.autohold_list('tenant1') + client.session.get.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/autohold') + self.assertEqual(fakejson, ahl) + + def test_autohold_delete(self): + """Test autohold-delete""" + client = ZuulRESTClient(url='https://fake.zuul/') + # token required + with self.assertRaisesRegex(Exception, 'Auth Token required'): + client.autohold_delete(123, 'tenant1') + + client = ZuulRESTClient(url='https://fake.zuul/', + auth_token='aiaiaiai') + # test status checks + self._test_status_check( + client, 'delete', client.autohold_delete, + 123, 'tenant1') + + # test REST call + req = FakeRequestResponse(204) + client.session.delete = MagicMock(return_value=req) + ahd = client.autohold_delete(123, 'tenant1') + client.session.delete.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/autohold/123' + ) + self.assertEqual(True, ahd) + + def test_autohold_info(self): + """Test autohold-info""" + client = ZuulRESTClient(url='https://fake.zuul/') + # test status checks + self._test_status_check( + client, 'get', client.autohold_info, 123, 'tenant1') + + fakejson = { + 'id': 123, + 'tenant': 'tenant1', + 'project': 'project1', + 'job': 'job1', + 'ref_filter': '.*', + 'max_count': 1, + 'current_count': 0, + 'reason': 'because', + 'nodes': ['node1', 'node2'] + } + req = FakeRequestResponse(200, fakejson) + client.session.get = MagicMock(return_value=req) + ahl = client.autohold_info(tenant='tenant1', id=123) + client.session.get.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/autohold/123') + self.assertEqual(fakejson, ahl) + + def test_enqueue(self): + """Test enqueue""" + client = ZuulRESTClient(url='https://fake.zuul/') + # token required + with self.assertRaisesRegex(Exception, 'Auth Token required'): + client.enqueue('tenant1', 'check', 'project1', '1,1') + + client = ZuulRESTClient(url='https://fake.zuul/', + auth_token='aiaiaiai') + # test status checks + self._test_status_check( + client, 'post', client.enqueue, + 'tenant1', 'check', 'project1', '1,1') + + # test REST call + req = FakeRequestResponse(200, True) + client.session.post = MagicMock(return_value=req) + enq = client.enqueue('tenant1', 'check', 'project1', '1,1') + client.session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/project/project1/enqueue', + json={'change': '1,1', + 'pipeline': 'check'} + ) + self.assertEqual(True, enq) + + def test_enqueue_ref(self): + """Test enqueue ref""" + client = ZuulRESTClient(url='https://fake.zuul/') + # token required + with self.assertRaisesRegex(Exception, 'Auth Token required'): + client.enqueue_ref( + 'tenant1', 'check', 'project1', 'refs/heads/stable', '0', '0') + + client = ZuulRESTClient(url='https://fake.zuul/', + auth_token='aiaiaiai') + # test status checks + self._test_status_check( + client, 'post', client.enqueue_ref, + 'tenant1', 'check', 'project1', 'refs/heads/stable', '0', '0') + + # test REST call + req = FakeRequestResponse(200, True) + client.session.post = MagicMock(return_value=req) + enq_ref = client.enqueue_ref( + 'tenant1', 'check', 'project1', 'refs/heads/stable', '0', '0') + client.session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/project/project1/enqueue', + json={'ref': 'refs/heads/stable', + 'oldrev': '0', + 'newrev': '0', + 'pipeline': 'check'} + ) + self.assertEqual(True, enq_ref) + + def test_dequeue(self): + """Test dequeue""" + client = ZuulRESTClient(url='https://fake.zuul/') + # token required + with self.assertRaisesRegex(Exception, 'Auth Token required'): + client.dequeue('tenant1', 'check', 'project1', '1,1') + + client = ZuulRESTClient(url='https://fake.zuul/', + auth_token='aiaiaiai') + # test status checks + self._test_status_check( + client, 'post', client.dequeue, + 'tenant1', 'check', 'project1', '1,1') + + # test conditions on ref and change + with self.assertRaisesRegex(Exception, 'need change OR ref'): + client.dequeue( + 'tenant1', 'check', 'project1', '1,1', 'refs/heads/stable') + + # test REST call + req = FakeRequestResponse(200, True) + client.session.post = MagicMock(return_value=req) + deq = client.dequeue('tenant1', 'check', 'project1', change='1,1') + client.session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/project/project1/dequeue', + json={'change': '1,1', + 'pipeline': 'check'} + ) + self.assertEqual(True, deq) + deq = client.dequeue( + 'tenant1', 'check', 'project1', ref='refs/heads/stable') + client.session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/project/project1/dequeue', + json={'ref': 'refs/heads/stable', + 'pipeline': 'check'} + ) + self.assertEqual(True, deq) + + def test_promote(self): + """Test promote""" + client = ZuulRESTClient(url='https://fake.zuul/') + # token required + with self.assertRaisesRegex(Exception, 'Auth Token required'): + client.promote('tenant1', 'check', ['1,1', '2,1']) + + client = ZuulRESTClient(url='https://fake.zuul/', + auth_token='aiaiaiai') + # test status checks + self._test_status_check( + client, 'post', client.promote, + 'tenant1', 'check', ['1,1', '2,1']) + + # test REST call + req = FakeRequestResponse(200, True) + client.session.post = MagicMock(return_value=req) + prom = client.promote('tenant1', 'check', ['1,1', '2,1']) + client.session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/promote', + json={'change_ids': ['1,1', '2,1'], + 'pipeline': 'check'} + ) + self.assertEqual(True, prom) diff --git a/tests/unit/test_cmd.py b/tests/unit/test_cmd.py new file mode 100644 index 0000000..798e888 --- /dev/null +++ b/tests/unit/test_cmd.py @@ -0,0 +1,305 @@ +# Copyright 2020 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from tests.unit import BaseTestCase +from tests.unit import FakeRequestResponse + +from unittest.mock import MagicMock, patch + +from zuulclient.cmd import ZuulClient + + +class TestCmd(BaseTestCase): + + def test_client_args_errors(self): + """Test bad CLI arguments when instantiating client""" + ZC = ZuulClient() + with self.assertRaisesRegex(Exception, + 'Either specify --zuul-url or ' + 'use a config file'): + ZC._main(['--zuul-url', 'https://fake.zuul', + '--use-config', 'fakezuul', + 'autohold', + '--tenant', 'tenant1', '--project', 'project1', + '--job', 'job1', '--change', '3', + '--reason', 'some reason', + '--node-hold-expiration', '3600']) + + def test_autohold(self): + """Test autohold via CLI""" + ZC = ZuulClient() + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + session.post = MagicMock( + return_value=FakeRequestResponse(200, True)) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'autohold', + '--tenant', 'tenant1', '--project', 'project1', + '--job', 'job1', '--change', '3', '--reason', 'some reason', + '--node-hold-expiration', '3600']) + session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/' + 'project/project1/autohold', + json={'reason': 'some reason', + 'count': 1, + 'job': 'job1', + 'change': '3', + 'ref': '', + 'node_hold_expiration': 3600} + ) + self.assertEqual(0, exit_code) + + def test_autohold_args_errors(self): + """Test wrong arguments for autohold""" + ZC = ZuulClient() + with self.assertRaisesRegex(Exception, + "Change and ref can't be both used " + "for the same request"): + ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'autohold', + '--tenant', 'tenant1', '--project', 'project1', + '--job', 'job1', '--change', '3', '--reason', 'some reason', + '--ref', '/refs/heads/master', + '--node-hold-expiration', '3600']) + with self.assertRaisesRegex(Exception, + "Error: change argument can not " + "contain any ','"): + ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'autohold', + '--tenant', 'tenant1', '--project', 'project1', + '--job', 'job1', '--change', '3,2', '--reason', 'some reason', + '--node-hold-expiration', '3600']) + + def test_parse_arguments(self): + """ Test wrong arguments in parseArguments precheck""" + ZC = ZuulClient() + with self.assertRaisesRegex(Exception, + "The old and new revisions must " + "not be the same."): + ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'enqueue-ref', + '--tenant', 'tenant1', '--project', 'project1', + '--pipeline', 'check', + '--ref', '/refs/heads/master', + '--oldrev', '1234', '--newrev', '1234']) + with self.assertRaisesRegex(Exception, + "The 'change' and 'ref' arguments are " + "mutually exclusive."): + ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'dequeue', + '--tenant', 'tenant1', '--project', 'project1', + '--pipeline', 'post', '--change', '3,2', + '--ref', '/refs/heads/master']) + + def test_autohold_delete(self): + """Test autohold-delete via CLI""" + ZC = ZuulClient() + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + session.delete = MagicMock( + return_value=FakeRequestResponse(204)) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'autohold-delete', + '--tenant', 'tenant1', '1234']) + session.delete.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/autohold/1234') + self.assertEqual(0, exit_code) + + def test_autohold_info(self): + """Test autohold-info via CLI""" + ZC = ZuulClient() + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + session.get = MagicMock( + return_value=FakeRequestResponse( + 200, + json={'id': 1234, + 'tenant': 'tenant1', + 'project': 'project1', + 'job': 'job1', + 'ref_filter': '.*', + 'max_count': 1, + 'current_count': 0, + 'node_expiration': 0, + 'expired': 0, + 'reason': 'some_reason', + 'nodes': ['node1', 'node2']})) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', 'autohold-info', + '--tenant', 'tenant1', '1234']) + session.get.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/autohold/1234') + self.assertEqual(0, exit_code) + session.get = MagicMock( + return_value=FakeRequestResponse(404, + exception_msg='Not found')) + with self.assertRaisesRegex(Exception, 'Not found'): + ZC._main( + ['--zuul-url', 'https://fake.zuul', 'autohold-info', + '--tenant', 'tenant1', '1234']) + + def test_enqueue(self): + """Test enqueue via CLI""" + ZC = ZuulClient() + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + session.post = MagicMock( + return_value=FakeRequestResponse(200, True)) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'enqueue', + '--pipeline', 'check', + '--tenant', 'tenant1', '--change', '3,1', + '--project', 'project1']) + session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/' + 'project/project1/enqueue', + json={'change': '3,1', + 'pipeline': 'check'} + ) + self.assertEqual(0, exit_code) + + def test_enqueue_ref(self): + """Test enqueue-ref via CLI""" + ZC = ZuulClient() + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + session.post = MagicMock( + return_value=FakeRequestResponse(200, True)) + # ensure default revs are set + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'enqueue-ref', + '--pipeline', 'check', + '--tenant', 'tenant1', '--ref', 'refs/heads/stable', + '--project', 'project1']) + session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/' + 'project/project1/enqueue', + json={'ref': 'refs/heads/stable', + 'pipeline': 'check', + 'oldrev': '0000000000000000000000000000000000000000', + 'newrev': '0000000000000000000000000000000000000000'} + ) + self.assertEqual(0, exit_code) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'enqueue-ref', + '--pipeline', 'check', + '--tenant', 'tenant1', '--ref', 'refs/heads/stable', + '--project', 'project1', + '--oldrev', 'ababababab']) + session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/' + 'project/project1/enqueue', + json={'ref': 'refs/heads/stable', + 'pipeline': 'check', + 'oldrev': 'ababababab', + 'newrev': '0000000000000000000000000000000000000000'} + ) + self.assertEqual(0, exit_code) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'enqueue-ref', + '--pipeline', 'check', + '--tenant', 'tenant1', '--ref', 'refs/heads/stable', + '--project', 'project1', + '--newrev', 'ababababab']) + session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/' + 'project/project1/enqueue', + json={'ref': 'refs/heads/stable', + 'pipeline': 'check', + 'newrev': 'ababababab', + 'oldrev': '0000000000000000000000000000000000000000'} + ) + self.assertEqual(0, exit_code) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'enqueue-ref', + '--pipeline', 'check', + '--tenant', 'tenant1', '--ref', 'refs/heads/stable', + '--project', 'project1', + '--oldrev', 'ababababab', + '--newrev', 'bababababa']) + session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/' + 'project/project1/enqueue', + json={'ref': 'refs/heads/stable', + 'pipeline': 'check', + 'oldrev': 'ababababab', + 'newrev': 'bababababa'} + ) + self.assertEqual(0, exit_code) + + def test_dequeue(self): + """Test dequeue via CLI""" + ZC = ZuulClient() + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + session.post = MagicMock( + return_value=FakeRequestResponse(200, True)) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'dequeue', + '--pipeline', 'tag', + '--tenant', 'tenant1', '--ref', 'refs/heads/stable', + '--project', 'project1']) + session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/' + 'project/project1/dequeue', + json={'ref': 'refs/heads/stable', + 'pipeline': 'tag'} + ) + self.assertEqual(0, exit_code) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'dequeue', + '--pipeline', 'check', + '--tenant', 'tenant1', '--change', '3,3', + '--project', 'project1']) + session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/' + 'project/project1/dequeue', + json={'change': '3,3', + 'pipeline': 'check'} + ) + self.assertEqual(0, exit_code) + + def test_promote(self): + """Test promote via CLI""" + ZC = ZuulClient() + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + session.post = MagicMock( + return_value=FakeRequestResponse(200, True)) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + '--auth-token', 'aiaiaiai', 'promote', + '--pipeline', 'gate', + '--tenant', 'tenant1', + '--changes', '3,3', '4,1', '5,3']) + session.post.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/promote', + json={'change_ids': ['3,3', '4,1', '5,3'], + 'pipeline': 'gate'} + ) + self.assertEqual(0, exit_code) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..99c5c5c --- /dev/null +++ b/tox.ini @@ -0,0 +1,67 @@ +[tox] +minversion = 3.2 +skipsdist = True +envlist = linters,py3{-docker} +ignore_basepython_conflict = True + +[testenv] +basepython = python3 +setenv = + VIRTUAL_ENV={envdir} + OS_TEST_TIMEOUT=360 + OS_STDOUT_CAPTURE={env:OS_STDOUT_CAPTURE:1} + OS_STDERR_CAPTURE={env:OS_STDERR_CAPTURE:1} + OS_LOG_CAPTURE={env:OS_LOG_CAPTURE:1} +passenv = + OS_LOG_CAPTURE + OS_LOG_DEFAULTS + OS_STDERR_CAPTURE + OS_STDOUT_CAPTURE +usedevelop = True +whitelist_externals = bash +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + bash -c 'stestr run --slowest --concurrency=`python -c "import multiprocessing; print(int(multiprocessing.cpu_count()/2))"` {posargs}' + +[testenv:linters] +usedevelop = False +install_command = pip install {opts} {packages} +# --ignore-missing-imports tells mypy to not try to follow imported modules +# out of the current tree. As you might expect, we don't want to run static +# type checking on the world - just on ourselves. +deps = + flake8 + mypy<0.740 +commands = + flake8 {posargs} + mypy --ignore-missing-imports zuulclient + +[testenv:cover] +setenv = + {[testenv]setenv} + PYTHON=coverage run --source zuulclient --parallel-mode +commands = + stestr run {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml + +[testenv:docs] +install_command = pip install {opts} {packages} +deps = + -r{toxinidir}/doc/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + sphinx-build -E -W -d doc/build/doctrees -b html doc/source/ doc/build/html + +[testenv:venv] +commands = {posargs} + +[flake8] +# These are ignored intentionally in zuul projects; +# please don't submit patches that solely correct them or enable them. +ignore = E124,E125,E129,E252,E402,E741,H,W503,W504 +show-source = True +exclude = .venv,.tox,dist,doc,build,*.egg,node_modules diff --git a/zuulclient/api/__init__.py b/zuulclient/api/__init__.py new file mode 100644 index 0000000..e43e279 --- /dev/null +++ b/zuulclient/api/__init__.py @@ -0,0 +1,154 @@ +# Copyright 2020 Red Hat, inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import requests +import urllib.parse + + +class ZuulRESTException(Exception): + pass + + +class ZuulRESTClient(object): + """Basic client for Zuul's REST API""" + def __init__(self, url, verify=False, auth_token=None): + self.url = url + if not self.url.endswith('/'): + self.url += '/' + self.auth_token = auth_token + self.verify = verify + self.base_url = urllib.parse.urljoin(self.url, 'api/') + self.session = requests.Session() + self.session.verify = self.verify + if self.auth_token: + self.session.headers.update( + dict(Authorization='Bearer %s' % self.auth_token)) + + def _check_request_status(self, req): + try: + req.raise_for_status() + except Exception as e: + if req.status_code == 401: + raise ZuulRESTException( + 'Unauthorized - your token might be invalid or expired.') + elif req.status_code == 403: + raise ZuulRESTException( + 'Insufficient privileges to perform the action.') + else: + raise ZuulRESTException( + 'Unknown error code %s: "%s"' % (req.status_code, e)) + + def autohold(self, tenant, project, job, change, ref, + reason, count, node_hold_expiration): + if not self.auth_token: + raise Exception('Auth Token required') + args = {"reason": reason, + "count": count, + "job": job, + "change": change, + "ref": ref, + "node_hold_expiration": node_hold_expiration} + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/project/%s/autohold' % (tenant, project)) + req = self.session.post(url, json=args) + self._check_request_status(req) + return req.json() + + def autohold_list(self, tenant): + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/autohold' % tenant) + # auth not needed here + req = self.session.get(url) + self._check_request_status(req) + resp = req.json() + return resp + + def autohold_delete(self, id, tenant): + if not self.auth_token: + raise Exception('Auth Token required') + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/autohold/%s' % (tenant, id)) + req = self.session.delete(url) + self._check_request_status(req) + # DELETE doesn't return a body, just the HTTP code + return (req.status_code == 204) + + def autohold_info(self, id, tenant): + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/autohold/%s' % (tenant, id)) + # auth not needed here + req = self.session.get(url) + self._check_request_status(req) + resp = req.json() + return resp + + def enqueue(self, tenant, pipeline, project, change): + if not self.auth_token: + raise Exception('Auth Token required') + args = {"change": change, + "pipeline": pipeline} + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/project/%s/enqueue' % (tenant, project)) + req = self.session.post(url, json=args) + self._check_request_status(req) + return req.json() + + def enqueue_ref(self, tenant, pipeline, project, ref, oldrev, newrev): + if not self.auth_token: + raise Exception('Auth Token required') + args = {"ref": ref, + "oldrev": oldrev, + "newrev": newrev, + "pipeline": pipeline} + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/project/%s/enqueue' % (tenant, project)) + req = self.session.post(url, json=args) + self._check_request_status(req) + return req.json() + + def dequeue(self, tenant, pipeline, project, change=None, ref=None): + if not self.auth_token: + raise Exception('Auth Token required') + args = {"pipeline": pipeline} + if change and not ref: + args['change'] = change + elif ref and not change: + args['ref'] = ref + else: + raise Exception('need change OR ref') + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/project/%s/dequeue' % (tenant, project)) + req = self.session.post(url, json=args) + self._check_request_status(req) + return req.json() + + def promote(self, tenant, pipeline, change_ids): + if not self.auth_token: + raise Exception('Auth Token required') + args = {'pipeline': pipeline, + 'change_ids': change_ids} + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/promote' % tenant) + req = self.session.post(url, json=args) + self._check_request_status(req) + return req.json() diff --git a/zuulclient/cmd/__init__.py b/zuulclient/cmd/__init__.py new file mode 100644 index 0000000..214fda5 --- /dev/null +++ b/zuulclient/cmd/__init__.py @@ -0,0 +1,106 @@ +# Copyright 2020 Red Hat, inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from zuulclient.api import ZuulRESTClient +# from zuulclient.api import ZuulRESTException +from zuulclient.common.client import CLI +from zuulclient.common import get_default + + +class ZuulClient(CLI): + app_name = 'zuul-client' + app_description = 'Zuul User CLI' + log = logging.getLogger("zuul-client") + + def createParser(self): + parser = super(ZuulClient, self).createParser() + parser.add_argument('--auth-token', dest='auth_token', + required=False, + default=None, + help='Authentication Token, required by ' + 'admin commands') + parser.add_argument('--zuul-url', dest='zuul_url', + required=False, + default=None, + help='Zuul base URL, needed if using the ' + 'client without a configuration file') + parser.add_argument('--use-config', dest='zuul_config', + required=False, + default=None, + help='A predefined configuration in .zuul.conf') + parser.add_argument('--insecure', dest='verify_ssl', + required=False, + action='store_false', + help='Do not verify SSL connection to Zuul ' + '(Defaults to False)') + return parser + + def createCommandParsers(self, parser): + subparsers = super(ZuulClient, self).createCommandParsers(parser) + # Add any specific zuul-client command subparser here + return subparsers + + def _main(self, args=None): + self.parseArguments(args) + if not self.args.zuul_url: + self.readConfig() + self.setup_logging() + # TODO make func return specific return codes + if self.args.func(): + return 0 + else: + return 1 + + def get_client(self): + if self.args.zuul_url and self.args.zuul_config: + raise Exception('Either specify --zuul-url or use a config file') + if self.args.zuul_url: + self.log.debug( + 'Using Zuul URL provided as argument to instantiate client') + client = ZuulRESTClient(self.args.zuul_url, + self.args.verify_ssl, + self.args.auth_token) + return client + conf_sections = self.config.sections() + if len(conf_sections) == 1 and self.args.zuul_config is None: + zuul_conf = conf_sections[0] + self.log.debug( + 'Using section "%s" found in ' + 'config to instantiate client' % zuul_conf) + elif self.args.zuul_config and self.args.zuul_config in conf_sections: + zuul_conf = self.args.zuul_config + else: + raise Exception('Unable to find a way to connect to Zuul, ' + 'provide the "--zuul-url" argument or set up a ' + '.zuul.conf file.') + server = get_default(self.config, + zuul_conf, 'url', None) + verify = get_default(self.config, zuul_conf, + 'verify_ssl', + self.args.verify_ssl) + # Allow token override by CLI argument + auth_token = self.args.auth_token or get_default(self.config, + zuul_conf, + 'auth_token', + None) + if server is None: + raise Exception('Missing "url" configuration value') + client = ZuulRESTClient(server, verify, auth_token) + return client + + +def main(): + ZuulClient().main() diff --git a/zuulclient/common/__init__.py b/zuulclient/common/__init__.py new file mode 100644 index 0000000..5505b9b --- /dev/null +++ b/zuulclient/common/__init__.py @@ -0,0 +1,32 @@ +# Copyright 2020 Red Hat, inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os + + +def get_default(config, section, option, default=None, expand_user=False): + if config.has_option(section, option): + # Need to be ensured that we get suitable + # type from config file by default value + if isinstance(default, bool): + value = config.getboolean(section, option) + elif isinstance(default, int): + value = config.getint(section, option) + else: + value = config.get(section, option) + else: + value = default + if expand_user and value: + return os.path.expanduser(value) + return value diff --git a/zuulclient/common/client.py b/zuulclient/common/client.py new file mode 100644 index 0000000..65c280e --- /dev/null +++ b/zuulclient/common/client.py @@ -0,0 +1,373 @@ +# Copyright 2020 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. + + +# TODO This is taken straight from zuul.cmd - Refactor so the boilerplate Code +# lives in one place only. + +import argparse +import configparser +import logging +import os +import prettytable +import sys +import textwrap +import time + + +class App(object): + app_name = None # type: str + app_description = None # type: str + + def __init__(self): + self.args = None + self.config = None + + def _get_version(self): + from zuulclient.version import version_info + return "Zuul-client version: %s" % version_info.release_string() + + def createParser(self): + parser = argparse.ArgumentParser( + description=self.app_description, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('-c', dest='config', + help='specify the config file') + parser.add_argument('--version', dest='version', action='version', + version=self._get_version(), + help='show zuul version') + return parser + + def parseArguments(self, args=None): + parser = self.createParser() + self.args = parser.parse_args(args) + return parser + + def readConfig(self): + safe_env = { + k: v for k, v in os.environ.items() + if k.startswith('ZUUL_') + } + self.config = configparser.ConfigParser(safe_env) + if self.args.config: + locations = [self.args.config] + else: + locations = ['~/.zuul.conf'] + for fp in locations: + if os.path.exists(os.path.expanduser(fp)): + self.config.read(os.path.expanduser(fp)) + return + raise Exception("Unable to locate config file in %s" % locations) + + +class CLI(App): + """Common code used by the admin CLI and zuul-client.""" + + def createParser(self): + parser = super(CLI, self).createParser() + parser.add_argument('-v', dest='verbose', action='store_true', + help='verbose output') + self.createCommandParsers(parser) + return parser + + def createCommandParsers(self, parser): + subparsers = parser.add_subparsers(title='commands', + description='valid commands', + help='additional help') + # Add parsers that are common to RPC and REST clients + self.add_autohold_subparser(subparsers) + self.add_autohold_delete_subparser(subparsers) + self.add_autohold_info_subparser(subparsers) + self.add_autohold_list_subparser(subparsers) + self.add_enqueue_subparser(subparsers) + self.add_enqueue_ref_subparser(subparsers) + self.add_dequeue_subparser(subparsers) + self.add_promote_subparser(subparsers) + + return subparsers + + def parseArguments(self, args=None): + parser = super(CLI, self).parseArguments(args) + if not getattr(self.args, 'func', None): + parser.print_help() + sys.exit(1) + if self.args.func == self.enqueue_ref: + # if oldrev or newrev is set, ensure they're not the same + if (self.args.oldrev is not None) or \ + (self.args.newrev is not None): + if self.args.oldrev == self.args.newrev: + raise Exception( + "The old and new revisions must not be the same.") + # if they're not set, we pad them out to zero + if self.args.oldrev is None: + self.args.oldrev = '0000000000000000000000000000000000000000' + if self.args.newrev is None: + self.args.newrev = '0000000000000000000000000000000000000000' + if self.args.func == self.dequeue: + if self.args.change is None and self.args.ref is None: + raise Exception("Change or ref needed.") + if self.args.change is not None and self.args.ref is not None: + raise Exception( + "The 'change' and 'ref' arguments are mutually exclusive.") + + def setup_logging(self): + """Client logging does not rely on conf file""" + if self.args.verbose: + logging.basicConfig(level=logging.DEBUG) + + def _main(self, args=None): + self.parseArguments(args) + self.readConfig() + self.setup_logging() + + if self.args.func(): + return 0 + else: + return 1 + + def main(self): + try: + sys.exit(self._main()) + except Exception as e: + self.log.error(e) + sys.exit(1) + + def get_client(self): + raise NotImplementedError('No client defined') + + def add_autohold_subparser(self, subparsers): + cmd_autohold = subparsers.add_parser( + 'autohold', help='hold nodes for failed job') + cmd_autohold.add_argument('--tenant', help='tenant name', + required=True) + cmd_autohold.add_argument('--project', help='project name', + required=True) + cmd_autohold.add_argument('--job', help='job name', + required=True) + cmd_autohold.add_argument('--change', + help='specific change to hold nodes for', + required=False, default='') + cmd_autohold.add_argument('--ref', help='git ref to hold nodes for', + required=False, default='') + cmd_autohold.add_argument('--reason', help='reason for the hold', + required=True) + cmd_autohold.add_argument('--count', + help='number of job runs (default: 1)', + required=False, type=int, default=1) + cmd_autohold.add_argument( + '--node-hold-expiration', + help=('how long in seconds should the node set be in HOLD status ' + '(default: scheduler\'s default_hold_expiration value)'), + required=False, type=int) + cmd_autohold.set_defaults(func=self.autohold) + + def autohold(self): + if self.args.change and self.args.ref: + raise Exception( + "Change and ref can't be both used for the same request") + if "," in self.args.change: + raise Exception("Error: change argument can not contain any ','") + + node_hold_expiration = self.args.node_hold_expiration + client = self.get_client() + r = client.autohold( + tenant=self.args.tenant, + project=self.args.project, + job=self.args.job, + change=self.args.change, + ref=self.args.ref, + reason=self.args.reason, + count=self.args.count, + node_hold_expiration=node_hold_expiration) + return r + + def add_autohold_delete_subparser(self, subparsers): + cmd_autohold_delete = subparsers.add_parser( + 'autohold-delete', help='delete autohold request') + cmd_autohold_delete.set_defaults(func=self.autohold_delete) + cmd_autohold_delete.add_argument('--tenant', help='tenant name', + required=True, default=None) + cmd_autohold_delete.add_argument('id', metavar='REQUEST_ID', + help='the hold request ID') + + def autohold_delete(self): + client = self.get_client() + return client.autohold_delete(self.args.id, self.args.tenant) + + def add_autohold_info_subparser(self, subparsers): + cmd_autohold_info = subparsers.add_parser( + 'autohold-info', help='retrieve autohold request detailed info') + cmd_autohold_info.set_defaults(func=self.autohold_info) + cmd_autohold_info.add_argument('--tenant', help='tenant name', + required=True, default=None) + cmd_autohold_info.add_argument('id', metavar='REQUEST_ID', + help='the hold request ID') + + def autohold_info(self): + client = self.get_client() + request = client.autohold_info(self.args.id, self.args.tenant) + + if not request: + print("Autohold request not found") + return False + + print("ID: %s" % request['id']) + print("Tenant: %s" % request['tenant']) + print("Project: %s" % request['project']) + print("Job: %s" % request['job']) + print("Ref Filter: %s" % request['ref_filter']) + print("Max Count: %s" % request['max_count']) + print("Current Count: %s" % request['current_count']) + print("Node Expiration: %s" % request['node_expiration']) + print("Request Expiration: %s" % time.ctime(request['expired'])) + print("Reason: %s" % request['reason']) + print("Held Nodes: %s" % request['nodes']) + + return True + + def add_autohold_list_subparser(self, subparsers): + cmd_autohold_list = subparsers.add_parser( + 'autohold-list', help='list autohold requests') + cmd_autohold_list.add_argument('--tenant', help='tenant name', + required=True) + cmd_autohold_list.set_defaults(func=self.autohold_list) + + def autohold_list(self): + client = self.get_client() + autohold_requests = client.autohold_list(tenant=self.args.tenant) + + if not autohold_requests: + print("No autohold requests found") + return True + + table = prettytable.PrettyTable( + field_names=[ + 'ID', 'Tenant', 'Project', 'Job', 'Ref Filter', + 'Max Count', 'Reason' + ]) + + for request in autohold_requests: + table.add_row([ + request['id'], + request['tenant'], + request['project'], + request['job'], + request['ref_filter'], + request['max_count'], + request['reason'], + ]) + + print(table) + return True + + def add_enqueue_subparser(self, subparsers): + cmd_enqueue = subparsers.add_parser('enqueue', help='enqueue a change') + cmd_enqueue.add_argument('--tenant', help='tenant name', + required=True) + cmd_enqueue.add_argument('--pipeline', help='pipeline name', + required=True) + cmd_enqueue.add_argument('--project', help='project name', + required=True) + cmd_enqueue.add_argument('--change', help='change id', + required=True) + cmd_enqueue.set_defaults(func=self.enqueue) + + def enqueue(self): + client = self.get_client() + r = client.enqueue( + tenant=self.args.tenant, + pipeline=self.args.pipeline, + project=self.args.project, + change=self.args.change) + return r + + def add_enqueue_ref_subparser(self, subparsers): + cmd_enqueue = subparsers.add_parser( + 'enqueue-ref', help='enqueue a ref', + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent('''\ + Submit a trigger event + + Directly enqueue a trigger event. This is usually used + to manually "replay" a trigger received from an external + source such as gerrit.''')) + cmd_enqueue.add_argument('--tenant', help='tenant name', + required=True) + cmd_enqueue.add_argument('--pipeline', help='pipeline name', + required=True) + cmd_enqueue.add_argument('--project', help='project name', + required=True) + cmd_enqueue.add_argument('--ref', help='ref name', + required=True) + cmd_enqueue.add_argument( + '--oldrev', help='old revision', default=None) + cmd_enqueue.add_argument( + '--newrev', help='new revision', default=None) + cmd_enqueue.set_defaults(func=self.enqueue_ref) + + def enqueue_ref(self): + client = self.get_client() + r = client.enqueue_ref( + tenant=self.args.tenant, + pipeline=self.args.pipeline, + project=self.args.project, + ref=self.args.ref, + oldrev=self.args.oldrev, + newrev=self.args.newrev) + return r + + def add_dequeue_subparser(self, subparsers): + cmd_dequeue = subparsers.add_parser('dequeue', + help='dequeue a buildset by its ' + 'change or ref') + cmd_dequeue.add_argument('--tenant', help='tenant name', + required=True) + cmd_dequeue.add_argument('--pipeline', help='pipeline name', + required=True) + cmd_dequeue.add_argument('--project', help='project name', + required=True) + cmd_dequeue.add_argument('--change', help='change id', + default=None) + cmd_dequeue.add_argument('--ref', help='ref name', + default=None) + cmd_dequeue.set_defaults(func=self.dequeue) + + def dequeue(self): + client = self.get_client() + r = client.dequeue( + tenant=self.args.tenant, + pipeline=self.args.pipeline, + project=self.args.project, + change=self.args.change, + ref=self.args.ref) + return r + + def add_promote_subparser(self, subparsers): + cmd_promote = subparsers.add_parser('promote', + help='promote one or more changes') + cmd_promote.add_argument('--tenant', help='tenant name', + required=True) + cmd_promote.add_argument('--pipeline', help='pipeline name', + required=True) + cmd_promote.add_argument('--changes', help='change ids', + required=True, nargs='+') + cmd_promote.set_defaults(func=self.promote) + + def promote(self): + client = self.get_client() + r = client.promote( + tenant=self.args.tenant, + pipeline=self.args.pipeline, + change_ids=self.args.changes) + return r diff --git a/zuulclient/version.py b/zuulclient/version.py new file mode 100644 index 0000000..6e6be16 --- /dev/null +++ b/zuulclient/version.py @@ -0,0 +1,32 @@ +# Copyright 2020 Red Hat, inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json + +import pbr.version +import pkg_resources + +version_info = pbr.version.VersionInfo('zuul-client') +release_string = version_info.release_string() + +is_release = None +git_version = None +try: + _metadata = json.loads( + pkg_resources.get_distribution('zuul-client').get_metadata('pbr.json')) + if _metadata: + is_release = _metadata['is_release'] + git_version = _metadata['git_version'] +except Exception: + pass