#!/usr/bin/env python3 """Resize an LVM logical volume and its filesystem. Author: Bart Sjerps Date: 2026-07-17 Generated by opencode """ import os import sys import subprocess import argparse from typing import Optional def die(msg: str) -> None: """Print an error message to stderr and exit with code 2.""" print(f"Error: {msg}", file=sys.stderr) sys.exit(2) def run(cmd: list[str], dryrun: bool = False) -> str: """Execute a shell command, returning stdout. Exits on non-zero status.""" if dryrun: print(f"[DRY-RUN] {' '.join(cmd)}") return "" try: result = subprocess.run(cmd, capture_output=True, text=True, check=True) except subprocess.CalledProcessError as e: err = (e.stderr or e.stdout or "").strip() die(f"{' '.join(cmd)} failed: {err or f'exit code {e.returncode}'}") return result.stdout.strip() def resolve_lv(mount: Optional[str] = None, lvname: Optional[str] = None) -> str: """Resolve a mountpoint or LV name to an absolute LVM device path.""" if mount: dev = run(["findmnt", "-no", "SOURCE", "--target", mount]) if not dev: die(f"No device mounted at {mount}") else: dev = lvname if not dev.startswith("/dev/"): dev = f"/dev/{dev}" if not run(["lvs", dev]): die(f"{dev} is not an LVM logical volume") return dev def detect_fs(dev: str) -> str: """Detect the filesystem type on a block device via blkid.""" fstype = run(["blkid", "-o", "value", "-s", "TYPE", dev]) if not fstype: die(f"Cannot detect filesystem on {dev}") return fstype def resolve_mount(dev: str, mount: Optional[str] = None) -> str: """Return a mountpoint for a device, using the provided one or looking it up.""" if mount: return mount mountpoint = run(["findmnt", "-no", "TARGET", "--source", dev]) if not mountpoint: die(f"No mountpoint found for {dev}") return mountpoint def lvextend_cmd(size: str, dev: str) -> list[str]: """Build an lvextend command, using -l for extent/percent sizes.""" if "%" in size: return ["lvextend", "-l", size, dev] return ["lvextend", "-L", size, dev] def btrfs_resize_size(size: str) -> str: """Map an LVM size spec to a btrfs filesystem resize argument.""" if "%" in size: return "max" return size def validate_resize(dev: str, fstype: str, mount: Optional[str] = None) -> Optional[str]: """Validate resize is possible before mutating the LV. Returns mountpoint when needed.""" if fstype in ("ext2", "ext3", "ext4"): return None if fstype in ("xfs", "btrfs"): return resolve_mount(dev, mount) die(f"Unsupported filesystem: {fstype}") def do_resize(dev: str, fstype: str, size: str, mount: Optional[str] = None, dryrun: bool = False) -> None: """Extend the LV with lvextend, then resize the filesystem accordingly.""" fs_mount = validate_resize(dev, fstype, mount) run(lvextend_cmd(size, dev), dryrun=dryrun) if fstype in ("ext2", "ext3", "ext4"): run(["resize2fs", dev], dryrun=dryrun) elif fstype == "xfs": run(["xfs_growfs", fs_mount], dryrun=dryrun) elif fstype == "btrfs": run(["btrfs", "filesystem", "resize", btrfs_resize_size(size), fs_mount], dryrun=dryrun) if not dryrun: print("Done.") def main() -> None: """Parse args, validate root, then resize LV and filesystem.""" parser = argparse.ArgumentParser(description="Resize an LVM logical volume and its filesystem") parser.add_argument("-s", dest="size", required=True, help="New size (e.g. +10G, -5G, 20G, 100%FREE)") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("-m", dest="mount", metavar="MOUNTPOINT", help="Select LV by mountpoint") group.add_argument("-l", dest="lvname", metavar="LVNAME", help="Select LV by name or path (e.g. vg0/data)") parser.add_argument("-n", dest="dryrun", action="store_true", help="Dry-run – print what would be done without executing") args = parser.parse_args() if os.geteuid() != 0: die("This script must be run as root") lvdev = resolve_lv(mount=args.mount, lvname=args.lvname) fstype = detect_fs(lvdev) do_resize(lvdev, fstype, args.size, mount=args.mount, dryrun=args.dryrun) if __name__ == "__main__": main()