Files
cv4pve-cli-tui/scripts/pct-shrink-rootfs
T
DuckbootandClaude Opus 5.5 890b6b115b
CI / pytest and ruff (push) Successful in 5m6s
Fix the inode count in pct-shrink-rootfs
GNU df refuses -i together with --output, so the script stopped right
after mounting the container ("options -i and --output are mutually
exclusive"). The iused field needs no -i: use df --output=iused.

The df stub in the tests accepted the combination and hid the bug; it now
rejects it the way GNU df does.

Generated with AI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:10:42 +01:00

187 lines
7.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Shrink a container's root disk by copying it into a smaller volume. Run as root
# on the Proxmox node. Without --yes it only measures and prints the plan.
# Spec: docs/plans/lxc-shrink.md (section B).
set -euo pipefail
CONF_DIR=${PCT_CONF_DIR:-/etc/pve/lxc}
BACKUP_DIR=${PCT_SHRINK_BACKUP_DIR:-/root}
GIB=1073741824
ctid="" conf="" mounted=0 new_volid=""
die() { echo "pct-shrink-rootfs: $*" >&2; exit 1; }
usage() { die "usage: pct-shrink-rootfs [--yes] <vmid> <new-size-GiB>"; }
conf_get() { awk -v k="$1" 'index($0, k ": ") == 1 { print substr($0, length(k) + 3); exit }' "$conf"; }
refuse_if() { if grep -Eq "$1" "$conf"; then die "$2"; fi; }
# The value of the unusedK line that holds exactly this volid (no regex on the volid).
unused_key_of() {
awk -v v="$1" '/^unused[0-9]+: / { if (substr($0, index($0, ": ") + 2) == v) { print substr($0, 1, index($0, ":") - 1); exit } }' "$conf"
}
size_bytes() {
[[ $1 =~ ^([0-9]+)([KMGT]?)$ ]] || die "cannot read the current rootfs size '$1'"
local m=1
case ${BASH_REMATCH[2]} in K) m=1024 ;; M) m=$((1024 ** 2)) ;; G) m=$GIB ;; T) m=$((1024 ** 4)) ;; *) ;; esac
echo $((BASH_REMATCH[1] * m))
}
manual_cleanup() {
cat >&2 <<EOT
pct-shrink-rootfs: 'pct unmount $ctid' failed. Nothing else was touched. Clean up by hand:
pct unmount $ctid
pct unlock $ctid # if the lock remains
EOT
}
unmount_or_die() {
mounted=0
pct unmount "$ctid" || { manual_cleanup; exit 1; }
}
on_exit() {
local rc=$?
trap - EXIT
if ((mounted)); then unmount_or_die; fi
if ((rc != 0)) && [[ -n $new_volid ]]; then
echo "pct-shrink-rootfs: the new volume $new_volid is still in the config as mp9;" \
"remove it with 'pct set $ctid --delete mp9' and then the unusedK that holds it." >&2
fi
exit "$rc"
}
# Runs inside flock. Writes like PVE's file_set_contents: "<file>.tmp.<pid>" in the same
# pmxcfs directory, then rename. Re-checks both volids under the lock before writing.
swap_config() {
local conf=$1 old=$2 new=$3 size=$4 backup=$5 tmp=$1.tmp.$$
cp "$conf" "$backup"
if ! awk -v old="$old" -v new="$new" -v size="$size" '
{ key = substr($0, 1, index($0, ":") - 1); val = substr($0, index($0, ": ") + 2) }
key ~ /^unused[0-9]+$/ { used[substr(key, 7) + 0] = 1 }
key == "mp9" { split(val, m, ","); ok_new = (m[1] == new); next }
key == "rootfs" {
n = split(val, a, ",")
ok_old = (a[1] == old)
out = new; seen = 0
for (i = 2; i <= n; i++) {
if (substr(a[i], 1, 5) == "size=") { out = out ",size=" size; seen = 1 }
else out = out "," a[i]
}
if (!seen) out = out ",size=" size
print "rootfs: " out; next
}
{ print }
END { if (!ok_old || !ok_new) exit 3; k = 0; while (k in used) k++; print "unused" k ": " old }
' "$conf" > "$tmp"; then
rm -f "$tmp"
echo "pct-shrink-rootfs: config changed during the copy (rootfs or mp9 differs); not swapped" >&2
return 1
fi
mv -f "$tmp" "$conf"
}
export -f swap_config
free_new_volume() {
local key vol=$new_volid
pct set "$ctid" --delete mp9
new_volid="" # no longer mp9: the on_exit hint would be wrong
key=$(unused_key_of "$vol")
[[ -n $key ]] || die "cannot find $vol among the unused volumes; delete it by hand"
pct set "$ctid" --delete "$key"
}
main() {
local yes=0 size root rootfs old_volid storage cur_bytes used iused out mp9 new_size backup key
while [[ ${1:-} == --* ]]; do
[[ $1 == --yes ]] || usage
yes=1
shift
done
[[ $# -eq 2 ]] || usage
ctid=$1 size=$2
[[ $ctid =~ ^[0-9]+$ ]] || die "vmid must be a number"
[[ $size =~ ^[1-9][0-9]{0,8}$ ]] || die "new size must be a positive integer (GiB)"
[[ $(id -u) -eq 0 ]] || die "must run as root on the node"
command -v pct > /dev/null || die "pct not found; run this on a Proxmox node"
conf=$CONF_DIR/$ctid.conf
[[ -f $conf ]] || die "no config $conf"
[[ $(pct status "$ctid") == "status: stopped" ]] || die "container $ctid must be stopped"
refuse_if '^\[' "config has a snapshot or pending section"
refuse_if '^lock:' "container is locked"
refuse_if '^template: 1$' "container is a template"
refuse_if '^protection: 1$' "container has protection set"
refuse_if '^(mp[0-9]+|lxc\.mount\.entry):' "container has a mount point (mpN or lxc.mount.entry); not supported"
out=$(ha-manager status)
if grep -Eq "(^| )ct:$ctid( |\$)" <<< "$out"; then die "container is HA-managed"; fi
out=$(pvesr list)
if grep -Eq "^$ctid-" <<< "$out"; then die "container has a replication job"; fi
rootfs=$(conf_get rootfs)
old_volid=${rootfs%%,*}
[[ $old_volid == *:* && $old_volid != /* ]] || die "rootfs is not a volume: $old_volid"
storage=${old_volid%%:*}
[[ $rootfs =~ (^|,)size=([^,]+) ]] || die "rootfs has no size= option"
cur_bytes=$(size_bytes "${BASH_REMATCH[2]}")
((size * GIB < cur_bytes)) || die "new size must be smaller than the current rootfs (${BASH_REMATCH[2]})"
trap on_exit EXIT
root=/var/lib/lxc/$ctid/rootfs
mounted=1
pct mount "$ctid"
used=$(df -B1 --output=used "$root" | tail -n1 | tr -d ' ')
iused=$(df --output=iused "$root" | tail -n1 | tr -d ' ')
unmount_or_die
[[ $used =~ ^[0-9]+$ && $iused =~ ^[0-9]+$ ]] || die "cannot read df output"
echo "Container $ctid, rootfs $old_volid ($storage): $used bytes, $iused inodes used."
echo "Config dir: $CONF_DIR; backup dir: $BACKUP_DIR"
# 5 % root reserve + ~2 % ext4 metadata; mkfs.ext4 default is one inode per 16 KiB.
((used * 1100 <= size * GIB * 930)) || die "data too full for ${size}G: need used * 1.1 <= 93 % of the disk"
((iused * 11 <= size * 65536 * 10)) || die "too many inodes for ${size}G: $iused used, ${size}G gives $((size * 65536))"
if ((!yes)); then
echo "Plan: allocate ${size}G on $storage, rsync the root disk into it, swap it into rootfs."
echo "The old volume is kept as unusedK. Re-run with --yes to do it."
return 0
fi
pct set "$ctid" --mp9 "$storage:$size,mp=/.shrink-new"
mp9=$(conf_get mp9)
[[ -n $mp9 ]] || die "mp9 not found after pct set"
new_volid=${mp9%%,*}
[[ ,$mp9, =~ ,size=([^,]+), ]] || die "mp9 has no size= option: $mp9"
new_size=${BASH_REMATCH[1]}
mounted=1
pct mount "$ctid"
if ! rsync -aHAX --numeric-ids --whole-file --sparse --one-file-system \
--exclude=/.shrink-new "$root/" "$root/.shrink-new/"; then
unmount_or_die
free_new_volume
die "rsync failed; the new volume was removed and the config is unchanged"
fi
unmount_or_die
backup=$BACKUP_DIR/pct-shrink-$ctid-$(date +%Y%m%d-%H%M%S).conf
flock "/run/lock/lxc/pve-config-$ctid.lock" \
bash -c 'set -euo pipefail; swap_config "$@"' _ "$conf" "$old_volid" "$new_volid" "$new_size" "$backup"
new_volid=""
key=$(unused_key_of "$old_volid")
cat <<EOT
Done. rootfs is now on the new ${new_size} volume; config backup: $backup
The old root disk is kept as '$key: $old_volid'.
Start the container and check it. Then free the old volume:
pct set $ctid --delete $key
rsync does not copy chattr flags (+i, +a); re-apply them in the container if you used any.
EOT
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi