diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 9eb521018f..6fabe92dee 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -448,6 +448,8 @@ files: maintainers: hkariti $modules/bitbucket_: maintainers: catcombo + $modules/bootc_manage.py: + maintainers: cooktheryan $modules/bower.py: maintainers: mwarkentin $modules/btrfs_: diff --git a/plugins/modules/bootc_manage.py b/plugins/modules/bootc_manage.py new file mode 100644 index 0000000000..5628ffcca0 --- /dev/null +++ b/plugins/modules/bootc_manage.py @@ -0,0 +1,95 @@ +#!/usr/bin/python + +# Copyright (c) 2024, Ryan Cook +# 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: bootc_manage +version_added: 9.3.0 +author: +- Ryan Cook (@cooktheryan) +short_description: Bootc Switch and Upgrade +description: + - This module manages the switching and upgrading of C(bootc). +options: + state: + description: + - 'Control to apply the latest image or switch the image.' + - 'B(Note:) This will not reboot the system.' + - 'Please use M(ansible.builtin.reboot) to reboot the system.' + required: true + type: str + choices: ['switch', 'latest'] + image: + description: + - 'The image to switch to.' + - 'This is required when O(state=switch).' + required: false + type: str + +''' + +EXAMPLES = ''' +# Switch to a different image +- name: Provide image to switch to a different image and retain the current running image + community.general.bootc_manage: + state: switch + image: "example.com/image:latest" + +# Apply updates of the current running image +- name: Apply updates of the current running image + community.general.bootc_manage: + state: latest +''' + +RETURN = ''' +''' + + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.locale import get_best_parsable_locale + + +def main(): + argument_spec = dict( + state=dict(type='str', required=True, choices=['switch', 'latest']), + image=dict(type='str', required=False), + ) + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + ('state', 'switch', ['image']), + ], + ) + + state = module.params['state'] + image = module.params['image'] + + if state == 'switch': + command = ['bootc', 'switch', image, '--retain'] + elif state == 'latest': + command = ['bootc', 'upgrade'] + + locale = get_best_parsable_locale(module) + module.run_command_environ_update = dict(LANG=locale, LC_ALL=locale, LC_MESSAGES=locale, LC_CTYPE=locale, LANGUAGE=locale) + rc, stdout, err = module.run_command(command, check_rc=True) + + if 'Queued for next boot: ' in stdout: + result = {'changed': True, 'stdout': stdout} + module.exit_json(**result) + elif 'No changes in ' in stdout or 'Image specification is unchanged.' in stdout: + result = {'changed': False, 'stdout': stdout} + module.exit_json(**result) + else: + result = {'changed': False, 'stderr': err} + module.fail_json(msg='ERROR: Command execution failed.', **result) + + +if __name__ == '__main__': + main() diff --git a/tests/unit/plugins/modules/test_bootc_manage.py b/tests/unit/plugins/modules/test_bootc_manage.py new file mode 100644 index 0000000000..5393a57a07 --- /dev/null +++ b/tests/unit/plugins/modules/test_bootc_manage.py @@ -0,0 +1,72 @@ +# 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 + +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.plugins.modules import bootc_manage +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args + + +class TestBootcManageModule(ModuleTestCase): + + def setUp(self): + super(TestBootcManageModule, self).setUp() + self.module = bootc_manage + + def tearDown(self): + super(TestBootcManageModule, self).tearDown() + + def test_switch_without_image(self): + """Failure if state is 'switch' but no image provided""" + set_module_args({'state': 'switch'}) + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], "state is switch but all of the following are missing: image") + + def test_switch_with_image(self): + """Test successful switch with image provided""" + set_module_args({'state': 'switch', 'image': 'example.com/image:latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (0, 'Queued for next boot: ', '') + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + self.assertTrue(result.exception.args[0]['changed']) + + def test_latest_state(self): + """Test successful upgrade to the latest state""" + set_module_args({'state': 'latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (0, 'Queued for next boot: ', '') + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + self.assertTrue(result.exception.args[0]['changed']) + + def test_latest_state_no_change(self): + """Test no change for latest state""" + set_module_args({'state': 'latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (0, 'No changes in ', '') + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + self.assertFalse(result.exception.args[0]['changed']) + + def test_switch_image_failure(self): + """Test failure during image switch""" + set_module_args({'state': 'switch', 'image': 'example.com/image:latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (1, '', 'ERROR') + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], 'ERROR: Command execution failed.') + + def test_latest_state_failure(self): + """Test failure during upgrade""" + set_module_args({'state': 'latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (1, '', 'ERROR') + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], 'ERROR: Command execution failed.')