Initialize repository

Import doc, code, various boilerplate elements from zuul.
Add basic unit testing.

Change-Id: I44b78cd9d2a31fb62ddf4ffd56546066c5db2689
This commit is contained in:
Matthieu Huin 2020-09-03 18:12:16 +02:00
parent 2e7c14b411
commit 0bc9db6c89
30 changed files with 2263 additions and 0 deletions

0
.coveragerc Normal file
View File

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
*.egg
*.egg-info
*.pyc
*.retry
.idea
.mypy_cache
.test
.testrepository
.tox
.venv
.coverage
.stestr
AUTHORS
build/*
ChangeLog
doc/build/*
dist/

3
.stestr.conf Normal file
View File

@ -0,0 +1,3 @@
[DEFAULT]
test_path=tests/unit
top_dir=./

25
.zuul.yaml Normal file
View File

@ -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

202
LICENSE Normal file
View File

@ -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.

7
MANIFEST.in Normal file
View File

@ -0,0 +1,7 @@
include AUTHORS
include ChangeLog
exclude .gitignore
exclude .gitreview
global-exclude *.pyc

57
README.rst Normal file
View File

@ -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 <http://lists.zuul-ci.org/cgi-bin/mailman/listinfo/zuul-announce>`_
A low-traffic announcement-only list to which every Zuul operator or
power-user should subscribe.
`zuul-discuss <http://lists.zuul-ci.org/cgi-bin/mailman/listinfo/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.

51
TESTING.rst Normal file
View File

@ -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 = <list of available environments>
To run the test suite in just one of the environments in envlist execute::
tox -e <env>
so for example, *run the test suite in py36*::
tox -e py36
Run One Test
------------
To run individual tests with tox::
tox -e <env> -- path.to.module.Class.test

153
doc/Makefile Normal file
View File

@ -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 <target>' where <target> 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."

5
doc/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
sphinx>=1.6.1
sphinxcontrib-programoutput
sphinx-autodoc-typehints
reno>=2.8.0 # Apache-2.0
zuul-sphinx

167
doc/source/commands.rst Normal file
View File

@ -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 <number>,<patchset>.
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
<https://gerrit-review.googlesource.com/admin/projects/plugins/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 <number>,<patchset>.
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.

60
doc/source/conf.py Normal file
View File

@ -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']

View File

@ -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.

View File

@ -0,0 +1,7 @@
[opendev]
url=https://zuul.opendev.org
verify_ssl=True
[softwarefactory]
url=https://softwarefactory-project.io/zuul/
verify_ssl=True

16
doc/source/index.rst Normal file
View File

@ -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

View File

@ -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

3
requirements.txt Normal file
View File

@ -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

32
setup.cfg Normal file
View File

@ -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

21
setup.py Normal file
View File

@ -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)

3
test-requirements.txt Normal file
View File

@ -0,0 +1,3 @@
coverage>=3.6
stestr>=1.0.0 # Apache-2.0
testtools>=0.9.32

13
tests/__init__.py Normal file
View File

@ -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.

38
tests/unit/__init__.py Normal file
View File

@ -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)

284
tests/unit/test_api.py Normal file
View File

@ -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)

305
tests/unit/test_cmd.py Normal file
View File

@ -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)

67
tox.ini Normal file
View File

@ -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

154
zuulclient/api/__init__.py Normal file
View File

@ -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()

106
zuulclient/cmd/__init__.py Normal file
View File

@ -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()

View File

@ -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

373
zuulclient/common/client.py Normal file
View File

@ -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

32
zuulclient/version.py Normal file
View File

@ -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