Skip to content

Commit 4c3f8d1

Browse files
committed
feat(growpart): add LVM resize support
Fixes GH-3265 This change integrates LVM logical volume resizing into the existing growpart flow. When the target block device is an LVM LV, cloud-init now: - Performs lvs lookup to determine the volume group - Enumerates all PVs in the VG using new helper functions - Intelligently handles pvresize: * Skips pvresize for single-PV VGs when using growpart (growpart already handles it via maybe_lvm_resize) * Resizes all PVs for multi-PV VGs (growpart only resizes the partition's PV) - Conditionally extends LV based on `resize_lv` config option * When `resize_lv: true` (default): lvextend +100%FREE on the LV * When `resize_lv: false`: Only resize PVs, leaving LV unchanged (useful for multi-LV setups where free space should be preserved) The `resize_lv` option is particularly useful when multiple LVs exist in the same VG (e.g., separate LVs for /home, /var/log, etc.), allowing users to preserve free space for other LVs. New helper functions: - `_get_vg_for_lv()`: Returns VG name for a given LV device - `_get_pvs_for_vg()`: Returns list of PV device paths for a VG Filesystem resizing is intentionally omitted because resizefs is handled by a separate module. Unit tests have been added under test_cc_growpart.py to validate: - successful pvresize and lvextend calls - error propagation for failed operations - resize_lv=False behavior - Single-PV VG with growpart (skip pvresize) - Multi-PV VG with growpart (resize all PVs) This change does not affect existing non-LVM growpart behaviour. Signed-off-by: Amy Chen <xiachen@redhat.com>
1 parent 0cb6427 commit 4c3f8d1

File tree

5 files changed

+775
-17
lines changed

5 files changed

+775
-17
lines changed

cloudinit/config/cc_growpart.py

Lines changed: 221 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"mode": "auto",
4040
"devices": ["/"],
4141
"ignore_growroot_disabled": False,
42+
"resize_lv": True,
4243
}
4344

4445
KEYDATA_PATH = Path("/cc_growpart_keydata")
@@ -363,6 +364,149 @@ def resize_encrypted(blockdev, partition) -> Tuple[str, str]:
363364
)
364365

365366

