From 0bc9db6c898d1cd202719ca7f5859447fbf5382f Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Thu, 3 Sep 2020 18:12:16 +0200 Subject: [PATCH] Initialize repository Import doc, code, various boilerplate elements from zuul. Add basic unit testing. Change-Id: I44b78cd9d2a31fb62ddf4ffd56546066c5db2689 --- .coveragerc | 0 .gitignore | 17 ++ .stestr.conf | 3 + .zuul.yaml | 25 +++ LICENSE | 202 ++++++++++++++++++ MANIFEST.in | 7 + README.rst | 57 +++++ TESTING.rst | 51 +++++ doc/Makefile | 153 ++++++++++++++ doc/requirements.txt | 5 + doc/source/commands.rst | 167 +++++++++++++++ doc/source/conf.py | 60 ++++++ doc/source/configuration.rst | 12 ++ doc/source/examples/.zuul.conf | 7 + doc/source/index.rst | 16 ++ doc/source/installation.rst | 18 ++ requirements.txt | 3 + setup.cfg | 32 +++ setup.py | 21 ++ test-requirements.txt | 3 + tests/__init__.py | 13 ++ tests/unit/__init__.py | 38 ++++ tests/unit/test_api.py | 284 +++++++++++++++++++++++++ tests/unit/test_cmd.py | 305 +++++++++++++++++++++++++++ tox.ini | 67 ++++++ zuulclient/api/__init__.py | 154 ++++++++++++++ zuulclient/cmd/__init__.py | 106 ++++++++++ zuulclient/common/__init__.py | 32 +++ zuulclient/common/client.py | 373 +++++++++++++++++++++++++++++++++ zuulclient/version.py | 32 +++ 30 files changed, 2263 insertions(+) create mode 100644 .coveragerc create mode 100644 .gitignore create mode 100644 .stestr.conf create mode 100644 .zuul.yaml create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 TESTING.rst create mode 100644 doc/Makefile create mode 100644 doc/requirements.txt create mode 100644 doc/source/commands.rst create mode 100644 doc/source/conf.py create mode 100644 doc/source/configuration.rst create mode 100644 doc/source/examples/.zuul.conf create mode 100644 doc/source/index.rst create mode 100644 doc/source/installation.rst create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 test-requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_api.py create mode 100644 tests/unit/test_cmd.py create mode 100644 tox.ini create mode 100644 zuulclient/api/__init__.py create mode 100644 zuulclient/cmd/__init__.py create mode 100644 zuulclient/common/__init__.py create mode 100644 zuulclient/common/client.py create mode 100644 zuulclient/version.py 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