Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 215 additions & 4 deletions cloudinit/config/cc_growpart.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"mode": "auto",
"devices": ["/"],
"ignore_growroot_disabled": False,
"resize_lv": True,
}

KEYDATA_PATH = Path("/cc_growpart_keydata")
Expand Down Expand Up @@ -363,6 +364,178 @@ def resize_encrypted(blockdev, partition) -> Tuple[str, str]:
)


def _get_vg_for_lv(lv_dev):
"""
Return the VG name for a logical volume device,
e.g. /dev/mapper/vg-lv or /dev/vg/lv.
Uses `lvs --noheadings -o vg_name <lv_dev>`.
"""
try:
out = subp.subp(
["lvs", "--noheadings", "-o", "vg_name", lv_dev]
).stdout
# lvs often prints whitespace padded output; take last token
vg = out.strip().split()[-1]
LOG.debug("lv %s belongs to vg %s", lv_dev, vg)
return vg
except Exception as e:
LOG.warning("failed to get VG for %s: %s", lv_dev, e)
raise


def _get_pvs_for_vg(vg_name):
"""
Return list of PV device paths for a volume group,
using `vgs -o pv_name --noheadings --separator ' ' <vg>`.
"""
try:
out = subp.subp(
[
"vgs",
"--noheadings",
"-o",
"pv_name",
"--separator",
" ",
vg_name,
]
).stdout
# vgs returns space separated PV names (may include trailing spaces)
pvs = [p for p in out.split() if p]
LOG.debug("vg %s pvs: %s", vg_name, pvs)
return pvs
except Exception as e:
LOG.warning("failed to list PVs for VG %s: %s", vg_name, e)
raise


def _pvresize(pv_dev):
"""Run pvresize on each PV; idempotent: if it fails log and raise."""
try:
subp.subp(["pvresize", pv_dev])
LOG.info("pvresize succeeded for %s", pv_dev)
return True
except Exception as e:
LOG.warning("pvresize failed for %s: %s", pv_dev, e)
raise


def _lvextend_to_free(lv_dev):
"""Extend the LV to consume all free extents in its VG."""
try:
subp.subp(["lvextend", "-l", "+100%FREE", lv_dev])
LOG.info("lvextend +100%%FREE succeeded for %s", lv_dev)
return True
except Exception as e:
LOG.warning("lvextend failed for %s: %s", lv_dev, e)
raise


def resize_lvm(
blockdev, resize_lv: bool = True, resizer: Optional[Resizer] = None
) -> Tuple[str, str]:
"""
High-level procedure to resize LVM logical volume
after underlying PVs were expanded:
- find VG for lv (devpath)
- if resizer is a ResizeGrowPart, skip pvresize if VG has only one PV
- for each PV in VG: pvresize (unless skipped)
- optionally lvextend the lv to use free space (if resize_lv=True)

Args:
blockdev: The logical volume device path
resize_lv: If True, extend the LV to consume all free space in the VG.
If False, only resize PVs, leaving LV size unchanged.
Default: True (for backward compatibility).
resizer: The partition resizer instance. If ResizeGrowPart and VG has
single PV, pvresize will be automatically skipped (growpart
already handled it). If None or not ResizeGrowPart, pvresize
will run on all PVs. Default: None.
"""
LOG.info("starting LVM resize flow for %s", blockdev)
# Get VG and PV information
# If these fail, cannot proceed with LVM resize
try:
vg = _get_vg_for_lv(blockdev)
pvs = _get_pvs_for_vg(vg)
except Exception as e:
raise ResizeFailedException(
f"Failed to query LVM information for {blockdev}: {e}. "
f"Cannot proceed with LVM resize operations."
) from e

# Determine if we should skip pvresize
# (growpart's maybe_lvm_resize only resizes the specific partition's PV,
# so for multi-PV VGs we need to resize all PVs)
skip_pvresize = False
if isinstance(resizer, ResizeGrowPart):
if len(pvs) == 1:
skip_pvresize = True
LOG.debug(
"VG %s has single PV, skipping pvresize "
"(growpart already handled it)",
vg,
)
else:
LOG.info(
"VG %s has %d PVs, resizing all PVs "
"(growpart only resized the partition's PV)",
vg,
len(pvs),
)
# try pvresize for each PV (unless skipped, e.g., growpart already did it)
if not skip_pvresize:
for pv in pvs:
try:
_pvresize(pv)
except Exception:
LOG.warning(
"pvresize failed for %s, continuing to next PV", pv
)
else:
LOG.debug(
"Skipping pvresize for %s (already handled by partition resizer)",
blockdev,
)

# extend the LV to use free space (if enabled)
if resize_lv:
_lvextend_to_free(blockdev)
pv_status = (
"PV already resized" if skip_pvresize else "PV and LV resized"
)
return (
RESIZE.CHANGED,
f"Successfully resized LVM device '{blockdev}' ({pv_status})",
)
else:
LOG.info(
"LV resize disabled for %s; %s. "
"Free space remains available in VG for other LVs.",
blockdev,
"PV already resized" if skip_pvresize else "PVs were resized",
)
pv_status = "PV already resized" if skip_pvresize else "PV resized"
return (
RESIZE.CHANGED,
f"Successfully resized LVM device '{blockdev}' "
f"({pv_status}, LV unchanged)",
)


def is_lvm_device(blockdev) -> bool:
"""
Checks if a given device path points to an LVM device.
"""
try:
# Run lsblk to check if the device type is 'lvm'
out = subp.subp(["lsblk", "-n", "-o", "TYPE", blockdev]).stdout
return out.strip() == "lvm"
except Exception as e:
LOG.warning("Error checking if device is LVM: %s", e)
return False


