Merge ~ballot/jenkins-plugin-manager/+git/jenkins-plugin-manager:master into jenkins-plugin-manager:master

Proposed by Benjamin Allot
Status: Merged
Approved by: Tom Haddon
Approved revision: d3e15d25fe82715581c2497d25db4e5d76c8223f
Merged at revision: 421dc1082891d2215f1198de9eef377b82fe458d
Proposed branch: ~ballot/jenkins-plugin-manager/+git/jenkins-plugin-manager:master
Merge into: jenkins-plugin-manager:master
Diff against target: 1059 lines (+946/-0)
19 files modified
.flake8 (+4/-0)
.gitignore (+9/-0)
Makefile (+67/-0)
README.md (+48/-0)
debian/changelog (+19/-0)
debian/compat (+1/-0)
debian/control (+14/-0)
debian/rules (+22/-0)
debian/source/format (+1/-0)
jenkins_plugin_manager/__init__.py (+0/-0)
jenkins_plugin_manager/core.py (+94/-0)
jenkins_plugin_manager/exceptions.py (+19/-0)
jenkins_plugin_manager/main.py (+122/-0)
jenkins_plugin_manager/plugin.py (+216/-0)
requirements.txt (+2/-0)
setup.py (+44/-0)
tests/test_jenkins_plugin_manager_core.py (+52/-0)
tests/test_jenkins_plugin_manager_plugin.py (+176/-0)
tox.ini (+36/-0)
Reviewer Review Type Date Requested Status
Tom Haddon Approve
Stuart Bishop (community) Approve
Review via email: mp+375262@code.launchpad.net

Commit message

* Initial commit
* Change name into jenkins_plugin_manager and add debian package

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
Benjamin Allot (ballot) wrote :

The JSON files can be ignored because they are a snapshot of https://updates.jenkins.io/stable/update-center.json and https://updates.jenkins.io/plugin-versions.json

TODO in others MP:
* Download jenkins package from cli (only the library is implmented for now)
* Checksum of the downloaded package
* add various option in the "download" subcommand to allow optional dependencies and without if needed
* symlink the latest downloaded version if desired
* document the format of the plugin --from-file format

Revision history for this message
Tom Haddon (mthaddon) wrote :

Some comments inline

Revision history for this message
Stuart Bishop (stub) wrote :

All looks great and in a good style. Some inline comments in addition to Toms. Giving this a preemptive approve, although maybe the branch is being handed over instead of landed per discussion with Tom.

review: Approve
Revision history for this message
Benjamin Allot (ballot) wrote :

Inline comments of both addressed.

Basically took all of them into account except the try/import mock , because this would be needed for a python 3.2 support (the one on Precise). I added a comment related to that.

Also added a few tests on the JenkinsCore class.

Revision history for this message
Tom Haddon (mthaddon) wrote :

No need for precise/python3.2 support, and looks like the url property of setup.py needs updating.

Revision history for this message
Tom Haddon (mthaddon) wrote :

Looks good, thanks.