367+
def _get_vg_for_lv(lv_dev):
368+
"""
369+
Return the VG name for a logical volume device,
370+
e.g. /dev/mapper/vg-lv or /dev/vg/lv.
371+
Uses `lvs --noheadings -o vg_name <lv_dev>`.
372+
"""
373+
try:
374+
out = subp.subp(
375+
["lvs", "--noheadings", "-o", "vg_name", lv_dev]
376+
).stdout
377+
# lvs often prints whitespace padded output; take last token
378+
vg = out.strip().split()[-1]
379+
LOG.debug("lv %s belongs to vg %s", lv_dev, vg)
380+
return vg
381+
except Exception as e:
382+
LOG.warning("failed to get VG for %s: %s", lv_dev, e)
383+
raise
384+
385+
386+
def _get_pvs_for_vg(vg_name):
387+
"""
388+
Return list of PV device paths for a volume group,
389+
using `vgs -o pv_name --noheadings --separator ' ' <vg>`.
390+
"""
391+
try:
392+
out = subp.subp(
393+
[
394+
"vgs",
395+
"--noheadings",
396+
"-o",
397+
"pv_name",
398+
"--separator",
399+
" ",
400+
vg_name,
401+
]
402+
).stdout
403+
# vgs returns space separated PV names (may include trailing spaces)
404+
pvs = [p for p in out.split() if p]
405+
LOG.debug("vg %s pvs: %s", vg_name, pvs)
406+
return pvs
407+
except Exception as e:
408+
LOG.warning("failed to list PVs for VG %s: %s", vg_name, e)
409+
raise
410+
411+
412+
def _pvresize(pv_dev):
413+
"""Run pvresize on each PV; idempotent: if it fails log and raise."""
414+
try:
415+
subp.subp(["pvresize", pv_dev])
416+
LOG.info("pvresize succeeded for %s", pv_dev)
417+
return True
418+
except Exception as e:
419+
LOG.warning("pvresize failed for %s: %s", pv_dev, e)
420+
raise
421+
422+
423+
def _lvextend_to_free(lv_dev):
424+
"""Extend the LV to consume all free extents in its VG."""
425+
try:
426+
subp.subp(["lvextend", "-l", "+100%FREE", lv_dev])
427+
LOG.info("lvextend +100%%FREE succeeded for %s", lv_dev)
428+
return True
429+
except Exception as e:
430+
LOG.warning("lvextend failed for %s: %s", lv_dev, e)
431+
raise
432+
433+
434+
def resize_lvm(
435+
blockdev, resize_lv: bool = True, skip_pvresize: bool = False
436+
) -> Tuple[str, str]:
437+
"""
438+
High-level procedure to resize LVM logical volume
439+
after underlying PVs were expanded:
440+
- find VG for lv (devpath)
441+
- for each PV in VG: pvresize (unless skip_pvresize=True)
442+
- optionally lvextend the lv to use free space (if resize_lv=True)
443+
444+
Args:
445+
blockdev: The logical volume device path
446+
resize_lv: If True, extend the LV to consume all free space in the VG.
447+
If False, only resize PVs, leaving LV size unchanged.
448+
Default: True (for backward compatibility).
449+
skip_pvresize: If True, skip pvresize (e.g., when growpart already
450+
handled it). If False, run pvresize on all PVs in
451+
the VG. Default: False.
452+
"""
453+
LOG.info("starting LVM resize flow for %s", blockdev)
454+
vg = _get_vg_for_lv(blockdev)
455+
pvs = _get_pvs_for_vg(vg)
456+
457+
# try pvresize for each PV (unless skipped, e.g., growpart already did it)
458+
if not skip_pvresize:
459+
for pv in pvs:
460+
try:
461+
_pvresize(pv)
462+
except Exception:
463+
LOG.warning(
464+
"pvresize failed for %s, continuing to next PV", pv
465+
)
466+
else:
467+
LOG.debug(
468+
"Skipping pvresize for %s (already handled by partition resizer)",
469+
blockdev,
470+
)
471+
472+
# extend the LV to use free space (if enabled)
473+
if resize_lv:
474+
_lvextend_to_free(blockdev)
475+
pv_status = (
476+
"PV already resized" if skip_pvresize else "PV and LV resized"
477+
)
478+
return (
479+
RESIZE.CHANGED,
480+
f"Successfully resized LVM device '{blockdev}' ({pv_status})",
481+
)
482+
else:
483+
LOG.info(
484+
"LV resize disabled for %s; %s. "
485+
"Free space remains available in VG for other LVs.",
486+
blockdev,
487+
"PV already resized" if skip_pvresize else "PVs were resized",
488+
)
489+
pv_status = "PV already resized" if skip_pvresize else "PV resized"
490+
return (
491+
RESIZE.CHANGED,
492+
f"Successfully resized LVM device '{blockdev}' "
493+
f"({pv_status}, LV unchanged)",
494+
)
495+
496+
497+
def is_lvm_device(blockdev) -> bool:
498+
"""
499+
Checks if a given device path points to an LVM device.
500+
"""
501+
try:
502+
# Run lsblk to check if the device type is 'lvm'
503+
out = subp.subp(["lsblk", "-n", "-o", "TYPE", blockdev]).stdout
504+
return out.strip() == "lvm"
505+
except Exception as e:
506+
LOG.warning("Error checking if device is LVM: %s", e)
507+
return False
508+
509+
366510
def _call_resizer(resizer, devent, disk, ptnum, blockdev, fs):
367511
info = []
368512
try:
@@ -409,7 +553,9 @@ def _call_resizer(resizer, devent, disk, ptnum, blockdev, fs):
409553
return info
410554

411555

