summaryrefslogtreecommitdiff
path: root/web_infrastructure
diff options
context:
space:
mode:
authorJiri Tyr <jtyr@users.noreply.github.com>2016-08-24 03:13:55 +0200
committerMatt Clay <matt@mystile.com>2016-08-23 18:13:55 -0700
commitf25bf010532d53b325aa235ffba89550983e3e4a (patch)
treeb59c5398f9e4cb3965f7ff02639c3c90b6c03170 /web_infrastructure
parentcfff23fa4afab9276f7a097aa37598098900fd18 (diff)
downloadansible-modules-extras-f25bf010532d53b325aa235ffba89550983e3e4a.tar.gz
Adding jenkins_plugin module (#1730)
Diffstat (limited to 'web_infrastructure')
-rw-r--r--web_infrastructure/jenkins_plugin.py830
1 files changed, 830 insertions, 0 deletions
diff --git a/web_infrastructure/jenkins_plugin.py b/web_infrastructure/jenkins_plugin.py
new file mode 100644
index 00000000..4dc2c345
--- /dev/null
+++ b/web_infrastructure/jenkins_plugin.py
@@ -0,0 +1,830 @@
+#!/usr/bin/python
+# encoding: utf-8
+
+# (c) 2016, Jiri Tyr <jiri.tyr@gmail.com>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+
+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()