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()) == []