From 629fec5784e55b2dcb2c5145602a402d4b0e240d Mon Sep 17 00:00:00 2001 From: YoussefKhaildAli Date: Wed, 2 Jul 2025 13:46:47 +0300 Subject: [PATCH 1/9] Fix version compatibility issue --- plugins/modules/jenkins_plugin.py | 66 ++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/plugins/modules/jenkins_plugin.py b/plugins/modules/jenkins_plugin.py index 73ff40c7257..fd3972cbede 100644 --- a/plugins/modules/jenkins_plugin.py +++ b/plugins/modules/jenkins_plugin.py @@ -315,6 +315,7 @@ import json import os import tempfile +import time from ansible.module_utils.basic import AnsibleModule, to_bytes from ansible.module_utils.six.moves import http_cookiejar as cookiejar @@ -467,6 +468,7 @@ def _get_installed_plugins(self): self.is_installed = False self.is_pinned = False self.is_enabled = False + self.installed_plugins = plugins_data['plugins'] for p in plugins_data['plugins']: if p['shortName'] == self.params['name']: @@ -612,6 +614,53 @@ def _get_latest_plugin_urls(self): urls.append("{0}/{1}/{2}.hpi".format(base_url, update_segment, self.params['name'])) return urls + def _get_latest_compatible_plugin_version(self, plugin_name=None): + if not hasattr(self, 'jenkins_version'): + 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']) + + try: # Check if file is saved localy + with open(cache_path, "r") as f: + file_mtime = os.path.getmtime(cache_path) + now = time.time() + if now - file_mtime < 86400: + plugin_data = json.load(f) + else: + raise FileNotFoundError("Cache file is outdated.") + except Exception: + resp, info = fetch_url(self.module, "https://updates.jenkins.io/current/plugin-versions.json") # Get list of plugins and their dependencies + + if info.get("status") != 200: + self.module.fail_json(msg="Failed to fetch plugin-versions.json", details=info) + + try: + plugin_data = json.loads(to_native(resp.read())) + + # Save it to file for next time + with open(cache_path, "w") as f: + json.dump(plugin_data, f) + except Exception as e: + 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 = dict(reversed(list(plugin_versions.items()))) + + for idx, (version_title, version_info) in enumerate(sorted_versions.items()): + 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): urls = [] for base_url in self.params['updates_url']: @@ -779,6 +828,10 @@ def _pm_query(self, action, msg): msg_exception="%s has failed." % msg, method="POST") + @staticmethod + def parse_version(version_str): + return tuple(int(x) for x in version_str.split('.')) + def main(): # Module arguments @@ -829,11 +882,17 @@ def main(): module.fail_json( msg='Cannot convert %s to float.' % module.params['timeout'], details=to_native(e)) + # Instantiate the JenkinsPlugin object + jp = JenkinsPlugin(module) # Set version to latest if state is latest if module.params['state'] == 'latest': module.params['state'] = 'present' - module.params['version'] = 'latest' + module.params['version'] = jp._get_latest_compatible_plugin_version() + + # Ser 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 name = module.params['name'] @@ -842,9 +901,6 @@ def main(): # 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() @@ -860,7 +916,7 @@ def main(): changed = jp.disable() # 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__': From d1870f8bf97431cecc829fca49ece76b82b89178 Mon Sep 17 00:00:00 2001 From: YoussefKhaildAli Date: Thu, 3 Jul 2025 16:05:32 +0300 Subject: [PATCH 2/9] Add dependencies installation to specific versions --- plugins/modules/jenkins_plugin.py | 48 ++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/plugins/modules/jenkins_plugin.py b/plugins/modules/jenkins_plugin.py index fd3972cbede..ee03c534ab9 100644 --- a/plugins/modules/jenkins_plugin.py +++ b/plugins/modules/jenkins_plugin.py @@ -112,7 +112,6 @@ with_dependencies: description: - Defines whether to install plugin dependencies. - - This option takes effect only if the O(version) is not defined. type: bool default: true @@ -482,6 +481,37 @@ def _get_installed_plugins(self): 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 = self.module + 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): if not self.module.check_mode: # Install the plugin (with dependencies) @@ -542,6 +572,10 @@ def install(self): plugin_content = plugin_fh.read() checksum_old = hashlib.sha1(plugin_content).hexdigest() + # Install dependencies + if self.params['with_dependencies']: + self._install_dependencies() + if self.params['version'] in [None, 'latest']: # Take latest version plugin_urls = self._get_latest_plugin_urls() @@ -675,6 +709,18 @@ def _get_update_center_urls(self): urls.append("{0}/{1}".format(base_url, update_json)) 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): try: updates_file, download_updates = download_updates_file(self.params['updates_expiration']) From f4efe55eab908120e34f461b091ea71808190eb3 Mon Sep 17 00:00:00 2001 From: YoussefKhaildAli Date: Sat, 5 Jul 2025 00:04:38 +0300 Subject: [PATCH 3/9] Seperate Jenkins and updates_url credentials --- plugins/modules/jenkins_plugin.py | 70 ++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/plugins/modules/jenkins_plugin.py b/plugins/modules/jenkins_plugin.py index ee03c534ab9..91f26881993 100644 --- a/plugins/modules/jenkins_plugin.py +++ b/plugins/modules/jenkins_plugin.py @@ -74,6 +74,16 @@ - 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. 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 + 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 update_json_url_segment: type: list elements: str @@ -315,11 +325,12 @@ import os import tempfile import time +import base64 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.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, open_url from ansible.module_utils.six import text_type, binary_type from ansible.module_utils.common.text.converters import to_native @@ -340,14 +351,28 @@ def __init__(self, module): self.url = self.params['url'] self.timeout = self.params['timeout'] + # Authentication for non-Jenkins calls + self.updates_url_credentials = {} + if self.params['updates_url_username'] and self.params['updates_url_password']: + auth = f"{self.params['updates_url_username']}:{self.params['updates_url_password']}".encode("utf-8") + b64_auth = base64.b64encode(auth).decode("ascii") + self.updates_url_credentials["Authorization"] = f"Basic {b64_auth}" + # Crumb self.crumb = {} + + # Authentication for Jenkins calls + if self.params['url_username'] and self.params['url_password']: + auth = f"{self.params['url_username']}:{self.params['url_password']}".encode("utf-8") + b64_auth = base64.b64encode(auth).decode("ascii") + self.crumb["Authorization"] = f"Basic {b64_auth}" + # Cookie jar for crumb session self.cookies = None if self._csrf_enabled(): self.cookies = cookiejar.LWPCookieJar() - self.crumb = self._get_crumb() + self._get_crumb() # Get list of installed plugins self._get_installed_plugins() @@ -390,16 +415,18 @@ def _get_urls_data(self, urls, what=None, msg_status=None, msg_exception=None, * err_msg = None try: self.module.debug("fetching url: %s" % url) - response, info = fetch_url( - self.module, url, timeout=self.timeout, cookies=self.cookies, - headers=self.crumb, **kwargs) - if info['status'] == 200: + is_jenkins_call = url.startswith(self.url) + + response = open_url( + url, timeout=self.timeout, + cookies=self.cookies if is_jenkins_call else None, + headers=self.crumb if is_jenkins_call else self.updates_url_credentials, **kwargs) + if response.getcode() == 200: return response else: - err_msg = ("%s. fetching url %s failed. response code: %s" % (msg_status, url, info['status'])) - if info['status'] > 400: # extend error message - err_msg = "%s. response body: %s" % (err_msg, info['body']) + err_msg = ("%s. fetching url %s failed. response code: %s" % (msg_status, url, response.getcode())) + except Exception as e: err_msg = "%s. fetching url %s failed. error msg: %s" % (msg_status, url, to_native(e)) finally: @@ -422,15 +449,20 @@ def _get_url_data( # Get the URL data try: - response, info = fetch_url( - self.module, url, timeout=self.timeout, cookies=self.cookies, - headers=self.crumb, **kwargs) + is_jenkins_call = url.startswith(self.url) + response = open_url( + url, timeout=self.timeout, + cookies=self.cookies if is_jenkins_call else None, + headers=self.crumb if is_jenkins_call else self.updates_url_credentials, **kwargs) - if info['status'] != 200: + if response.getcode() != 200: if dont_fail: - raise FailedInstallingWithPluginManager(info['msg']) + raise FailedInstallingWithPluginManager(f"HTTP {response.getcode()}") else: - self.module.fail_json(msg=msg_status, details=info['msg']) + self.module.fail_json( + msg=msg_status, + details=f"Received status code {response.getcode()} from {url}" + ) except Exception as e: if dont_fail: raise FailedInstallingWithPluginManager(e) @@ -444,16 +476,12 @@ def _get_crumb(self): "%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'] - } + self.crumb[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"), @@ -902,6 +930,8 @@ def main(): updates_expiration=dict(default=86400, type="int"), updates_url=dict(type="list", elements="str", default=['https://updates.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', 'updates/update-center.json']), latest_plugins_url_segments=dict(type="list", elements="str", default=['latest']), From fcac64a8427235b082f577b0aa860ebad8538d04 Mon Sep 17 00:00:00 2001 From: YoussefKhaildAli Date: Sat, 5 Jul 2025 00:36:08 +0300 Subject: [PATCH 4/9] Create changelog fragment --- changelogs/fragments/10346-jenkins-plugins-fixes.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelogs/fragments/10346-jenkins-plugins-fixes.yml diff --git a/changelogs/fragments/10346-jenkins-plugins-fixes.yml b/changelogs/fragments/10346-jenkins-plugins-fixes.yml new file mode 100644 index 00000000000..853eadff0a7 --- /dev/null +++ b/changelogs/fragments/10346-jenkins-plugins-fixes.yml @@ -0,0 +1,6 @@ +bugfixes: + - "jenkins plugins plugin - install latest compatible version instead of latest (https://github.com/ansible-collections/community.general/pull/10346)." + - "jenkins plugins plugin - seperate Jenkins and external url credentials (https://github.com/ansible-collections/community.general/pull/10346)." + +minor_changes: + - "jenkins plugins plugin - install dependencies for specific version (https://github.com/ansible-collections/community.general/pull/10346)." From 6414ae14537b4bf91382f8f5fc34bf0a4782c6af Mon Sep 17 00:00:00 2001 From: YoussefKhaildAli Date: Sun, 6 Jul 2025 05:38:59 +0300 Subject: [PATCH 5/9] Added a test and some adjustments --- plugins/modules/jenkins_plugin.py | 27 +++++---- .../plugins/modules/test_jenkins_plugin.py | 60 +++++++++++++++++++ 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/plugins/modules/jenkins_plugin.py b/plugins/modules/jenkins_plugin.py index 91f26881993..7a8c745513a 100644 --- a/plugins/modules/jenkins_plugin.py +++ b/plugins/modules/jenkins_plugin.py @@ -326,6 +326,7 @@ import tempfile import time import base64 +from collections import OrderedDict from ansible.module_utils.basic import AnsibleModule, to_bytes from ansible.module_utils.six.moves import http_cookiejar as cookiejar @@ -353,19 +354,19 @@ def __init__(self, module): # Authentication for non-Jenkins calls self.updates_url_credentials = {} - if self.params['updates_url_username'] and self.params['updates_url_password']: - auth = f"{self.params['updates_url_username']}:{self.params['updates_url_password']}".encode("utf-8") + if self.params.get('updates_url_username') and self.params.get('updates_url_password'): + auth = "{}:{}".format(self.params['updates_url_username'], self.params['updates_url_password']).encode("utf-8") b64_auth = base64.b64encode(auth).decode("ascii") - self.updates_url_credentials["Authorization"] = f"Basic {b64_auth}" + self.updates_url_credentials["Authorization"] = "Basic {}".format(b64_auth) # Crumb self.crumb = {} # Authentication for Jenkins calls - if self.params['url_username'] and self.params['url_password']: - auth = f"{self.params['url_username']}:{self.params['url_password']}".encode("utf-8") + if self.params.get('url_username') and self.params.get('url_password'): + auth = "{}:{}".format(self.params['url_username'], self.params['url_password']).encode("utf-8") b64_auth = base64.b64encode(auth).decode("ascii") - self.crumb["Authorization"] = f"Basic {b64_auth}" + self.crumb["Authorization"] = "Basic {}".format(b64_auth) # Cookie jar for crumb session self.cookies = None @@ -457,11 +458,11 @@ def _get_url_data( if response.getcode() != 200: if dont_fail: - raise FailedInstallingWithPluginManager(f"HTTP {response.getcode()}") + raise FailedInstallingWithPluginManager("HTTP {}".format(response.getcode())) else: self.module.fail_json( msg=msg_status, - details=f"Received status code {response.getcode()} from {url}" + details="Received status code {} from {}".format(response.getcode(), url) ) except Exception as e: if dont_fail: @@ -693,13 +694,13 @@ def _get_latest_compatible_plugin_version(self, plugin_name=None): else: raise FileNotFoundError("Cache file is outdated.") except Exception: - resp, info = fetch_url(self.module, "https://updates.jenkins.io/current/plugin-versions.json") # Get list of plugins and their dependencies + response = open_url("https://updates.jenkins.io/current/plugin-versions.json") # Get list of plugins and their dependencies - if info.get("status") != 200: + if response.getcode() != 200: self.module.fail_json(msg="Failed to fetch plugin-versions.json", details=info) try: - plugin_data = json.loads(to_native(resp.read())) + 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: @@ -711,9 +712,9 @@ def _get_latest_compatible_plugin_version(self, plugin_name=None): if not plugin_versions: self.module.fail_json(msg="Plugin '{}' not found.".format(name)) - sorted_versions = dict(reversed(list(plugin_versions.items()))) + sorted_versions = list(reversed(plugin_versions.items())) - for idx, (version_title, version_info) in enumerate(sorted_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 diff --git a/tests/unit/plugins/modules/test_jenkins_plugin.py b/tests/unit/plugins/modules/test_jenkins_plugin.py index 194cc2d724a..7161ef4b37a 100644 --- a/tests/unit/plugins/modules/test_jenkins_plugin.py +++ b/tests/unit/plugins/modules/test_jenkins_plugin.py @@ -6,9 +6,16 @@ __metaclass__ = type from io import BytesIO +import json +import socket +from collections import OrderedDict from ansible_collections.community.general.plugins.modules.jenkins_plugin import JenkinsPlugin from ansible.module_utils.common._collections_compat import Mapping +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import ( + MagicMock, + patch, +) def pass_function(*args, **kwargs): @@ -190,3 +197,56 @@ def isInList(l, i): if item == i: return True return False + + +@patch("ansible_collections.community.general.plugins.modules.jenkins_plugin.open_url") +@patch("ansible_collections.community.general.plugins.modules.jenkins_plugin.fetch_url") +def test__get_latest_compatible_plugin_version(fetch_mock, open_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"], + "latest_plugins_url_segments": ["test_latest"], + "jenkins_home": "/var/lib/jenkins", + } + module = mocker.Mock() + module.params = params + + mock_response = MagicMock() + mock_response.read.return_value = b"" + fetch_mock.return_value = (mock_response, {"x-jenkins": "2.263.1"}) + + try: + socket.gethostbyname("updates.jenkins.io") + online = True + except socket.gaierror: + online = False + + # Mock the open_url to simulate the response from Jenkins update center if tests are run offline + if not online: + 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"}), + ]) + } + } + mock_open_resp = MagicMock() + mock_open_resp.getcode.return_value = 200 + mock_open_resp.read.return_value = json.dumps(plugin_data).encode("utf-8") + open_mock.return_value = mock_open_resp + + JenkinsPlugin._csrf_enabled = pass_function + JenkinsPlugin._get_installed_plugins = pass_function + + jenkins_plugin = JenkinsPlugin(module) + + latest_version = jenkins_plugin._get_latest_compatible_plugin_version() + assert latest_version == '4.8.3' From c47e2856be8abae9acd21a180d92a7d6947c241e Mon Sep 17 00:00:00 2001 From: YoussefKhaildAli Date: Mon, 7 Jul 2025 09:30:52 +0300 Subject: [PATCH 6/9] Return to fetch_url --- .../fragments/10346-jenkins-plugins-fixes.yml | 6 +- plugins/modules/jenkins_plugin.py | 62 +++++------ .../plugins/modules/test_jenkins_plugin.py | 103 ++++++++++++------ 3 files changed, 105 insertions(+), 66 deletions(-) diff --git a/changelogs/fragments/10346-jenkins-plugins-fixes.yml b/changelogs/fragments/10346-jenkins-plugins-fixes.yml index 853eadff0a7..6cd12fc3a38 100644 --- a/changelogs/fragments/10346-jenkins-plugins-fixes.yml +++ b/changelogs/fragments/10346-jenkins-plugins-fixes.yml @@ -1,6 +1,6 @@ bugfixes: - - "jenkins plugins plugin - install latest compatible version instead of latest (https://github.com/ansible-collections/community.general/pull/10346)." - - "jenkins plugins plugin - seperate Jenkins and external url credentials (https://github.com/ansible-collections/community.general/pull/10346)." + - "jenkins_plugin - install latest compatible version instead of latest (https://github.com/ansible-collections/community.general/issues/854)." + - "jenkins_plugin - seperate Jenkins and external URL credentials (https://github.com/ansible-collections/community.general/issues/4419)." minor_changes: - - "jenkins plugins plugin - install dependencies for specific version (https://github.com/ansible-collections/community.general/pull/10346)." + - "jenkins_plugin - install dependencies for specific version (https://github.com/ansible-collections/community.general/issue/4995)." diff --git a/plugins/modules/jenkins_plugin.py b/plugins/modules/jenkins_plugin.py index 7a8c745513a..94f11db991d 100644 --- a/plugins/modules/jenkins_plugin.py +++ b/plugins/modules/jenkins_plugin.py @@ -76,14 +76,16 @@ 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 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.1.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 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.1.0 update_json_url_segment: type: list elements: str @@ -122,6 +124,8 @@ with_dependencies: description: - Defines whether to install plugin dependencies. + - In earlier versions, this option had no effect when a specific C(version) was set. + - Since community.general 11.1.0, dependencies are also installed for versioned plugins. type: bool default: true @@ -325,13 +329,12 @@ import os import tempfile import time -import base64 from collections import OrderedDict 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.urllib.parse import urlencode -from ansible.module_utils.urls import fetch_url, url_argument_spec, open_url +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.common.text.converters import to_native @@ -355,18 +358,14 @@ def __init__(self, module): # Authentication for non-Jenkins calls self.updates_url_credentials = {} if self.params.get('updates_url_username') and self.params.get('updates_url_password'): - auth = "{}:{}".format(self.params['updates_url_username'], self.params['updates_url_password']).encode("utf-8") - b64_auth = base64.b64encode(auth).decode("ascii") - self.updates_url_credentials["Authorization"] = "Basic {}".format(b64_auth) + self.updates_url_credentials["Authorization"] = basic_auth_header(self.params['updates_url_username'], self.params['updates_url_password']) # Crumb self.crumb = {} # Authentication for Jenkins calls if self.params.get('url_username') and self.params.get('url_password'): - auth = "{}:{}".format(self.params['url_username'], self.params['url_password']).encode("utf-8") - b64_auth = base64.b64encode(auth).decode("ascii") - self.crumb["Authorization"] = "Basic {}".format(b64_auth) + self.crumb["Authorization"] = basic_auth_header(self.params['url_username'], self.params['url_password']) # Cookie jar for crumb session self.cookies = None @@ -418,16 +417,18 @@ def _get_urls_data(self, urls, what=None, msg_status=None, msg_exception=None, * self.module.debug("fetching url: %s" % url) is_jenkins_call = url.startswith(self.url) + self.module.params['force_basic_auth'] = is_jenkins_call - response = open_url( - url, timeout=self.timeout, - cookies=self.cookies if is_jenkins_call else None, - headers=self.crumb if is_jenkins_call else self.updates_url_credentials, **kwargs) - if response.getcode() == 200: + response, info = fetch_url( + self.module, url, timeout=self.timeout, cookies=self.cookies, + headers=self.crumb if is_jenkins_call else self.updates_url_credentials or self.crumb, + **kwargs) + if info['status'] == 200: return response else: - err_msg = ("%s. fetching url %s failed. response code: %s" % (msg_status, url, response.getcode())) - + err_msg = ("%s. fetching url %s failed. response code: %s" % (msg_status, url, info['status'])) + if info['status'] > 400: # extend error message + err_msg = "%s. response body: %s" % (err_msg, info['body']) except Exception as e: err_msg = "%s. fetching url %s failed. error msg: %s" % (msg_status, url, to_native(e)) finally: @@ -451,19 +452,18 @@ def _get_url_data( # Get the URL data try: is_jenkins_call = url.startswith(self.url) - response = open_url( - url, timeout=self.timeout, - cookies=self.cookies if is_jenkins_call else None, - headers=self.crumb if is_jenkins_call else self.updates_url_credentials, **kwargs) + self.module.params['force_basic_auth'] = is_jenkins_call + + response, info = fetch_url( + self.module, url, timeout=self.timeout, cookies=self.cookies, + headers=self.crumb if is_jenkins_call else self.updates_url_credentials or self.crumb, + **kwargs) - if response.getcode() != 200: + if info['status'] != 200: if dont_fail: - raise FailedInstallingWithPluginManager("HTTP {}".format(response.getcode())) + raise FailedInstallingWithPluginManager(info['msg']) else: - self.module.fail_json( - msg=msg_status, - details="Received status code {} from {}".format(response.getcode(), url) - ) + self.module.fail_json(msg=msg_status, details=info['msg']) except Exception as e: if dont_fail: raise FailedInstallingWithPluginManager(e) @@ -679,6 +679,7 @@ def _get_latest_plugin_urls(self): 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) @@ -694,9 +695,9 @@ def _get_latest_compatible_plugin_version(self, plugin_name=None): else: raise FileNotFoundError("Cache file is outdated.") except Exception: - response = open_url("https://updates.jenkins.io/current/plugin-versions.json") # Get list of plugins and their dependencies + response, info = fetch_url(self.module, "https://updates.jenkins.io/current/plugin-versions.json") # Get list of plugins and their dependencies - if response.getcode() != 200: + if info['status'] != 200: self.module.fail_json(msg="Failed to fetch plugin-versions.json", details=info) try: @@ -949,9 +950,6 @@ def main(): supports_check_mode=True, ) - # Force basic authentication - module.params['force_basic_auth'] = True - # Convert timeout to float try: module.params['timeout'] = float(module.params['timeout']) diff --git a/tests/unit/plugins/modules/test_jenkins_plugin.py b/tests/unit/plugins/modules/test_jenkins_plugin.py index 7161ef4b37a..cfa5bc13e42 100644 --- a/tests/unit/plugins/modules/test_jenkins_plugin.py +++ b/tests/unit/plugins/modules/test_jenkins_plugin.py @@ -7,7 +7,6 @@ from io import BytesIO import json -import socket from collections import OrderedDict from ansible_collections.community.general.plugins.modules.jenkins_plugin import JenkinsPlugin @@ -16,6 +15,7 @@ MagicMock, patch, ) +from ansible.module_utils.urls import basic_auth_header def pass_function(*args, **kwargs): @@ -199,9 +199,8 @@ def isInList(l, i): return False -@patch("ansible_collections.community.general.plugins.modules.jenkins_plugin.open_url") @patch("ansible_collections.community.general.plugins.modules.jenkins_plugin.fetch_url") -def test__get_latest_compatible_plugin_version(fetch_mock, open_mock, mocker): +def test__get_latest_compatible_plugin_version(fetch_mock, mocker): "test the latest compatible plugin version retrieval" params = { @@ -216,37 +215,79 @@ def test__get_latest_compatible_plugin_version(fetch_mock, open_mock, mocker): module = mocker.Mock() module.params = params - mock_response = MagicMock() - mock_response.read.return_value = b"" - fetch_mock.return_value = (mock_response, {"x-jenkins": "2.263.1"}) - - try: - socket.gethostbyname("updates.jenkins.io") - online = True - except socket.gaierror: - online = False - - # Mock the open_url to simulate the response from Jenkins update center if tests are run offline - if not online: - 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"}), - ]) - } + 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"}), + ]) } - mock_open_resp = MagicMock() - mock_open_resp.getcode.return_value = 200 - mock_open_resp.read.return_value = json.dumps(plugin_data).encode("utf-8") - open_mock.return_value = mock_open_resp + } + plugin_versions_response = MagicMock() + plugin_versions_response.read.return_value = json.dumps(plugin_data).encode("utf-8") + plugin_versions_info = {"status": 200} - JenkinsPlugin._csrf_enabled = pass_function - JenkinsPlugin._get_installed_plugins = pass_function + 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) - jenkins_plugin = JenkinsPlugin(module) + 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 From 2e39d0b7afb1037e99df74f428cecfcad9d8c7ce Mon Sep 17 00:00:00 2001 From: YoussefKhaildAli Date: Sun, 13 Jul 2025 13:53:21 +0300 Subject: [PATCH 7/9] Add pull link to changelog and modify install latest deps function --- .../fragments/10346-jenkins-plugins-fixes.yml | 6 +-- plugins/modules/jenkins_plugin.py | 37 ++++++++++--------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/changelogs/fragments/10346-jenkins-plugins-fixes.yml b/changelogs/fragments/10346-jenkins-plugins-fixes.yml index 6cd12fc3a38..382fe7aa53a 100644 --- a/changelogs/fragments/10346-jenkins-plugins-fixes.yml +++ b/changelogs/fragments/10346-jenkins-plugins-fixes.yml @@ -1,6 +1,6 @@ bugfixes: - - "jenkins_plugin - install latest compatible version instead of latest (https://github.com/ansible-collections/community.general/issues/854)." - - "jenkins_plugin - seperate Jenkins and external URL credentials (https://github.com/ansible-collections/community.general/issues/4419)." + - "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)." + - "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)." diff --git a/plugins/modules/jenkins_plugin.py b/plugins/modules/jenkins_plugin.py index 94f11db991d..3fdf0c8d34b 100644 --- a/plugins/modules/jenkins_plugin.py +++ b/plugins/modules/jenkins_plugin.py @@ -124,8 +124,8 @@ with_dependencies: description: - Defines whether to install plugin dependencies. - - In earlier versions, this option had no effect when a specific C(version) was set. - - Since community.general 11.1.0, dependencies are also installed for versioned plugins. + - In earlier versions, this option had no effect when a specific O(version) was set. + Since community.general 11.1.0, dependencies are also installed for versioned plugins. type: bool default: true @@ -519,7 +519,10 @@ def _install_dependencies(self): dep_params = self.params.copy() dep_params['name'] = dep_name dep_params['version'] = dep_version - dep_module = self.module + 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(): @@ -687,27 +690,27 @@ def _get_latest_compatible_plugin_version(self, plugin_name=None): cache_path = "{}/ansible_jenkins_plugin_cache.json".format(self.params['jenkins_home']) try: # Check if file is saved localy - with open(cache_path, "r") as f: + if os.path.exists(cache_path): file_mtime = os.path.getmtime(cache_path) - now = time.time() - if now - file_mtime < 86400: - plugin_data = json.load(f) - else: - raise FileNotFoundError("Cache file is outdated.") - except Exception: - response, info = fetch_url(self.module, "https://updates.jenkins.io/current/plugin-versions.json") # Get list of plugins and their dependencies - - if info['status'] != 200: - self.module.fail_json(msg="Failed to fetch plugin-versions.json", details=info) + else: + file_mtime = 0 - try: + now = time.time() + if now - file_mtime >= 86400: + response, info = fetch_url(self.module, "https://updates.jenkins.io/current/plugin-versions.json") + if info['status'] != 200: + self.module.fail_json(msg="Failed to fetch plugin-versions.json", details=info) 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) - except Exception as e: - self.module.fail_json(msg="Failed to parse plugin-versions.json", details=to_native(e)) + + with open(cache_path, "r") as f: + plugin_data = json.load(f) + + except Exception as e: + 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: From a8f0dca5bdfc00ea4d2c8f7599a6bff6af4acc81 Mon Sep 17 00:00:00 2001 From: YoussefKhaildAli Date: Mon, 14 Jul 2025 12:13:53 +0300 Subject: [PATCH 8/9] Use updates_url for plugin version if it exists --- plugins/modules/jenkins_plugin.py | 24 +++++++++++++++---- .../plugins/modules/test_jenkins_plugin.py | 1 + 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/plugins/modules/jenkins_plugin.py b/plugins/modules/jenkins_plugin.py index 3fdf0c8d34b..cd442e62ceb 100644 --- a/plugins/modules/jenkins_plugin.py +++ b/plugins/modules/jenkins_plugin.py @@ -93,6 +93,13 @@ - A list of URL segment(s) to retrieve the update center JSON file from. default: ['update-center.json', 'updates/update-center.json'] 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.1.0 latest_plugins_url_segments: type: list elements: str @@ -137,6 +144,9 @@ - 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 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: - ansible.builtin.url - ansible.builtin.files @@ -688,6 +698,10 @@ def _get_latest_compatible_plugin_version(self, plugin_name=None): 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): @@ -697,9 +711,7 @@ def _get_latest_compatible_plugin_version(self, plugin_name=None): now = time.time() if now - file_mtime >= 86400: - response, info = fetch_url(self.module, "https://updates.jenkins.io/current/plugin-versions.json") - if info['status'] != 200: - self.module.fail_json(msg="Failed to fetch plugin-versions.json", details=info) + 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 @@ -710,6 +722,8 @@ def _get_latest_compatible_plugin_version(self, plugin_name=None): 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) @@ -939,6 +953,8 @@ def main(): updates_url_password=dict(type="str", no_log=True), update_json_url_segment=dict(type="list", elements="str", default=['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']), versioned_plugins_url_segments=dict(type="list", elements="str", default=['download/plugins', 'plugins']), url=dict(default='http://localhost:8080'), @@ -968,7 +984,7 @@ def main(): module.params['state'] = 'present' module.params['version'] = jp._get_latest_compatible_plugin_version() - # Ser version to latest compatible version if version is latest + # Set version to latest compatible version if version is latest if module.params['version'] == 'latest': module.params['version'] = jp._get_latest_compatible_plugin_version() diff --git a/tests/unit/plugins/modules/test_jenkins_plugin.py b/tests/unit/plugins/modules/test_jenkins_plugin.py index cfa5bc13e42..5bd14bcbee5 100644 --- a/tests/unit/plugins/modules/test_jenkins_plugin.py +++ b/tests/unit/plugins/modules/test_jenkins_plugin.py @@ -209,6 +209,7 @@ def test__get_latest_compatible_plugin_version(fetch_mock, mocker): "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", } From b95ab803f6ab4d949c46734a42bfc3c5f51e249e Mon Sep 17 00:00:00 2001 From: YoussefKhaildAli Date: Wed, 16 Jul 2025 01:38:17 +0300 Subject: [PATCH 9/9] Change version number --- plugins/modules/jenkins_plugin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/modules/jenkins_plugin.py b/plugins/modules/jenkins_plugin.py index cd442e62ceb..b1d6742a990 100644 --- a/plugins/modules/jenkins_plugin.py +++ b/plugins/modules/jenkins_plugin.py @@ -79,13 +79,13 @@ - 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.1.0 + 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.1.0 + version_added: 11.2.0 update_json_url_segment: type: list elements: str @@ -99,7 +99,7 @@ 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.1.0 + version_added: 11.2.0 latest_plugins_url_segments: type: list elements: str @@ -132,7 +132,7 @@ description: - Defines whether to install plugin dependencies. - In earlier versions, this option had no effect when a specific O(version) was set. - Since community.general 11.1.0, dependencies are also installed for versioned plugins. + Since community.general 11.2.0, dependencies are also installed for versioned plugins. type: bool default: true