lvm_pv_move_data: new module (#10416)

* Added lvm_pv_move_data module

* Removed trailing whitespace

* Decreased loop devices file size

* Remove test VG if exists

* Force remove test VG if exists

* Renamed test VG and LV names

* Updated assert conditions

* Added .ansible to .gitignore

* Force extending VG

* Wiping LVM metadata from PVs before creating VG

* Clean FS, LV, VG and PSs before run

* Migrated to CmdRunner

* Added more detailed info in case of failure and cosmetic changes

* Remove redundant params from CmdRunner call

* Updates the RETURN documentation block to properly specify the return type
of the 'actions' field:
- Changes return status from 'always' to 'success'
- Adds missing 'elements: str' type specification
This commit is contained in:
Klention Mali 2025-08-04 19:59:54 +02:00 committed by GitHub
commit e91e2ef6f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 487 additions and 0 deletions

View file

@ -0,0 +1,220 @@
#!/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
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()