Skip to content

lvm_pv: new module for LVM Physical Volumes #10070

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 7, 2025
2 changes: 2 additions & 0 deletions .github/BOTMETA.yml
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,8 @@ files:
maintainers: nerzhul
$modules/lvg.py:
maintainers: abulimov
$modules/lvm_pv.py:
maintainers: klention
$modules/lvg_rename.py:
maintainers: lszomor
$modules/lvol.py:
Expand Down
192 changes: 192 additions & 0 deletions plugins/modules/lvm_pv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright (c) 2025, Klention Mali <[email protected]>
# Based on lvol module by Jeroen Hoekx <[email protected]>
# 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()
13 changes: 13 additions & 0 deletions tests/integration/targets/lvm_pv/aliases
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions tests/integration/targets/lvm_pv/meta/main.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions tests/integration/targets/lvm_pv/tasks/cleanup.yml
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions tests/integration/targets/lvm_pv/tasks/creation.yml
Original file line number Diff line number Diff line change
@@ -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"
27 changes: 27 additions & 0 deletions tests/integration/targets/lvm_pv/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions tests/integration/targets/lvm_pv/tasks/removal.yml
Original file line number Diff line number Diff line change
@@ -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"
27 changes: 27 additions & 0 deletions tests/integration/targets/lvm_pv/tasks/resizing.yml
Original file line number Diff line number Diff line change
@@ -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"