CI / pytest and ruff (push) Successful in 5m6s
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>
187 lines
7.0 KiB
Bash
Executable File
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
|