From 064f76c27bcbb7236de1f8bdfd884a5d6ff12e38 Mon Sep 17 00:00:00 2001 From: Connor Newton Date: Sat, 19 Oct 2024 11:51:50 +0100 Subject: [PATCH] New module: Jenkins node management (#9016) * New module: Jenkins node management * Fix sanity errors * (Try to) fix mock import for Python 2.7 * Remove encoding from XML tostring in hopes of appeasing py27 Default encoding is probably always good enough. * Turns out that encoding parameter was important for python3... It's not super obvious how to resolve in a 2 + 3 compatible way, so branch and omit encoding for 2. * Implement review fixes and suggestions --- .github/BOTMETA.yml | 2 + plugins/modules/jenkins_node.py | 385 ++++++++++++ .../unit/plugins/modules/test_jenkins_node.py | 575 ++++++++++++++++++ tests/unit/requirements.txt | 3 + 4 files changed, 965 insertions(+) create mode 100644 plugins/modules/jenkins_node.py create mode 100644 tests/unit/plugins/modules/test_jenkins_node.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index c49080bc94b..935fe11664f 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -762,6 +762,8 @@ files: maintainers: sermilrod $modules/jenkins_job_info.py: maintainers: stpierre + $modules/jenkins_node.py: + maintainers: phyrwork $modules/jenkins_plugin.py: maintainers: jtyr $modules/jenkins_script.py: diff --git a/plugins/modules/jenkins_node.py b/plugins/modules/jenkins_node.py new file mode 100644 index 00000000000..2ee4a481a58 --- /dev/null +++ b/plugins/modules/jenkins_node.py @@ -0,0 +1,385 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: jenkins_node +short_description: Manage Jenkins nodes +version_added: 10.0.0 +description: + - Manage Jenkins nodes with Jenkins REST API. +requirements: + - "python-jenkins >= 0.4.12" +author: + - Connor Newton (@phyrwork) +extends_documentation_fragment: + - community.general.attributes +attributes: + check_mode: + support: partial + details: + - Check mode is unable to show configuration changes for a node that is not yet + present. + diff_mode: + support: none +options: + url: + description: + - URL of the Jenkins server. + default: http://localhost:8080 + type: str + name: + description: + - Name of the Jenkins node to manage. + required: true + type: str + user: + description: + - User to authenticate with the Jenkins server. + type: str + token: + description: + - API token to authenticate with the Jenkins server. + type: str + state: + description: + - Specifies whether the Jenkins node should be V(present) (created), V(absent) + (deleted), V(enabled) (online) or V(disabled) (offline). + default: present + choices: ['enabled', 'disabled', 'present', 'absent'] + type: str + num_executors: + description: + - When specified, sets the Jenkins node executor count. + type: int + labels: + description: + - When specified, sets the Jenkins node labels. + type: list + elements: str +''' + +EXAMPLES = ''' +- name: Create a Jenkins node using token authentication + community.general.jenkins_node: + url: http://localhost:8080 + user: jenkins + token: 11eb751baabb66c4d1cb8dc4e0fb142cde + name: my-node + state: present + +- name: Set number of executors on Jenkins node + community.general.jenkins_node: + name: my-node + state: present + num_executors: 4 + +- name: Set labels on Jenkins node + community.general.jenkins_node: + name: my-node + state: present + labels: + - label-1 + - label-2 + - label-3 +''' + +RETURN = ''' +--- +url: + description: URL used to connect to the Jenkins server. + returned: success + type: str + sample: https://jenkins.mydomain.com +user: + description: User used for authentication. + returned: success + type: str + sample: jenkins +name: + description: Name of the Jenkins node. + returned: success + type: str + sample: my-node +state: + description: State of the Jenkins node. + returned: success + type: str + sample: present +created: + description: Whether or not the Jenkins node was created by the task. + returned: success + type: bool +deleted: + description: Whether or not the Jenkins node was deleted by the task. + returned: success + type: bool +disabled: + description: Whether or not the Jenkins node was disabled by the task. + returned: success + type: bool +enabled: + description: Whether or not the Jenkins node was enabled by the task. + returned: success + type: bool +configured: + description: Whether or not the Jenkins node was configured by the task. + returned: success + type: bool +''' + +import sys +from xml.etree import ElementTree + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native +from ansible_collections.community.general.plugins.module_utils import deps + +with deps.declare( + "python-jenkins", + reason="python-jenkins is required to interact with Jenkins", + url="https://opendev.org/jjb/python-jenkins", +): + import jenkins + + +IS_PYTHON_2 = sys.version_info[0] <= 2 + + +class JenkinsNode: + def __init__(self, module): + self.module = module + + self.name = module.params['name'] + self.state = module.params['state'] + self.token = module.params['token'] + self.user = module.params['user'] + self.url = module.params['url'] + self.num_executors = module.params['num_executors'] + self.labels = module.params['labels'] + + if self.labels is not None: + for label in self.labels: + if " " in label: + self.module.fail_json("labels must not contain spaces: got invalid label {}".format(label)) + + self.instance = self.get_jenkins_instance() + self.result = { + 'changed': False, + 'url': self.url, + 'user': self.user, + 'name': self.name, + 'state': self.state, + 'created': False, + 'deleted': False, + 'disabled': False, + 'enabled': False, + 'configured': False, + 'warnings': [], + } + + def get_jenkins_instance(self): + try: + if self.user and self.token: + return jenkins.Jenkins(self.url, self.user, self.token) + elif self.user and not self.token: + return jenkins.Jenkins(self.url, self.user) + else: + return jenkins.Jenkins(self.url) + except Exception as e: + self.module.fail_json(msg='Unable to connect to Jenkins server, %s' % to_native(e)) + + def configure_node(self, present): + if not present: + # Node would only not be present if in check mode and if not present there + # is no way to know what would and would not be changed. + if not self.module.check_mode: + raise Exception("configure_node present is False outside of check mode") + return + + configured = False + + data = self.instance.get_node_config(self.name) + root = ElementTree.fromstring(data) + + if self.num_executors is not None: + elem = root.find('numExecutors') + if elem is None: + elem = ElementTree.SubElement(root, 'numExecutors') + if elem.text is None or int(elem.text) != self.num_executors: + elem.text = str(self.num_executors) + configured = True + + if self.labels is not None: + elem = root.find('label') + if elem is None: + elem = ElementTree.SubElement(root, 'label') + labels = [] + if elem.text: + labels = elem.text.split() + if labels != self.labels: + elem.text = " ".join(self.labels) + configured = True + + if configured: + if IS_PYTHON_2: + data = ElementTree.tostring(root) + else: + data = ElementTree.tostring(root, encoding="unicode") + + self.instance.reconfig_node(self.name, data) + + self.result['configured'] = configured + if configured: + self.result['changed'] = True + + def present_node(self): + def create_node(): + try: + self.instance.create_node(self.name, launcher=jenkins.LAUNCHER_SSH) + except jenkins.JenkinsException as e: + # Some versions of python-jenkins < 1.8.3 has an authorization bug when + # handling redirects returned when posting new resources. If the node is + # created OK then can ignore the error. + if not self.instance.node_exists(self.name): + raise e + + # TODO: Remove authorization workaround. + self.result['warnings'].append( + "suppressed 401 Not Authorized on redirect after node created: see https://review.opendev.org/c/jjb/python-jenkins/+/931707" + ) + + present = self.instance.node_exists(self.name) + created = False + if not present: + if not self.module.check_mode: + create_node() + present = True + + created = True + + self.configure_node(present) + + self.result['created'] = created + if created: + self.result['changed'] = True + + return present # Used to gate downstream queries when in check mode. + + def absent_node(self): + def delete_node(): + try: + self.instance.delete_node(self.name) + except jenkins.JenkinsException as e: + # Some versions of python-jenkins < 1.8.3 has an authorization bug when + # handling redirects returned when posting new resources. If the node is + # deleted OK then can ignore the error. + if self.instance.node_exists(self.name): + raise e + + # TODO: Remove authorization workaround. + self.result['warnings'].append( + "suppressed 401 Not Authorized on redirect after node deleted: see https://review.opendev.org/c/jjb/python-jenkins/+/931707" + ) + + present = self.instance.node_exists(self.name) + deleted = False + if present: + if not self.module.check_mode: + delete_node() + + deleted = True + + self.result['deleted'] = deleted + if deleted: + self.result['changed'] = True + + def enabled_node(self): + present = self.present_node() + + enabled = False + + if present: + info = self.instance.get_node_info(self.name) + + if info['offline']: + if not self.module.check_mode: + self.instance.enable_node(self.name) + + enabled = True + else: + # Would have created node with initial state enabled therefore would not have + # needed to enable therefore not enabled. + if not self.module.check_mode: + raise Exception("enabled_node present is False outside of check mode") + enabled = False + + self.result['enabled'] = enabled + if enabled: + self.result['changed'] = True + + def disabled_node(self): + present = self.present_node() + + disabled = False + + if present: + info = self.instance.get_node_info(self.name) + + if not info['offline']: + if not self.module.check_mode: + self.instance.disable_node(self.name) + + disabled = True + else: + # Would have created node with initial state enabled therefore would have + # needed to disable therefore disabled. + if not self.module.check_mode: + raise Exception("disabled_node present is False outside of check mode") + disabled = True + + self.result['disabled'] = disabled + if disabled: + self.result['changed'] = True + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict(required=True, type='str'), + url=dict(default='http://localhost:8080'), + user=dict(), + token=dict(no_log=True), + state=dict(choices=['enabled', 'disabled', 'present', 'absent'], default='present'), + num_executors=dict(type='int'), + labels=dict(type='list', elements='str'), + ), + supports_check_mode=True, + ) + + deps.validate(module) + + jenkins_node = JenkinsNode(module) + + state = module.params.get('state') + if state == 'enabled': + jenkins_node.enabled_node() + elif state == 'disabled': + jenkins_node.disabled_node() + elif state == 'present': + jenkins_node.present_node() + else: + jenkins_node.absent_node() + + module.exit_json(**jenkins_node.result) + + +if __name__ == '__main__': + main() diff --git a/tests/unit/plugins/modules/test_jenkins_node.py b/tests/unit/plugins/modules/test_jenkins_node.py new file mode 100644 index 00000000000..33e7ca0f133 --- /dev/null +++ b/tests/unit/plugins/modules/test_jenkins_node.py @@ -0,0 +1,575 @@ +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import jenkins +import json + +from xml.etree import ElementTree as et + +import pytest + +from ansible.module_utils import basic +from ansible.module_utils.common.text.converters import to_bytes +from ansible_collections.community.general.tests.unit.compat.mock import patch, call +from ansible_collections.community.general.plugins.modules import jenkins_node +from pytest import fixture, raises, mark, param + + +def xml_equal(x, y): + # type: (et.Element | str, et.Element | str) -> bool + if isinstance(x, str): + x = et.fromstring(x) + + if isinstance(y, str): + y = et.fromstring(y) + + if x.tag != y.tag: + return False + + if x.attrib != y.attrib: + return False + + if (x.text or "").strip() != (y.text or "").strip(): + return False + + x_children = list(x) + y_children = list(y) + + if len(x_children) != len(y_children): + return False + + for x, y in zip(x_children, y_children): + if not xml_equal(x, y): + return False + + return True + + +def assert_xml_equal(x, y): + if xml_equal(x, y): + return True + + if not isinstance(x, str): + x = et.tostring(x) + + if not isinstance(y, str): + y = et.tostring(y) + + raise AssertionError("{} != {}".format(x, y)) + + +def set_module_args(args): + """prepare arguments so that they will be picked up during module creation""" + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class AnsibleExitJson(Exception): + def __init__(self, value): + self.value = value + + def __getitem__(self, item): + return self.value[item] + + +def exit_json(*args, **kwargs): + if 'changed' not in kwargs: + kwargs['changed'] = False + raise AnsibleExitJson(kwargs) + + +class AnsibleFailJson(Exception): + pass + + +def fail_json(*args, **kwargs): + kwargs['failed'] = True + raise AnsibleFailJson(kwargs) + + +@fixture(autouse=True) +def module(): + with patch.multiple( + "ansible.module_utils.basic.AnsibleModule", + exit_json=exit_json, + fail_json=fail_json, + ): + yield + + +@fixture +def instance(): + with patch("jenkins.Jenkins", autospec=True) as instance: + yield instance + + +@fixture +def get_instance(instance): + with patch( + "ansible_collections.community.general.plugins.modules.jenkins_node.JenkinsNode.get_jenkins_instance", + autospec=True, + ) as mock: + mock.return_value = instance + yield mock + + +def test_get_jenkins_instance_with_user_and_token(instance): + instance.node_exists.return_value = False + + set_module_args({ + "name": "my-node", + "state": "absent", + "url": "https://localhost:8080", + "user": "admin", + "token": "password", + }) + + with pytest.raises(AnsibleExitJson): + jenkins_node.main() + + assert instance.call_args == call("https://localhost:8080", "admin", "password") + + +def test_get_jenkins_instance_with_user(instance): + instance.node_exists.return_value = False + + set_module_args({ + "name": "my-node", + "state": "absent", + "url": "https://localhost:8080", + "user": "admin", + }) + + with pytest.raises(AnsibleExitJson): + jenkins_node.main() + + assert instance.call_args == call("https://localhost:8080", "admin") + + +def test_get_jenkins_instance_with_no_credential(instance): + instance.node_exists.return_value = False + + set_module_args({ + "name": "my-node", + "state": "absent", + "url": "https://localhost:8080", + }) + + with pytest.raises(AnsibleExitJson): + jenkins_node.main() + + assert instance.call_args == call("https://localhost:8080") + + +PRESENT_STATES = ["present", "enabled", "disabled"] + + +@mark.parametrize(["state"], [param(state) for state in PRESENT_STATES]) +def test_state_present_when_absent(get_instance, instance, state): + instance.node_exists.return_value = False + instance.get_node_config.return_value = "" + + set_module_args({ + "name": "my-node", + "state": state, + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert instance.create_node.call_args == call("my-node", launcher=jenkins.LAUNCHER_SSH) + + assert result.value["created"] is True + assert result.value["changed"] is True + + +@mark.parametrize(["state"], [param(state) for state in PRESENT_STATES]) +def test_state_present_when_absent_check_mode(get_instance, instance, state): + instance.node_exists.return_value = False + instance.get_node_config.return_value = "" + + set_module_args({ + "name": "my-node", + "state": state, + "_ansible_check_mode": True, + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.create_node.called + + assert result.value["created"] is True + assert result.value["changed"] is True + + +def test_state_present_when_present(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + + set_module_args({ + "name": "my-node", + "state": "present", + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.create_node.called + + assert result.value["created"] is False + assert result.value["changed"] is False + + +def test_state_absent_when_present(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + + set_module_args({ + "name": "my-node", + "state": "absent", + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert instance.delete_node.call_args == call("my-node") + + assert result.value["deleted"] is True + assert result.value["changed"] is True + + +def test_state_absent_when_present_check_mode(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + + set_module_args({ + "name": "my-node", + "state": "absent", + "_ansible_check_mode": True, + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.delete_node.called + + assert result.value["deleted"] is True + assert result.value["changed"] is True + + +def test_state_absent_when_absent(get_instance, instance): + instance.node_exists.return_value = False + instance.get_node_config.return_value = "" + + set_module_args({ + "name": "my-node", + "state": "absent", + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.delete_node.called + + assert result.value["deleted"] is False + assert result.value["changed"] is False + + +def test_state_enabled_when_offline(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + instance.get_node_info.return_value = {"offline": True} + + set_module_args({ + "name": "my-node", + "state": "enabled", + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert instance.enable_node.call_args == call("my-node") + + assert result.value["enabled"] is True + assert result.value["changed"] is True + + +def test_state_enabled_when_offline_check_mode(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + instance.get_node_info.return_value = {"offline": True} + + set_module_args({ + "name": "my-node", + "state": "enabled", + "_ansible_check_mode": True, + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.enable_node.called + + assert result.value["enabled"] is True + assert result.value["changed"] is True + + +def test_state_enabled_when_not_offline(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + instance.get_node_info.return_value = {"offline": False} + + set_module_args({ + "name": "my-node", + "state": "enabled", + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.enable_node.called + + assert result.value["enabled"] is False + assert result.value["changed"] is False + + +def test_state_disabled_when_not_offline(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + instance.get_node_info.return_value = {"offline": False} + + set_module_args({ + "name": "my-node", + "state": "disabled", + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert instance.disable_node.call_args == call("my-node") + + assert result.value["disabled"] is True + assert result.value["changed"] is True + + +def test_state_disabled_when_not_offline_check_mode(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + instance.get_node_info.return_value = {"offline": False} + + set_module_args({ + "name": "my-node", + "state": "disabled", + "_ansible_check_mode": True, + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.disable_node.called + + assert result.value["disabled"] is True + assert result.value["changed"] is True + + +def test_state_disabled_when_offline(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + instance.get_node_info.return_value = {"offline": True} + + set_module_args({ + "name": "my-node", + "state": "disabled", + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.disable_node.called + + assert result.value["disabled"] is False + assert result.value["changed"] is False + + +def test_configure_num_executors_when_not_configured(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + + set_module_args({ + "name": "my-node", + "state": "present", + "num_executors": 3, + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert instance.reconfig_node.call_args[0][0] == "my-node" + assert_xml_equal(instance.reconfig_node.call_args[0][1], """ + + 3 + +""") + + assert result.value["configured"] is True + assert result.value["changed"] is True + + +def test_configure_num_executors_when_not_equal(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = """ + + 3 + +""" + + set_module_args({ + "name": "my-node", + "state": "present", + "num_executors": 2, + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert_xml_equal(instance.reconfig_node.call_args[0][1], """ + + 2 + +""") + + assert result.value["configured"] is True + assert result.value["changed"] is True + + +def test_configure_num_executors_when_equal(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = """ + + 2 + +""" + + set_module_args({ + "name": "my-node", + "state": "present", + "num_executors": 2, + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.reconfig_node.called + + assert result.value["configured"] is False + assert result.value["changed"] is False + + +def test_configure_labels_when_not_configured(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + + set_module_args({ + "name": "my-node", + "state": "present", + "labels": [ + "a", + "b", + "c", + ], + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert instance.reconfig_node.call_args[0][0] == "my-node" + assert_xml_equal(instance.reconfig_node.call_args[0][1], """ + + + +""") + + assert result.value["configured"] is True + assert result.value["changed"] is True + + +def test_configure_labels_when_not_equal(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = """ + + + +""" + + set_module_args({ + "name": "my-node", + "state": "present", + "labels": [ + "a", + "z", + "c", + ], + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert instance.reconfig_node.call_args[0][0] == "my-node" + assert_xml_equal(instance.reconfig_node.call_args[0][1], """ + + + +""") + + assert result.value["configured"] is True + assert result.value["changed"] is True + + +def test_configure_labels_when_equal(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = """ + + + +""" + + set_module_args({ + "name": "my-node", + "state": "present", + "labels": [ + "a", + "b", + "c", + ], + }) + + with raises(AnsibleExitJson) as result: + jenkins_node.main() + + assert not instance.reconfig_node.called + + assert result.value["configured"] is False + assert result.value["changed"] is False + + +def test_configure_labels_fail_when_contains_space(get_instance, instance): + instance.node_exists.return_value = True + instance.get_node_config.return_value = "" + + set_module_args({ + "name": "my-node", + "state": "present", + "labels": [ + "a error", + ], + }) + + with raises(AnsibleFailJson): + jenkins_node.main() + + assert not instance.reconfig_node.called diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt index 218fe456738..cfc8493912f 100644 --- a/tests/unit/requirements.txt +++ b/tests/unit/requirements.txt @@ -54,3 +54,6 @@ proxmoxer ; python_version > '3.6' #requirements for nomad_token modules python-nomad < 2.0.0 ; python_version <= '3.6' python-nomad >= 2.0.0 ; python_version >= '3.7' + +# requirement for jenkins_build, jenkins_node, jenkins_plugin modules +python-jenkins >= 0.4.12 \ No newline at end of file