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/.gitignore b/.gitignore index 5c6e9c86c6..e427699798 100644 --- a/.gitignore +++ b/.gitignore @@ -530,3 +530,4 @@ tests/integration/cloud-config-*.ini # VSCode specific extensions .vscode/settings.json +.ansible diff --git a/plugins/modules/lvm_pv_move_data.py b/plugins/modules/lvm_pv_move_data.py new file mode 100644 index 0000000000..7ebdfad02b --- /dev/null +++ b/plugins/modules/lvm_pv_move_data.py @@ -0,0 +1,220 @@ +#!/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 + auto_answer: + description: + - Answer yes to all prompts automatically. + type: bool + default: false + atomic: + description: + - Makes the C(pvmove) operation atomic, ensuring that all affected LVs are moved to the destination PV, + or none are if the operation is aborted. + type: bool + default: true + autobackup: + description: + - Automatically backup metadata before changes (strongly advised!). + type: bool + default: true +requirements: + - LVM2 utilities + - Both O(source) and O(destination) devices must exist, and the PVs must be in the same volume group. + - The O(destination) PV must have enough free space to accommodate the O(source) PV's allocated extents. + - Verbosity is automatically controlled by Ansible's verbosity level (using multiple C(-v) flags). +""" + +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""" +actions: + description: List of actions performed during module execution. + returned: success + type: list + elements: str + sample: [ + "moved data from /dev/sdb to /dev/sdc", + "no allocated extents to move", + "would move data from /dev/sdb to /dev/sdc" + ] +""" + + +import os +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt + + +def main(): + module = AnsibleModule( + argument_spec=dict( + source=dict(type='path', required=True), + destination=dict(type='path', required=True), + auto_answer=dict(type='bool', default=False), + atomic=dict(type='bool', default=True), + autobackup=dict(type='bool', default=True), + ), + supports_check_mode=True, + ) + + pvs_runner = CmdRunner( + module, + command="pvs", + arg_formats=dict( + noheadings=cmd_runner_fmt.as_fixed("--noheadings"), + readonly=cmd_runner_fmt.as_fixed("--readonly"), + vg_name=cmd_runner_fmt.as_fixed("-o", "vg_name"), + pv_pe_alloc_count=cmd_runner_fmt.as_fixed("-o", "pv_pe_alloc_count"), + pv_pe_count=cmd_runner_fmt.as_fixed("-o", "pv_pe_count"), + device=cmd_runner_fmt.as_list(), + ) + ) + + source = module.params['source'] + destination = module.params['destination'] + changed = False + actions = [] + result = {'changed': False} + + # 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") + + def run_pvs_command(arguments, device): + with pvs_runner(arguments) as ctx: + rc, out, err = ctx.run(device=device) + if rc != 0: + module.fail_json( + msg="Command failed: %s" % err, + stdout=out, + stderr=err, + rc=rc, + cmd=ctx.cmd, + arguments=arguments, + device=device, + ) + return out.strip() + + def is_pv(device): + with pvs_runner("noheadings readonly device", check_rc=False) as ctx: + rc, out, err = ctx.run(device=device) + return rc == 0 + + if not is_pv(source): + module.fail_json(msg="Source device %s is not a PV" % source) + if not is_pv(destination): + module.fail_json(msg="Destination device %s is not a PV" % destination) + + vg_src = run_pvs_command("noheadings vg_name device", source) + vg_dest = run_pvs_command("noheadings vg_name device", 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) + ) + + def get_allocated_pe(device): + try: + return int(run_pvs_command("noheadings pv_pe_alloc_count device", device)) + except ValueError: + module.fail_json(msg="Invalid allocated PE count for device %s" % device) + + allocated = get_allocated_pe(source) + if allocated == 0: + actions.append('no allocated extents to move') + else: + # Check destination has enough free space + def get_total_pe(device): + try: + return int(run_pvs_command("noheadings pv_pe_count device", device)) + except ValueError: + module.fail_json(msg="Invalid total PE count for device %s" % device) + + def get_free_pe(device): + return get_total_pe(device) - get_allocated_pe(device) + + free_pe_dest = get_free_pe(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: + pvmove_runner = CmdRunner( + module, + command="pvmove", + arg_formats=dict( + auto_answer=cmd_runner_fmt.as_bool("-y"), + atomic=cmd_runner_fmt.as_bool("--atomic"), + autobackup=cmd_runner_fmt.as_fixed("--autobackup", "y" if module.params['autobackup'] else "n"), + verbosity=cmd_runner_fmt.as_func(lambda v: ['-' + 'v' * v] if v > 0 else []), + source=cmd_runner_fmt.as_list(), + destination=cmd_runner_fmt.as_list(), + ) + ) + + verbosity = module._verbosity + with pvmove_runner("auto_answer atomic autobackup verbosity source destination") as ctx: + rc, out, err = ctx.run( + verbosity=verbosity, + source=source, + destination=destination + ) + result['stdout'] = out + result['stderr'] = err + + changed = True + actions.append('moved data from %s to %s' % (source, destination)) + + result['changed'] = changed + result['actions'] = actions + if actions: + result['msg'] = "PV data move: %s" % ", ".join(actions) + else: + result['msg'] = "No data to move from %s" % source + + module.exit_json(**result) + + +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..1c121008b8 --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/tasks/cleanup.yml @@ -0,0 +1,39 @@ +--- +# 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 temp file system + 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 + state: absent + force: true + +- name: Deleting testlv logical volume + community.general.lvol: + vg: testvg + lv: testlv + force: true + state: 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..8ce11fb5c5 --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/tasks/creation.yml @@ -0,0 +1,115 @@ +--- +# 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 500MB 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=500 + args: + creates: "{{ remote_tmp_dir }}/test_lvm_pv_01.img" + +- name: Creating a 1000MB 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=1000 + args: + creates: "{{ remote_tmp_dir }}/test_lvm_pv_02.img" + +- name: Pausing for a random time between 5-15 seconds + ansible.builtin.pause: + seconds: "{{ range(5, 16) | random }}" + +- name: Creating loop device + ansible.builtin.command: losetup -f + register: loop_device_01 + +- name: Wiping existing LVM metadata + community.general.lvm_pv: + device: "{{ loop_device_01.stdout }}" + force: true + state: absent + +- name: Associating loop device with file + ansible.builtin.command: 'losetup {{ loop_device_01.stdout }} {{ remote_tmp_dir }}/test_lvm_pv_01.img' + +- name: Pausing for a random time between 5-15 seconds + ansible.builtin.pause: + seconds: "{{ range(5, 16) | random }}" + +- name: Creating loop device + ansible.builtin.command: losetup -f + register: loop_device_02 + +- name: Wiping existing LVM metadata + community.general.lvm_pv: + device: "{{ loop_device_02.stdout }}" + force: true + state: absent + +- 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 + community.general.lvg: + vg: testvg + pvs: + - "{{ loop_device_01.stdout }}" + - "{{ loop_device_02.stdout }}" + force: true + register: vg_creation_result + +- name: Creating LV testlv on VG testvg + community.general.lvol: + vg: testvg + lv: testlv + size: 100%FREE + force: true + register: lv_creation_result + +- name: Creating xfs filesystem on LV testlv + community.general.filesystem: + dev: /dev/testvg/testlv + fstype: xfs + state: present + register: fs_creation_result + +- name: Mounting LV testlv + ansible.posix.mount: + fstab: '{{ remote_tmp_dir }}/fstab' + path: '{{ remote_tmp_dir }}/tmp_mount' + src: '/dev/testvg/testlv' + fstype: xfs + opts: rw,noauto + state: mounted + register: mount_result + +- name: Asserting PVs, VG, LV and filesystem were created successfully + ansible.builtin.assert: + that: + - pv_creation_result_01 is changed + - pv_creation_result_02 is changed + - vg_creation_result is changed + - lv_creation_result is changed + - fs_creation_result is changed + - (pv_size_output_01.stdout | trim | regex_replace('M', '') | float) > 450 + - (pv_size_output_01.stdout | trim | regex_replace('M', '') | float) < 600 + - (pv_size_output_02.stdout | trim | regex_replace('M', '') | float) > 950 + - (pv_size_output_02.stdout | trim | regex_replace('M', '') | float) < 1100 + - "'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..908e1b9a99 --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/tasks/main.yml @@ -0,0 +1,29 @@ +--- +#################################################################### +# 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 integration 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 + - xfsprogs + state: present + +- name: Testing lvm_pv_move_data module + block: + - import_tasks: prepare.yml + + - 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..9b52f35f7e --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/tasks/moving.yml @@ -0,0 +1,36 @@ +--- +# 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: Growing the second loop device file to 1500MB + ansible.builtin.shell: truncate -s 1500M {{ remote_tmp_dir }}/test_lvm_pv_02.img + +- name: Refreshing the second loop device + ansible.builtin.shell: losetup -c {{ loop_device_02.stdout }} + +- name: Resizing the second physical volume + community.general.lvm_pv: + device: "{{ loop_device_02.stdout }}" + resize: true + +- name: Moving data from between PVs (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 is changed + - "'moved data from' in move_result.msg" diff --git a/tests/integration/targets/lvm_pv_move_data/tasks/prepare.yml b/tests/integration/targets/lvm_pv_move_data/tasks/prepare.yml new file mode 100644 index 0000000000..ced3f74dc6 --- /dev/null +++ b/tests/integration/targets/lvm_pv_move_data/tasks/prepare.yml @@ -0,0 +1,23 @@ +--- +# 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 xfs filesystem from LV lv_tmp_test + community.general.filesystem: + dev: /dev/vg_tmp_test/lv_tmp_test + state: absent + force: true + +- name: Deleting testlv logical volume + community.general.lvol: + vg: testvg + lv: testlv + force: true + state: absent + +- name: Deleting volume group testvg + community.general.lvg: + vg: testvg + force: true + state: absent