Compare commits
3
Commits
9b472e8cf6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
890b6b115b | ||
|
|
792667897a | ||
|
|
82902edfc8 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Executable
+186
@@ -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 --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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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 == []
|
||||
@@ -0,0 +1,288 @@
|
||||
"""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()) == []
|
||||
Reference in new issue
Block a user