Added lvm_pv_move_data module

This commit is contained in:
Klention Mali 2025-07-14 19:27:34 +02:00
commit bd0c613177
No known key found for this signature in database
GPG key ID: 777C0B2D8F048DAB
8 changed files with 368 additions and 0 deletions

2
.github/BOTMETA.yml vendored
View file

@ -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:

View file

@ -0,0 +1,167 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2025, Klention Mali <klention@gmail.com>
# 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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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"