This commit is contained in:
Youssef Ali 2025-07-30 00:20:04 -04:00 committed by GitHub
commit dd67f7e39d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 277 additions and 19 deletions

View file

@ -0,0 +1,6 @@
bugfixes:
- "jenkins_plugin - install latest compatible version instead of latest (https://github.com/ansible-collections/community.general/issues/854, https://github.com/ansible-collections/community.general/pull/10346)."
- "jenkins_plugin - separate Jenkins and external URL credentials (https://github.com/ansible-collections/community.general/issues/4419, https://github.com/ansible-collections/community.general/pull/10346)."
minor_changes:
- "jenkins_plugin - install dependencies for specific version (https://github.com/ansible-collections/community.general/issue/4995, https://github.com/ansible-collections/community.general/pull/10346)."

View file

@ -74,6 +74,18 @@ options:
- A list of base URL(s) to retrieve C(update-center.json), and direct plugin files from. - A list of base URL(s) to retrieve C(update-center.json), and direct plugin files from.
- This can be a list since community.general 3.3.0. - This can be a list since community.general 3.3.0.
default: ['https://updates.jenkins.io', 'http://mirrors.jenkins.io'] default: ['https://updates.jenkins.io', 'http://mirrors.jenkins.io']
updates_url_username:
description:
- If using a custom O(updates_url), set this as the username of the user with access to the URL.
- If the custom O(updates_url) does not require authentication, this can be left empty.
type: str
version_added: 11.2.0
updates_url_password:
description:
- If using a custom O(updates_url), set this as the password of the user with access to the URL.
- If the custom O(updates_url) does not require authentication, this can be left empty.
type: str
version_added: 11.2.0
update_json_url_segment: update_json_url_segment:
type: list type: list
elements: str elements: str
@ -81,6 +93,13 @@ options:
- A list of URL segment(s) to retrieve the update center JSON file from. - A list of URL segment(s) to retrieve the update center JSON file from.
default: ['update-center.json', 'updates/update-center.json'] default: ['update-center.json', 'updates/update-center.json']
version_added: 3.3.0 version_added: 3.3.0
plugin_versions_url_segment:
type: list
elements: str
description:
- A list of URL segment(s) to retrieve the plugin versions JSON file from.
default: ['plugin-versions.json', 'current/plugin-versions.json']
version_added: 11.2.0
latest_plugins_url_segments: latest_plugins_url_segments:
type: list type: list
elements: str elements: str
@ -112,7 +131,8 @@ options:
with_dependencies: with_dependencies:
description: description:
- Defines whether to install plugin dependencies. - Defines whether to install plugin dependencies.
- This option takes effect only if the O(version) is not defined. - In earlier versions, this option had no effect when a specific O(version) was set.
Since community.general 11.2.0, dependencies are also installed for versioned plugins.
type: bool type: bool
default: true default: true
@ -124,6 +144,9 @@ notes:
- Pinning works only if the plugin is installed and Jenkins service was successfully restarted after the plugin installation. - Pinning works only if the plugin is installed and Jenkins service was successfully restarted after the plugin installation.
- It is not possible to run the module remotely by changing the O(url) parameter to point to the Jenkins server. The module - It is not possible to run the module remotely by changing the O(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. must be used on the host where Jenkins runs as it needs direct access to the plugin files.
- If using a custom O(updates_url), ensure that the URL provides a C(plugin-versions.json) file.
This file must include metadata for all available plugin versions to support version compatibility resolution.
The file should be in the same format as the one provided by Jenkins update center (https://updates.jenkins.io/current/plugin-versions.json).
extends_documentation_fragment: extends_documentation_fragment:
- ansible.builtin.url - ansible.builtin.url
- ansible.builtin.files - ansible.builtin.files
@ -315,11 +338,13 @@ import io
import json import json
import os import os
import tempfile import tempfile
import time
from collections import OrderedDict
from ansible.module_utils.basic import AnsibleModule, to_bytes from ansible.module_utils.basic import AnsibleModule, to_bytes
from ansible.module_utils.six.moves import http_cookiejar as cookiejar from ansible.module_utils.six.moves import http_cookiejar as cookiejar
from ansible.module_utils.six.moves.urllib.parse import urlencode from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible.module_utils.urls import fetch_url, url_argument_spec from ansible.module_utils.urls import fetch_url, url_argument_spec, basic_auth_header
from ansible.module_utils.six import text_type, binary_type from ansible.module_utils.six import text_type, binary_type
from ansible.module_utils.common.text.converters import to_native from ansible.module_utils.common.text.converters import to_native
@ -340,14 +365,24 @@ class JenkinsPlugin(object):
self.url = self.params['url'] self.url = self.params['url']
self.timeout = self.params['timeout'] self.timeout = self.params['timeout']
# Authentication for non-Jenkins calls
self.updates_url_credentials = {}
if self.params.get('updates_url_username') and self.params.get('updates_url_password'):
self.updates_url_credentials["Authorization"] = basic_auth_header(self.params['updates_url_username'], self.params['updates_url_password'])
# Crumb # Crumb
self.crumb = {} self.crumb = {}
# Authentication for Jenkins calls
if self.params.get('url_username') and self.params.get('url_password'):
self.crumb["Authorization"] = basic_auth_header(self.params['url_username'], self.params['url_password'])
# Cookie jar for crumb session # Cookie jar for crumb session
self.cookies = None self.cookies = None
if self._csrf_enabled(): if self._csrf_enabled():
self.cookies = cookiejar.LWPCookieJar() self.cookies = cookiejar.LWPCookieJar()
self.crumb = self._get_crumb() self._get_crumb()
# Get list of installed plugins # Get list of installed plugins
self._get_installed_plugins() self._get_installed_plugins()
@ -390,10 +425,14 @@ class JenkinsPlugin(object):
err_msg = None err_msg = None
try: try:
self.module.debug("fetching url: %s" % url) self.module.debug("fetching url: %s" % url)
is_jenkins_call = url.startswith(self.url)
self.module.params['force_basic_auth'] = is_jenkins_call
response, info = fetch_url( response, info = fetch_url(
self.module, url, timeout=self.timeout, cookies=self.cookies, self.module, url, timeout=self.timeout, cookies=self.cookies,
headers=self.crumb, **kwargs) headers=self.crumb if is_jenkins_call else self.updates_url_credentials or self.crumb,
**kwargs)
if info['status'] == 200: if info['status'] == 200:
return response return response
else: else:
@ -422,9 +461,13 @@ class JenkinsPlugin(object):
# Get the URL data # Get the URL data
try: try:
is_jenkins_call = url.startswith(self.url)
self.module.params['force_basic_auth'] = is_jenkins_call
response, info = fetch_url( response, info = fetch_url(
self.module, url, timeout=self.timeout, cookies=self.cookies, self.module, url, timeout=self.timeout, cookies=self.cookies,
headers=self.crumb, **kwargs) headers=self.crumb if is_jenkins_call else self.updates_url_credentials or self.crumb,
**kwargs)
if info['status'] != 200: if info['status'] != 200:
if dont_fail: if dont_fail:
@ -444,16 +487,12 @@ class JenkinsPlugin(object):
"%s/%s" % (self.url, "crumbIssuer/api/json"), 'Crumb') "%s/%s" % (self.url, "crumbIssuer/api/json"), 'Crumb')
if 'crumbRequestField' in crumb_data and 'crumb' in crumb_data: if 'crumbRequestField' in crumb_data and 'crumb' in crumb_data:
ret = { self.crumb[crumb_data['crumbRequestField']] = crumb_data['crumb']
crumb_data['crumbRequestField']: crumb_data['crumb']
}
else: else:
self.module.fail_json( self.module.fail_json(
msg="Required fields not found in the Crum response.", msg="Required fields not found in the Crum response.",
details=crumb_data) details=crumb_data)
return ret
def _get_installed_plugins(self): def _get_installed_plugins(self):
plugins_data = self._get_json_data( plugins_data = self._get_json_data(
"%s/%s" % (self.url, "pluginManager/api/json?depth=1"), "%s/%s" % (self.url, "pluginManager/api/json?depth=1"),
@ -467,6 +506,7 @@ class JenkinsPlugin(object):
self.is_installed = False self.is_installed = False
self.is_pinned = False self.is_pinned = False
self.is_enabled = False self.is_enabled = False
self.installed_plugins = plugins_data['plugins']
for p in plugins_data['plugins']: for p in plugins_data['plugins']:
if p['shortName'] == self.params['name']: if p['shortName'] == self.params['name']:
@ -480,6 +520,40 @@ class JenkinsPlugin(object):
break break
def _install_dependencies(self):
dependencies = self._get_versioned_dependencies()
self.dependencies_states = []
for dep_name, dep_version in dependencies.items():
if not any(p['shortName'] == dep_name and p['version'] == dep_version for p in self.installed_plugins):
dep_params = self.params.copy()
dep_params['name'] = dep_name
dep_params['version'] = dep_version
dep_module = AnsibleModule(
argument_spec=self.module.argument_spec,
supports_check_mode=self.module.check_mode
)
dep_module.params = dep_params
dep_plugin = JenkinsPlugin(dep_module)
if not dep_plugin.install():
self.dependencies_states.append(
{
'name': dep_name,
'version': dep_version,
'state': 'absent'})
else:
self.dependencies_states.append(
{
'name': dep_name,
'version': dep_version,
'state': 'present'})
else:
self.dependencies_states.append(
{
'name': dep_name,
'version': dep_version,
'state': 'present'})
def _install_with_plugin_manager(self): def _install_with_plugin_manager(self):
if not self.module.check_mode: if not self.module.check_mode:
# Install the plugin (with dependencies) # Install the plugin (with dependencies)
@ -540,6 +614,10 @@ class JenkinsPlugin(object):
plugin_content = plugin_fh.read() plugin_content = plugin_fh.read()
checksum_old = hashlib.sha1(plugin_content).hexdigest() checksum_old = hashlib.sha1(plugin_content).hexdigest()
# Install dependencies
if self.params['with_dependencies']:
self._install_dependencies()
if self.params['version'] in [None, 'latest']: if self.params['version'] in [None, 'latest']:
# Take latest version # Take latest version
plugin_urls = self._get_latest_plugin_urls() plugin_urls = self._get_latest_plugin_urls()
@ -612,6 +690,58 @@ class JenkinsPlugin(object):
urls.append("{0}/{1}/{2}.hpi".format(base_url, update_segment, self.params['name'])) urls.append("{0}/{1}/{2}.hpi".format(base_url, update_segment, self.params['name']))
return urls return urls
def _get_latest_compatible_plugin_version(self, plugin_name=None):
if not hasattr(self, 'jenkins_version'):
self.module.params['force_basic_auth'] = True
resp, info = fetch_url(self.module, self.url)
raw_version = info.get("x-jenkins")
self.jenkins_version = self.parse_version(raw_version)
name = plugin_name or self.params['name']
cache_path = "{}/ansible_jenkins_plugin_cache.json".format(self.params['jenkins_home'])
plugin_version_urls = []
for base_url in self.params['updates_url']:
for update_json in self.params['plugin_versions_url_segment']:
plugin_version_urls.append("{}/{}".format(base_url, update_json))
try: # Check if file is saved localy
if os.path.exists(cache_path):
file_mtime = os.path.getmtime(cache_path)
else:
file_mtime = 0
now = time.time()
if now - file_mtime >= 86400:
response = self._get_urls_data(plugin_version_urls, what="plugin-versions.json")
plugin_data = json.loads(to_native(response.read()), object_pairs_hook=OrderedDict)
# Save it to file for next time
with open(cache_path, "w") as f:
json.dump(plugin_data, f)
with open(cache_path, "r") as f:
plugin_data = json.load(f)
except Exception as e:
if os.path.exists(cache_path):
os.remove(cache_path)
self.module.fail_json(msg="Failed to parse plugin-versions.json", details=to_native(e))
plugin_versions = plugin_data.get("plugins", {}).get(name)
if not plugin_versions:
self.module.fail_json(msg="Plugin '{}' not found.".format(name))
sorted_versions = list(reversed(plugin_versions.items()))
for idx, (version_title, version_info) in enumerate(sorted_versions):
required_core = version_info.get("requiredCore", "0.0")
if self.parse_version(required_core) <= self.jenkins_version:
return 'latest' if idx == 0 else version_title
self.module.warn(
"No compatible version found for plugin '{}'. "
"Installing latest version.".format(name))
return 'latest'
def _get_versioned_plugin_urls(self): def _get_versioned_plugin_urls(self):
urls = [] urls = []
for base_url in self.params['updates_url']: for base_url in self.params['updates_url']:
@ -626,6 +756,18 @@ class JenkinsPlugin(object):
urls.append("{0}/{1}".format(base_url, update_json)) urls.append("{0}/{1}".format(base_url, update_json))
return urls return urls
def _get_versioned_dependencies(self):
# Get dependencies for the specified plugin version
plugin_data = self._download_updates()['dependencies']
dependencies_info = {
dep["name"]: self._get_latest_compatible_plugin_version(dep["name"])
for dep in plugin_data
if not dep.get("optional", False)
}
return dependencies_info
def _download_updates(self): def _download_updates(self):
try: try:
updates_file, download_updates = download_updates_file(self.params['updates_expiration']) updates_file, download_updates = download_updates_file(self.params['updates_expiration'])
@ -779,6 +921,10 @@ class JenkinsPlugin(object):
msg_exception="%s has failed." % msg, msg_exception="%s has failed." % msg,
method="POST") method="POST")
@staticmethod
def parse_version(version_str):
return tuple(int(x) for x in version_str.split('.'))
def main(): def main():
# Module arguments # Module arguments
@ -803,8 +949,12 @@ def main():
updates_expiration=dict(default=86400, type="int"), updates_expiration=dict(default=86400, type="int"),
updates_url=dict(type="list", elements="str", default=['https://updates.jenkins.io', updates_url=dict(type="list", elements="str", default=['https://updates.jenkins.io',
'http://mirrors.jenkins.io']), 'http://mirrors.jenkins.io']),
updates_url_username=dict(type="str"),
updates_url_password=dict(type="str", no_log=True),
update_json_url_segment=dict(type="list", elements="str", default=['update-center.json', update_json_url_segment=dict(type="list", elements="str", default=['update-center.json',
'updates/update-center.json']), 'updates/update-center.json']),
plugin_versions_url_segment=dict(type="list", elements="str", default=['plugin-versions.json',
'current/plugin-versions.json']),
latest_plugins_url_segments=dict(type="list", elements="str", default=['latest']), latest_plugins_url_segments=dict(type="list", elements="str", default=['latest']),
versioned_plugins_url_segments=dict(type="list", elements="str", default=['download/plugins', 'plugins']), versioned_plugins_url_segments=dict(type="list", elements="str", default=['download/plugins', 'plugins']),
url=dict(default='http://localhost:8080'), url=dict(default='http://localhost:8080'),
@ -819,9 +969,6 @@ def main():
supports_check_mode=True, supports_check_mode=True,
) )
# Force basic authentication
module.params['force_basic_auth'] = True
# Convert timeout to float # Convert timeout to float
try: try:
module.params['timeout'] = float(module.params['timeout']) module.params['timeout'] = float(module.params['timeout'])
@ -829,11 +976,17 @@ def main():
module.fail_json( module.fail_json(
msg='Cannot convert %s to float.' % module.params['timeout'], msg='Cannot convert %s to float.' % module.params['timeout'],
details=to_native(e)) details=to_native(e))
# Instantiate the JenkinsPlugin object
jp = JenkinsPlugin(module)
# Set version to latest if state is latest # Set version to latest if state is latest
if module.params['state'] == 'latest': if module.params['state'] == 'latest':
module.params['state'] = 'present' module.params['state'] = 'present'
module.params['version'] = 'latest' module.params['version'] = jp._get_latest_compatible_plugin_version()
# Set version to latest compatible version if version is latest
if module.params['version'] == 'latest':
module.params['version'] = jp._get_latest_compatible_plugin_version()
# Create some shortcuts # Create some shortcuts
name = module.params['name'] name = module.params['name']
@ -842,9 +995,6 @@ def main():
# Initial change state of the task # Initial change state of the task
changed = False changed = False
# Instantiate the JenkinsPlugin object
jp = JenkinsPlugin(module)
# Perform action depending on the requested state # Perform action depending on the requested state
if state == 'present': if state == 'present':
changed = jp.install() changed = jp.install()
@ -860,7 +1010,7 @@ def main():
changed = jp.disable() changed = jp.disable()
# Print status of the change # Print status of the change
module.exit_json(changed=changed, plugin=name, state=state) module.exit_json(changed=changed, plugin=name, state=state, dependencies=jp.dependencies_states if hasattr(jp, 'dependencies_states') else None)
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -6,9 +6,16 @@ from __future__ import (absolute_import, division, print_function)
__metaclass__ = type __metaclass__ = type
from io import BytesIO from io import BytesIO
import json
from collections import OrderedDict
from ansible_collections.community.general.plugins.modules.jenkins_plugin import JenkinsPlugin from ansible_collections.community.general.plugins.modules.jenkins_plugin import JenkinsPlugin
from ansible.module_utils.common._collections_compat import Mapping from ansible.module_utils.common._collections_compat import Mapping
from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import (
MagicMock,
patch,
)
from ansible.module_utils.urls import basic_auth_header
def pass_function(*args, **kwargs): def pass_function(*args, **kwargs):
@ -190,3 +197,98 @@ def isInList(l, i):
if item == i: if item == i:
return True return True
return False return False
@patch("ansible_collections.community.general.plugins.modules.jenkins_plugin.fetch_url")
def test__get_latest_compatible_plugin_version(fetch_mock, mocker):
"test the latest compatible plugin version retrieval"
params = {
"url": "http://fake.jenkins.server",
"timeout": 30,
"name": "git",
"version": "latest",
"updates_url": ["https://some.base.url"],
"plugin_versions_url_segment": ["plugin-versions.json"],
"latest_plugins_url_segments": ["test_latest"],
"jenkins_home": "/var/lib/jenkins",
}
module = mocker.Mock()
module.params = params
jenkins_info = {"x-jenkins": "2.263.1"}
jenkins_response = MagicMock()
jenkins_response.read.return_value = b"{}"
plugin_data = {
"plugins": {
"git": OrderedDict([
("4.8.2", {"requiredCore": "2.263.1"}),
("4.8.3", {"requiredCore": "2.263.1"}),
("4.9.0", {"requiredCore": "2.289.1"}),
("4.9.1", {"requiredCore": "2.289.1"}),
])
}
}
plugin_versions_response = MagicMock()
plugin_versions_response.read.return_value = json.dumps(plugin_data).encode("utf-8")
plugin_versions_info = {"status": 200}
def fetch_url_side_effect(module, url, **kwargs):
if "plugin-versions.json" in url:
return (plugin_versions_response, plugin_versions_info)
else:
return (jenkins_response, jenkins_info)
fetch_mock.side_effect = fetch_url_side_effect
JenkinsPlugin._csrf_enabled = lambda self: False
JenkinsPlugin._get_installed_plugins = lambda self: None
jenkins_plugin = JenkinsPlugin(module)
latest_version = jenkins_plugin._get_latest_compatible_plugin_version()
assert latest_version == '4.8.3'
@patch("ansible_collections.community.general.plugins.modules.jenkins_plugin.fetch_url")
def test__get_urls_data_sets_correct_headers(fetch_mock, mocker):
params = {
"url": "http://jenkins.example.com",
"timeout": 30,
"name": "git",
"jenkins_home": "/var/lib/jenkins",
"updates_url": ["http://updates.example.com"],
"latest_plugins_url_segments": ["latest"],
"update_json_url_segment": ["update-center.json"],
"versioned_plugins_url_segments": ["plugins"],
"url_username": "jenkins_user",
"url_password": "jenkins_pass",
"updates_url_username": "update_user",
"updates_url_password": "update_pass",
}
module = mocker.Mock()
module.params = params
dummy_response = MagicMock()
fetch_mock.return_value = (dummy_response, {"status": 200})
JenkinsPlugin._csrf_enabled = lambda self: False
JenkinsPlugin._get_installed_plugins = lambda self: None
jp = JenkinsPlugin(module)
update_url = "http://updates.example.com/plugin-versions.json"
jp._get_urls_data([update_url])
jenkins_url = "http://jenkins.example.com/some-endpoint"
jp._get_urls_data([jenkins_url])
calls = fetch_mock.call_args_list
dummy, kwargs_2 = calls[1]
jenkins_auth = basic_auth_header("jenkins_user", "jenkins_pass")
assert kwargs_2["headers"]["Authorization"] == jenkins_auth
dummy, kwargs_1 = calls[0]
updates_auth = basic_auth_header("update_user", "update_pass")
assert kwargs_1["headers"]["Authorization"] == updates_auth