def _call_resizer(resizer, devent, disk, ptnum, blockdev, fs):
info = []
try:
Expand Down Expand Up @@ -409,7 +582,9 @@ def _call_resizer(resizer, devent, disk, ptnum, blockdev, fs):
return info


def resize_devices(resizer: Resizer, devices, distro: Distro):
def resize_devices(
resizer: Resizer, devices, distro: Distro, resize_lv: bool = True
):
# returns a tuple of tuples containing (entry-in-devices, action, message)
devices = copy.copy(devices)
info = []
Expand Down Expand Up @@ -488,21 +663,53 @@ def resize_devices(resizer: Resizer, devices, distro: Distro):
message,
)
)
# If device is lvm
elif is_lvm_device(blockdev):
# resize the partition firstly
disk, ptnum = distro.device_part_info(partition)
info += _call_resizer(
resizer, devent, disk, ptnum, partition, fs
)
try:
# Call the LVM resize procedure
# resize_lvm() will automatically determine if pvresize
# should be skipped based on resizer type and PV count
status, message = resize_lvm(
blockdev,
resize_lv=resize_lv,
resizer=resizer,
)
info.append(
(
devent,
status,
message,
)
)
except Exception as e:
info.append(
(
devent,
RESIZE.FAILED,
f"Resizing LVM device ({blockdev}) failed: "
f"{e}",
)
)
else:
info.append(
(
devent,
RESIZE.SKIPPED,
f"Resizing mapped device ({blockdev}) skipped "
"as it is not encrypted.",
f"as it is neither encrypted nor lvm.",
)
)
except Exception as e:
info.append(
(
devent,
RESIZE.FAILED,
f"Resizing encrypted device ({blockdev}) failed: {e}",
f"Resizing device ({blockdev}) failed: {e}",
)
)
# At this point, we WON'T resize a non-encrypted mapped device
Expand Down Expand Up @@ -559,6 +766,8 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
LOG.debug("growpart: empty device list")
return

resize_lv = util.get_cfg_option_bool(mycfg, "resize_lv", True)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think the code will look cleaner if resize_lv is made global so that it does not need to be passed around various functions. This comes from the config so global seems reasonable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion and I see the appeal of making resize_lv global and I agree that could slightly reduce parameter passing.

That said, I’d prefer to keep the parameter-passing approach here. It keeps the pattern consistent with other modules, makes the dependencies explicit, and helps with testability since the functions can be exercised independently without relying on global state. It also keeps the functions reusable if we ever need to call them with different values in the future.

In this case the passing depth is fairly small (handle() → resize_devices() → resize_lvm()), so the added clarity and flexibility feel worth the small amount of extra wiring.

Happy to revisit this if we see the parameter being threaded through many more layers later on.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok sounds good.


try:
resizer = resizer_factory(mode, distro=cloud.distro, devices=devices)
except (ValueError, TypeError) as e:
Expand All @@ -568,7 +777,9 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
return

with performance.Timed("Resizing devices"):
resized = resize_devices(resizer, devices, cloud.distro)
resized = resize_devices(
resizer, devices, cloud.distro, resize_lv=resize_lv
)
for entry, action, msg in resized:
if action == RESIZE.CHANGED:
LOG.info("'%s' resized: %s", entry, msg)
Expand Down
5 changes: 5 additions & 0 deletions cloudinit/config/schemas/schema-cloud-config-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,11 @@
"type": "boolean",
"default": false,
"description": "If ``true``, ignore the presence of ``/etc/growroot-disabled``. If ``false`` and the file exists, then don't resize. Default: ``false``."
},
"resize_lv": {
"type": "boolean",
"default": true,
"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``."
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions doc/module-docs/cc_growpart/data.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ cc_growpart:
on a disk with classic partitioning scheme (MBR, BSD, GPT). LVM, Btrfs and
ZFS have no such restrictions.

For LVM devices, the module will resize physical volumes (PVs) after the
underlying partition is grown. When using the ``growpart`` utility (the
default), ``pvresize`` is automatically handled by ``growpart`` itself for
the specific partition's PV. However, if a Volume Group spans multiple
Physical Volumes, ``growpart`` only resizes the PV corresponding to the
resized partition. In this case, cloud-init will detect the multi-PV
configuration and resize all PVs in the Volume Group to ensure complete
coverage. For single-PV Volume Groups, cloud-init skips ``pvresize`` to
avoid duplication. For other resizers (e.g., ``gpart``), cloud-init will
always perform ``pvresize`` on all PVs in the Volume Group.

By default, the module will also extend the logical volume (LV) to consume
all free space in the volume group. This behavior can be controlled with
the ``resize_lv`` option. When multiple logical volumes exist in the same
volume group (e.g., separate LVs for ``/home``, ``/var/log``, etc.),
setting ``resize_lv: false`` will preserve free space in the volume group
for other LVs.

The devices on which to run growpart are specified as a list under the
``devices`` key.

Expand Down Expand Up @@ -45,6 +63,7 @@ cc_growpart:
mode: auto
devices: [\"/\"]
ignore_growroot_disabled: false
resize_lv: true
examples:
- comment: |
Example 1:
Expand Down
4 changes: 4 additions & 0 deletions doc/rtd/spelling_word_list.txt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ libvirt
linux
livepatch
localdomain
lv
lvm
lxd
maipo
manpage
Expand Down Expand Up @@ -160,6 +162,7 @@ preseed
proxmox
puppetlabs
puppetserver
pv
py
pycloudlib
pytest
Expand Down Expand Up @@ -238,6 +241,7 @@ vcloud
ve
veth
vfstype
vg
virtuozzo
vm
vpc
Expand Down
Loading
Loading