Skip to content

Instantly share code, notes, and snippets.

@onyxhat
Created March 10, 2026 15:03
Show Gist options
  • Select an option

  • Save onyxhat/99219433a4404bc953a14b6f4f70bb51 to your computer and use it in GitHub Desktop.

Select an option

Save onyxhat/99219433a4404bc953a14b6f4f70bb51 to your computer and use it in GitHub Desktop.
#!/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