From f25bf010532d53b325aa235ffba89550983e3e4a Mon Sep 17 00:00:00 2001 From: Jiri Tyr Date: Wed, 24 Aug 2016 03:13:55 +0200 Subject: [PATCH] Adding jenkins_plugin module (#1730) --- web_infrastructure/jenkins_plugin.py | 830 +++++++++++++++++++++++++++ 1 file changed, 830 insertions(+) create mode 100644 web_infrastructure/jenkins_plugin.py diff --git a/web_infrastructure/jenkins_plugin.py b/web_infrastructure/jenkins_plugin.py new file mode 100644 index 00000000000..4dc2c345a6b --- /dev/null +++ b/web_infrastructure/jenkins_plugin.py @@ -0,0 +1,830 @@ +#!/usr/bin/python +# encoding: utf-8 + +# (c) 2016, Jiri Tyr +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import fetch_url +from ansible.module_utils.urls import url_argument_spec +import base64 +import hashlib +import json +import os +import tempfile +import time +import urllib + + +DOCUMENTATION = ''' +--- +module: jenkins_plugin +author: Jiri Tyr (@jtyr) +version_added: '2.2' +short_description: Add or remove Jenkins plugin +description: + - Ansible module which helps to manage Jenkins plugins. + +options: + group: + required: false + default: jenkins + description: + - Name of the Jenkins group on the OS. + jenkins_home: + required: false + default: /var/lib/jenkins + description: + - Home directory of the Jenkins user. + mode: + required: false + default: '0664' + description: + - File mode applied on versioned plugins. + name: + required: true + description: + - Plugin name. + owner: + required: false + default: jenkins + description: + - Name of the Jenkins user on the OS. + params: + required: false + default: null + description: + - Option used to allow the user to overwrite any of the other options. To + remove an option, set the value of the option to C(null). + state: + required: false + choices: [absent, present, pinned, unpinned, enabled, disabled, latest] + default: present + description: + - Desired plugin state. + - If the C(latest) is set, the check for new version will be performed + every time. This is suitable to keep the plugin up-to-date. + timeout: + required: false + default: 30 + description: + - Server connection timeout in secs. + updates_expiration: + required: false + default: 86400 + description: + - Number of seconds after which a new copy of the I(update-center.json) + file is downloaded. This is used to avoid the need to download the + plugin to calculate its checksum when C(latest) is specified. + - Set it to C(0) if no cache file should be used. In that case, the + plugin file will always be downloaded to calculate its checksum when + C(latest) is specified. + updates_url: + required: false + default: https://updates.jenkins-ci.org + description: + - URL of the Update Centre. + - Used as the base URL to download the plugins and the + I(update-center.json) JSON file. + url: + required: false + default: http://localhost:8080 + description: + - URL of the Jenkins server. + version: + required: false + default: null + description: + - Plugin version number. + - If this option is specified, all plugin dependencies must be installed + manually. + - It might take longer to verify that the correct version is installed. + This is especially true if a specific version number is specified. + with_dependencies: + required: false + choices: ['yes', 'no'] + default: 'yes' + description: + - Defines whether to install plugin dependencies. + +notes: + - Plugin installation shoud be run under root or the same user which owns + the plugin files on the disk. Only if the plugin is not installed yet and + no version is specified, the API installation is performed which requires + only the Web UI credentials. + - It's necessary to notify the handler or call the I(service) module to + restart the Jenkins service after a new plugin was installed. + - Pinning works only if the plugin is installed and Jenkis service was + successfully restarted after the plugin installation. + - It is not possible to run the module remotely by changing the I(url) + parameter to point to the Jenkins server. The module must be used on the + host where Jenkins runs as it needs direct access to the plugin files. +''' + +EXAMPLES = ''' +- name: Install plugin + jenkins_plugin: + name: build-pipeline-plugin + +- name: Install plugin without its dependencies + jenkins_plugin: + name: build-pipeline-plugin + with_dependencies: no + +- name: Make sure the plugin is always up-to-date + jenkins_plugin: + name: token-macro + state: latest + +- name: Install specific version of the plugin + jenkins_plugin: + name: token-macro + version: 1.15 + +- name: Pin the plugin + jenkins_plugin: + name: token-macro + state: pinned + +- name: Unpin the plugin + jenkins_plugin: + name: token-macro + state: unpinned + +- name: Enable the plugin + jenkins_plugin: + name: token-macro + state: enabled + +- name: Disable the plugin + jenkins_plugin: + name: token-macro + state: disabled + +- name: Uninstall plugin + jenkins_plugin: + name: build-pipeline-plugin + state: absent + +# +# Example of how to use the params +# +# Define a variable and specify all default parameters you want to use across +# all jenkins_plugin calls: +# +# my_jenkins_params: +# url_username: admin +# url_password: p4ssw0rd +# url: http://localhost:8888 +# +- name: Install plugin + jenkins_plugin: + name: build-pipeline-plugin + params: "{{ my_jenkins_params }}" + +# +# Example of a Play which handles Jenkins restarts during the state changes +# +- name: Jenkins Master play + hosts: jenkins-master + vars: + my_jenkins_plugins: + token-macro: + enabled: yes + build-pipeline-plugin: + version: 1.4.9 + pinned: no + enabled: yes + tasks: + - name: Install plugins without a specific version + jenkins_plugin: + name: "{{ item.key }}" + register: my_jenkins_plugin_unversioned + when: > + 'version' not in item.value + with_dict: my_jenkins_plugins + + - name: Install plugins with a specific version + jenkins_plugin: + name: "{{ item.key }}" + version: "{{ item.value['version'] }}" + register: my_jenkins_plugin_versioned + when: > + 'version' in item.value + with_dict: my_jenkins_plugins + + - name: Initiate the fact + set_fact: + jenkins_restart_required: no + + - name: Check if restart is required by any of the versioned plugins + set_fact: + jenkins_restart_required: yes + when: item.changed + with_items: my_jenkins_plugin_versioned.results + + - name: Check if restart is required by any of the unversioned plugins + set_fact: + jenkins_restart_required: yes + when: item.changed + with_items: my_jenkins_plugin_unversioned.results + + - name: Restart Jenkins if required + service: + name: jenkins + state: restarted + when: jenkins_restart_required + + # Requires python-httplib2 to be installed on the guest + - name: Wait for Jenkins to start up + uri: + url: http://localhost:8080 + status_code: 200 + timeout: 5 + register: jenkins_service_status + # Keep trying for 5 mins in 5 sec intervals + retries: 60 + delay: 5 + until: > + 'status' in jenkins_service_status and + jenkins_service_status['status'] == 200 + when: jenkins_restart_required + + - name: Reset the fact + set_fact: + jenkins_restart_required: no + when: jenkins_restart_required + + - name: Plugin pinning + jenkins_plugin: + name: "{{ item.key }}" + state: "{{ 'pinned' if item.value['pinned'] else 'unpinned'}}" + when: > + 'pinned' in item.value + with_dict: my_jenkins_plugins + + - name: Plugin enabling + jenkins_plugin: + name: "{{ item.key }}" + state: "{{ 'enabled' if item.value['enabled'] else 'disabled'}}" + when: > + 'enabled' in item.value + with_dict: my_jenkins_plugins +''' + +RETURN = ''' +plugin: + description: plugin name + returned: success + type: string + sample: build-pipeline-plugin +state: + description: state of the target, after execution + returned: success + type: string + sample: "present" +''' + + +class JenkinsPlugin(object): + def __init__(self, module): + # To be able to call fail_json + self.module = module + + # Shortcuts for the params + self.params = self.module.params + self.url = self.params['url'] + self.timeout = self.params['timeout'] + + # Crumb + self.crumb = {} + + if self._csrf_enabled(): + self.crumb = self._get_crumb() + + # Get list of installed plugins + self._get_installed_plugins() + + def _csrf_enabled(self): + csrf_data = self._get_json_data( + "%s/%s" % (self.url, "api/json"), 'CSRF') + + return csrf_data["useCrumbs"] + + def _get_json_data(self, url, what, **kwargs): + # Get the JSON data + r = self._get_url_data(url, what, **kwargs) + + # Parse the JSON data + try: + json_data = json.load(r) + except Exception: + e = get_exception() + self.module.fail_json( + msg="Cannot parse %s JSON data." % what, + details=e.message) + + return json_data + + def _get_url_data( + self, url, what=None, msg_status=None, msg_exception=None, + **kwargs): + # Compose default messages + if msg_status is None: + msg_status = "Cannot get %s" % what + + if msg_exception is None: + msg_exception = "Retrieval of %s failed." % what + + # Get the URL data + try: + response, info = fetch_url( + self.module, url, timeout=self.timeout, **kwargs) + + if info['status'] != 200: + self.module.fail_json(msg=msg_status, details=info['msg']) + except Exception: + e = get_exception() + self.module.fail_json(msg=msg_exception, details=e.message) + + return response + + def _get_crumb(self): + crumb_data = self._get_json_data( + "%s/%s" % (self.url, "crumbIssuer/api/json"), 'Crumb') + + if 'crumbRequestField' in crumb_data and 'crumb' in crumb_data: + ret = { + crumb_data['crumbRequestField']: crumb_data['crumb'] + } + else: + self.module.fail_json( + msg="Required fields not found in the Crum response.", + details=crumb_data) + + return ret + + def _get_installed_plugins(self): + plugins_data = self._get_json_data( + "%s/%s" % (self.url, "pluginManager/api/json?depth=1"), + 'list of plugins') + + # Check if we got valid data + if 'plugins' not in plugins_data: + self.module.fail_json(msg="No valid plugin data found.") + + # Create final list of installed/pined plugins + self.is_installed = False + self.is_pinned = False + self.is_enabled = False + + for p in plugins_data['plugins']: + if p['shortName'] == self.params['name']: + self.is_installed = True + + if p['pinned']: + self.is_pinned = True + + if p['enabled']: + self.is_enabled = True + + break + + def install(self): + changed = False + plugin_file = ( + '%s/plugins/%s.jpi' % ( + self.params['jenkins_home'], + self.params['name'])) + + if not self.is_installed and self.params['version'] is None: + if not self.module.check_mode: + # Install the plugin (with dependencies) + install_script = ( + 'd = Jenkins.instance.updateCenter.getPlugin("%s")' + '.deploy(); d.get();' % self.params['name']) + + if self.params['with_dependencies']: + install_script = ( + 'Jenkins.instance.updateCenter.getPlugin("%s")' + '.getNeededDependencies().each{it.deploy()}; %s' % ( + self.params['name'], install_script)) + + script_data = { + 'script': install_script + } + script_data.update(self.crumb) + data = urllib.urlencode(script_data) + + # Send the installation request + r = self._get_url_data( + "%s/scriptText" % self.url, + msg_status="Cannot install plugin.", + msg_exception="Plugin installation has failed.", + data=data) + + changed = True + else: + # Check if the plugin directory exists + if not os.path.isdir(self.params['jenkins_home']): + self.module.fail_json( + msg="Jenkins home directory doesn't exist.") + + md5sum_old = None + if os.path.isfile(plugin_file): + # Make the checksum of the currently installed plugin + md5sum_old = hashlib.md5( + open(plugin_file, 'rb').read()).hexdigest() + + if self.params['version'] in [None, 'latest']: + # Take latest version + plugin_url = ( + "%s/latest/%s.hpi" % ( + self.params['updates_url'], + self.params['name'])) + else: + # Take specific version + plugin_url = ( + "{0}/download/plugins/" + "{1}/{2}/{1}.hpi".format( + self.params['updates_url'], + self.params['name'], + self.params['version'])) + + if ( + self.params['updates_expiration'] == 0 or + self.params['version'] not in [None, 'latest'] or + md5sum_old is None): + + # Download the plugin file directly + r = self._download_plugin(plugin_url) + + # Write downloaded plugin into file if checksums don't match + if md5sum_old is None: + # No previously installed plugin + if not self.module.check_mode: + self._write_file(plugin_file, r) + + changed = True + else: + # Get data for the MD5 + data = r.read() + + # Make new checksum + md5sum_new = hashlib.md5(data).hexdigest() + + # If the checksum is different from the currently installed + # plugin, store the new plugin + if md5sum_old != md5sum_new: + if not self.module.check_mode: + self._write_file(plugin_file, data) + + changed = True + else: + # Check for update from the updates JSON file + plugin_data = self._download_updates() + + try: + sha1_old = hashlib.sha1(open(plugin_file, 'rb').read()) + except Exception: + e = get_exception() + self.module.fail_json( + msg="Cannot calculate SHA1 of the old plugin.", + details=e.message) + + sha1sum_old = base64.b64encode(sha1_old.digest()) + + # If the latest version changed, download it + if sha1sum_old != plugin_data['sha1']: + if not self.module.check_mode: + r = self._download_plugin(plugin_url) + self._write_file(plugin_file, r) + + changed = True + + # Change file attributes if needed + if os.path.isfile(plugin_file): + params = { + 'dest': plugin_file + } + params.update(self.params) + file_args = self.module.load_file_common_arguments(params) + + if not self.module.check_mode: + # Not sure how to run this in the check mode + changed = self.module.set_fs_attributes_if_different( + file_args, changed) + else: + # See the comment above + changed = True + + return changed + + def _download_updates(self): + updates_filename = 'jenkins-plugin-cache.json' + updates_dir = os.path.expanduser('~/.ansible/tmp') + updates_file = "%s/%s" % (updates_dir, updates_filename) + download_updates = True + + # Check if we need to download new updates file + if os.path.isfile(updates_file): + # Get timestamp when the file was changed last time + ts_file = os.stat(updates_file).st_mtime + ts_now = time.time() + + if ts_now - ts_file < self.params['updates_expiration']: + download_updates = False + + updates_file_orig = updates_file + + # Download the updates file if needed + if download_updates: + url = "%s/update-center.json" % self.params['updates_url'] + + # Get the data + r = self._get_url_data( + url, + msg_status="Remote updates not found.", + msg_exception="Updates download failed.") + + # Write the updates file + updates_file = tempfile.mktemp() + + try: + fd = open(updates_file, 'wb') + except IOError: + e = get_exception() + self.module.fail_json( + msg="Cannot open the tmp updates file %s." % updates_file, + details=str(e)) + + fd.write(r.read()) + + try: + fd.close() + except IOError: + e = get_exception() + self.module.fail_json( + msg="Cannot close the tmp updates file %s." % updates_file, + detail=str(e)) + + # Open the updates file + try: + f = open(updates_file) + except IOError: + e = get_exception() + self.module.fail_json( + msg="Cannot open temporal updates file.", + details=str(e)) + + i = 0 + for line in f: + # Read only the second line + if i == 1: + try: + data = json.loads(line) + except Exception: + e = get_exception() + self.module.fail_json( + msg="Cannot load JSON data from the tmp updates file.", + details=e.message) + + break + + i += 1 + + # Move the updates file to the right place if we could read it + if download_updates: + # Make sure the destination directory exists + if not os.path.isdir(updates_dir): + try: + os.makedirs(updates_dir, int('0700', 8)) + except OSError: + e = get_exception() + self.module.fail_json( + msg="Cannot create temporal directory.", + details=e.message) + + self.module.atomic_move(updates_file, updates_file_orig) + + # Check if we have the plugin data available + if 'plugins' not in data or self.params['name'] not in data['plugins']: + self.module.fail_json( + msg="Cannot find plugin data in the updates file.") + + return data['plugins'][self.params['name']] + + def _download_plugin(self, plugin_url): + # Download the plugin + r = self._get_url_data( + plugin_url, + msg_status="Plugin not found.", + msg_exception="Plugin download failed.") + + return r + + def _write_file(self, f, data): + # Store the plugin into a temp file and then move it + tmp_f = tempfile.mktemp() + + try: + fd = open(tmp_f, 'wb') + except IOError: + e = get_exception() + self.module.fail_json( + msg='Cannot open the temporal plugin file %s.' % tmp_f, + details=str(e)) + + if isinstance(data, str): + d = data + else: + d = data.read() + + fd.write(d) + + try: + fd.close() + except IOError: + e = get_exception() + self.module.fail_json( + msg='Cannot close the temporal plugin file %s.' % tmp_f, + details=str(e)) + + # Move the file onto the right place + self.module.atomic_move(tmp_f, f) + + def uninstall(self): + changed = False + + # Perform the action + if self.is_installed: + if not self.module.check_mode: + self._pm_query('doUninstall', 'Uninstallation') + + changed = True + + return changed + + def pin(self): + return self._pinning('pin') + + def unpin(self): + return self._pinning('unpin') + + def _pinning(self, action): + changed = False + + # Check if the plugin is pinned/unpinned + if ( + action == 'pin' and not self.is_pinned or + action == 'unpin' and self.is_pinned): + + # Perform the action + if not self.module.check_mode: + self._pm_query(action, "%sning" % action.capitalize()) + + changed = True + + return changed + + def enable(self): + return self._enabling('enable') + + def disable(self): + return self._enabling('disable') + + def _enabling(self, action): + changed = False + + # Check if the plugin is pinned/unpinned + if ( + action == 'enable' and not self.is_enabled or + action == 'disable' and self.is_enabled): + + # Perform the action + if not self.module.check_mode: + self._pm_query( + "make%sd" % action.capitalize(), + "%sing" % action[:-1].capitalize()) + + changed = True + + return changed + + def _pm_query(self, action, msg): + url = "%s/pluginManager/plugin/%s/%s" % ( + self.params['url'], self.params['name'], action) + data = urllib.urlencode(self.crumb) + + # Send the request + self._get_url_data( + url, + msg_status="Plugin not found. %s" % url, + msg_exception="%s has failed." % msg, + data=data) + + +def main(): + # Module arguments + argument_spec = url_argument_spec() + argument_spec.update( + group=dict(default='jenkins'), + jenkins_home=dict(default='/var/lib/jenkins'), + mode=dict(default='0644', type='raw'), + name=dict(required=True), + owner=dict(default='jenkins'), + params=dict(type='dict'), + state=dict( + choices=[ + 'present', + 'absent', + 'pinned', + 'unpinned', + 'enabled', + 'disabled', + 'latest'], + default='present'), + timeout=dict(default=30, type="int"), + updates_expiration=dict(default=86400, type="int"), + updates_url=dict(default='https://updates.jenkins-ci.org'), + url=dict(default='http://localhost:8080'), + url_password=dict(no_log=True), + version=dict(), + with_dependencies=dict(default=True, type='bool'), + ) + # Module settings + module = AnsibleModule( + argument_spec=argument_spec, + add_file_common_args=True, + supports_check_mode=True, + ) + + # Update module parameters by user's parameters if defined + if 'params' in module.params and isinstance(module.params['params'], dict): + module.params.update(module.params['params']) + # Remove the params + module.params.pop('params', None) + + # Force basic authentication + module.params['force_basic_auth'] = True + + # Convert timeout to float + try: + module.params['timeout'] = float(module.params['timeout']) + except ValueError: + e = get_exception() + module.fail_json( + msg='Cannot convert %s to float.' % module.params['timeout'], + details=str(e)) + + # Set version to latest if state is latest + if module.params['state'] == 'latest': + module.params['state'] = 'present' + module.params['version'] = 'latest' + + # Create some shortcuts + name = module.params['name'] + state = module.params['state'] + + # Initial change state of the task + changed = False + + # Instantiate the JenkinsPlugin object + jp = JenkinsPlugin(module) + + # Perform action depending on the requested state + if state == 'present': + changed = jp.install() + elif state == 'absent': + changed = jp.uninstall() + elif state == 'pinned': + changed = jp.pin() + elif state == 'unpinned': + changed = jp.unpin() + elif state == 'enabled': + changed = jp.enable() + elif state == 'disabled': + changed = jp.disable() + + # Print status of the change + module.exit_json(changed=changed, plugin=name, state=state) + + +if __name__ == '__main__': + main()