Add a node script that shrinks a container's root disk
CI / pytest and ruff (push) Successful in 5m19s

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 <noreply@anthropic.com>
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-04 19:58:02 +01:00
1 parent 82902edfc8
commit 792667897a
3 files changed
+506 -1

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
"""Tests for scripts/pct-shrink-rootfs; spec: docs/plans/lxc-shrink.md (section B).
The script runs for real, but pct, df, rsync, flock, ha-manager, pvesr and id are
stubs first on PATH. The pct stub edits a temp <vmid>.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()) == []