412-
def resize_devices(resizer: Resizer, devices, distro: Distro):
556+
def resize_devices(
557+
resizer: Resizer, devices, distro: Distro, resize_lv: bool = True
558+
):
413559
# returns a tuple of tuples containing (entry-in-devices, action, message)
414560
devices = copy.copy(devices)
415561
info = []
@@ -488,21 +634,88 @@ def resize_devices(resizer: Resizer, devices, distro: Distro):
488634
message,
489635
)
490636
)
637+
# If device is lvm
638+
elif is_lvm_device(blockdev):
639+
# resize the partition firstly
640+
disk, ptnum = distro.device_part_info(partition)
641+
info += _call_resizer(
642+
resizer, devent, disk, ptnum, partition, fs
643+
)
644+
try:
645+
# Call the LVM resize procedure
646+
# Skip pvresize if using growpart AND VG has only
647+
# one PV
648+
# (growpart's maybe_lvm_resize only resizes the
649+
# specific partition's PV, so for multi-PV VGs we need
650+
# to resize all PVs)
651+
skip_pvresize = False
652+
if isinstance(resizer, ResizeGrowPart):
653+
try:
654+
vg = _get_vg_for_lv(blockdev)
655+
pvs = _get_pvs_for_vg(vg)
656+
# Only skip if single PV
657+
# (growpart already handled it)
658+
if len(pvs) == 1:
659+
skip_pvresize = True
660+
LOG.debug(
661+
"VG %s has single PV, "
662+
"skipping pvresize "
663+
"(growpart already handled it)",
664+
vg,
665+
)
666+
else:
667+
LOG.info(
668+
"VG %s has %d PVs, resizing all PVs "
669+
"(growpart only resized the "
670+
"partition's PV)",
671+
vg,
672+
len(pvs),
673+
)
674+
except Exception as e:
675+
LOG.warning(
676+
"Failed to check VG PV count, will resize "
677+
"all PVs: %s",
678+
e,
679+
)
680+
# On error, don't skip (safer to resize all)
681+
skip_pvresize = False
682+
683+
status, message = resize_lvm(
684+
blockdev,
685+
resize_lv=resize_lv,
686+
skip_pvresize=skip_pvresize,
687+
)
688+
info.append(
689+
(
690+
devent,
691+
status,
692+
message,
693+
)
694+
)
695+
except Exception as e:
696+
info.append(
697+
(
698+
devent,
699+
RESIZE.FAILED,
700+
f"Resizing LVM device ({blockdev}) failed: "
701+
f"{e}",
702+
)
703+
)
491704
else:
492705
info.append(
493706
(
494707
devent,
495708
RESIZE.SKIPPED,
496709
f"Resizing mapped device ({blockdev}) skipped "
497-
"as it is not encrypted.",
710+
f"as it is neither encrypted nor lvm.",
498711
)
499712
)
500713
except Exception as e:
501714
info.append(
502715
(
503716
devent,
504717
RESIZE.FAILED,
505-
f"Resizing encrypted device ({blockdev}) failed: {e}",
718+
f"Resizing device ({blockdev}) failed: {e}",
506719
)
507720
)
508721
# At this point, we WON'T resize a non-encrypted mapped device
@@ -559,6 +772,8 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
559772
LOG.debug("growpart: empty device list")
560773
return
561774

775+
resize_lv = util.get_cfg_option_bool(mycfg, "resize_lv", True)
776+
562777
try:
563778
resizer = resizer_factory(mode, distro=cloud.distro, devices=devices)
564779
except (ValueError, TypeError) as e:
@@ -568,7 +783,9 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
568783
return
569784

570785
with performance.Timed("Resizing devices"):
571-
resized = resize_devices(resizer, devices, cloud.distro)
786+
resized = resize_devices(
787+
resizer, devices, cloud.distro, resize_lv=resize_lv
788+
)
572789
for entry, action, msg in resized:
573790
if action == RESIZE.CHANGED:
574791
LOG.info("'%s' resized: %s", entry, msg)

cloudinit/config/schemas/schema-cloud-config-v1.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,6 +1682,11 @@
16821682
"type": "boolean",
16831683
"default": false,
16841684
"description": "If ``true``, ignore the presence of ``/etc/growroot-disabled``. If ``false`` and the file exists, then don't resize. Default: ``false``."
1685+
},
1686+
"resize_lv": {
1687+
"type": "boolean",
1688+
"default": true,
1689+
"description": "For LVM devices, if ``true``, extend the logical volume to consume all free space in the volume group after resizing physical volumes. If ``false``, only resize physical volumes, leaving the logical volume size unchanged. This is useful when multiple logical volumes exist in the same volume group (e.g., separate LVs for ``/home``, ``/var/log``, etc.) and you want to preserve free space for other LVs. Default: ``true``."
16851690
}
16861691
}
16871692
}

