Compare commits

...
2 Commits
Author SHA1 Message Date
DuckbootandClaude Opus 5.5 792667897a 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>
2026-11-04 19:58:02 +01:00
DuckbootandClaude Opus 5.5 82902edfc8 Restore a container backup with a smaller root disk
Proxmox only grows disks. The restore dialog gets an optional "Root
disk GiB" field for container backups; with a size, the backup is
restored as a new guest with --rootfs <storage>:<N> (Proxmox's advanced
restore mode).

- A smaller disk is only allowed for a new VMID, checked in the dialog
  and again in cli.restore(): with force, Proxmox deletes the old disks
  before it unpacks, and a failed unpack then removes the guest.
- The size must be a positive integer (0 gives an unlimited subvol on
  ZFS/dir storage) and above the used root disk space of a running
  guest.
- The confirm screen is a separate DANGER screen with a red border: it
  says mount points are dropped and their backed-up files land in the
  root disk, raw lxc.* lines can come back under root@pam, root disk
  options are not carried over, and the used space is no fit check.
- Never --unique or ignore-unpack-errors.

Design: docs/plans/lxc-shrink.md (section A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:58:02 +01:00
8 changed files with 963 additions and 9 deletions

No files matched your search

+34 -1
View File
@@ -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@<node> pct enter <vmid>`) |
| `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 <vmid> <new-size-GiB> # measure and print the plan
scripts/pct-shrink-rootfs --yes <vmid> <new-size-GiB> # 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 <vmid> --delete unusedK` the script prints; the old config is copied to
`/root/pct-shrink-<vmid>-<timestamp>.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
+121
View File
@@ -0,0 +1,121 @@
# Shrink a container's root disk
## Problem
Proxmox only grows disks (`resize_vm` dies with "unable to shrink disk size"). A
container whose rootfs was sized too large cannot be made smaller from the web UI or
this TUI. Two routes exist, verified against pve-container 6.1.18 (master) source:
1. API: restore a backup with an explicit `rootfs=<storage>:<GiB>` ("advanced restore
mode", pct.adoc). Reachable through cv4pve-cli (`do restore ct ... --rootfs S:N`
forwards the key; checked with `--dry-run`).
2. Node shell: copy the files into a new, smaller volume and swap it in.
Both are dangerous; the TUI must say so plainly.
## Approach
### A. TUI: smaller rootfs on restore (container backups only)
- `RestoreScreen` gets an optional field "Root disk GiB" for lxc backups. Empty =
today's behaviour, unchanged.
- With a size set, only **New guest** is allowed. Overwrite + size is refused: with
`force=1` PVE destroys every old volume before it extracts, and a failed extraction
then deletes the new disks and the config too; the container is gone.
- `cli.restore()` gets `rootfs_gb: int | None`; when set it checks it with
`_positive` (a size of 0 makes PVE allocate an *unlimited* subvol on ZFS/dir) and
adds `--rootfs <storage>:<N>` (same storage as the dialog's choice). Never
`ignore-unpack-errors`.
- The confirm screen for a shrink restore is a separate, red warning:
- every mpX of the backup is dropped from the new config; files of `backup=1`
mount points land inside the root disk and count against its size; `backup=0`
mount points are not in the backup at all;
- raw `lxc.*` lines (including `lxc.mount.entry` bind mounts) may come back when
cv4pve-cli runs as root@pam: do not run both guests at once;
- rootfs options (acl, quota, mountoptions, replicate) are not carried over;
- names the guest's *current* mount points, saying the backup may differ;
- shows rootfs used space when the guest is running (`status/current` `disk`;
0 when stopped, shown as "unknown"), and says this is not a fit check: the
backup may be older, mount point data and ext4 overhead (5 % reserve, inodes)
come on top;
- a too-small disk makes the restore task fail; the original is untouched because
this is always a new VMID.
- Hard refusal only where it is certain: size not a positive integer, or size <=
used rootfs GiB of a running guest.
- Known limit, unchanged from today's restore: `run(timeout=3600)` can give up while
the PVE task still runs.
### B. Node script `scripts/pct-shrink-rootfs` (bash, run as root on the node)
Usage: `pct-shrink-rootfs [--yes] <vmid> <new-size-GiB>`. Without `--yes` it only
measures and prints the plan; measuring runs `pct mount`/`pct unmount`, which sets
and clears `lock: mounted` (so even a dry run touches the config briefly).
Refuses (exit non-zero, nothing changed) when: not root; `pct` missing; CT not
stopped; config has any `[...]` section (snapshots, `[pve:pending]`); `lock:` set;
`template: 1` or `protection: 1`; HA-managed (`ha-manager status`); a replication
job exists (`pvesr list`); rootfs is not a volume; **any `mpN` or `lxc.mount.entry`**
(rootfs-only CTs: no shadowed files, `df` measures rootfs alone, `pct mount` cannot
fail on another storage); new size >= current size; size not a positive integer.
Fit check after `pct mount` (`df -B1` and `df -i` on the rootfs): used bytes * 1.1 <=
N GiB * 0.93 (5 % root reserve + ~2 % ext4 metadata), and used inodes * 1.1 <= the
new disk's inodes (N * 65536, mkfs.ext4 default ratio). On any error after
`pct mount` the trap runs `pct unmount`; if that fails it stops and prints the manual
cleanup (`pct unmount`, `pct unlock`), touching nothing else.
Steps (copy route, any storage):
1. `pct set <id> --mp9 <storage>:<N>,mp=/.shrink-new` - PVE allocates and formats;
the script reads back mp9's volid and `size=`.
2. `pct mount <id>`; `rsync -aHAX --numeric-ids --whole-file --sparse
--one-file-system --exclude=/.shrink-new` old rootfs -> new; `pct unmount <id>`.
On rsync failure: unmount, then free the new volume explicitly - `pct set
--delete mp9` only moves it to `unusedN`, so the script then deletes the
`unusedK` whose value is exactly the new volid (found by value, never by index).
3. Swap, under PVE's config flock (`flock /run/lock/lxc/pve-config-<id>.lock`):
copy the config to `/root/pct-shrink-<id>-<timestamp>.conf`; rewrite the main
section with awk (exact string compare, no regex on volids) into
`<conf>.tmp.<pid>` and rename it, as PVE's `file_set_contents` does on pmxcfs;
refuse if rootfs or mp9 changed since the copy: `rootfs:` gets the
new volid with the old options and mp9's `size=`, the `mp9:` line goes, and
`unusedK: <old volid>` is added at the first free K. No `pct rescan`: the script
knows both volids and the size, so rescan's side effects (every orphan of the
VMID added as unused, `size=0` on pve-container < 6.1.9) are avoided.
4. Print the exact `unusedK` key and value of the old rootfs: start the CT, check
it, then `pct set <id> --delete unusedK`. The script never deletes the old volume.
The in-place ZFS `refquota` shortcut is left out: one route for every storage is
less code to get wrong, and the copy keeps the original until the user deletes it.
chattr flags (`+i`, `+a`) are not copied by rsync; the script says so.
Tests: pytest runs the script with stub `pct`, `df`, `rsync`, `flock`,
`ha-manager`, `pvesr` on `PATH` and a temp config dir (`PCT_CONF_DIR` override,
default `/etc/pve/lxc`; the plan output prints the directory used). bats is not
installed and CI only runs pytest. ShellCheck via the edit hook.
## Out of scope
- Shrinking mpX volumes, and containers that have any mount point (script).
- Overwrite-in-place restore with a smaller disk.
- Restoring back to the original VMID (second backup/restore cycle).
- Running the node script from the TUI (it needs a root shell on the node).
- Moving `backup=0` mount points to the new guest.
## Steps
- [x] `cli.restore(rootfs_gb=...)` adds `--rootfs S:N`; tests in `test_cli.py`.
- [x] RestoreScreen: size field for lxc, refuse with overwrite, refuse <= used of a
running guest; shrink confirm text with the warnings above; tests in
`test_app.py`.
- [x] `scripts/pct-shrink-rootfs` + `tests/test_pct_shrink.py`: each refusal, dry
run changes no config, happy path (exact new config, `unusedK` at first free
K with existing `unused0`), dir-style volid with `/` and `.`, rsync failure
frees the new volume by value, unmount failure stops.
- [x] README: key table row and a warning paragraph; script usage.
- [ ] Full `uv run pytest`, ruff, shellcheck green.
## Open questions
- Should a shrink restore pass `--unique 1` (new MACs)? Today's restore does not, it
warns instead. Proposal: keep that, the warning already covers it.
+186
View File
@@ -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] <vmid> <new-size-GiB>"; }
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 <<EOT
pct-shrink-rootfs: 'pct unmount $ctid' failed. Nothing else was touched. Clean up by hand:
pct unmount $ctid
pct unlock $ctid # if the lock remains
EOT
}
unmount_or_die() {
mounted=0
pct unmount "$ctid" || { manual_cleanup; exit 1; }
}
on_exit() {
local rc=$?
trap - EXIT
if ((mounted)); then unmount_or_die; fi
if ((rc != 0)) && [[ -n $new_volid ]]; then
echo "pct-shrink-rootfs: the new volume $new_volid is still in the config as mp9;" \
"remove it with 'pct set $ctid --delete mp9' and then the unusedK that holds it." >&2
fi
exit "$rc"
}
# Runs inside flock. Writes like PVE's file_set_contents: "<file>.tmp.<pid>" 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 <<EOT
Done. rootfs is now on the new ${new_size} volume; config backup: $backup
The old root disk is kept as '$key: $old_volid'.
Start the container and check it. Then free the old volume:
pct set $ctid --delete $key
rsync does not copy chattr flags (+i, +a); re-apply them in the container if you used any.
EOT
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+83 -6
View File
@@ -56,6 +56,7 @@ class DialogScreen(ModalScreen):
DialogScreen .row Label { width: 14; height: 3; content-align: left middle; }
DialogScreen .row Select, DialogScreen .row Input { width: 1fr; }
DialogScreen .row Select.mode { width: 16; }
DialogScreen #dialog.danger { border: heavy $error; }
DialogScreen .sub {
height: auto; border: round $accent; border-title-style: bold; padding: 0 1;
}
@@ -72,12 +73,15 @@ class DialogScreen(ModalScreen):
class ConfirmScreen(DialogScreen):
BINDINGS = [("escape", "dismiss(False)", "No")] # noqa: RUF012 - Textual idiom
def __init__(self, message: str, title: str = "Confirm") -> None:
def __init__(
self, message: str, title: str = "Confirm", danger: bool = False
) -> None:
super().__init__()
self.message, self.title_text = message, title
self.message, self.title_text, self.danger = message, title, danger
def compose(self) -> ComposeResult:
with self.box(self.title_text):
with self.box(self.title_text) as box:
box.set_class(self.danger, "danger")
yield Static(self.message, markup=False)
with Horizontal(classes="buttons"):
yield Button("Yes", id="yes", variant="error")
@@ -706,7 +710,7 @@ class UpdatesScreen(ModalScreen):
class RestoreScreen(DialogScreen):
"""Restore one backup; dismisses with {overwrite, vmid, storage, start}, or None."""
"""Restore one backup; dismisses with {overwrite, vmid, storage, start, rootfs_gb}, or None."""
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
@@ -735,6 +739,13 @@ class RestoreScreen(DialogScreen):
id="storage",
)
yield Checkbox("Start after restore", id="start")
if "vzdump-lxc-" in self.backup["volid"]:
with Horizontal(classes="row"):
yield Label("Root disk GiB")
yield Input(
placeholder="empty = as in the backup; smaller = DANGEROUS",
id="rootfs",
)
with Horizontal(classes="row"):
yield Label("Confirm")
yield Input(placeholder="type the VMID to overwrite", id="confirm")
@@ -752,6 +763,21 @@ class RestoreScreen(DialogScreen):
self.dismiss(None)
return
overwrite = self.query_one("#overwrite", RadioButton).value
rootfs_gb = None
for field in self.query("#rootfs"):
text = field.value.strip()
if text and (not text.isdecimal() or int(text) <= 0):
self.app.notify(
"The root disk size must be a positive whole number of GiB",
severity="error",
)
return
rootfs_gb = int(text) if text else None
if rootfs_gb and overwrite: # force=1 destroys the old disks before extracting
self.app.notify(
"A smaller root disk is only possible as a new guest", severity="error"
)
return
if overwrite:
vmid = self.guest["vmid"]
if self.query_one("#confirm", Input).value != str(vmid):
@@ -778,6 +804,7 @@ class RestoreScreen(DialogScreen):
"vmid": vmid,
"storage": self.query_one("#storage", Select).value,
"start": self.query_one("#start", Checkbox).value,
"rootfs_gb": rootfs_gb,
}
)
@@ -1198,8 +1225,9 @@ class InfoScreen(ModalScreen):
if values["overwrite"] and cli.status(guest) != "stopped":
raise cli.CliError(f"{who} must be stopped to be overwritten")
cli.restore(
guest, volid, vmid, values["storage"], values["overwrite"], start
)
guest, volid, vmid, values["storage"], values["overwrite"], start,
rootfs_gb=values["rootfs_gb"],
) # fmt: skip
def confirmed(ok: bool) -> None:
if ok:
@@ -1215,12 +1243,61 @@ class InfoScreen(ModalScreen):
),
confirmed,
)
elif values["rootfs_gb"]:
self._confirm_shrink(what, text, values, confirmed)
else:
self.app.push_screen(
ConfirmScreen(f"Restore {what} as new guest {vmid}? {text}", "Restore"),
confirmed,
)
@work(thread=True)
def _confirm_shrink(self, what: str, text: str, values: dict, confirmed) -> None:
size = values["rootfs_gb"]
try: # live: the used space only exists while the guest runs
now = cli.current(self.guest)
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
return
used = int(now.get("disk") or 0) if now["status"] == "running" else 0
if used and size * 2**30 <= used:
self.app.call_from_thread(
self.app.notify,
f"{size} GiB is not above the {used / 2**30:.1f} GiB the running guest "
"already uses on its root disk",
severity="error",
)
return
mounts = ", ".join(
f"{k} ({m[1]})"
for k, v in sorted(self.config.items())
if re.fullmatch(r"mp\d+", k) and (m := re.search(r"\bmp=([^,]+)", str(v)))
)
message = (
f"DANGER: restore {what} as new guest {values['vmid']} with a root disk of "
f"only {size} GiB.\n\n"
"- Every mount point (mpN) of the backup is dropped from the new guest. "
"Files of mount points with backup=1 land inside the root disk and count "
"against its size; mount points with backup=0 are not in the backup at all.\n"
f"- The guest has these mount points now: {mounts or 'none'}. The backup "
"may differ.\n"
"- Raw lxc.* lines, including lxc.mount.entry bind mounts, may come back "
"when cv4pve-cli runs as root@pam: never run both guests at once.\n"
"- Root disk options (acl, quota, mountoptions, replicate) are not carried "
"over.\n"
f"- Used on the root disk now: "
f"{f'{used / 2**30:.1f} GiB' if used else 'unknown'}. This is not a fit "
"check: the backup may be older, mount point data and ext4 overhead (5 % "
"reserve, inodes) come on top.\n"
"- If the disk is too small the restore task fails. The original is "
f"untouched: this is always a new guest.\n\n{text}"
)
self.app.call_from_thread(
self.app.push_screen,
ConfirmScreen(message, "DANGER: smaller root disk", danger=True),
confirmed,
)
@work(thread=True)
def _confirm_overwrite(self, screen: ConfirmScreen, confirmed) -> None:
try: # live status: the guest table's status may be stale
+12 -1
View File
@@ -215,8 +215,12 @@ def restore(
storage: str,
force: bool = False,
start: bool = False,
rootfs_gb: int | None = None,
) -> None:
"""Restore `volid` as `vmid`; `force` (overwrite) only for the guest's own VMID."""
"""Restore `volid` as `vmid`; `force` (overwrite) only for the guest's own VMID.
`rootfs_gb` (container, new guest only) restores onto a root disk of that size.
"""
_positive(vmid, "vmid")
if force and vmid != guest["vmid"]:
raise ValueError(
@@ -230,11 +234,18 @@ def restore(
)
if not re.fullmatch(_STORAGE, storage):
raise ValueError(f"invalid storage name: {storage!r}")
if rootfs_gb is not None:
# 0 would make PVE allocate an unlimited subvol on ZFS
_positive(rootfs_gb, "root disk size")
if force or backup_type != "lxc":
raise ValueError("a smaller root disk needs a container and a new guest")
opts = ["--storage", storage]
if force:
opts += ["--force", "1"]
if start:
opts += ["--start", "1"]
if rootfs_gb is not None:
opts += ["--rootfs", f"{storage}:{rootfs_gb}"]
run(
"do", "restore", kind(guest), guest["node"], str(vmid), volid, *opts,
"-y", "--wait", timeout=3600,
+189 -1
View File
@@ -105,6 +105,7 @@ class Fake:
self.nextid, self.nextid_error = 105, None
self.restore_store = ["local-lvm", "zfs-pool"]
self.restore_calls, self.restore_error, self.restore_hook = [], None, None
self.restore_sizes = [] # rootfs_gb of every restore call
def list_guests(self):
self.list_calls += 1
@@ -298,8 +299,11 @@ class Fake:
def restore_storages(self, guest):
return list(self.restore_store)
def restore(self, guest, volid, vmid, storage, force=False, start=False):
def restore(
self, guest, volid, vmid, storage, force=False, start=False, rootfs_gb=None
):
self.restore_calls.append((guest["vmid"], volid, vmid, storage, force, start))
self.restore_sizes.append(rootfs_gb)
if self.restore_hook:
self.restore_hook()
if self.restore_error:
@@ -4921,3 +4925,187 @@ async def test_keybar_follows_the_info_screen_bindings(fake):
await open_info(app, pilot)
_bar, text = keybar_text(app)
assert "Restore backup" in text
# --- shrink restore: smaller rootfs (docs/plans/lxc-shrink.md, A) ---------------------------
CT_VOLID_SHRINK = "truenas-backup:backup/vzdump-lxc-100-new.tar.zst"
CT_MPS = {
"rootfs": "zfs-pool:subvol-100-disk-0,size=8G",
"mp0": "local-lvm:vm-100-disk-1,mp=/data,size=4G",
"mp1": "/srv/share,mp=/share",
}
def ct_shrink_setup(fake, status="stopped", disk=0, mps=True):
fake.guests = [VM, dict(CT, status=status, disk=disk)]
fake.net_configs[100] = dict(CT_MPS) if mps else {"rootfs": CT_MPS["rootfs"]}
fake.disk_map = {"rootfs": "8G"}
fake.backup_list = [
{**BACKUPS[0], "volid": CT_VOLID_SHRINK, "notes": "backrest (manual)"}
]
async def open_ct_restore(app, pilot):
await open_restore(app, pilot, down=1)
async def apply_shrink(app, pilot, size):
set_value(app, "#rootfs", size)
await press_button(app, pilot, "#apply")
async def test_rootfs_field_exists_for_lxc_backup_and_is_empty(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
assert app.screen.query_one("#rootfs", Input).value == ""
async def test_rootfs_field_is_absent_for_vm_backup(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_restore(app, pilot)
assert not app.screen.query("#rootfs")
async def test_lxc_restore_with_empty_size_is_unchanged(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_new(app, pilot)
assert app.screen.title_text == "Restore"
await press_button(app, pilot, "#yes")
assert fake.restore_sizes == [None]
async def test_shrink_confirm_is_a_danger_screen_naming_mount_points(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "4")
assert isinstance(app.screen, ConfirmScreen)
assert app.screen.title_text.startswith("DANGER")
assert app.screen.query_one("#dialog").has_class("danger")
text = text_of(app.screen)
assert text.startswith("DANGER")
for needle in ("mp0", "/data", "mp1", "/share", "may differ", "backup=0",
"lxc.mount.entry", "root@pam", "acl", "unknown", "4 GiB",
"105", "zfs-pool"): # fmt: skip
assert needle in text, needle
assert fake.restore_calls == []
async def test_shrink_confirm_without_mount_points_says_none(fake):
ct_shrink_setup(fake, mps=False)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "4")
assert "mp0" not in text_of(app.screen)
assert "none" in text_of(app.screen).lower()
async def test_shrink_confirmed_restores_new_guest_with_rootfs_gb(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, " 4 ")
await press_button(app, pilot, "#yes")
assert fake.restore_calls == [
(100, CT_VOLID_SHRINK, 105, "zfs-pool", False, False)
]
assert fake.restore_sizes == [4]
assert app.busy == set() and errors(app) == []
@pytest.mark.parametrize("how", ["no", "escape"])
async def test_shrink_confirm_declined_makes_no_call(fake, how):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "4")
if how == "no":
await press_button(app, pilot, "#no")
else:
await pilot.press("escape")
await settle(app, pilot)
assert fake.restore_calls == [] and app.busy == set()
@pytest.mark.parametrize("bad", ["0", "-3", "abc", "1.5", "4G", "1e1"])
async def test_shrink_size_must_be_a_positive_integer(fake, bad):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, bad)
assert errors(app)
assert fake.restore_calls == []
assert not isinstance(app.screen, ConfirmScreen)
async def test_shrink_with_overwrite_is_refused(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
set_value(app, "#rootfs", "4")
await apply_overwrite(app, pilot, confirm="100")
assert errors(app)
assert fake.restore_calls == []
assert not isinstance(app.screen, ConfirmScreen)
@pytest.mark.parametrize("size", ["1", "3"])
async def test_shrink_size_at_or_below_used_of_running_guest_is_refused(fake, size):
ct_shrink_setup(fake, status="running", disk=3 * 2**30)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, size)
assert errors(app)
assert fake.restore_calls == []
assert not isinstance(app.screen, ConfirmScreen)
async def test_shrink_size_above_used_of_running_guest_shows_used_space(fake):
ct_shrink_setup(fake, status="running", disk=3 * 2**30)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "4")
assert isinstance(app.screen, ConfirmScreen)
text = text_of(app.screen)
assert "3.0 GiB" in text and "unknown" not in text
assert "not a fit check" in text
@pytest.mark.parametrize(("status", "disk"), [("running", 0), ("stopped", 3 * 2**30)])
async def test_shrink_is_not_refused_when_used_space_is_unknown_or_guest_stopped(
fake, status, disk
):
ct_shrink_setup(fake, status=status, disk=disk)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "1")
assert isinstance(app.screen, ConfirmScreen)
assert errors(app) == []
async def test_shrink_status_failure_is_notified_without_confirm(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
fake.current_error = cli_error("Error: boom")
await apply_shrink(app, pilot, "4")
assert errors(app)
assert fake.restore_calls == []
assert not isinstance(app.screen, ConfirmScreen)
+52
View File
@@ -1767,3 +1767,55 @@ def test_restore_refuses_a_backup_of_the_other_guest_type(fake, force):
ct, "local:backup/vzdump-qemu-100-x.vma.zst", 100, "local-lvm", force=force
)
assert fake.calls == []
# --- shrink restore: smaller rootfs (docs/plans/lxc-shrink.md, A) ------------
def test_restore_ct_with_rootfs_gb_adds_rootfs_on_the_chosen_storage(fake):
cli.restore(CT, CT_VOLID, 105, "zfs", rootfs_gb=4)
assert fake.argv == [
"cv4pve-cli", "do", "restore", "ct", "mainframe", "105", CT_VOLID,
"--storage", "zfs", "--rootfs", "zfs:4", "-y", "--wait",
] # fmt: skip
def test_restore_ct_with_rootfs_gb_and_start_exact_argv(fake):
cli.restore(CT, CT_VOLID, 105, "zfs", start=True, rootfs_gb=4)
assert fake.argv == [
"cv4pve-cli", "do", "restore", "ct", "mainframe", "105", CT_VOLID,
"--storage", "zfs", "--start", "1", "--rootfs", "zfs:4", "-y", "--wait",
] # fmt: skip
def test_restore_without_rootfs_gb_argv_is_unchanged(fake):
cli.restore(CT, CT_VOLID, 105, "zfs", rootfs_gb=None)
assert "--rootfs" not in fake.argv
assert fake.argv[-4:] == ["--storage", "zfs", "-y", "--wait"]
def test_restore_with_rootfs_gb_never_adds_unique_or_ignore_unpack_errors(fake):
cli.restore(CT, CT_VOLID, 105, "zfs", rootfs_gb=4)
joined = " ".join(fake.argv)
assert "--unique" not in joined and "ignore-unpack-errors" not in joined
@pytest.mark.parametrize("bad", [0, -1, True, "4", 1.5])
def test_restore_rootfs_gb_must_be_a_positive_int_and_never_runs(fake, bad):
# a size of 0 makes PVE allocate an unlimited subvol on ZFS/dir
with pytest.raises(ValueError):
cli.restore(CT, CT_VOLID, 105, "zfs", rootfs_gb=bad)
assert fake.calls == []
def test_restore_rootfs_gb_with_force_raises_and_never_runs(fake):
# force=1 destroys the old volumes first; a failed extraction would lose the guest
with pytest.raises(ValueError):
cli.restore(CT, CT_VOLID, 100, "zfs", force=True, rootfs_gb=4)
assert fake.calls == []
def test_restore_rootfs_gb_on_a_vm_backup_raises_and_never_runs(fake):
with pytest.raises(ValueError):
cli.restore(VM, VM_VOLID, 109, "local-lvm", rootfs_gb=4)
assert fake.calls == []
+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()) == []