From 487114069655ce6ea46358eb2ebcff0430635e11 Mon Sep 17 00:00:00 2001 From: Laszlo Szomor Date: Mon, 19 Jun 2023 13:11:03 +0200 Subject: [PATCH] lvg_rename: New module to support VG renaming (#6721) * lvg_rename: New module to support VG renaming * Remove vg option aliases Fix YAML boolean case-formatting Co-authored-by: Felix Fontein --------- Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + plugins/modules/lvg_rename.py | 170 ++++++++++++++++++ tests/integration/targets/lvg_rename/aliases | 13 ++ .../targets/lvg_rename/meta/main.yml | 9 + .../targets/lvg_rename/tasks/main.yml | 25 +++ .../targets/lvg_rename/tasks/setup.yml | 50 ++++++ .../targets/lvg_rename/tasks/teardown.yml | 46 +++++ .../targets/lvg_rename/tasks/test.yml | 105 +++++++++++ tests/unit/plugins/modules/test_lvg_rename.py | 160 +++++++++++++++++ 9 files changed, 580 insertions(+) create mode 100644 plugins/modules/lvg_rename.py create mode 100644 tests/integration/targets/lvg_rename/aliases create mode 100644 tests/integration/targets/lvg_rename/meta/main.yml create mode 100644 tests/integration/targets/lvg_rename/tasks/main.yml create mode 100644 tests/integration/targets/lvg_rename/tasks/setup.yml create mode 100644 tests/integration/targets/lvg_rename/tasks/teardown.yml create mode 100644 tests/integration/targets/lvg_rename/tasks/test.yml create mode 100644 tests/unit/plugins/modules/test_lvg_rename.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index ffe0985039..53047afa90 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -757,6 +757,8 @@ files: maintainers: nerzhul $modules/lvg.py: maintainers: abulimov + $modules/lvg_rename.py: + maintainers: lszomor $modules/lvol.py: maintainers: abulimov jhoekx zigaSRC unkaputtbar112 $modules/lxc_container.py: diff --git a/plugins/modules/lvg_rename.py b/plugins/modules/lvg_rename.py new file mode 100644 index 0000000000..bd48ffa62f --- /dev/null +++ b/plugins/modules/lvg_rename.py @@ -0,0 +1,170 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) Contributors to the 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 = r''' +--- +author: + - Laszlo Szomor (@lszomor) +module: lvg_rename +short_description: Renames LVM volume groups +description: + - This module renames volume groups using the C(vgchange) command. +extends_documentation_fragment: + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: full +version_added: 7.1.0 +options: + vg: + description: + - The name or UUID of the source VG. + - See V(vgrename(8\)) for valid values. + type: str + required: true + vg_new: + description: + - The new name of the VG. + - See V(lvm(8\)) for valid names. + type: str + required: true +seealso: +- module: community.general.lvg +notes: + - This module does not modify VG renaming-related configurations like C(fstab) entries or boot parameters. +''' + +EXAMPLES = r''' +- name: Rename a VG by name + community.general.lvg_rename: + vg: vg_orig_name + vg_new: vg_new_name + +- name: Rename a VG by UUID + community.general.lvg_rename: + vg_uuid: SNgd0Q-rPYa-dPB8-U1g6-4WZI-qHID-N7y9Vj + vg_new: vg_new_name +''' + +from ansible.module_utils.basic import AnsibleModule + +argument_spec = dict( + vg=dict(type='str', required=True,), + vg_new=dict(type='str', required=True,), +) + + +class LvgRename(object): + def __init__(self, module): + ''' + Orchestrates the lvg_rename module logic. + + :param module: An AnsibleModule instance. + ''' + self.module = module + self.result = {'changed': False} + self.vg_list = [] + self._load_params() + + def run(self): + """Performs the module logic.""" + + self._load_vg_list() + + old_vg_exists = self._is_vg_exists(vg=self.vg) + new_vg_exists = self._is_vg_exists(vg=self.vg_new) + + if old_vg_exists: + if new_vg_exists: + self.module.fail_json(msg='The new VG name (%s) is already in use.' % (self.vg_new)) + else: + self._rename_vg() + else: + if new_vg_exists: + self.result['msg'] = 'The new VG (%s) already exists, nothing to do.' % (self.vg_new) + self.module.exit_json(**self.result) + else: + self.module.fail_json(msg='Both current (%s) and new (%s) VG are missing.' % (self.vg, self.vg_new)) + + self.module.exit_json(**self.result) + + def _load_params(self): + """Load the parameters from the module.""" + + self.vg = self.module.params['vg'] + self.vg_new = self.module.params['vg_new'] + + def _load_vg_list(self): + """Load the VGs from the system.""" + + vgs_cmd = self.module.get_bin_path('vgs', required=True) + vgs_cmd_with_opts = [vgs_cmd, '--noheadings', '--separator', ';', '-o', 'vg_name,vg_uuid'] + dummy, vg_raw_list, dummy = self.module.run_command(vgs_cmd_with_opts, check_rc=True) + + for vg_info in vg_raw_list.splitlines(): + vg_name, vg_uuid = vg_info.strip().split(';') + self.vg_list.append(vg_name) + self.vg_list.append(vg_uuid) + + def _is_vg_exists(self, vg): + ''' + Checks VG existence by name or UUID. It removes the '/dev/' prefix before checking. + + :param vg: A string with the name or UUID of the VG. + :returns: A boolean indicates whether the VG exists or not. + ''' + + vg_found = False + dev_prefix = '/dev/' + + if vg.startswith(dev_prefix): + vg_id = vg[len(dev_prefix):] + else: + vg_id = vg + + vg_found = vg_id in self.vg_list + + return vg_found + + def _rename_vg(self): + """Renames the volume group.""" + + vgrename_cmd = self.module.get_bin_path('vgrename', required=True) + + if self.module._diff: + self.result['diff'] = {'before': {'vg': self.vg}, 'after': {'vg': self.vg_new}} + + if self.module.check_mode: + self.result['msg'] = "Running in check mode. The module would rename VG %s to %s." % (self.vg, self.vg_new) + self.result['changed'] = True + else: + vgrename_cmd_with_opts = [vgrename_cmd, self.vg, self.vg_new] + dummy, vg_rename_out, dummy = self.module.run_command(vgrename_cmd_with_opts, check_rc=True) + + self.result['msg'] = vg_rename_out + self.result['changed'] = True + + +def setup_module_object(): + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + return module + + +def main(): + module = setup_module_object() + lvg_rename = LvgRename(module=module) + lvg_rename.run() + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/lvg_rename/aliases b/tests/integration/targets/lvg_rename/aliases new file mode 100644 index 0000000000..64d439099c --- /dev/null +++ b/tests/integration/targets/lvg_rename/aliases @@ -0,0 +1,13 @@ +# Copyright (c) Contributors to the Ansible project +# Based on the integraton test for the lvg module +# 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 + +azp/posix/1 +azp/posix/vm +destructive +needs/privileged +skip/aix +skip/freebsd +skip/osx +skip/macos diff --git a/tests/integration/targets/lvg_rename/meta/main.yml b/tests/integration/targets/lvg_rename/meta/main.yml new file mode 100644 index 0000000000..90c5d5cb8d --- /dev/null +++ b/tests/integration/targets/lvg_rename/meta/main.yml @@ -0,0 +1,9 @@ +--- +# Copyright (c) Ansible Project +# Based on the integraton test for the lvg module +# 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 + +dependencies: + - setup_pkg_mgr + - setup_remote_tmp_dir diff --git a/tests/integration/targets/lvg_rename/tasks/main.yml b/tests/integration/targets/lvg_rename/tasks/main.yml new file mode 100644 index 0000000000..18dd6f1baf --- /dev/null +++ b/tests/integration/targets/lvg_rename/tasks/main.yml @@ -0,0 +1,25 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) Contributors to the Ansible project +# Based on the integraton test for the lvg module +# 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 + +- name: Install required packages (Linux) + when: ansible_system == 'Linux' + ansible.builtin.package: + name: lvm2 + state: present + +- name: Test lvg_rename module + block: + - import_tasks: setup.yml + + - import_tasks: test.yml + + always: + - import_tasks: teardown.yml diff --git a/tests/integration/targets/lvg_rename/tasks/setup.yml b/tests/integration/targets/lvg_rename/tasks/setup.yml new file mode 100644 index 0000000000..01721e42da --- /dev/null +++ b/tests/integration/targets/lvg_rename/tasks/setup.yml @@ -0,0 +1,50 @@ +--- +# Copyright (c) Contributors to the Ansible project +# Based on the integraton test for the lvg module +# 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 + +- name: Create files to use as disk devices + with_sequence: 'count=2' + ansible.builtin.command: + cmd: "dd if=/dev/zero of={{ remote_tmp_dir }}/img{{ item }} bs=1M count=10" + creates: "{{ remote_tmp_dir }}/img{{ item }}" + +- name: Show next free loop device + ansible.builtin.command: + cmd: "losetup -f" + changed_when: false + register: loop_device1 + +- name: "Create loop device for file {{ remote_tmp_dir }}/img1" + ansible.builtin.command: + cmd: "losetup -f {{ remote_tmp_dir }}/img1" + changed_when: true + +- name: Show next free loop device + ansible.builtin.command: + cmd: "losetup -f" + changed_when: false + register: loop_device2 + +- name: "Create loop device for file {{ remote_tmp_dir }}/img2" + ansible.builtin.command: + cmd: "losetup -f {{ remote_tmp_dir }}/img2" + changed_when: true + +- name: Affect name on disk to work on + ansible.builtin.set_fact: + loop_device1: "{{ loop_device1.stdout }}" + loop_device2: "{{ loop_device2.stdout }}" + +- name: "Create test volume group testvg on {{ loop_device1 }}" + community.general.lvg: + vg: "testvg" + state: present + pvs: "{{ loop_device1 }}" + +- name: "Create test volume group testvg2 on {{ loop_device2 }}" + community.general.lvg: + vg: "testvg2" + state: present + pvs: "{{ loop_device2 }}" diff --git a/tests/integration/targets/lvg_rename/tasks/teardown.yml b/tests/integration/targets/lvg_rename/tasks/teardown.yml new file mode 100644 index 0000000000..71c33d56d9 --- /dev/null +++ b/tests/integration/targets/lvg_rename/tasks/teardown.yml @@ -0,0 +1,46 @@ +--- +# Copyright (c) Contributors to the Ansible project +# Based on the integraton test for the lvg module +# 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 + +- name: Collect test volume groups + ansible.builtin.command: + cmd: "pvs --noheadings -ovg_name {{ loop_device1 | default('') }} {{ loop_device2 | default('') }}" + register: test_vgs_output + changed_when: false + +- name: Remove test volume groups + loop: "{{ test_vgs_output.stdout_lines }}" + loop_control: + label: "{{ item | trim }}" + community.general.lvg: + vg: "{{ item | trim }}" + state: absent + +- name: Remove lvmdevices + loop: + - "{{ loop_device1 | default('') }}" + - "{{ loop_device2 | default('') }}" + when: + - item | length > 0 + ansible.builtin.command: + cmd: "lvmdevices --deldev {{ item }}" + failed_when: false + changed_when: true + +- name: Detach loop devices + loop: + - "{{ loop_device1 | default('') }}" + - "{{ loop_device2 | default('') }}" + when: + - item | length > 0 + ansible.builtin.command: + cmd: "losetup -d {{ item }}" + changed_when: true + +- name: Remove device files + with_sequence: 'count=2' + ansible.builtin.file: + path: "{{ remote_tmp_dir }}/img{{ item }}" + state: absent diff --git a/tests/integration/targets/lvg_rename/tasks/test.yml b/tests/integration/targets/lvg_rename/tasks/test.yml new file mode 100644 index 0000000000..ab62b679b0 --- /dev/null +++ b/tests/integration/targets/lvg_rename/tasks/test.yml @@ -0,0 +1,105 @@ +--- +# Copyright (c) Contributors to the 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 + +- name: Rename a VG in check mode + community.general.lvg_rename: + vg: testvg + vg_new: testvg_renamed + check_mode: true + register: check_mode_vg_rename + +- name: Check if testvg still exists + ansible.builtin.command: + cmd: vgs testvg + changed_when: false + +- name: Assert that renaming a VG is changed - check mode + assert: + that: + - check_mode_vg_rename is changed + +- name: Rename testvg to testvg_renamed + community.general.lvg_rename: + vg: testvg + vg_new: testvg_renamed + register: vg_renamed + +- name: Assert that renaming a VG is changed + assert: + that: + - vg_renamed is changed + +- name: Check if testvg does not exists + ansible.builtin.command: + cmd: vgs testvg + register: check_testvg_existence_result + failed_when: check_testvg_existence_result.rc == 0 + changed_when: false + +- name: Check if testvg_renamed exists + ansible.builtin.command: + cmd: vgs testvg_renamed + changed_when: false + +- name: Rename testvg to testvg_renamed again for testing idempotency - check mode + community.general.lvg_rename: + vg: testvg + vg_new: testvg_renamed + check_mode: true + register: check_mode_vg_renamed_again + +- name: Rename testvg to testvg_renamed again for testing idempotency + community.general.lvg_rename: + vg: testvg + vg_new: testvg_renamed + register: vg_renamed_again + +- name: Assert that renaming a VG again is not changed + assert: + that: + - check_mode_vg_renamed_again is not changed + - vg_renamed_again is not changed + +- name: Rename a non-existing VG - check mode + community.general.lvg_rename: + vg: testvg + vg_new: testvg_ne + check_mode: true + ignore_errors: true + register: check_mode_ne_vg_rename + +- name: Rename a non-existing VG + community.general.lvg_rename: + vg: testvg + vg_new: testvg_ne + ignore_errors: true + register: ne_vg_rename + +- name: Assert that renaming a no-existing VG failed + assert: + that: + - check_mode_ne_vg_rename is failed + - ne_vg_rename is failed + +- name: Rename testvg_renamed to the existing testvg2 name - check mode + community.general.lvg_rename: + vg: testvg_renamed + vg_new: testvg2 + check_mode: true + ignore_errors: true + register: check_mode_vg_rename_collision + +- name: Rename testvg_renamed to the existing testvg2 name + community.general.lvg_rename: + vg: testvg_renamed + vg_new: testvg2 + ignore_errors: true + register: vg_rename_collision + +- name: Assert that renaming to an existing VG name failed + assert: + that: + - check_mode_vg_rename_collision is failed + - vg_rename_collision is failed diff --git a/tests/unit/plugins/modules/test_lvg_rename.py b/tests/unit/plugins/modules/test_lvg_rename.py new file mode 100644 index 0000000000..0f2fcb7fa7 --- /dev/null +++ b/tests/unit/plugins/modules/test_lvg_rename.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Contributors to the 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.plugins.modules import lvg_rename +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import ( + AnsibleFailJson, AnsibleExitJson, ModuleTestCase, set_module_args) + + +VGS_OUTPUT = '''\ +vg_data_testhost1;XKZ5gn-YhWY-NlrT-QCFN-qmMG-VGT9-7uOmex +vg_sys_testhost2;xgy2SJ-YlYd-fde2-e3oG-zdXL-0xGf-ihqG2H +''' + + +class TestLvgRename(ModuleTestCase): + """Tests for lvg_rename internals""" + module = lvg_rename + module_path = 'ansible_collections.community.general.plugins.modules.lvg_rename' + + def setUp(self): + """Prepare mocks for module testing""" + super(TestLvgRename, self).setUp() + + self.mock_run_responses = {} + + patched_module_get_bin_path = patch('%s.AnsibleModule.get_bin_path' % (self.module_path)) + self.mock_module_get_bin_path = patched_module_get_bin_path.start() + self.mock_module_get_bin_path.return_value = '/mocpath' + self.addCleanup(patched_module_get_bin_path.stop) + + patched_module_run_command = patch('%s.AnsibleModule.run_command' % (self.module_path)) + self.mock_module_run_command = patched_module_run_command.start() + self.addCleanup(patched_module_run_command.stop) + + def test_vg_not_found_by_name(self): + """When the VG by the specified by vg name not found, the module should exit with error""" + failed = True + self.mock_module_run_command.side_effect = [(0, VGS_OUTPUT, '')] + expected_msg = 'Both current (vg_missing) and new (vg_data_testhost2) VG are missing.' + + module_args = { + 'vg': 'vg_missing', + 'vg_new': 'vg_data_testhost2', + } + set_module_args(args=module_args) + + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + + self.assertEqual(len(self.mock_module_run_command.mock_calls), 1) + self.assertIs(result.exception.args[0]['failed'], failed) + self.assertEqual(result.exception.args[0]['msg'], expected_msg) + + def test_vg_not_found_by_uuid(self): + """When the VG by the specified vg UUID not found, the module should exit with error""" + failed = True + self.mock_module_run_command.side_effect = [(0, VGS_OUTPUT, '')] + expected_msg = 'Both current (Yfj4YG-c8nI-z7w5-B7Fw-i2eM-HqlF-ApFVp0) and new (vg_data_testhost2) VG are missing.' + + module_args = { + 'vg': 'Yfj4YG-c8nI-z7w5-B7Fw-i2eM-HqlF-ApFVp0', + 'vg_new': 'vg_data_testhost2', + } + set_module_args(args=module_args) + + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + + self.assertEqual(len(self.mock_module_run_command.mock_calls), 1) + self.assertIs(result.exception.args[0]['failed'], failed) + self.assertEqual(result.exception.args[0]['msg'], expected_msg) + + def test_vg_and_vg_new_both_exists(self): + """When a VG found for both vg and vg_new options, the module should exit with error""" + failed = True + self.mock_module_run_command.side_effect = [(0, VGS_OUTPUT, '')] + expected_msg = 'The new VG name (vg_sys_testhost2) is already in use.' + + module_args = { + 'vg': 'vg_data_testhost1', + 'vg_new': 'vg_sys_testhost2', + } + set_module_args(args=module_args) + + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + + self.assertEqual(len(self.mock_module_run_command.mock_calls), 1) + self.assertIs(result.exception.args[0]['failed'], failed) + self.assertEqual(result.exception.args[0]['msg'], expected_msg) + + def test_vg_needs_renaming(self): + """When the VG found for vg option and there is no VG for vg_new option, + the module should call vgrename""" + changed = True + self.mock_module_run_command.side_effect = [ + (0, VGS_OUTPUT, ''), + (0, ' Volume group "vg_data_testhost1" successfully renamed to "vg_data_testhost2"', '') + ] + expected_msg = ' Volume group "vg_data_testhost1" successfully renamed to "vg_data_testhost2"' + + module_args = { + 'vg': '/dev/vg_data_testhost1', + 'vg_new': 'vg_data_testhost2', + } + set_module_args(args=module_args) + + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + + self.assertEqual(len(self.mock_module_run_command.mock_calls), 2) + self.assertIs(result.exception.args[0]['changed'], changed) + self.assertEqual(result.exception.args[0]['msg'], expected_msg) + + def test_vg_needs_renaming_in_check_mode(self): + """When running in check mode and the VG found for vg option and there is no VG for vg_new option, + the module should not call vgrename""" + changed = True + self.mock_module_run_command.side_effect = [(0, VGS_OUTPUT, '')] + expected_msg = 'Running in check mode. The module would rename VG /dev/vg_data_testhost1 to vg_data_testhost2.' + + module_args = { + 'vg': '/dev/vg_data_testhost1', + 'vg_new': 'vg_data_testhost2', + '_ansible_check_mode': True, + } + set_module_args(args=module_args) + + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + + self.assertEqual(len(self.mock_module_run_command.mock_calls), 1) + self.assertIs(result.exception.args[0]['changed'], changed) + self.assertEqual(result.exception.args[0]['msg'], expected_msg) + + def test_vg_needs_no_renaming(self): + """When the VG not found for vg option and the VG found for vg_new option, + the module should not call vgrename""" + changed = False + self.mock_module_run_command.side_effect = [(0, VGS_OUTPUT, '')] + expected_msg = 'The new VG (vg_data_testhost1) already exists, nothing to do.' + + module_args = { + 'vg': 'vg_data_testhostX', + 'vg_new': 'vg_data_testhost1', + } + set_module_args(args=module_args) + + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + + self.assertEqual(len(self.mock_module_run_command.mock_calls), 1) + self.assertIs(result.exception.args[0]['changed'], changed) + self.assertEqual(result.exception.args[0]['msg'], expected_msg)