Created
March 10, 2026 15:03
-
-
Save onyxhat/99219433a4404bc953a14b6f4f70bb51 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # ============================================================================= | |
| # expand_lvm.sh - Automatically expand LVM volumes on Ubuntu 24.04 | |
| # ============================================================================= | |
| # This script: | |
| # 1. Detects unallocated disk space on physical volumes | |
| # 2. Expands the partition table entry if needed | |
| # 3. Resizes PVs, extends VGs, extends LVs, and resizes filesystems | |
| # | |
| # Usage: sudo bash expand_lvm.sh [OPTIONS] | |
| # -d, --dry-run Show what would be done without making changes | |
| # -y, --yes Skip confirmation prompts | |
| # -v, --verbose Enable verbose output | |
| # -h, --help Show this help message | |
| # | |
| # Supports: ext2/3/4, xfs filesystems | |
| # Requires: lvm2, parted, growpart (cloud-guest-utils), e2fsprogs/xfsprogs | |
| # ============================================================================= | |
| set -euo pipefail | |
| # ── Colours ────────────────────────────────────────────────────────────────── | |
| RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m' | |
| CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' | |
| # ── Defaults ───────────────────────────────────────────────────────────────── | |
| DRY_RUN=false | |
| AUTO_YES=false | |
| VERBOSE=false | |
| ERRORS=0 | |
| CHANGES_MADE=false | |
| # ── Helpers ────────────────────────────────────────────────────────────────── | |
| log() { echo -e "${CYAN}[INFO]${RESET} $*"; } | |
| ok() { echo -e "${GREEN}[OK]${RESET} $*"; } | |
| warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } | |
| error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; (( ERRORS++ )) || true; } | |
| verbose() { $VERBOSE && echo -e " $*" || true; } | |
| header() { echo -e "\n${BOLD}${CYAN}=== $* ===${RESET}"; } | |
| dry() { echo -e "${YELLOW}[DRY]${RESET} (would run) $*"; } | |
| run() { | |
| if $DRY_RUN; then | |
| dry "$*" | |
| else | |
| verbose "Running: $*" | |
| eval "$*" | |
| fi | |
| } | |
| usage() { | |
| sed -n '/^# Usage:/,/^# ====/p' "$0" | grep -E '^#' | sed 's/^# \?//' | |
| exit 0 | |
| } | |
| confirm() { | |
| $AUTO_YES && return 0 | |
| read -rp "$(echo -e "${YELLOW}Proceed? [y/N]${RESET} ")" ans | |
| [[ "${ans,,}" == "y" ]] || { warn "Skipped."; return 1; } | |
| } | |
| bytes_to_human() { | |
| local b=$1 | |
| if (( b >= 1073741824 )); then printf "%.1f GiB" "$(echo "scale=1; $b/1073741824" | bc)" | |
| elif (( b >= 1048576 )); then printf "%.1f MiB" "$(echo "scale=1; $b/1048576" | bc)" | |
| else printf "%d KiB" $(( b / 1024 )) | |
| fi | |
| } | |
| # ── Argument parsing ────────────────────────────────────────────────────────── | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -d|--dry-run) DRY_RUN=true ;; | |
| -y|--yes) AUTO_YES=true ;; | |
| -v|--verbose) VERBOSE=true ;; | |
| -h|--help) usage ;; | |
| *) error "Unknown option: $1"; usage ;; | |
| esac | |
| shift | |
| done | |
| # ── Root check ──────────────────────────────────────────────────────────────── | |
| if [[ $EUID -ne 0 ]]; then | |
| error "This script must be run as root (use sudo)." | |
| exit 1 | |
| fi | |
| # ── Dependency check ────────────────────────────────────────────────────────── | |
| header "Checking dependencies" | |
| MISSING=() | |
| for cmd in pvs vgs lvs pvresize pvscan vgextend lvextend \ | |
| parted growpart resize2fs xfs_growfs lsblk bc; do | |
| if ! command -v "$cmd" &>/dev/null; then | |
| MISSING+=("$cmd") | |
| fi | |
| done | |
| if [[ ${#MISSING[@]} -gt 0 ]]; then | |
| warn "Missing tools: ${MISSING[*]}" | |
| log "Installing missing packages..." | |
| run "apt-get update -qq" | |
| run "apt-get install -y cloud-guest-utils lvm2 parted e2fsprogs xfsprogs bc" | |
| fi | |
| ok "All dependencies satisfied." | |
| # ── Discover LVM layout ─────────────────────────────────────────────────────── | |
| header "Current LVM layout" | |
| echo "" | |
| echo "Physical Volumes:" | |
| pvs --units b --nosuffix -o pv_name,pv_size,pv_free --separator ' ' 2>/dev/null || true | |
| echo "" | |
| echo "Volume Groups:" | |
| vgs --units b --nosuffix -o vg_name,vg_size,vg_free --separator ' ' 2>/dev/null || true | |
| echo "" | |
| echo "Logical Volumes:" | |
| lvs -o lv_name,vg_name,lv_size,lv_path --separator ' ' 2>/dev/null || true | |
| # ── Step 1: Grow partitions that back PVs ──────────────────────────────────── | |
| header "Step 1: Expand partitions backing Physical Volumes" | |
| mapfile -t PV_PATHS < <(pvs --noheadings -o pv_name 2>/dev/null | tr -d ' ') | |
| for PV in "${PV_PATHS[@]}"; do | |
| [[ -z "$PV" ]] && continue | |
| log "Examining PV: $PV" | |
| # Resolve underlying disk and partition number | |
| # Handles /dev/sda2, /dev/nvme0n1p3, /dev/vda1, /dev/xvda1 etc. | |
| if [[ "$PV" =~ ^(/dev/[a-z]+)([0-9]+)$ ]]; then | |
| DISK="${BASH_REMATCH[1]}" | |
| PART="${BASH_REMATCH[2]}" | |
| elif [[ "$PV" =~ ^(/dev/[a-z]+[0-9]+p)([0-9]+)$ ]]; then | |
| DISK="${BASH_REMATCH[1]%p}" | |
| PART="${BASH_REMATCH[2]}" | |
| else | |
| warn " Cannot parse disk/partition from $PV — skipping growpart." | |
| continue | |
| fi | |
| verbose " Disk: $DISK Partition: $PART" | |
| # Check if growpart would do anything | |
| GROW_OUT=$(growpart "$DISK" "$PART" 2>&1) && GREW=true || GREW=false | |
| if $GREW; then | |
| ok " Grew partition ${DISK}${PART}: $GROW_OUT" | |
| CHANGES_MADE=true | |
| else | |
| if echo "$GROW_OUT" | grep -qi "NOCHANGE"; then | |
| verbose " Partition already at maximum size — nothing to grow." | |
| else | |
| if $DRY_RUN; then | |
| dry "growpart $DISK $PART" | |
| else | |
| warn " growpart output: $GROW_OUT" | |
| fi | |
| fi | |
| fi | |
| done | |
| # ── Step 2: pvresize ────────────────────────────────────────────────────────── | |
| header "Step 2: Resize Physical Volumes (pvresize)" | |
| for PV in "${PV_PATHS[@]}"; do | |
| [[ -z "$PV" ]] && continue | |
| PV_FREE_BEFORE=$(pvs --noheadings --units b --nosuffix -o pv_free "$PV" 2>/dev/null | tr -d ' ') | |
| log "pvresize $PV" | |
| run "pvresize '$PV'" | |
| if ! $DRY_RUN; then | |
| PV_FREE_AFTER=$(pvs --noheadings --units b --nosuffix -o pv_free "$PV" 2>/dev/null | tr -d ' ') | |
| GAINED=$(( PV_FREE_AFTER - PV_FREE_BEFORE )) | |
| if (( GAINED > 0 )); then | |
| ok " Gained $(bytes_to_human $GAINED) of free PE space on $PV" | |
| CHANGES_MADE=true | |
| else | |
| verbose " No new space found on $PV" | |
| fi | |
| fi | |
| done | |
| # ── Step 3: Extend LVs to consume free VG space ─────────────────────────────── | |
| header "Step 3: Extend Logical Volumes" | |
| mapfile -t VG_NAMES < <(vgs --noheadings -o vg_name 2>/dev/null | tr -d ' ') | |
| for VG in "${VG_NAMES[@]}"; do | |
| [[ -z "$VG" ]] && continue | |
| VG_FREE=$(vgs --noheadings --units b --nosuffix -o vg_free "$VG" 2>/dev/null | tr -d ' ') | |
| verbose "VG $VG free space: $(bytes_to_human $VG_FREE)" | |
| if (( VG_FREE < 4194304 )); then # < 4 MiB — nothing worth extending | |
| log "VG $VG: no significant free space ($(bytes_to_human $VG_FREE)) — skipping." | |
| continue | |
| fi | |
| log "VG $VG has $(bytes_to_human $VG_FREE) free — looking for LVs to extend." | |
| # Find all LVs in this VG | |
| mapfile -t LV_PATHS < <(lvs --noheadings -o lv_path --select "vg_name=$VG" 2>/dev/null | tr -d ' ') | |
| if [[ ${#LV_PATHS[@]} -eq 0 ]]; then | |
| warn " No LVs found in VG $VG." | |
| continue | |
| fi | |
| # If there's only one LV, give it everything automatically | |
| if [[ ${#LV_PATHS[@]} -eq 1 ]]; then | |
| LV="${LV_PATHS[0]}" | |
| log " Single LV detected: $LV — extending to use all free space." | |
| if ! $AUTO_YES; then | |
| echo -e " Will run: ${BOLD}lvextend -l +100%FREE '$LV'${RESET}" | |
| confirm || continue | |
| fi | |
| run "lvextend -l +100%FREE '$LV'" | |
| CHANGES_MADE=true | |
| else | |
| # Multiple LVs — show the user and ask which to extend | |
| echo "" | |
| echo " Multiple LVs in VG $VG:" | |
| i=1 | |
| for L in "${LV_PATHS[@]}"; do | |
| SZ=$(lvs --noheadings --units h -o lv_size "$L" 2>/dev/null | tr -d ' ') | |
| echo " $i) $L ($SZ)" | |
| (( i++ )) | |
| done | |
| echo "" | |
| if $AUTO_YES; then | |
| # In auto mode extend the first/root LV | |
| LV="${LV_PATHS[0]}" | |
| warn " --yes specified: auto-extending first LV: $LV" | |
| else | |
| read -rp " Enter LV number to extend (or 'a' for all, 's' to skip): " CHOICE | |
| case "$CHOICE" in | |
| a) | |
| for LV in "${LV_PATHS[@]}"; do | |
| SHARE=$(( VG_FREE / ${#LV_PATHS[@]} )) | |
| run "lvextend -L +${SHARE}B '$LV' 2>/dev/null || lvextend -l +100%FREE '$LV'" | |
| CHANGES_MADE=true | |
| done | |
| LV="" | |
| ;; | |
| s) warn " Skipped VG $VG."; LV=""; continue ;; | |
| [0-9]*) | |
| IDX=$(( CHOICE - 1 )) | |
| LV="${LV_PATHS[$IDX]:-}" | |
| [[ -z "$LV" ]] && { warn "Invalid selection."; continue; } | |
| ;; | |
| *) warn " Invalid input — skipping."; LV=""; continue ;; | |
| esac | |
| fi | |
| if [[ -n "${LV:-}" ]]; then | |
| run "lvextend -l +100%FREE '$LV'" | |
| CHANGES_MADE=true | |
| fi | |
| fi | |
| done | |
| # ── Step 4: Resize filesystems ──────────────────────────────────────────────── | |
| header "Step 4: Resize filesystems" | |
| mapfile -t ALL_LVS < <(lvs --noheadings -o lv_path 2>/dev/null | tr -d ' ') | |
| for LV in "${ALL_LVS[@]}"; do | |
| [[ -z "$LV" ]] && continue | |
| # Detect filesystem type | |
| FSTYPE=$(blkid -o value -s TYPE "$LV" 2>/dev/null || echo "unknown") | |
| verbose " $LV → fstype=$FSTYPE" | |
| case "$FSTYPE" in | |
| ext2|ext3|ext4) | |
| log " Resizing ext filesystem on $LV …" | |
| if $DRY_RUN; then | |
| dry "resize2fs '$LV'" | |
| else | |
| resize2fs "$LV" && ok " resize2fs succeeded on $LV" || warn " resize2fs failed on $LV (may already be optimal)" | |
| fi | |
| ;; | |
| xfs) | |
| # xfs_growfs requires the mount point, not the device | |
| MOUNT=$(findmnt -n -o TARGET "$LV" 2>/dev/null || true) | |
| if [[ -n "$MOUNT" ]]; then | |
| log " Resizing XFS on $LV (mount: $MOUNT) …" | |
| run "xfs_growfs '$MOUNT'" | |
| ok " xfs_growfs succeeded on $MOUNT" | |
| else | |
| warn " XFS on $LV is not mounted — cannot resize online. Mount it first." | |
| fi | |
| ;; | |
| swap) | |
| verbose " $LV is swap — skipping filesystem resize." | |
| ;; | |
| unknown|"") | |
| verbose " $LV: no recognised filesystem — skipping resize." | |
| ;; | |
| *) | |
| warn " $LV: unsupported filesystem '$FSTYPE' — resize manually." | |
| ;; | |
| esac | |
| done | |
| # ── Summary ─────────────────────────────────────────────────────────────────── | |
| header "Final LVM layout" | |
| echo "" | |
| echo "Physical Volumes:" | |
| pvs --units h -o pv_name,pv_size,pv_free 2>/dev/null || true | |
| echo "" | |
| echo "Volume Groups:" | |
| vgs --units h -o vg_name,vg_size,vg_free 2>/dev/null || true | |
| echo "" | |
| echo "Logical Volumes + filesystem usage:" | |
| lvs -o lv_path,lv_size 2>/dev/null | while read -r LV _; do | |
| [[ "$LV" == "Path" ]] && continue | |
| MOUNT=$(findmnt -n -o TARGET "$LV" 2>/dev/null || echo "(not mounted)") | |
| if [[ "$MOUNT" != "(not mounted)" ]]; then | |
| DF=$(df -h "$MOUNT" 2>/dev/null | awk 'NR==2 {print $2, "used:", $3, "avail:", $4}') | |
| echo " $LV [$MOUNT] $DF" | |
| else | |
| echo " $LV $MOUNT" | |
| fi | |
| done | |
| echo "" | |
| if $DRY_RUN; then | |
| warn "Dry-run mode — no changes were made." | |
| elif $CHANGES_MADE; then | |
| ok "LVM expansion complete." | |
| else | |
| ok "Nothing to do — all volumes are already at maximum size." | |
| fi | |
| if (( ERRORS > 0 )); then | |
| error "$ERRORS error(s) encountered. Review output above." | |
| exit 1 | |
| fi | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment