From 792667897acaa7f54f4895a02ad4b3c193f98cdc Mon Sep 17 00:00:00 2001 From: Per-Arne Hellarvik Date: Wed, 4 Nov 2026 19:58:02 +0100 Subject: [PATCH] Add a node script that shrinks a container's root disk scripts/pct-shrink-rootfs runs as root on the Proxmox node and keeps the VMID. Without --yes it only measures and prints the plan. It adds a temporary mp9 of the new size (Proxmox allocates and formats it), rsyncs the root disk into it, and swaps it into rootfs under Proxmox's config flock. The config is written like file_set_contents does on pmxcfs (tmp file, then rename), after checking that rootfs and mp9 did not change during the copy. The old volume is kept as the first free unusedK and the exact delete command is printed; pct rescan is not used, so other orphans of the VMID are not pulled in. It refuses snapshots and pending sections, a lock, template, protection, HA, replication, any mount point, and sizes the data does not fit (10 % headroom on top of the ext4 reserve and metadata, and an inode check). On an rsync failure the new volume is freed by its volid. Tests run the real script against stub pct, df, rsync, flock, ha-manager, pvesr and id. README documents both shrink routes. Design: docs/plans/lxc-shrink.md (section B). Co-Authored-By: Claude Opus 5.5 --- README.md | 35 ++++- scripts/pct-shrink-rootfs | 186 +++++++++++++++++++++++++ tests/test_pct_shrink.py | 286 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 506 insertions(+), 1 deletion(-) create mode 100755 scripts/pct-shrink-rootfs create mode 100644 tests/test_pct_shrink.py diff --git a/README.md b/README.md index 34c8011..d8ddf5a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ uv run cv4pve-tui | `Enter` then `o` | in info: edit autostart, startup order (order, up and down delay) and tags (one confirmed `set config` call; empty fields remove the setting) | | `Enter` then `p` / `R` / `D` | in info: take a snapshot (name, description, VM: include RAM) / roll back to the snapshot under the cursor (confirms; starts the guest again if it is running) / delete the snapshot under the cursor (confirms) | | `Enter` then `f` | in info: fstrim in a running VM with the guest agent enabled (confirms; containers: use `pct fstrim` on the node) | -| `Enter` then `r` | in info: restore the backup under the cursor, as a new VMID (default, the original stays untouched; a copy keeps the original's network settings, so leave Start off until they are changed) or overwriting the guest itself (stopped guests only; type the VMID, then confirm) | +| `Enter` then `r` | in info: restore the backup under the cursor, as a new VMID (default, the original stays untouched; a copy keeps the original's network settings, so leave Start off until they are changed) or overwriting the guest itself (stopped guests only; type the VMID, then confirm); container backups: optional smaller root disk, see *Shrinking a container's root disk* | | `t` | shell in a running LXC container (`ssh -t root@ pct enter `) | | `l` | tasks: recent cluster tasks (start, duration, node, type, VMID, user, status; `!` marks failures); `Enter` shows the log of the task under the cursor, `g` reloads. In info: `l` = only this guest's tasks | | `U` | package updates: pending updates per online node (node, package, current, new, origin, priority), read-only; `g` reloads. The list is as of the node's last `apt update`. The frame's bottom border shows the count | @@ -69,6 +69,39 @@ A `!` in the first column marks a guest that no backup job covers (Proxmox's Empty fields in the edit dialog are left unchanged. If a CPU/memory change cannot be hot-plugged, the app tells you it applies at the next reboot. +## Shrinking a container's root disk + +Proxmox only grows disks. Two ways to get a smaller container root disk, both +**dangerous**; take a backup first and test on a throwaway container. + +**In the TUI** (`Enter`, then `r` on a container backup): fill in *Root disk GiB*. +This restores the backup as a **new** guest with a new, smaller root disk +(Proxmox's "advanced restore mode"); overwriting the original with a smaller disk is +refused, because Proxmox deletes the old disks before it unpacks the backup. In this +mode every mount point (`mpN`) of the backup is dropped: files of mount points with +`backup=1` land inside the root disk, `backup=0` ones are not in the backup at all. +Raw `lxc.*` lines (bind mounts too) can come back if cv4pve-cli runs as root@pam, so +do not run both guests at once. A disk that is too small makes the restore fail; the +original is untouched. + +**On the node** (root shell, container stopped, keeps the VMID): + +```sh +scripts/pct-shrink-rootfs # measure and print the plan +scripts/pct-shrink-rootfs --yes # do it +``` + +It adds a temporary `mp9` of the new size (Proxmox allocates and formats it), copies +the root disk into it with rsync, then swaps it into `rootfs` under Proxmox's config +lock. The old root disk stays as an `unusedK` volume until you delete it with the +exact `pct set --delete unusedK` the script prints; the old config is copied to +`/root/pct-shrink--.conf`. It refuses containers with snapshots, +pending changes, a lock, template or protection flag, HA, replication, or any mount +point (`mpN`, `lxc.mount.entry`), and sizes the data does not fit with headroom +(5 % ext4 reserve, metadata, inodes). chattr flags (`+i`, `+a`) are not copied. Even +the dry run mounts the container briefly (`pct mount`). Design: +`docs/plans/lxc-shrink.md`. + ## Development ```sh diff --git a/scripts/pct-shrink-rootfs b/scripts/pct-shrink-rootfs new file mode 100755 index 0000000..d4aa48b --- /dev/null +++ b/scripts/pct-shrink-rootfs @@ -0,0 +1,186 @@ +#!/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] "; } + +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 <&2 + fi + exit "$rc" +} + +# Runs inside flock. Writes like PVE's file_set_contents: ".tmp." 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 -i --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 <.conf the way PVE does (mp9 set, +mpN -> first free unusedK on delete, lock: mounted on mount); every stub logs its argv. +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "pct-shrink-rootfs" +GIB = 1024**3 + +STUBS = { + "id": 'echo "${STUB_UID:-0}"', + "flock": 'echo "flock $*" >> "$STUB_LOG"\nshift\nexec "$@"', + "ha-manager": 'echo "${STUB_HA_OUT:-}"', + "pvesr": 'echo "${STUB_PVESR_OUT:-}"', + "df": """echo "df $*" >> "$STUB_LOG" +if [[ "$*" == *-i* ]]; then echo IUsed; echo "${STUB_IUSED:-1000}" +else echo Used; echo "${STUB_USED:-1073741824}"; fi""", + "rsync": """echo "rsync $*" >> "$STUB_LOG" +[[ -z ${STUB_RSYNC_FAIL:-} ]]""", + "pct": """echo "pct $*" >> "$STUB_LOG" +conf="$PCT_CONF_DIR/$2.conf" +case "$1" in + status) echo "status: ${STUB_STATUS:-stopped}" ;; + config) cat "$conf" ;; + mount) echo "lock: mounted" >> "$conf" ;; + unmount) + n=$(grep -c '^pct unmount' "$STUB_LOG") + [[ ${STUB_FAIL_UNMOUNT:-0} == "$n" ]] && exit 1 + grep -vx 'lock: mounted' "$conf" > "$conf.new"; mv "$conf.new" "$conf" ;; + set) + if [[ $3 == --mp9 ]]; then + spec=${4%%,*}; storage=${spec%%:*}; gib=${spec#*:} + vol=${STUB_NEW_VOLID:-$storage:vm-$2-disk-1} + echo "mp9: $vol,mp=/.shrink-new,size=${gib}G" >> "$conf" + else + key=$4 + line=$(grep "^$key: " "$conf") + grep -v "^$key: " "$conf" > "$conf.new" + if [[ $key == mp* ]]; then + vol=${line#*: }; vol=${vol%%,*}; k=0 + while grep -q "^unused$k: " "$conf.new"; do k=$((k+1)); done + echo "unused$k: $vol" >> "$conf.new" + fi + mv "$conf.new" "$conf" + fi ;; +esac""", +} + +BASE = """\ +arch: amd64 +cores: 2 +hostname: web +memory: 512 +net0: name=eth0,bridge=vmbr0,ip=dhcp +ostype: debian +rootfs: local-lvm:vm-101-disk-0,size=8G,acl=1 +swap: 512 +unused0: local-lvm:vm-101-disk-5 +unused2: local-lvm:vm-101-disk-7 +""" + + +class Node: + def __init__(self, tmp: Path): + self.bin, self.confdir, self.backup = tmp / "bin", tmp / "lxc", tmp / "backup" + for d in (self.bin, self.confdir, self.backup): + d.mkdir() + for name, body in STUBS.items(): + (self.bin / name).write_text(f"#!/usr/bin/env bash\n{body}\n") + (self.bin / name).chmod(0o755) + self.conf = self.confdir / "101.conf" + self.log = tmp / "calls.log" + self.log.touch() + self.conf.write_text(BASE) + + def run(self, *args, **env): + full = { + **os.environ, + "PATH": f"{self.bin}:{os.environ['PATH']}", + "PCT_CONF_DIR": str(self.confdir), + "PCT_SHRINK_BACKUP_DIR": str(self.backup), + "STUB_LOG": str(self.log), + **env, + } + return subprocess.run( + [str(SCRIPT), *args], check=False, env=full, capture_output=True, text=True + ) + + def calls(self, *prefixes): + return [x for x in self.log.read_text().splitlines() if x.startswith(prefixes)] + + +@pytest.fixture +def node(tmp_path): + return Node(tmp_path) + + +MUTATING = ("pct set", "rsync", "flock") + +# name: (extra conf text, replacement for the rootfs line or None, env, stderr keyword) +REFUSALS = { + "not-root": ("", None, {"STUB_UID": "1000"}, "root"), + "running": ("", None, {"STUB_STATUS": "running"}, "stopped"), + "snapshot": ("[snap1]\ncores: 1\n", None, {}, "section"), + "pending": ("[pve:pending]\ncores: 4\n", None, {}, "section"), + "lock": ("lock: backup\n", None, {}, "lock"), + "template": ("template: 1\n", None, {}, "template"), + "protection": ("protection: 1\n", None, {}, "protection"), + "ha": ("", None, {"STUB_HA_OUT": "service ct:101 (node1, started)"}, "HA"), + "replication": ( + "", + None, + {"STUB_PVESR_OUT": "101-0 node2 */15 - yes"}, + "replication", + ), + "bind-rootfs": ("", "rootfs: /srv/ct101,size=8G", {}, "not a volume"), + "mp": ("mp0: local-lvm:vm-101-disk-2,mp=/data,size=1G\n", None, {}, "mount point"), + "mount-entry": ("lxc.mount.entry: /a b none bind 0 0\n", None, {}, "mount point"), + "too-full": ("", None, {"STUB_USED": str(2 * GIB)}, "too full"), + "too-many-inodes": ("", None, {"STUB_IUSED": "200000"}, "inodes"), +} + + +@pytest.mark.parametrize("name", REFUSALS) +def test_refusal_changes_nothing(node, name): + extra, rootfs, env, keyword = REFUSALS[name] + text = BASE + extra + if rootfs: + text = text.replace("rootfs: local-lvm:vm-101-disk-0,size=8G,acl=1", rootfs) + node.conf.write_text(text) + before = node.conf.read_bytes() + r = node.run("--yes", "101", "2", **env) + assert r.returncode != 0 + assert keyword in r.stderr + assert node.conf.read_bytes() == before + assert node.calls(*MUTATING) == [] + assert list(node.backup.iterdir()) == [] + + +@pytest.mark.parametrize( + "args", + [ + ("101", "8"), + ("101", "9"), + ("101", "0"), + ("101", "abc"), + ("101", "-1"), + ("101", "1.5"), + ("101",), + ("101", "2", "3"), + ("abc", "2"), + ], +) +def test_bad_arguments_or_size_not_smaller(node, args): + before = node.conf.read_bytes() + r = node.run("--yes", *args) + assert r.returncode != 0 + assert r.stderr.strip() + assert node.conf.read_bytes() == before + assert node.calls(*MUTATING) == [] + + +def test_missing_config_refused(node): + r = node.run("--yes", "999", "2") + assert r.returncode != 0 + assert "999.conf" in r.stderr + + +def test_pct_missing_refused(node): + (node.bin / "pct").unlink() + # Minimal PATH, so a real pct on the host cannot be found either. + r = node.run("--yes", "101", "2", PATH=f"{node.bin}:/usr/bin:/bin") + assert r.returncode != 0 + assert "pct not found" in r.stderr + assert node.conf.read_bytes() == BASE.encode() + + +def test_dry_run_leaves_config_and_prints_plan(node): + r = node.run("101", "2", STUB_HA_OUT="service ct:1010 (node1, started)") + assert r.returncode == 0, r.stderr + assert node.conf.read_bytes() == BASE.encode() + assert node.calls("pct mount", "pct unmount") == [ + "pct mount 101", + "pct unmount 101", + ] + assert node.calls(*MUTATING) == [] + assert str(node.confdir) in r.stdout + assert str(node.backup) in r.stdout + assert "--yes" in r.stdout + + +def run_happy(node, **env): + r = node.run("--yes", "101", "2", **env) + assert r.returncode == 0, r.stderr + return r + + +def test_happy_path_exact_config_and_call_sequence(node): + r = run_happy(node) + assert ( + node.conf.read_text() + == BASE.replace( + "rootfs: local-lvm:vm-101-disk-0,size=8G,acl=1", + "rootfs: local-lvm:vm-101-disk-1,size=2G,acl=1", + ) + + "unused1: local-lvm:vm-101-disk-0\n" + ) + assert node.calls("pct", "rsync") == [ + "pct status 101", + "pct mount 101", + "pct unmount 101", + "pct set 101 --mp9 local-lvm:2,mp=/.shrink-new", + "pct mount 101", + ( + "rsync -aHAX --numeric-ids --whole-file --sparse --one-file-system" + " --exclude=/.shrink-new /var/lib/lxc/101/rootfs/ /var/lib/lxc/101/rootfs/.shrink-new/" + ), + "pct unmount 101", + ] + assert ( + node.log.read_text() + .splitlines()[-1] + .startswith("flock /run/lock/lxc/pve-config-101.lock") + ) + backups = list(node.backup.glob("pct-shrink-101-*.conf")) + # Copied under the lock just before the swap, so it already holds mp9. + mp9 = "mp9: local-lvm:vm-101-disk-1,mp=/.shrink-new,size=2G\n" + assert [b.read_text() for b in backups] == [BASE + mp9] + assert "unused1: local-lvm:vm-101-disk-0" in r.stdout + assert "pct set 101 --delete unused1" in r.stdout + assert "chattr" in r.stdout + + +def test_happy_path_dir_style_volid(node): + node.conf.write_text( + BASE.replace("local-lvm:vm-101-disk-0", "local:101/vm-101-disk-0.raw") + .replace("unused0: local-lvm:vm-101-disk-5\n", "") + .replace("unused2: local-lvm:vm-101-disk-7\n", "") + ) + run_happy(node, STUB_NEW_VOLID="local:101/vm-101-disk-1.raw") + assert ( + "rootfs: local:101/vm-101-disk-1.raw,size=2G,acl=1\n" in node.conf.read_text() + ) + assert node.conf.read_text().endswith("unused0: local:101/vm-101-disk-0.raw\n") + assert "mp9" not in node.conf.read_text() + + +def test_rsync_failure_frees_new_volume_by_value(node): + r = node.run("--yes", "101", "2", STUB_RSYNC_FAIL="1") + assert r.returncode != 0 + assert node.conf.read_bytes() == BASE.encode() + # mp9 moved to the first free unusedK (1), which is found by value and deleted + assert node.calls("pct")[-3:] == [ + "pct unmount 101", + "pct set 101 --delete mp9", + "pct set 101 --delete unused1", + ] + assert node.calls("flock") == [] + assert list(node.backup.iterdir()) == [] + + +def test_unmount_failure_while_measuring_stops_before_any_set(node): + r = node.run("--yes", "101", "2", STUB_FAIL_UNMOUNT="1") + assert r.returncode != 0 + assert "pct unlock 101" in r.stderr + assert node.calls("pct unmount") == ["pct unmount 101"] + assert node.calls(*MUTATING) == [] + + +def test_unmount_failure_after_rsync_stops_without_further_set(node): + r = node.run("--yes", "101", "2", STUB_FAIL_UNMOUNT="2") + assert r.returncode != 0 + assert "pct unlock 101" in r.stderr + assert node.calls("pct set") == ["pct set 101 --mp9 local-lvm:2,mp=/.shrink-new"] + assert node.calls("flock") == [] + text = node.conf.read_text() + assert "rootfs: local-lvm:vm-101-disk-0,size=8G,acl=1" in text + assert "mp9: local-lvm:vm-101-disk-1" in text + assert list(node.backup.iterdir()) == []