doc/module-docs/cc_growpart/data.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,24 @@ cc_growpart:
99
on a disk with classic partitioning scheme (MBR, BSD, GPT). LVM, Btrfs and
1010
ZFS have no such restrictions.
1111
12+
For LVM devices, the module will resize physical volumes (PVs) after the
13+
underlying partition is grown. When using the ``growpart`` utility (the
14+
default), ``pvresize`` is automatically handled by ``growpart`` itself for
15+
the specific partition's PV. However, if a Volume Group spans multiple
16+
Physical Volumes, ``growpart`` only resizes the PV corresponding to the
17+
resized partition. In this case, cloud-init will detect the multi-PV
18+
configuration and resize all PVs in the Volume Group to ensure complete
19+
coverage. For single-PV Volume Groups, cloud-init skips ``pvresize`` to
20+
avoid duplication. For other resizers (e.g., ``gpart``), cloud-init will
21+
always perform ``pvresize`` on all PVs in the Volume Group.
22+
23+
By default, the module will also extend the logical volume (LV) to consume
24+
all free space in the volume group. This behavior can be controlled with
25+
the ``resize_lv`` option. When multiple logical volumes exist in the same
26+
volume group (e.g., separate LVs for ``/home``, ``/var/log``, etc.),
27+
setting ``resize_lv: false`` will preserve free space in the volume group
28+
for other LVs.
29+
1230
The devices on which to run growpart are specified as a list under the
1331
``devices`` key.
1432
@@ -45,6 +63,7 @@ cc_growpart:
4563
mode: auto
4664
devices: [\"/\"]
4765
ignore_growroot_disabled: false
66+
resize_lv: true
4867
examples:
4968
- comment: |
5069
Example 1:

tests/integration_tests/modules/test_growpart.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,57 @@ def test_grow_part(self, client: IntegrationInstance):
7070
assert len(sdb["children"]) == 1
7171
assert sdb["children"][0]["name"] == "sdb1"
7272
assert sdb["size"] == "16M"
73+
74+
75+
class TestGrowPartLVM:
76+
"""
77+
Test that LVM partitions are correctly resized.
78+
79+
This test uses a bootcmd to create a loop device and partition it using
80+
LVM. The underlying partition does not used the whole loop "disk", nor
81+
does the Logical Volume we create use the whole of the Physical Volume.
82+
83+
This test checks that, after boot, the Logical Volume has been resized to
84+
use the whole loop device (with some allowance for partitioning/LVM
85+
overhead).
86+
"""
87+
88+
# These steps pulled from https://ops.tips/blog/lvm-on-loopback-devices/
89+
LVM_USER_DATA = """\
90+
#cloud-config
91+
bootcmd:
92+
# Create our LVM "disk"
93+
- dd if=/dev/zero of=/lvm0.img bs=50 count=1M
94+
# Use loop7 because snaps take up the early numbers
95+
- losetup /dev/loop7 /lvm0.img
96+
# Create an LVM partition on the first half of the disk
97+
- echo "start=1,size=25M,type=8e" | sfdisk /dev/loop7
98+
# Update the kernel's partition table
99+
- partx --update /dev/loop7
100+
# Create our LVM PV and VG
101+
- pvcreate /dev/loop7p1
102+
- vgcreate myvg /dev/loop7p1
103+
# Create our LV with a smaller size than the whole PV
104+
- lvcreate --size 10M --name lv1 myvg
105+
# Create a filesystem to resize
106+
- mkfs.ext4 /dev/mapper/myvg-lv1
107+
growpart:
108+
devices: ["/dev/mapper/myvg-lv1"]
109+
"""
110+
111+
# Our disk is 50M; with 4MB extents, this will mean 48M for the Logical
112+
# Volume, so use a slightly lower threshold than that
113+
LVM_LOWER_THRESHOLD = 47 * 1024 * 1024
114+
115+
@pytest.mark.user_data(LVM_USER_DATA)
116+
def test_resize_successful(self, client):
117+
def _get_size_of(device):
118+
ret = client.execute(
119+
["lsblk", "-b", "--output", "SIZE", "-n", "-d", device]
120+
)
121+
if ret.ok:
122+
return int(ret.stdout.strip())
123+
pytest.fail("Failed to get size of {}: {}".format(device, ret))
124+
125+
assert _get_size_of("/dev/loop7p1") > self.LVM_LOWER_THRESHOLD
126+
assert _get_size_of("/dev/mapper/myvg-lv1") > self.LVM_LOWER_THRESHOLD

0 commit comments

Comments
 (0)