Files
DuckbootandClaude Opus 5.5 890b6b115b
CI / pytest and ruff (push) Successful in 5m6s
Fix the inode count in pct-shrink-rootfs
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>
2026-11-10 13:10:42 +01:00

289 lines
9.8 KiB
Python

"""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"
# like GNU df: -i and --output cannot be combined
if [[ " $* " == *" -i "* && "$*" == *--output* ]]; then echo "df: options -i and --output are mutually exclusive" >&2; exit 1; fi
if [[ "$*" == *--output=iused* ]]; 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()) == []