review: Approve
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision 421dc1082891d2215f1198de9eef377b82fe458d

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.flake8 b/.flake8
2new file mode 100644
3index 0000000..9a6d4d5
4--- /dev/null
5+++ b/.flake8
6@@ -0,0 +1,4 @@
7+[flake8]
8+exclude = .venv
9+max-complexity = 10
10+max-line-length = 120
11diff --git a/.gitignore b/.gitignore
12new file mode 100644
13index 0000000..e238002
14--- /dev/null
15+++ b/.gitignore
16@@ -0,0 +1,9 @@
17+*.pyc
18+*.swp
19+*~
20+.venv
21+/.coverage
22+/.pybuild
23+/.tox
24+__pycache__
25+debian/files
26diff --git a/Makefile b/Makefile
27new file mode 100644
28index 0000000..67498c0
29--- /dev/null
30+++ b/Makefile
31@@ -0,0 +1,67 @@
32+PROJECTPATH = $(dir $(realpath $(firstword $(MAKEFILE_LIST))))
33+
34+VENV := .venv
35+VENV_PIP := $(PROJECTPATH)/$(VENV)/bin/pip
36+VENV_PYTHON := $(PROJECTPATH)/$(VENV)/bin/python3
37+
38+BLACK := $(VENV_PYTHON) -m black
39+FLAKE8 := $(VENV_PYTHON) -m flake8
40+
41+PROJECT := jenkins-plugin-manager
42+PKG_NAME := python3-$(PROJECT)
43+
44+all: lint test
45+
46+clean: clean-python clean-venv clean-tox
47+
48+clean-python:
49+ @find $(PROJECTPATH) -name "__pycache__" -type d -exec rm -rf {} +
50+ @find $(PROJECTPATH) -name "pytest_cache" -type d -exec rm -rf {} +
51+
52+clean-tox:
53+ rm -rf $(PROJECTPATH)/.tox
54+
55+clean-venv:
56+ rm -rf $(PROJECTPATH)/$(VENV)
57+
58+dch:
59+ gbp dch --debian-tag='%(version)s' -D bionic --git-log --first-parent
60+
61+deb-src:
62+ debuild -S -sa -I.git -I.tox -I$(VENV)
63+
64+deb:
65+ debuild -us -uc -sa -I.git -I.tox -I$(VENV)
66+
67+/var/cache/pbuilder/base-bionic-amd64.cow:
68+ ARCH=amd64 DIST=bionic git-pbuilder create
69+
70+pbuilder-deb-src: /var/cache/pbuilder/base-bionic-amd64.cow
71+ GIT_PBUILDER_OPTIONS='--source-only-changes' \
72+ ARCH=amd64 DIST=bionic git-pbuilder -sa -I.git -I.tox -I$(VENV)
73+ @echo "Now use debsign on $(PROJECTPATH)../$(PKG_NAME)_x.y_source.changes"
74+
75+install-build-depends:
76+ sudo apt install \
77+ debhelper \
78+ git-buildpackage \
79+ virtualenv
80+
81+lint: lint-python
82+
83+# See .flake8 for config options.
84+lint-python:
85+ tox -e black
86+ tox -e lint
87+
88+test: test-python
89+
90+test-python:
91+ $(VENV_PYTHON) -m unittest discover -v tests
92+
93+$(VENV):
94+ virtualenv -p python3 $(PROJECTPATH)/$(VENV)
95+ $(VENV_PIP) install -I -r requirements.txt
96+
97+.PHONY: test lint lint-python test-python all lint lint-python clean clean-python clean-tox clean-venv deb-src dch
98+
99diff --git a/README.md b/README.md
100new file mode 100644
101index 0000000..fd3882f
102--- /dev/null
103+++ b/README.md
104@@ -0,0 +1,48 @@
105+# Jenkins Plugin Manager
106+
107+This repository contains the jenkins\_plugin\_manager python module.
108+this module allows a management of the Jenkins plugins and their dependencies as well as getting them
109+
110+The Launchpad project can be found
111+[here](https://launchpad.net/jenkins-plugin-manager).
112+
113+The code for this repository can be cloned with:
114+
115+ git clone git+ssh://git.launchpad.net/jenkins-plugin-manager
116+
117+## Code Location
118+
119+The Python code can be found at `./jenkins_plugin_manager.py`, with tests in the
120+`tests` directory. Lint configuration can be found in `./.flake8`.
121+
122+## Testing Code
123+
124+To get started, you need `make` and `tox`.
125+
126+ make install-build-depends
127+ make test
128+
129+## Building a Release
130+
131+The release process for a project would normally be something like:
132+
133+ [edit setup.py with the right version number (if applicable)]
134+ make dch
135+ [check and edit debian/changelog]
136+ git commit -m 'x.y.z release' debian/changelog
137+ export GPG\_TTY=$(tty)
138+ git tag -s -u <keyid> -m 'x.y.z release' x.y.z
139+ git push --tags
140+ make deb-src
141+ dput ppa:jenkins-plugin-manager-devs/jpm-devel ../python3-jenkins-plugin-manager\_x.y.z\_source.changes
142+
143+Where `x.y.z` is the appropriate version number.
144+This will upload the debian source package to the
145+[devel PPA](https://launchpad.net/~jenkins-plugin-manager-devs/+archive/ubuntu/jpm-devel)
146+for building.
147+
148+Once the new packages have been successfully tested, they can be
149+[copied](https://launchpad.net/~jenkins-plugin-manager-devs/+archive/ubuntu/jpm-devel/+copy-packages)
150+as binary packages (not rebuilt from source) to the
151+[production PPA](https://launchpad.net/~jenkins-plugin-manager-devs/+archive/ubuntu/jpm)
152+The production environment can then be updated from this PPA.
153diff --git a/debian/changelog b/debian/changelog
154new file mode 100644
155index 0000000..5a48582
156--- /dev/null
157+++ b/debian/changelog
158@@ -0,0 +1,19 @@
159+python3-jenkins-plugin-manager (0.3) bionic; urgency=medium
160+
161+ * Address inline comments
162+ * 0.3 release
163+
164+ -- Benjamin Allot (ballot@Canonical) <benjamin.allot@canonical.com> Mon, 11 Nov 2019 23:12:30 +0100
165+
166+python3-jenkins-plugin-manager (0.2ubuntu1) bionic; urgency=medium
167+
168+ * 0.2ubuntu1 release
169+
170+ -- Benjamin Allot (ballot@Canonical) <benjamin.allot@canonical.com> Fri, 08 Nov 2019 10:02:45 +0100
171+
172+python3-jenkins-plugin-manager (0.1ubuntu1) bionic; urgency=medium
173+
174+ * Initial commit
175+ * Change name into jenkins_plugin_manager and add debian package
176+
177+ -- Benjamin Allot (ballot@Canonical) <benjamin.allot@canonical.com> Fri, 08 Nov 2019 03:49:08 +0100
178diff --git a/debian/compat b/debian/compat
179new file mode 100644
180index 0000000..f599e28
181--- /dev/null
182+++ b/debian/compat
183@@ -0,0 +1 @@
184+10
185diff --git a/debian/control b/debian/control
186new file mode 100644
187index 0000000..0b7ec32
188--- /dev/null
189+++ b/debian/control
190@@ -0,0 +1,14 @@
191+Source: python3-jenkins-plugin-manager
192+Section: misc
193+Priority: optional
194+Maintainer: Benjamin Allot <benjamin.allot@canonical.com>
195+Build-Depends: debhelper (>= 11), dh-python, python3-all, python3-setuptools, virtualenv
196+Standards-Version: 3.9.7
197+X-Python3-Version: >= 3.2
198+
199+Package: python3-jenkins-plugin-manager
200+Architecture: any
201+Depends: ${misc:Depends}, ${python3:Depends}
202+Description: Jenkins Plugin Manager
203+ A python module used for managing Jenkins plugins dependencies
204+ and downloading them.
205diff --git a/debian/rules b/debian/rules
206new file mode 100755
207index 0000000..b867466
208--- /dev/null
209+++ b/debian/rules
210@@ -0,0 +1,22 @@
211+#!/usr/bin/make -f
212+
213+# Determine the current release
214+# # Use specific control and changelog files for Precise
215+#
216+codename ?= $(shell lsb_release -cs)
217+module := jenkins_plugin_manager
218+pkg_name := python3-$(module)
219+
220+export DH_VERBOSE=1
221+export PYBUILD_NAME=$(module)
222+
223+%:
224+ dh $@ --with python3 --buildsystem=pybuild
225+
226+override_dh_auto_test:
227+
228+override_dh_auto_clean:
229+ dh_auto_clean
230+ rm -rf build
231+ rm -rf *.egg-info
232+
233diff --git a/debian/source/format b/debian/source/format
234new file mode 100644
235index 0000000..d3827e7
236--- /dev/null
237+++ b/debian/source/format
238@@ -0,0 +1 @@
239+1.0
240diff --git a/jenkins_plugin_manager/__init__.py b/jenkins_plugin_manager/__init__.py
241new file mode 100644
242index 0000000..e69de29
243--- /dev/null
244+++ b/jenkins_plugin_manager/__init__.py
245diff --git a/jenkins_plugin_manager/core.py b/jenkins_plugin_manager/core.py
246new file mode 100644
247index 0000000..8b25ad0
248--- /dev/null
249+++ b/jenkins_plugin_manager/core.py
250@@ -0,0 +1,94 @@
251+"""
252+Jenkins Core module.
253+
254+This module loads a file like "https://updates.jenkins.io/stable-2.190/latestCore.txt"
255+to determine the latest "stable" or "current" Jenkins version.
256+It then provides methods through a class to retrieve the latest jenkins package
257+on "https://pkg.jenkins.io/".
258+"""
259+
260+import logging
261+import os
262+import urllib.request
263+
264+logger = logging.getLogger(__name__)
265+
266+
267+DEFAULT_JENKINS_CORE_URL = "https://updates.jenkins.io"
268+DEFAULT_JENKINS_REPO = "https://pkg.jenkins.io"
269+DEFAULT_JENKINS_RELEASE = "stable"
270+
271+
272+class JenkinsCore(object):
273+ """Represents Jenkins core manager."""
274+
275+ def __init__(
276+ self,
277+ jenkins_core_url=DEFAULT_JENKINS_CORE_URL,
278+ jenkins_repo=DEFAULT_JENKINS_REPO,
279+ jenkins_release=DEFAULT_JENKINS_RELEASE,
280+ ):
281+ """Instantiate the JenkinsCore class.
282+
283+ Load the latest version of the `jenkins_release` available.
284+
285+ :param str jenkins_core_url: Url where the latest Jenkins core version is set.
286+ :param str jenkins_repo: Url where the Jenkins binary packages are stored.
287+ :param str jenkins_release: Flavor of Jenkins. Values are "stable" or "current".
288+ """
289+ # TODO: Use python-apt to use the real repository data (and manage correct checksum)
290+ # Right now this manage only the official jenkins repository layout, which is non-standard
291+ self.jenkins_repo = jenkins_repo
292+ if jenkins_release not in ("current", "stable"):
293+ err = "Wrong jenkins_release parameter. Valid values are [current|stable]"
294+ logger.error(err)
295+ raise ValueError(err)
296+ self.jenkins_release = jenkins_release
297+ self.latest_core_url = "{0}/{1}/latestCore.txt".format(jenkins_core_url, jenkins_release)
298+ self.core_version = self.load_latest_core_version(self.latest_core_url)
299+
300+ def load_latest_core_version(self, core_url):
301+ """Load the latest version available.
302+
303+ :param str core_url: Url of the latest Jenkins core
304+ """
305+ logger.info("Loading %s", core_url)
306+ with urllib.request.urlopen(core_url) as core_data:
307+ core_version = core_data.read().decode("utf-8")
308+ logger.debug("Latest jenkins core version: %s", core_version)
309+ return core_version
310+
311+ def get_binary_package(self, dst_dir, distribution="debian", version=None, arch="amd64"):
312+ """Get a Jenkins binary package.
313+
314+ Download a binary package of the said distribution and of the latest Jenkins
315+ release if not specified.
316+
317+ :param str dst_dir: Target directory for the download
318+ :param str distribution: Distribution family to retrieve package for (debian, redhat)
319+ :param str version: The version you want to download. Default to the latest Core if not specified
320+ :param str arch: Architecture to download. Default to amd64
321+ """
322+
323+ assert distribution in ("debian", "redhat")
324+ if self.jenkins_release == "stable":
325+ repo_dist = "{0}-stable".format(distribution)
326+ else:
327+ repo_dist = distribution
328+
329+ if not version:
330+ target_version = self.core_version
331+ else:
332+ target_version = version
333+ binary_package_url = "{url}/{dist}/binary/jenkins_{version}_all.deb".format(
334+ url=self.jenkins_repo, dist=repo_dist, version=target_version
335+ )
336+ package_file = os.path.basename(binary_package_url)
337+ package_file_path = os.path.join(dst_dir, package_file)
338+ with urllib.request.urlopen(binary_package_url) as binary_pkg:
339+ if not os.path.exists(os.path.dirname(package_file_path)):
340+ os.makedirs(os.path.dirname(package_file_path))
341+ logger.info("Downloading %s to %s", binary_package_url, package_file_path)
342+ with open(package_file_path, "wb") as target_pkg:
343+ target_pkg.write(binary_pkg.read())
344+ logger.info("%s downloaded", package_file)
345diff --git a/jenkins_plugin_manager/exceptions.py b/jenkins_plugin_manager/exceptions.py
346new file mode 100644
347index 0000000..e7871bb
348--- /dev/null
349+++ b/jenkins_plugin_manager/exceptions.py
350@@ -0,0 +1,19 @@
351+"""Jenkins Plugin Manager exceptions."""
352+
353+
354+class InvalidPluginVersionError(Exception):
355+ """Exception when a desired plugin is higher than the most recent one in the Update Center."""
356+
357+ pass
358+
359+
360+class InvalidPluginError(Exception):
361+ """Exception when the desired plugin is invalid or not available."""
362+
363+ pass
364+
365+
366+class PluginMinCoreVersionError(Exception):
367+ """Exception when a plugin need a higher jenkins core version than the one set as the current one."""
368+
369+ pass
370diff --git a/jenkins_plugin_manager/main.py b/jenkins_plugin_manager/main.py
371new file mode 100755
372index 0000000..155dde0
373--- /dev/null
374+++ b/jenkins_plugin_manager/main.py
375@@ -0,0 +1,122 @@
376+#!/usr/bin/env python3
377+"""
378+Entrypoint for the jenkins-plugin-manager module
379+"""
380+
381+import argparse
382+import logging
383+import sys
384+
385+from jenkins_plugin_manager.plugin import UpdateCenter
386+
387+
388+logger = logging.getLogger("jenkins_plugin_manager")
389+
390+
391+def parse_args():
392+ """Parse the arguments provided to the CLI."""
393+ parser = argparse.ArgumentParser(prog="jenkins-plugin-manager")
394+ parser.add_argument("--log-level", action="store", help="Logging level", default="WARNING")
395+ parser.add_argument("--log-file", action="store", help="Log file")
396+ parser.add_argument("--log-file-level", action="store", help="File logging level", default="INFO")
397+ subparsers = parser.add_subparsers(help="sub-command help")
398+
399+ # Sub command "plugin"
400+ parser_plugin = subparsers.add_parser("plugin", help="Manage Jenkins plugins")
401+ parser_plugin.add_argument("--from-file", action="store", help="Load a list of plugin from a file")
402+ subparser_plugin = parser_plugin.add_subparsers(help="plugin sub-command help")
403+
404+ # Sub command "plugin list-urls"
405+ parser_plugin_list = subparser_plugin.add_parser("list-urls", help="List Jenkins plugins url")
406+ parser_plugin_list.add_argument("args", nargs=argparse.REMAINDER, help="List of plugin")
407+ parser_plugin_list.set_defaults(func=plugin_list_urls)
408+
409+ # Sub command "plugin download"
410+ parser_plugin_download = subparser_plugin.add_parser("download", help="Download Jenkins plugins")
411+ parser_plugin_download.add_argument(
412+ "--dst", action="store", required=True, help="Directory for the downloaded plugins"
413+ )
414+ parser_plugin_download.add_argument("args", nargs=argparse.REMAINDER, help="List of plugin")
415+ parser_plugin_download.set_defaults(func=plugin_download)
416+
417+ return parser.parse_args()
418+
419+
420+def configure_logging(args):
421+ """Configure the logging.
422+
423+ :param :py:class:argparse.ArgumentParser args: Arguments provided
424+ """
425+ formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
426+ logger.setLevel(logging.DEBUG)
427+ log_level = getattr(logging, args.log_level.upper(), None)
428+ if not isinstance(log_level, int):
429+ raise ValueError("Invalid log-level level: %s" % args.log_level)
430+ console = logging.StreamHandler()
431+ console.setLevel(log_level)
432+ console.setFormatter(formatter)
433+ logger.addHandler(console)
434+ if args.log_file:
435+ log_file_level = getattr(logging, args.log_file_level.upper(), None)
436+ if not isinstance(log_file_level, int):
437+ raise ValueError("Invalid --log-file-level level: %s" % args.log_file_level)
438+ logfile_handler = logging.FileHandler(args.log_file)
439+ logfile_handler.setLevel(log_file_level)
440+ logfile_handler.setFormatter(formatter)
441+ logger.addHandler(logfile_handler)
442+
443+
444+def plugin_load(args):
445+ """Return the list of plugin provided by the options.
446+
447+ :param :py:class:argparse.ArgumentParser args: Arguments provided
448+ """
449+ plugins = []
450+ if args.from_file:
451+ try:
452+ with open(args.from_file, "r") as plugin_file:
453+ plugins.extend(plugin_file.read().splitlines())
454+ except FileNotFoundError:
455+ logger.error("File %s: Not found", args.from_file)
456+ sys.exit(1)
457+ if args.args:
458+ plugins.extend(args.args)
459+ if not plugins:
460+ logger.error("No plugin provided.")
461+ sys.exit(1)
462+ return plugins
463+
464+
465+def plugin_download(args):
466+ """Download the given plugins.
467+
468+ :param :py:class:argparse.ArgumentParser args: Arguments provided
469+ """
470+ plugins = plugin_load(args)
471+ uc = UpdateCenter()
472+ for plugin in uc.get_plugins(set(plugins)):
473+ print(uc.download_plugin(plugin, args.dst))
474+
475+
476+def plugin_list_urls(args):
477+ """Sub-command "plugin list-urls".
478+
479+ Print the url of each plugin that need is specified.
480+
481+ :param :py:class:argparse.ArgumentParser args: Arguments provided
482+ """
483+ plugins = plugin_load(args)
484+ uc = UpdateCenter()
485+ for plugin in uc.get_plugins(set(plugins)):
486+ print(uc.get_plugin_data(plugin)["url"])
487+
488+
489+def main():
490+ """Main entrypoint for jenkins-plugin-manager CLI tool."""
491+ args = parse_args()
492+ configure_logging(args)
493+ args.func(args)
494+
495+
496+if __name__ == "__main__":
497+ main()
498diff --git a/jenkins_plugin_manager/plugin.py b/jenkins_plugin_manager/plugin.py
499new file mode 100644
500index 0000000..84b6c19
501--- /dev/null
502+++ b/jenkins_plugin_manager/plugin.py
503@@ -0,0 +1,216 @@
504+#!/usr/bin/env python3
505+
506+"""
507+Jenkins Plugin Manager plugin module.
508+
509+This module loads the JSON file available in jenkins Update Center
510+(default "https://updates.jenkins.io/stable/update-center.json") and load it for further computation.
511+"""
512+
513+import base64
514+import codecs
515+import hashlib
516+import json
517+import logging
518+import os
519+import urllib.request
520+
521+from pkg_resources import parse_version
522+
523+from jenkins_plugin_manager.exceptions import (
524+ InvalidPluginVersionError,
525+ InvalidPluginError,
526+ PluginMinCoreVersionError,
527+)
528+
529+logger = logging.getLogger(__name__)
530+
531+
532+class UpdateCenter(object):
533+ """Represents the UpdateCenter data."""
534+
535+ def __init__(
536+ self, uc_url="https://updates.jenkins.io/stable/update-center.json",
537+ ):
538+ """Instantiate the UpdateCenter class.
539+
540+ ..code:: python
541+
542+ uc = jenkins_plugin_manager.UpdateCenter()
543+ for plugin in uc.get_plugins(["ansicolor", "docker-workflow"]):
544+ uc.download_plugin(plugin, "/var/lib/jenkins/plugins", with_version=False
545+
546+ :param str uc_url: The url of the UpdateCenter JSON file. Default to the official stable one.
547+ """
548+ self.uc_data = self.load_update_center_data(uc_url)
549+
550+ def load_update_center_data(self, uc_url):
551+ """Load the content of the Update Center JSON file.
552+
553+ :param str uc_url: Update Center JSON file url.
554+ """
555+ logger.info("Loading Jenkins Update Center data from %s", uc_url)
556+ with urllib.request.urlopen(uc_url) as uc_data:
557+ # Remove the "updateCenter.post(\n ... \n);" around the json payload
558+ json_payload = uc_data.read().decode("utf-8").lstrip("updateCenter.post(\n").rstrip("\n);")
559+ return json.loads(json_payload)
560+
561+ def get_plugin_data(self, plugin):
562+ """Return the specific plugin data.
563+
564+ The plugin can be specificed with "latest" of it's version in the form "<plugin>:(<version>|latest)".
565+ However, only the latest plugin is pulled. A check is performed to ensure the desired version is inferior or
566+ equal to the latest available.
567+
568+ :param str plugin: The name of the plugin, possibly with the version or "latest". e.g <plugin>:<version>.
569+ :returns: The dictionary of the plugin's data as seen in the Update Center JSON file.
570+ :rtype: dict
571+ """
572+ if ":" in plugin:
573+ plugin_name, plugin_version = plugin.split(":")
574+ else:
575+ plugin_name = plugin
576+ plugin_version = "latest"
577+
578+ try:
579+ if plugin_version != "latest":
580+ # Check that the desired version is <= to the one available in the latest verison of the plugin
581+ latest_plugin_version = self.uc_data["plugins"][plugin_name]["version"]
582+ if parse_version(plugin_version) > parse_version(latest_plugin_version):
583+ err = "Plugin {0} version {1} is higher than the latest available version {2}".format(
584+ plugin_name, plugin_version, latest_plugin_version
585+ )
586+ logger.error(err)
587+ raise InvalidPluginVersionError(err)
588+ elif parse_version(plugin_version) < parse_version(latest_plugin_version):
589+ logger.info("Getting %s:%s instead of %s", plugin_name, latest_plugin_version, plugin)
590+
591+ plugin_data = self.uc_data["plugins"][plugin_name]
592+ logger.debug("Plugin %s data: %s", plugin_name, plugin_data)
593+ except KeyError:
594+ err = "Cannot find plugin {0}".format(plugin_name)
595+ logger.error(err)
596+ raise InvalidPluginError(err) from None
597+ return plugin_data
598+
599+ def _verify_sha256sum(self, plugin_file_path, sha256sum):
600+ """Verify the sha256sum of the given file.
601+
602+ :param str plugin_file_path: Path of the downloaded file.
603+ :param str sha256sum: A base64 encoding of the sha256sum of the plugin.
604+ :returns: True if the file has the right content, False otherwise.
605+ :rtype: bool
606+ """
607+ # The sha256 given is a binary base64 that need to be encoded to hex then converted into a string
608+ # See https://github.com/jenkins-infra/update-center2/blob/master/src/main/java/org/jvnet/hudson/update_center/IndexHtmlBuilder.java#L82 # noqa
609+ sha256sum_hex = codecs.decode(codecs.encode(base64.b64decode(sha256sum), "hex"), "utf-8")
610+ try:
611+ with open(plugin_file_path, "rb") as plugin_file:
612+ content = plugin_file.read()
613+ sha256sum_check = hashlib.sha256(content).hexdigest()
614+ if sha256sum_check != sha256sum_hex:
615+ logger.error("SHA256sum check failed, found %s (expected %s)", sha256sum_check, sha256sum_hex)
616+ return False
617+ except FileNotFoundError:
618+ logger.error("Cannot open the file %s", plugin_file_path)
619+ return False
620+ return True
621+
622+ def _check_min_core_version(self, core_version, plugin_min_core_version):
623+ """Check the plugin required Jenkins core version.
624+
625+ :param str core_version: Version of Jenkins core.
626+ :param str plugin_min_core_version: The minimum version of Jenkins core required for this plugin.
627+ :returns: True if the Jenkins core version is sufficient, False otherwise.
628+ :rtype: bool
629+ """
630+ if parse_version(core_version) < parse_version(plugin_min_core_version):
631+ logger.error(
632+ "Jenkins core version %s or above is required (%s found)", plugin_min_core_version, core_version
633+ )
634+ return False
635+ return True
636+
637+ def download_plugin(self, plugin, dst_dir, plugin_url=None, with_version=True):
638+ """Download the given plugin in the "dst_dir" directory.
639+
640+ A checksum is done on the files downloaded.
641+
642+ :param str plugin: The name of the plugin, possibly with the version or "latest". e.g <plugin>:<version>.
643+ :param str dst_dir: A path where the files are downloaded.
644+ :param str plugin_url: If set, use this url to retrieve the plugin instead of the one in the UpdateCenter
645+ JSON file. This is useful when you use a in-house HTTP server hosting the plugins without having
646+ a Jenkins updateCenter.
647+ :param bool with_version: Append the version of the plugin to the name in the target file if True.
648+ Only the name of the plugin if False.
649+ :returns: The path of the downloaded file, False if it fails.
650+ :rtype: str
651+ """
652+ plugin_data = self.get_plugin_data(plugin)
653+ plugin_name = plugin_data["name"]
654+
655+ if not plugin_url:
656+ plugin_url = plugin_data["url"]
657+
658+ logger.info("Downloading plugin %s from %s", plugin_name, plugin_url)
659+ with urllib.request.urlopen(plugin_url) as final_plugin_url:
660+ if with_version:
661+ plugin_file_path = os.path.join(dst_dir, "{0}-{1}.jpi".format(plugin_name, plugin_data["version"]))
662+ else:
663+ plugin_file_path = os.path.join(dst_dir, "{0}.jpi".format(plugin_name))
664+ if not os.path.exists(os.path.dirname(plugin_file_path)):
665+ os.makedirs(os.path.dirname(plugin_file_path))
666+ with open(plugin_file_path, "wb") as plugin_file:
667+ plugin_file.write(final_plugin_url.read())
668+ if not self._verify_sha256sum(plugin_file_path, plugin_data["sha256"]):
669+ return False
670+ logger.info("Plugin %s downloaded to %s", plugin_name, plugin_file_path)
671+ return plugin_file_path
672+
673+ def get_plugins_dependencies(self, plugin, optional=False):
674+ """Get the list of the plugin's dependencies.
675+
676+ :param str plugin: The name of the plugin, possibly with the version or "latest". e.g <plugin>:<version>.
677+ :param str dst: A path where the files are downloaded.
678+ :param bool optional: Get the optional dependencies.
679+ :returns: A set of the plugin's dependency.
680+ :rtype: set
681+ """
682+ plugin_data = self.get_plugin_data(plugin)
683+ dependencies_to_fetch = set()
684+ for dependency in plugin_data["dependencies"]:
685+ # Skip optional dependency or if already in the list
686+ if dependency["optional"] and not optional:
687+ continue
688+ # Since the constraints are always >=, we don't care to pick up the latest dependency
689+ dependencies_to_fetch.add(dependency["name"])
690+ return dependencies_to_fetch
691+
692+ def get_plugins(self, plugins, current_core_version=None, with_dependency=True, optional=False):
693+ """Get a list of plugins with their dependencies.
694+
695+ You can also add optional dependencies if needed.
696+
697+ :param list plugins: List of plugins to download. Each plugin is a string containing the name of the plugin and
698+ possibly a colon (":") with the version of the plugin or "latest". If omitted, "latest" is assumed.
699+ :param str current_core_version: Jenkins core version running.
700+ :param bool optional: Add the optional dependencies if True.
701+ :returns: A set of plugins to download.
702+ :rtype: set
703+ """
704+ plugins_to_fetch = set()
705+ dependencies_to_fetch = set()
706+ for plugin in plugins:
707+ plugin_data = self.get_plugin_data(plugin)
708+ plugin_name = plugin_data["name"]
709+ # Check if the Jenkins Core version is enough
710+ if current_core_version:
711+ logger.info("Checking minimum Jenkins core version required for %s", plugin)
712+ if not self._check_min_core_version(current_core_version, plugin_data["requiredCore"]):
713+ raise PluginMinCoreVersionError(
714+ "Plugin {0} requires Jenkins core version {1}".format(plugin_name, plugin_data["requiredCore"])
715+ )
716+ plugins_to_fetch.add(plugin_data["name"])
717+ if with_dependency:
718+ dependencies_to_fetch = dependencies_to_fetch.union(self.get_plugins_dependencies(plugin, optional))
719+ return plugins_to_fetch.union(dependencies_to_fetch)
720diff --git a/requirements.txt b/requirements.txt
721new file mode 100644
722index 0000000..4e92b9d
723--- /dev/null
724+++ b/requirements.txt
725@@ -0,0 +1,2 @@
726+black
727+flake8
728diff --git a/setup.py b/setup.py
729new file mode 100644
730index 0000000..3659cd2
731--- /dev/null
732+++ b/setup.py
733@@ -0,0 +1,44 @@
734+"""Jenkins Plugin Manager module."""
735+
736+# Always prefer setuptools over distutils
737+from setuptools import setup, find_packages
738+from os import path
739+
740+here = path.abspath(path.dirname(__file__))
741+
742+# Get the long description from the README file
743+with open(path.join(here, "README.md"), encoding="utf-8") as f:
744+ long_description = f.read()
745+
746+# Arguments marked as "Required" below must be included for upload to PyPI.
747+# Fields marked as "Optional" may be commented out.
748+
749+setup(
750+ name="jenkins-plugin-manager", # Required
751+ version="0.3", # Required
752+ description="Manage Jenkins plugin (get dependencies, download them)", # Optional
753+ long_description=long_description, # Optional
754+ long_description_content_type="text/markdown", # Optional (see note above)
755+ url="https://launchpad.net/jenkins-plugin-manager", # Optional
756+ author="Benjamin Allot", # Optional
757+ author_email="benjamin.allot@canonical.com", # Optional
758+ classifiers=[ # Optional
759+ "Development Status :: 3 - Alpha",
760+ "Intended Audience :: Developers",
761+ "Topic :: Software Development :: Build Tools",
762+ "License :: OSI Approved :: MIT License",
763+ "Programming Language :: Python :: 3",
764+ ],
765+ keywords="jenkins", # Optional
766+ packages=find_packages(exclude=["tests"]), # Required
767+ install_requires="setuptools",
768+ python_requires=">=3.3",
769+ extras_require={"test": ["coverage"]}, # Optional
770+ # For example, the following would provide a command called `sample` which
771+ # executes the function `main` from this package when invoked:
772+ entry_points={"console_scripts": ["jenkins-plugin-manager=jenkins_plugin_manager.main:main"]}, # Optional
773+ project_urls={ # Optional
774+ "Bug Reports": "https://bugs.launchpad.net/jenkins-plugin-manager",
775+ "Source": "https://code.launchpad.net/jenkins-plugin-manager",
776+ },
777+)
778diff --git a/tests/test_jenkins_plugin_manager_core.py b/tests/test_jenkins_plugin_manager_core.py
779new file mode 100644
780index 0000000..79d1119
781--- /dev/null
782+++ b/tests/test_jenkins_plugin_manager_core.py
783@@ -0,0 +1,52 @@
784+import unittest
785+
786+from unittest import mock
787+
788+from jenkins_plugin_manager.core import (
789+ JenkinsCore,
790+ DEFAULT_JENKINS_RELEASE,
791+ DEFAULT_JENKINS_CORE_URL,
792+)
793+
794+
795+class TestJenkinsCore(unittest.TestCase):
796+ """Tests for the JenkinsCore class."""
797+
798+ LATEST_CORE_VERSION = "2.1.190"
799+
800+ def setUp(self):
801+ """Setup fxture for each test."""
802+ core_version_patch = mock.patch("jenkins_plugin_manager.core.JenkinsCore.load_latest_core_version")
803+ self.core_version_mock = core_version_patch.start()
804+ self.addCleanup(core_version_patch.stop)
805+ self.addCleanup(self.core_version_mock.reset_mock)
806+ self.core_version_mock.return_value = self.LATEST_CORE_VERSION
807+ self.jenkins_core = JenkinsCore()
808+
809+ def test_jenkinscore_attr(self):
810+ """Check the varios attributes of the class."""
811+ # Check default attribute
812+ self.assertEqual(self.jenkins_core.jenkins_release, DEFAULT_JENKINS_RELEASE)
813+ self.assertEqual(
814+ self.jenkins_core.latest_core_url,
815+ "{0}/{1}/latestCore.txt".format(DEFAULT_JENKINS_CORE_URL, DEFAULT_JENKINS_RELEASE),
816+ )
817+ self.assertEqual(self.jenkins_core.core_version, self.LATEST_CORE_VERSION)
818+
819+ # Check custom repository
820+ jenkins_repo = "http://pkg.jenkins.canonical.com"
821+ jenkins_release = "current"
822+ jenkins_core_url = "http://updates.jenkins.canonical.com"
823+ custom_jc = JenkinsCore(
824+ jenkins_release=jenkins_release, jenkins_repo=jenkins_repo, jenkins_core_url=jenkins_core_url
825+ )
826+ self.assertEqual(custom_jc.jenkins_release, jenkins_release)
827+ self.assertEqual(custom_jc.latest_core_url, "{0}/{1}/latestCore.txt".format(jenkins_core_url, jenkins_release))
828+
829+ # Check exception
830+ jenkins_release = "custom"
831+ with self.assertRaises(ValueError) as cm:
832+ custom_jc = JenkinsCore(jenkins_release=jenkins_release)
833+ self.assertEqual(
834+ cm.exception, ValueError("Wrong jenkins_release parameter. Valid values are [current|stable]")
835+ )
836diff --git a/tests/test_jenkins_plugin_manager_plugin.py b/tests/test_jenkins_plugin_manager_plugin.py
837new file mode 100644
838index 0000000..768a7ff
839--- /dev/null
840+++ b/tests/test_jenkins_plugin_manager_plugin.py
841@@ -0,0 +1,176 @@
842+import json
843+import os
844+import tempfile
845+import unittest
846+
847+from unittest import mock
848+
849+import jenkins_plugin_manager
850+
851+from jenkins_plugin_manager.plugin import UpdateCenter
852+
853+UC_DATA_FILE = "update_center_data.json"
854+
855+
856+class TestUpdateCenterLoad(unittest.TestCase):
857+ """Tests for the UpdateCenter methods loading data."""
858+
859+ dir_path = os.path.dirname(os.path.realpath(__file__))
860+ uc_data_file = os.path.join(dir_path, UC_DATA_FILE)
861+ with open(uc_data_file, "r") as uc_data:
862+ uc_data = uc_data.read()
863+
864+ @mock.patch("urllib.request.urlopen", autospec=True)
865+ def test_load_update_center_data(self, urllib_mock):
866+ cm = mock.MagicMock()
867+ cm.read.return_value = "updateCenter.post(\n{0}\n);".format(self.uc_data).encode("utf-8")
868+ cm.__enter__.return_value = cm
869+ urllib_mock.return_value = cm
870+ uc = UpdateCenter()
871+ self.assertEqual(uc.uc_data, json.loads(self.uc_data))
872+
873+
874+class TestUpdateCenter(unittest.TestCase):
875+ """Tests for the UpdateCenter class and methods."""
876+
877+ dir_path = os.path.dirname(os.path.realpath(__file__))
878+ uc_data_file = os.path.join(dir_path, UC_DATA_FILE)
879+ with open(uc_data_file, "r") as uc_data:
880+ uc_data = json.loads(uc_data.read())
881+
882+ def setUp(self):
883+ """Load samples of the Jenkins UpdateCenter JSON files."""
884+ uc_data_patch = mock.patch("jenkins_plugin_manager.plugin.UpdateCenter.load_update_center_data")
885+ self.uc_data_mock = uc_data_patch.start()
886+ self.addCleanup(self.uc_data_mock.reset_mock)
887+ self.addCleanup(uc_data_patch.stop)
888+ self.uc_data_mock.return_value = self.uc_data
889+ self.uc = UpdateCenter()
890+
891+ def test_default_load_data(self):
892+ """Test the default JSON files."""
893+ self.uc_data_mock.assert_called_once_with("https://updates.jenkins.io/stable/update-center.json")
894+
895+ self.uc_data_mock.reset_mock()
896+
897+ @mock.patch("jenkins_plugin_manager.plugin.UpdateCenter.load_update_center_data")
898+ def test_custom_load_data(self, custom_uc_data_mock):
899+ """Test custom urls for JSON files."""
900+ custom_uc_data_mock.return_value = self.uc_data
901+ UpdateCenter(uc_url="http://archive.admin.canonical.com/others/stable/update-center.json",)
902+ custom_uc_data_mock.assert_called_once_with(
903+ "http://archive.admin.canonical.com/others/stable/update-center.json"
904+ )
905+
906+ def test_get_plugin_data(self):
907+ """Test the plugin data and the exception raised."""
908+ plugin_test = "ansible"
909+ plugin_data = self.uc.get_plugin_data(plugin_test)
910+ self.assertEqual(plugin_data, self.uc_data["plugins"][plugin_test])
911+ plugin_test = "PluginThatDoesNotExist"
912+ with self.assertRaises(jenkins_plugin_manager.exceptions.InvalidPluginError):
913+ self.uc.get_plugin_data(plugin_test)
914+ plugin_test = "docker-workflow:999999"
915+ with self.assertRaises(jenkins_plugin_manager.exceptions.InvalidPluginVersionError):
916+ plugin_data = self.uc.get_plugin_data(plugin_test)
917+ plugin_test = "docker-workflow:1.20"
918+ with self.assertLogs(logger="jenkins_plugin_manager", level="INFO") as cm:
919+ plugin_data = self.uc.get_plugin_data(plugin_test)
920+ self.assertEqual(plugin_data["version"], "1.21")
921+ self.assertEqual(
922+ cm.output,
923+ ["INFO:jenkins_plugin_manager.plugin:Getting docker-workflow:1.21 instead of docker-workflow:1.20"],
924+ )
925+
926+ def test_verify_sha256sum(self):
927+ """Test the weird sha256sum.
928+
929+ The sha256sum provided in the Update Center is a weird one.
930+ See https://github.com/jenkins-infra/update-center2/blob/master/src/main/java/org/jvnet/hudson/update_center/IndexHtmlBuilder.java#L82 # noqa
931+ Fake a file containing "Jenkins Plugin Manager Rocks!\n" for the check.
932+ The sha256sum is "0290683d8fa6746e9d6de82d0ab8753a5e4078d5c2a9a40aa08f1a0c05429748".
933+ The sha256sum expected as argument is "ApBoPY+mdG6dbegtCrh1Ol5AeNXCqaQKoI8aDAVCl0g=".
934+ """
935+ with tempfile.TemporaryDirectory() as test_dir:
936+ stub_file = os.path.join(test_dir, "check")
937+ with open(stub_file, "w") as test_file:
938+ test_file.write("Jenkins Plugin Manager Rocks!\n")
939+ check = self.uc._verify_sha256sum(stub_file, "ApBoPY+mdG6dbegtCrh1Ol5AeNXCqaQKoI8aDAVCl0g=")
940+ self.assertTrue(check)
941+
942+ def test_check_min_core_version(self):
943+ """Test the required Jenkins core version is available."""
944+ with self.assertLogs(logger="jenkins_plugin_manager.plugin", level="ERROR") as cm:
945+ self.assertFalse(self.uc._check_min_core_version("2.1", "2.190.1"))
946+ self.assertEqual(
947+ cm.output,
948+ ["ERROR:jenkins_plugin_manager.plugin:Jenkins core version 2.190.1 or above is required (2.1 found)"],
949+ )
950+ self.assertTrue(self.uc._check_min_core_version("2.190.1", "2.190"))
951+
952+ @mock.patch("jenkins_plugin_manager.plugin.UpdateCenter.get_plugin_data")
953+ @mock.patch("jenkins_plugin_manager.plugin.UpdateCenter._verify_sha256sum", autospec=True)
954+ @mock.patch("urllib.request.urlopen", autospec=True)
955+ def test_download_plugin(self, urllib_mock, checksum_mock, get_plugin_data_mock):
956+ """Test the download of a plugin."""
957+
958+ def get_plugin_data(plugin):
959+ return self.uc_data["plugins"][plugin]
960+
961+ cm = mock.MagicMock()
962+ cm.read.return_value = b"This is a plugin"
963+ cm.__enter__.return_value = cm
964+ urllib_mock.return_value = cm
965+ checksum_mock.return_value = True
966+ get_plugin_data_mock.side_effect = get_plugin_data
967+ with tempfile.TemporaryDirectory() as test_dir:
968+ plugin_file = os.path.join(test_dir, "jenkins", "ansicolor-0.6.2.jpi")
969+ with self.assertLogs(logger="jenkins_plugin_manager.plugin", level="INFO") as cm:
970+ filepath = self.uc.download_plugin("ansicolor", os.path.join(test_dir, "jenkins"))
971+ self.assertTrue(os.path.exists(os.path.join(test_dir, "jenkins")))
972+ self.assertEqual(filepath, plugin_file)
973+ self.assertEqual(
974+ cm.output,
975+ [
976+ "INFO:jenkins_plugin_manager.plugin:Downloading plugin {0} from {1}".format(
977+ "ansicolor", "http://updates.jenkins-ci.org/download/plugins/ansicolor/0.6.2/ansicolor.hpi"
978+ ),
979+ "INFO:jenkins_plugin_manager.plugin:Plugin ansicolor downloaded to {0}".format(plugin_file),
980+ ],
981+ )
982+ # Check that when verify_sha256sum fails, return False
983+ checksum_mock.return_value = False
984+ with self.assertLogs(logger="jenkins_plugin_manager.plugin", level="INFO") as cm:
985+ self.assertFalse(
986+ self.uc.download_plugin(
987+ "ant",
988+ os.path.join(test_dir, "jenkins"),
989+ plugin_url="http://dummy.canonical.com/ant.hpi",
990+ with_version=False,
991+ )
992+ )
993+ self.assertEqual(
994+ cm.output,
995+ [
996+ "INFO:jenkins_plugin_manager.plugin:Downloading plugin {0} from {1}".format(
997+ "ant", "http://dummy.canonical.com/ant.hpi"
998+ )
999+ ],
1000+ )
1001+ self.assertFalse(os.path.exists(os.path.join(test_dir, "jenkins", "ant-1.10.jpi")))
1002+ self.assertTrue(os.path.exists(os.path.join(test_dir, "jenkins", "ant.jpi")))
1003+
1004+ def test_get_plugin_dependencies(self):
1005+ """Test the list of dependencies for a given plugin."""
1006+ # Should be empty because all dependencies are optional
1007+ deps = self.uc.get_plugins_dependencies("ansicolor")
1008+ self.assertEqual(set(), deps)
1009+ deps = self.uc.get_plugins_dependencies("ansicolor", optional=True)
1010+ self.assertEqual({"workflow-api", "workflow-step-api"}, deps)
1011+
1012+ def test_get_plugins(self):
1013+ """Test the list of plugins to fetch and the check with the current Jenkins core version."""
1014+ with self.assertRaises(jenkins_plugin_manager.exceptions.PluginMinCoreVersionError):
1015+ self.uc.get_plugins(["ansicolor"], current_core_version="2.144")
1016+ plugins = self.uc.get_plugins(["ant"])
1017+ self.assertEqual(plugins, {"ant", "structs"})
1018diff --git a/tox.ini b/tox.ini
1019new file mode 100644
1020index 0000000..e94d726
1021--- /dev/null
1022+++ b/tox.ini
1023@@ -0,0 +1,36 @@
1024+[tox]
1025+skipsdist=True
1026+envlist = unit
1027+skip_missing_interpreters = True
1028+
1029+[testenv]
1030+basepython = python3
1031+setenv =
1032+ PYTHONPATH = {toxinidir}
1033+
1034+[testenv:unit]
1035+commands =
1036+ pytest \
1037+ {posargs:-v --cov=jenkins_plugin_manager --cov-report=term-missing --cov-branch}
1038+deps =
1039+ -r{toxinidir}/requirements.txt
1040+ pytest
1041+ pytest-coverage
1042+
1043+[testenv:black]
1044+commands =
1045+ black --skip-string-normalization --line-length=120 {toxinidir}
1046+deps = black
1047+
1048+[testenv:lint]
1049+commands =
1050+ flake8 --exclude=.svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg,.venv
1051+deps = flake8
1052+
1053+[flake8]
1054+exclude =
1055+ .git,
1056+ __pycache__,
1057+ .tox,
1058+max-line-length = 120
1059+max-complexity = 10

Subscribers

People subscribed via source and target branches