From bd0c613177c71bb81f2177cd0e7c9effc157cb6f Mon Sep 17 00:00:00 2001 From: Klention Mali Date: Mon, 14 Jul 2025 19:27:34 +0200 Subject: [PATCH] Added lvm_pv_move_data module --- .github/BOTMETA.yml | 2 + plugins/modules/lvm_pv_move_data.py | 167 ++++++++++++++++++ .../targets/lvm_pv_move_data/aliases | 13 ++ .../targets/lvm_pv_move_data/meta/main.yml | 9 + .../lvm_pv_move_data/tasks/cleanup.yml | 31 ++++ .../lvm_pv_move_data/tasks/creation.yml | 91 ++++++++++ .../targets/lvm_pv_move_data/tasks/main.yml | 25 +++ .../targets/lvm_pv_move_data/tasks/moving.yml | 30 ++++ 8 files changed, 368 insertions(+) create mode 100644 plugins/modules/lvm_pv_move_data.py create mode 100644 tests/integration/targets/lvm_pv_move_data/aliases create mode 100644 tests/integration/targets/lvm_pv_move_data/meta/main.yml create mode 100644 tests/integration/targets/lvm_pv_move_data/tasks/cleanup.yml create mode 100644 tests/integration/targets/lvm_pv_move_data/tasks/creation.yml create mode 100644 tests/integration/targets/lvm_pv_move_data/tasks/main.yml create mode 100644 tests/integration/targets/lvm_pv_move_data/tasks/moving.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index fac3fae8f8..9185c11834 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -903,6 +903,8 @@ files: maintainers: abulimov $modules/lvm_pv.py: maintainers: klention + $modules/lvm_pv_move_data.py: + maintainers: klention $modules/lvg_rename.py: maintainers: lszomor $modules/lvol.py: diff --git a/plugins/modules/lvm_pv_move_data.py b/plugins/modules/lvm_pv_move_data.py new file mode 100644 index 0000000000..05e63b7667 --- /dev/null +++ b/plugins/modules/lvm_pv_move_data.py @@ -0,0 +1,167 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2025, Klention Mali +# 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_move_data +short_description: Move data between LVM Physical Volumes (PVs) +version_added: "11.2.0" +description: + - Moves data from one LVM Physical Volume (PV) to another. +author: + - Klention Mali (@klention) +options: + source: + description: + - Path to the source block device to move data from. + - Must be an existing PV. + type: path + required: true + destination: + description: + - Path to the destination block device to move data to. + - Must be an existing PV with enough free space. + type: path + required: true +notes: + - Requires LVM2 utilities installed on the target system. + - Both source and destination devices must exist. + - Both source and destination PVs must be in the same volume group. + - The destination PV must have enough free space to accommodate the source PV's allocated extents. +""" + +EXAMPLES = r""" +- name: Moving data from /dev/sdb to /dev/sdc + community.general.lvm_pv_move_data: + source: /dev/sdb + destination: /dev/sdc +""" + +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_vg(module, device): + """Get the VG name of a PV.""" + cmd = ['pvs', '--noheadings', '-o', 'vg_name', device] + rc, out, err = module.run_command(cmd) + if rc != 0: + module.fail_json(msg="Failed to get VG for device %s: %s" % (device, err)) + vg = out.strip() + return None if vg == '' else vg + + +def get_pv_allocated_pe(module, device): + """Get count of allocated physical extents in a PV.""" + cmd = ['pvs', '--noheadings', '-o', 'pv_pe_alloc_count', device] + rc, out, err = module.run_command(cmd) + if rc != 0: + module.fail_json(msg="Failed to get allocated PE count for device %s: %s" % (device, err)) + try: + return int(out.strip()) + except ValueError: + module.fail_json(msg="Invalid allocated PE count for device %s: %s" % (device, out)) + + +def get_pv_total_pe(module, device): + """Get total number of physical extents in a PV.""" + cmd = ['pvs', '--noheadings', '-o', 'pv_pe_count', device] + rc, out, err = module.run_command(cmd) + if rc != 0: + module.fail_json(msg="Failed to get total PE count for device %s: %s" % (device, err)) + try: + return int(out.strip()) + except ValueError: + module.fail_json(msg="Invalid total PE count for device %s: %s" % (device, out)) + + +def get_pv_free_pe(module, device): + """Calculate free physical extents in a PV.""" + total_pe = get_pv_total_pe(module, device) + allocated_pe = get_pv_allocated_pe(module, device) + return total_pe - allocated_pe + + +def main(): + module = AnsibleModule( + argument_spec=dict( + source=dict(type='path', required=True), + destination=dict(type='path', required=True), + ), + supports_check_mode=True, + ) + + source = module.params['source'] + destination = module.params['destination'] + changed = False + actions = [] + + # Validate device existence + if not os.path.exists(source): + module.fail_json(msg="Source device %s not found" % source) + if not os.path.exists(destination): + module.fail_json(msg="Destination device %s not found" % destination) + if source == destination: + module.fail_json(msg="Source and destination devices must be different") + + # Check both are PVs + if not get_pv_status(module, source): + module.fail_json(msg="Source device %s is not a PV" % source) + if not get_pv_status(module, destination): + module.fail_json(msg="Destination device %s is not a PV" % destination) + + # Check both are in the same VG + vg_src = get_pv_vg(module, source) + vg_dest = get_pv_vg(module, destination) + if vg_src != vg_dest: + module.fail_json( + msg="Source and destination must be in the same VG. Source VG: '%s', Destination VG: '%s'." % (vg_src, vg_dest) + ) + + # Check source has data to move + allocated = get_pv_allocated_pe(module, source) + if allocated == 0: + actions.append('no allocated extents to move') + else: + # Check destination has enough free space + free_pe_dest = get_pv_free_pe(module, destination) + if free_pe_dest < allocated: + module.fail_json( + msg="Destination device %s has only %d free physical extents, but source device %s has %d allocated extents. Not enough space." % + (destination, free_pe_dest, source, allocated) + ) + + if module.check_mode: + changed = True + actions.append('would move data from %s to %s' % (source, destination)) + else: + cmd = ['pvmove', source, destination] + rc, out, err = module.run_command(cmd, check_rc=True) + changed = True + actions.append('moved data from %s to %s' % (source, destination)) + + if actions: + msg = "PV data move: %s" % ', '.join(actions) + else: + msg = "No data to move from %s" % source + module.exit_json(changed=changed, msg=msg) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/lvm_pv_move_data/aliases b/tests/integration/targets/lvm_pv_move_data/aliases new file mode 100644 index 0000000000..232151c541 --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/aliases @@ -0,0 +1,13 @@ +# Copyright (c) Contributors to the Ansible project +# Based on the integraton test for the lvm_pv 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_move_data/meta/main.yml b/tests/integration/targets/lvm_pv_move_data/meta/main.yml new file mode 100644 index 0000000000..90c5d5cb8d --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/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_move_data/tasks/cleanup.yml b/tests/integration/targets/lvm_pv_move_data/tasks/cleanup.yml new file mode 100644 index 0000000000..65b8b0bbe4 --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/tasks/cleanup.yml @@ -0,0 +1,31 @@ +--- +# 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: Unmounting LV lv_tmp_test from {{ remote_tmp_dir }}/tmp_mount + ansible.posix.mount: + fstab: '{{ remote_tmp_dir }}/fstab' + path: '{{ remote_tmp_dir }}/tmp_mount' + state: absent + +- name: Removing xfs filesystem from LV lv_tmp_test + community.general.filesystem: + dev: /dev/vg_tmp_test/lv_tmp_test + status: absent + +- name: Detaching first loop device + ansible.builtin.command: losetup -d {{ loop_device_01.stdout }} + +- name: Detaching second loop device + ansible.builtin.command: losetup -d {{ loop_device_02.stdout }} + +- name: Removing first loop device file + ansible.builtin.file: + path: "{{ remote_tmp_dir }}/test_lvm_pv_01.img" + state: absent + +- name: Removing second loop device file + ansible.builtin.file: + path: "{{ remote_tmp_dir }}/test_lvm_pv_02.img" + state: absent diff --git a/tests/integration/targets/lvm_pv_move_data/tasks/creation.yml b/tests/integration/targets/lvm_pv_move_data/tasks/creation.yml new file mode 100644 index 0000000000..b48b00bcfb --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/tasks/creation.yml @@ -0,0 +1,91 @@ +--- +# 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 3000MB file for the first loop device + ansible.builtin.command: dd if=/dev/zero of={{ remote_tmp_dir }}/test_lvm_pv_01.img bs=1M count=3000 + args: + creates: "{{ remote_tmp_dir }}/test_lvm_pv_01.img" + +- name: Creating a 4000MB file for the second loop device + ansible.builtin.command: dd if=/dev/zero of={{ remote_tmp_dir }}/test_lvm_pv_02.img bs=1M count=4000 + args: + creates: "{{ remote_tmp_dir }}/test_lvm_pv_02.img" + +- name: Creating loop device + ansible.builtin.command: losetup -f + register: loop_device_01 + +- name: Associating loop device with file + ansible.builtin.command: 'losetup {{ loop_device_01.stdout }} {{ remote_tmp_dir }}/test_lvm_pv_01.img' + +- name: Creating loop device + ansible.builtin.command: losetup -f + register: loop_device_02 + +- name: Associating loop device with file + ansible.builtin.command: 'losetup {{ loop_device_02.stdout }} {{ remote_tmp_dir }}/test_lvm_pv_02.img' + +- name: Creating physical volume for the first loop device + community.general.lvm_pv: + device: "{{ loop_device_01.stdout }}" + register: pv_creation_result_01 + +- name: Checking the first physical volume size + ansible.builtin.command: pvs --noheadings -o pv_size --units M {{ loop_device_01.stdout }} + register: pv_size_output_01 + +- name: Creating physical volume for the second loop device + community.general.lvm_pv: + device: "{{ loop_device_02.stdout }}" + register: pv_creation_result_02 + +- name: Checking the second physical volume size + ansible.builtin.command: pvs --noheadings -o pv_size --units M {{ loop_device_02.stdout }} + register: pv_size_output_02 + +- name: Creating volume group on top of first device {{ loop_device_01.stdout }} + community.general.lvg: + vg: vg_tmp_test + pvs: "{{ loop_device_01.stdout }}" + register: vg_creation_result + +- name: Creating LV lv_tmp_test on VG vg_tmp_test + community.general.lvol: + vg: vg_tmp_test + lv: lv_tmp_test + size: 100%FREE + force: true + register: lv_creation_result + +- name: Creating xfs filesystem on LV lv_tmp_test + community.general.filesystem: + dev: /dev/vg_tmp_test/lv_tmp_test + fstype: xfs + register: fs_creation_result + +#- name: Mounting LV lv_tmp_test to {{ remote_tmp_dir }}/tmp_mount +# ansible.builtin.shell: mount -o rw,noauto /dev/vg_tmp_test/lv_tmp_test {{ remote_tmp_dir }}/tmp_mount + +- name: Mounting LV lv_tmp_test to {{ remote_tmp_dir }}/tmp_mount + ansible.posix.mount: + fstab: '{{ remote_tmp_dir }}/fstab' + path: '{{ remote_tmp_dir }}/tmp_mount' + src: '/dev/vg_tmp_test/lv_tmp_test' + fstype: xfs + opts: rw,noauto + state: mounted + +- name: Asserting physical volume was created + ansible.builtin.assert: + that: + - pv_creation_result_01.changed == true + - pv_creation_result_02.changed == true + - vg_creation_result.changed == true + - (pv_size_output_01.stdout | trim | regex_replace('M', '') | float) > 95 + - (pv_size_output_01.stdout | trim | regex_replace('M', '') | float) < 120 + - (pv_size_output_02.stdout | trim | regex_replace('M', '') | float) > 190 + - (pv_size_output_02.stdout | trim | regex_replace('M', '') | float) < 220 + - "'created' in pv_creation_result_01.msg" + - "'created' in pv_creation_result_02.msg" diff --git a/tests/integration/targets/lvm_pv_move_data/tasks/main.yml b/tests/integration/targets/lvm_pv_move_data/tasks/main.yml new file mode 100644 index 0000000000..d25c667114 --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/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 lvm_pv 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 lvm_pv_move_data module + block: + - import_tasks: creation.yml + + - import_tasks: moving.yml + + always: + - import_tasks: cleanup.yml diff --git a/tests/integration/targets/lvm_pv_move_data/tasks/moving.yml b/tests/integration/targets/lvm_pv_move_data/tasks/moving.yml new file mode 100644 index 0000000000..e132833b3d --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/tasks/moving.yml @@ -0,0 +1,30 @@ +--- +# 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 on the mounted LV + ansible.builtin.command: dd if=/dev/zero of={{ remote_tmp_dir }}/tmp_mount/test_file bs=1M count=50 + args: + creates: "{{ remote_tmp_dir }}/tmp_mount/test_file" + +- name: Extending VG vg_tmp_test with second device {{ loop_device_02.stdout }} + community.general.lvg: + vg: vg_tmp_test + pvs: "{{ loop_device_02.stdout }}" + +- name: Moving data from {{ loop_device_01.stdout }} to {{ loop_device_02.stdout }} (both in same VG) + community.general.lvm_pv_move_data: + source: "{{ loop_device_01.stdout }}" + destination: "{{ loop_device_02.stdout }}" + register: move_result + +- name: Debugging move result + ansible.builtin.debug: + var: move_result + +- name: Asserting data was moved successfully + ansible.builtin.assert: + that: + - move_result.changed == true + - "'moved data from' in creation_result.msg"