Add a node script that shrinks a container's root disk
CI / pytest and ruff (push) Successful in 5m19s
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:
1 parent
82902edfc8
commit
792667897a
3 files changed
+506
-1
No files matched your search
@@ -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()) == []
|
||||
Reference in new issue
Block a user