From 367b28d765e48288c237f68c1296930be3759b66 Mon Sep 17 00:00:00 2001 From: Klention Mali <45871249+klention@users.noreply.github.com> Date: Sat, 7 Jun 2025 17:55:20 +0200 Subject: [PATCH] lvm_pv: new module for LVM Physical Volumes (#10070) * Added LVM Physical Volume module * Fixed CI checks * python 2.7 compatibility * Fixed another fprint line not compatible with python 2.x * Applied cosmetic changes * Removed msg from RETURN section * Updated the 'absent state' block logic * Added integration tests * Updated logic for creating loop devices on Alpine Linux * Updated loop device path * Minor, cosmetic changes * Adjust indentation. --------- Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + plugins/modules/lvm_pv.py | 192 ++++++++++++++++++ tests/integration/targets/lvm_pv/aliases | 13 ++ .../integration/targets/lvm_pv/meta/main.yml | 9 + .../targets/lvm_pv/tasks/cleanup.yml | 12 ++ .../targets/lvm_pv/tasks/creation.yml | 33 +++ .../integration/targets/lvm_pv/tasks/main.yml | 27 +++ .../targets/lvm_pv/tasks/removal.yml | 16 ++ .../targets/lvm_pv/tasks/resizing.yml | 27 +++ 9 files changed, 331 insertions(+) create mode 100644 plugins/modules/lvm_pv.py create mode 100644 tests/integration/targets/lvm_pv/aliases create mode 100644 tests/integration/targets/lvm_pv/meta/main.yml create mode 100644 tests/integration/targets/lvm_pv/tasks/cleanup.yml create mode 100644 tests/integration/targets/lvm_pv/tasks/creation.yml create mode 100644 tests/integration/targets/lvm_pv/tasks/main.yml create mode 100644 tests/integration/targets/lvm_pv/tasks/removal.yml create mode 100644 tests/integration/targets/lvm_pv/tasks/resizing.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 2400628aac..24a9fc45e8 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -902,6 +902,8 @@ files: maintainers: nerzhul $modules/lvg.py: maintainers: abulimov + $modules/lvm_pv.py: + maintainers: klention $modules/lvg_rename.py: maintainers: lszomor $modules/lvol.py: diff --git a/plugins/modules/lvm_pv.py b/plugins/modules/lvm_pv.py new file mode 100644 index 0000000000..93606271fc --- /dev/null +++ b/plugins/modules/lvm_pv.py @@ -0,0 +1,192 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2025, Klention Mali +# Based on lvol module by Jeroen Hoekx +# 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''' +--- +module: lvm_pv +short_description: Manage LVM Physical Volumes +version_added: "11.0.0" +description: + - Creates, resizes or removes LVM Physical Volumes. +author: + - Klention Mali (@klention) +options: + device: + description: + - Path to the block device to manage. + type: path + required: true + state: + description: + - Control if the physical volume exists. + type: str + choices: [ present, absent ] + default: present + force: + description: + - Force the operation. + - When O(state=present) (creating a PV), this uses C(pvcreate -f) to force creation. + - When O(state=absent) (removing a PV), this uses C(pvremove -ff) to force removal even if part of a volume group. + type: bool + default: false + resize: + description: + - Resize PV to device size when O(state=present). + type: bool + default: false +notes: + - Requires LVM2 utilities installed on the target system. + - Device path must exist when creating a PV. +''' + +EXAMPLES = r''' +- name: Creating physical volume on /dev/sdb + community.general.lvm_pv: + device: /dev/sdb + +- name: Creating and resizing (if needed) physical volume + community.general.lvm_pv: + device: /dev/sdb + resize: true + +- name: Removing physical volume that is not part of any volume group + community.general.lvm_pv: + device: /dev/sdb + state: absent + +- name: Force removing physical volume that is already part of a volume group + community.general.lvm_pv: + device: /dev/sdb + force: true + state: absent +''' + +RETURN = r''' +''' + + +import os +from ansible.module_utils.basic import AnsibleModule + + +def get_pv_status(module, device): + """Check if the device is already a PV.""" + cmd = ['pvs', '--noheadings', '--readonly', device] + return module.run_command(cmd)[0] == 0 + + +def get_pv_size(module, device): + """Get current PV size in bytes.""" + cmd = ['pvs', '--noheadings', '--nosuffix', '--units', 'b', '-o', 'pv_size', device] + rc, out, err = module.run_command(cmd, check_rc=True) + return int(out.strip()) + + +def rescan_device(module, device): + """Perform storage rescan for the device.""" + # Extract the base device name (e.g., /dev/sdb -> sdb) + base_device = os.path.basename(device) + rescan_path = "/sys/block/{0}/device/rescan".format(base_device) + + if os.path.exists(rescan_path): + try: + with open(rescan_path, 'w') as f: + f.write('1') + return True + except IOError as e: + module.warn("Failed to rescan device {0}: {1}".format(device, str(e))) + return False + else: + module.warn("Rescan path not found for device {0}".format(device)) + return False + + +def main(): + module = AnsibleModule( + argument_spec=dict( + device=dict(type='path', required=True), + state=dict(type='str', default='present', choices=['present', 'absent']), + force=dict(type='bool', default=False), + resize=dict(type='bool', default=False), + ), + supports_check_mode=True, + ) + + device = module.params['device'] + state = module.params['state'] + force = module.params['force'] + resize = module.params['resize'] + changed = False + actions = [] + + # Validate device existence for present state + if state == 'present' and not os.path.exists(device): + module.fail_json(msg="Device %s not found" % device) + + is_pv = get_pv_status(module, device) + + if state == 'present': + # Create PV if needed + if not is_pv: + if module.check_mode: + changed = True + actions.append('would be created') + else: + cmd = ['pvcreate'] + if force: + cmd.append('-f') + cmd.append(device) + rc, out, err = module.run_command(cmd, check_rc=True) + changed = True + actions.append('created') + is_pv = True + + # Handle resizing + elif resize and is_pv: + if module.check_mode: + # In check mode, assume resize would change + changed = True + actions.append('would be resized') + else: + # Perform device rescan if each time + if rescan_device(module, device): + actions.append('rescanned') + original_size = get_pv_size(module, device) + rc, out, err = module.run_command(['pvresize', device], check_rc=True) + new_size = get_pv_size(module, device) + if new_size != original_size: + changed = True + actions.append('resized') + + elif state == 'absent': + if is_pv: + if module.check_mode: + changed = True + actions.append('would be removed') + else: + cmd = ['pvremove', '-y'] + if force: + cmd.append('-ff') + changed = True + cmd.append(device) + rc, out, err = module.run_command(cmd, check_rc=True) + actions.append('removed') + + # Generate final message + if actions: + msg = "PV %s: %s" % (device, ', '.join(actions)) + else: + msg = "No changes needed for PV %s" % device + module.exit_json(changed=changed, msg=msg) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/lvm_pv/aliases b/tests/integration/targets/lvm_pv/aliases new file mode 100644 index 0000000000..64d439099c --- /dev/null +++ b/tests/integration/targets/lvm_pv/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/lvm_pv/meta/main.yml b/tests/integration/targets/lvm_pv/meta/main.yml new file mode 100644 index 0000000000..90c5d5cb8d --- /dev/null +++ b/tests/integration/targets/lvm_pv/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/lvm_pv/tasks/cleanup.yml b/tests/integration/targets/lvm_pv/tasks/cleanup.yml new file mode 100644 index 0000000000..a9c0bb095d --- /dev/null +++ b/tests/integration/targets/lvm_pv/tasks/cleanup.yml @@ -0,0 +1,12 @@ +--- +# 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 + +- name: Detaching loop device + ansible.builtin.command: losetup -d {{ loop_device.stdout }} + +- name: Removing loop device file + ansible.builtin.file: + path: "{{ remote_tmp_dir }}/test_lvm_pv.img" + state: absent diff --git a/tests/integration/targets/lvm_pv/tasks/creation.yml b/tests/integration/targets/lvm_pv/tasks/creation.yml new file mode 100644 index 0000000000..a26a39c524 --- /dev/null +++ b/tests/integration/targets/lvm_pv/tasks/creation.yml @@ -0,0 +1,33 @@ +--- +# 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 + +- name: Creating a 50MB file for loop device + ansible.builtin.command: dd if=/dev/zero of={{ remote_tmp_dir }}/test_lvm_pv.img bs=1M count=50 + args: + creates: "{{ remote_tmp_dir }}/test_lvm_pv.img" + +- name: Creating loop device + ansible.builtin.command: losetup -f + register: loop_device + +- name: Associating loop device with file + ansible.builtin.command: 'losetup {{ loop_device.stdout }} {{ remote_tmp_dir }}/test_lvm_pv.img' + +- name: Creating physical volume + community.general.lvm_pv: + device: "{{ loop_device.stdout }}" + register: result + +- name: Checking physical volume size + ansible.builtin.command: pvs --noheadings -o pv_size --units M {{ loop_device.stdout }} + register: pv_size_output + +- name: Asserting physical volume was created + ansible.builtin.assert: + that: + - result.changed == true + - (pv_size_output.stdout | trim | regex_replace('M', '') | float) > 45 + - (pv_size_output.stdout | trim | regex_replace('M', '') | float) < 55 + - "'created' in result.msg" diff --git a/tests/integration/targets/lvm_pv/tasks/main.yml b/tests/integration/targets/lvm_pv/tasks/main.yml new file mode 100644 index 0000000000..d541140c1a --- /dev/null +++ b/tests/integration/targets/lvm_pv/tasks/main.yml @@ -0,0 +1,27 @@ +--- +#################################################################### +# 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: Testing lvg_pv module + block: + - import_tasks: creation.yml + + - import_tasks: resizing.yml + + - import_tasks: removal.yml + + always: + - import_tasks: cleanup.yml diff --git a/tests/integration/targets/lvm_pv/tasks/removal.yml b/tests/integration/targets/lvm_pv/tasks/removal.yml new file mode 100644 index 0000000000..d59a890a55 --- /dev/null +++ b/tests/integration/targets/lvm_pv/tasks/removal.yml @@ -0,0 +1,16 @@ +--- +# 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 + +- name: Removing physical volume + community.general.lvm_pv: + device: "{{ loop_device.stdout }}" + state: absent + register: remove_result + +- name: Asserting physical volume was removed + ansible.builtin.assert: + that: + - remove_result.changed == true + - "'removed' in remove_result.msg" diff --git a/tests/integration/targets/lvm_pv/tasks/resizing.yml b/tests/integration/targets/lvm_pv/tasks/resizing.yml new file mode 100644 index 0000000000..184fe7498c --- /dev/null +++ b/tests/integration/targets/lvm_pv/tasks/resizing.yml @@ -0,0 +1,27 @@ +--- +# 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 + +- name: Growing the loop device file to 100MB + ansible.builtin.shell: truncate -s 100M {{ remote_tmp_dir }}/test_lvm_pv.img + +- name: Refreshing the loop device + ansible.builtin.shell: losetup -c {{ loop_device.stdout }} + +- name: Resizing the physical volume + community.general.lvm_pv: + device: "{{ loop_device.stdout }}" + resize: true + register: resize_result + +- name: Checking physical volume size + ansible.builtin.command: pvs --noheadings -o pv_size --units M {{ loop_device.stdout }} + register: pv_size_output + +- name: Asserting physical volume was resized + ansible.builtin.assert: + that: + - resize_result.changed == true + - (pv_size_output.stdout | trim | regex_replace('M', '') | float) > 95 + - "'resized' in resize_result.msg"