Compare commits
19
Commits
62490af913
...
main
No files matched your search
@@ -0,0 +1,50 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Tests and lint for every PR into main, and a status for each commit on main.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# Reads the repository only.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# A newer push to the same ref makes an in-flight run obsolete.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: pytest and ruff
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
|
||||||
|
# Same uv as local development; the official script keeps the job free of
|
||||||
|
# third-party actions beyond checkout.
|
||||||
|
- name: Install uv
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/0.13.6/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
# --locked: fail if uv.lock does not match pyproject.toml. Python 3.12 is the
|
||||||
|
# lowest version requires-python allows.
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked --python 3.12
|
||||||
|
|
||||||
|
# The tests mock cv4pve-cli: no binary and no cluster access needed.
|
||||||
|
- name: Tests
|
||||||
|
run: uv run pytest -q
|
||||||
|
|
||||||
|
# ruff is pinned in the dev group, so CI checks the same rules as local runs
|
||||||
|
# (no config file: ruff's own defaults for that version).
|
||||||
|
- name: Lint
|
||||||
|
run: uv run ruff check src tests
|
||||||
|
|
||||||
|
- name: Format
|
||||||
|
run: uv run ruff format --check src tests
|
||||||
@@ -1,14 +1,35 @@
|
|||||||
# cv4pve-cli-tui
|
# cv4pve-cli-tui
|
||||||
|
|
||||||
|
[](https://git.duckboot.net/Duckboot/cv4pve-cli-tui/actions)
|
||||||
|
|
||||||
Textual TUI for [cv4pve-cli](https://github.com/Corsinvest/cv4pve-cli): list Proxmox VE
|
Textual TUI for [cv4pve-cli](https://github.com/Corsinvest/cv4pve-cli): list Proxmox VE
|
||||||
guests (VMs and LXC), start/shutdown/stop/reboot them, and change cores, memory and
|
guests (VMs and LXC), start/shutdown/stop/reboot them, and change cores, memory and
|
||||||
disk size.
|
disk size. The table shows CPU % and Mem % of running guests and the info screen shows
|
||||||
|
in-use values (refresh with `g`).
|
||||||
|
|
||||||
It shells out to `cv4pve-cli` and uses its current context (`cv4pve-cli config use <name>`);
|
It shells out to `cv4pve-cli` and uses its current context (`cv4pve-cli config use <name>`);
|
||||||
it stores no credentials itself. Tested with cv4pve-cli 2.4.0.
|
it stores no credentials itself. Tested with cv4pve-cli 2.4.0; with another version the app
|
||||||
|
warns at startup, because it parses output formats that are not a stable API.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Needs [uv](https://docs.astral.sh/uv/) and a working `cv4pve-cli` on `PATH`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv tool install git+https://git.duckboot.net/Duckboot/cv4pve-cli-tui
|
||||||
|
cv4pve-tui # from any directory
|
||||||
|
uv tool upgrade cv4pve-tui # later: get the newest main
|
||||||
|
uv tool uninstall cv4pve-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
From a local checkout instead: `uv tool install .` (add `--force` to reinstall after
|
||||||
|
changes).
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|
||||||
|
Installed: `cv4pve-tui`. From a checkout without installing:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv run cv4pve-tui
|
uv run cv4pve-tui
|
||||||
```
|
```
|
||||||
@@ -19,11 +40,19 @@ uv run cv4pve-tui
|
|||||||
| `S` | shutdown (confirm) |
|
| `S` | shutdown (confirm) |
|
||||||
| `x` | stop - hard power-off (confirm) |
|
| `x` | stop - hard power-off (confirm) |
|
||||||
| `r` | reboot (confirm) |
|
| `r` | reboot (confirm) |
|
||||||
| `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
| `Enter` | info: resources (CPU, memory, swap, disks), backups, backup job, network (config and runtime IPs) |
|
||||||
| `i` | info: backups, backup job, network (config and runtime IPs); `b` there = backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
|
| `Enter` then `e` | in info: edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
||||||
| `i` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) |
|
| `Enter` then `b` | in info: backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
|
||||||
|
| `Enter` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) |
|
||||||
|
| `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); 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>`) |
|
| `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 |
|
||||||
| `g` | refresh |
|
| `g` | refresh |
|
||||||
|
| `/` | filter the guest table as you type: space-separated terms, all must match (case-insensitive); plain term = part of name, VMID or a tag; `is:running` / `is:stopped`; `type:lxc` (or `ct`) / `type:vm` (or `qemu`). `Enter` keeps the filter and returns to the table, `Escape` clears it. The frame's bottom border shows `filter: <text> · N of M` |
|
||||||
| `c` | switch cv4pve-cli context (cluster) |
|
| `c` | switch cv4pve-cli context (cluster) |
|
||||||
| `q` | quit |
|
| `q` | quit |
|
||||||
|
|
||||||
@@ -34,14 +63,53 @@ The shell key needs key-based root SSH to the PVE node (LXC only).
|
|||||||
Switching context runs `cv4pve-cli config use`, which changes cv4pve-cli's global current
|
Switching context runs `cv4pve-cli config use`, which changes cv4pve-cli's global current
|
||||||
context: other shells and scripts follow, and it stays after the app exits.
|
context: other shells and scripts follow, and it stays after the app exits.
|
||||||
|
|
||||||
|
A `!` in the first column marks a guest that no backup job covers (Proxmox's
|
||||||
|
`not-backed-up` list); the frame's bottom border shows how many.
|
||||||
|
|
||||||
Empty fields in the edit dialog are left unchanged. If a CPU/memory change cannot be
|
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.
|
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
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv sync
|
uv sync
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
uv run ruff check src tests && uv run ruff format --check src tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests mock `cv4pve-cli`; they never touch a cluster. Design: `docs/plans/guest-tui-mvp.md`.
|
Tests mock `cv4pve-cli`; they never touch a cluster. CI (`.gitea/workflows/ci.yaml`) runs
|
||||||
|
the same commands on every PR into `main`; ruff is pinned in the dev group so local runs
|
||||||
|
and CI check the same rules. Design: `docs/plans/guest-tui-mvp.md`.
|
||||||
@@ -446,3 +446,352 @@ API facts (Proxmox VE 9.2, `api usage ... config set -v`):
|
|||||||
172.16.41.200/23 gw 172.16.40.1->DHCP. Bridge change cannot be tested live: the
|
172.16.41.200/23 gw 172.16.40.1->DHCP. Bridge change cannot be tested live: the
|
||||||
node has a single bridge (`vmbr0`). Final net0 string identical to the baseline.
|
node has a single bridge (`vmbr0`). Final net0 string identical to the baseline.
|
||||||
- [ ] Push `feat/network-edit`, merge to `main` (ask first).
|
- [ ] Push `feat/network-edit`, merge to `main` (ask first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Snapshots in the info screen (issue #8)
|
||||||
|
|
||||||
|
## Decision (user-approved)
|
||||||
|
|
||||||
|
Info screen section "Snapshots"; keys `p` take, `R` roll back, `D` delete. LXC and VM.
|
||||||
|
|
||||||
|
CLI facts (cv4pve-cli 2.4.0, read-only probes):
|
||||||
|
|
||||||
|
- `get guest snapshots <node> <qemu|lxc> <vmid> -o json` -> list of dicts: `name`,
|
||||||
|
`description`, `snaptime` (epoch or null), `parent`, VM also `vmstate`. Always
|
||||||
|
contains a pseudo entry `name == "current"` ("You are here!") - not a snapshot.
|
||||||
|
- `create ct|vm snapshot <node> <vmid> <snapname> <descr>` (+ `--vmstate 1` for VM RAM),
|
||||||
|
no `--yes`; with `--wait`.
|
||||||
|
- `do rollback ct|vm <node> <vmid> <snapname>` needs `-y`; API param `start` (bool)
|
||||||
|
starts the guest after a successful rollback.
|
||||||
|
- `delete ct|vm snapshot <node> <vmid> <snapname>` needs `-y`.
|
||||||
|
DANGER: `delete ct|vm <node> <vmid> -y` (without `snapshot`) DESTROYS THE WHOLE GUEST.
|
||||||
|
|
||||||
|
### `cli.py`
|
||||||
|
|
||||||
|
- `valid_snapname(name) -> str` - ValueError unless it matches
|
||||||
|
`[A-Za-z][A-Za-z0-9_-]{0,39}` and is not `current` (case-insensitive).
|
||||||
|
- `snapshots(guest) -> list[dict]` - without `current`, newest first (`snaptime`
|
||||||
|
desc, null last).
|
||||||
|
- `take_snapshot(guest, name, description="", vmstate=False)` ->
|
||||||
|
`create <kind> snapshot <node> <vmid> <name> <description or "cv4pve-tui">
|
||||||
|
[--vmstate 1 (VM only)] --wait`; name validated first.
|
||||||
|
- `rollback_snapshot(guest, name, start=False)` ->
|
||||||
|
`do rollback <kind> <node> <vmid> <name> [--start 1] -y --wait`; name validated.
|
||||||
|
- `delete_snapshot(guest, name)` ->
|
||||||
|
`delete <kind> snapshot <node> <vmid> <name> -y --wait`; name validated first, and
|
||||||
|
the argv is asserted to contain `snapshot` right after the kind and the name right
|
||||||
|
after the vmid (defence against ever sending the guest-destroying form).
|
||||||
|
|
||||||
|
### `app.py` (UI contract)
|
||||||
|
|
||||||
|
- InfoScreen gets `Vertical#snapshots_section` (border_title "Snapshots") with
|
||||||
|
`DataTable#snapshots` (Name, Date `YYYY-MM-DD HH:MM` or `-`, Description, and for VMs
|
||||||
|
RAM `yes`/`no`) and `Static#snap_note`. Loaded with the rest; a failure of
|
||||||
|
`cli.snapshots` is shown in `#snap_note` ("unavailable: <summary>"), the rest of the
|
||||||
|
screen still works. Empty list -> `#snap_note` "No snapshots".
|
||||||
|
- `p`: `SnapshotScreen` dialog (DialogScreen style, title "Take snapshot: <name>
|
||||||
|
(<vmid>)"): `Input#snapname` prefilled `tui_YYYYMMDD_HHMM` (local time),
|
||||||
|
`Input#snapdesc`, `Checkbox#vmstate` (VMs only; not shown for CTs), buttons
|
||||||
|
`#yes`/`#no`, escape = no. Invalid name -> error notify, dialog stays, no call.
|
||||||
|
Yes -> busy guard, worker `cli.take_snapshot`, done/error notify, info reloads.
|
||||||
|
- `R`: roll back the snapshot under the cursor of `#snapshots`. `ConfirmScreen`
|
||||||
|
titled "Roll back" naming snapshot and date and saying everything changed since then
|
||||||
|
is lost; if the guest is running (live status) it says it will be started again and
|
||||||
|
`start=True` is passed. Yes -> busy guard, worker, notify, info reloads.
|
||||||
|
- `D`: delete the snapshot under the cursor. `ConfirmScreen` titled "Delete snapshot"
|
||||||
|
naming it. Yes -> busy guard, worker `cli.delete_snapshot`, notify, info reloads.
|
||||||
|
- `R`/`D` with no snapshot -> info notify, no dialog.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [x] Tests - red first. - [x] Implementation - suite green, ruff clean, README keys.
|
||||||
|
- Review fixes: rollback reads the live status right before rolling back (if it cannot
|
||||||
|
be read, no rollback); the info screen scrolls; a description starting with `-` is
|
||||||
|
refused (positional argument).
|
||||||
|
- [x] Live on CT 104: take `tui_live_test`, listed; roll back (CT running -> started
|
||||||
|
again); delete; list back to the baseline (no snapshots); CT running.
|
||||||
|
- [ ] PR with `Closes #8`, merge after user OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Live usage: CPU %, memory used, disk used (issue #12)
|
||||||
|
|
||||||
|
## Decision (user-approved)
|
||||||
|
|
||||||
|
Usage in the main table and in the info screen's Resources section. Values refresh with
|
||||||
|
the listing (`g`, after actions); no auto-refresh and no rrddata trend (out of scope).
|
||||||
|
|
||||||
|
CLI facts (read-only probes):
|
||||||
|
|
||||||
|
- `get guests -o json` per guest: `cpu` (fraction of the guest's CPUs, 0..1; the web UI
|
||||||
|
shows `cpu*100` %), `mem`, `maxmem` (bytes), `disk`, `maxdisk` (bytes). Stopped guests
|
||||||
|
have 0s. VMs report `disk: 0` (no guest-agent disk info) - shown as `-`, not 0.
|
||||||
|
- `api get /nodes/<node>/<qemu|lxc>/<vmid>/status/current -o json` -> key/value list:
|
||||||
|
`status`, `cpu`, `cpus`, `mem`, `maxmem`, `disk`, `maxdisk`, `uptime`; CTs also
|
||||||
|
`swap`, `maxswap`; VMs also `freemem`, `balloon`.
|
||||||
|
|
||||||
|
### `cli.py`
|
||||||
|
|
||||||
|
- `current(guest) -> dict` - the status/current key/value list as a dict.
|
||||||
|
- `status(guest)` keeps its behaviour (returns `current(guest)["status"]`), argv
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
### `app.py`
|
||||||
|
|
||||||
|
- Main table: two new columns at the end, "CPU %" and "Mem %": `cpu*100` and
|
||||||
|
`mem/maxmem*100`, one decimal; `-` for a guest that is not running or lacks the field.
|
||||||
|
- Info screen Resources table gets a header and a third column: Resource | Configured |
|
||||||
|
In use. The load uses `cli.current(guest)` (one call) for both the live status and the
|
||||||
|
usage. In use, only when running:
|
||||||
|
- CPU: `<cpu*100:.1f> %`
|
||||||
|
- Memory: `<mem MiB> MiB (<pct:.0f> %)`
|
||||||
|
- Swap (CT): `<swap MiB> MiB`
|
||||||
|
- Disk rootfs (CT): `<disk GiB:.1f> GiB (<pct:.0f> %)`; other disks and all VM disks `-`
|
||||||
|
- otherwise `-`.
|
||||||
|
A failure of `cli.current` keeps today's behaviour (inline "unavailable", status
|
||||||
|
unknown -> not running).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task log (issue #10)
|
||||||
|
|
||||||
|
## Decision (user-approved)
|
||||||
|
|
||||||
|
`l` on the main screen: all recent cluster tasks. `l` on the info screen: the tasks of
|
||||||
|
that guest. Enter on a task shows its log.
|
||||||
|
|
||||||
|
CLI facts (read-only probes):
|
||||||
|
|
||||||
|
- `task list -o json` lacks start/end times. `api get /cluster/tasks -o json` has them:
|
||||||
|
list of dicts `upid`, `type` (e.g. `vzdump`, `qmstart`, `vzsnapshot`), `id` (VMID as a
|
||||||
|
string, `""` for node tasks), `node`, `user`, `starttime`, `endtime` (epoch; missing
|
||||||
|
or null while running), `status` (`OK`, an error text, or missing while running),
|
||||||
|
`saved`, `tokenid`.
|
||||||
|
- `task log <upid>` prints the log as plain text lines (e.g. `TASK OK` at the end).
|
||||||
|
|
||||||
|
### `cli.py`
|
||||||
|
|
||||||
|
- `tasks() -> list[dict]` - `/cluster/tasks`, newest first by `starttime`.
|
||||||
|
- `task_log(upid) -> str` - `task log <upid>`; ValueError unless `upid` starts with
|
||||||
|
`UPID:` (positional argument).
|
||||||
|
|
||||||
|
### `app.py` (UI contract)
|
||||||
|
|
||||||
|
- `TasksScreen(ModalScreen)`, opened by `l` on the main screen (all tasks) and on the
|
||||||
|
info screen (`vmid` filter: only tasks whose `id == str(vmid)`). Frame `#tasks_section`
|
||||||
|
titled "Tasks" or "Tasks: <name> (<vmid>)". `DataTable#tasks` (row cursor): Start
|
||||||
|
(`YYYY-MM-DD HH:MM` local), Duration (`1h 2m`, `3m 12s`, `45s`; `running` without
|
||||||
|
endtime), Node, Type, VMID (`-` for node tasks), User, Status (`OK`, the error text, or
|
||||||
|
`running`). A failed task (status present and not `OK`) gets a `!` in a leading
|
||||||
|
column. `Static#tasks_note` says "No tasks" when empty. Loaded in a thread worker;
|
||||||
|
failure -> error notify, screen stays. Keys: `enter` open the log, `g` reload,
|
||||||
|
`escape` close.
|
||||||
|
- `TaskLogScreen` (DialogScreen style, wide box), title "Log: <type> <vmid or node>",
|
||||||
|
a read-only plain-text log widget `#tasklog` (no markup parsing), loaded in a worker;
|
||||||
|
failure -> error notify. `escape` closes.
|
||||||
|
- Main-screen footer shows `l Tasks`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Node package updates (issue #13)
|
||||||
|
|
||||||
|
## Decision (user-approved)
|
||||||
|
|
||||||
|
`U` on the main screen shows pending package updates per node; the frame's bottom
|
||||||
|
border shows the count. Read-only: running `apt update`/`upgrade` stays out of scope.
|
||||||
|
|
||||||
|
CLI facts (read-only probes):
|
||||||
|
|
||||||
|
- `get nodes -o json` -> list of dicts with `node`, `status` (`online`/`offline`), ...
|
||||||
|
- `get node packages <node> -o json` -> list of dicts with `Package`, `OldVersion`,
|
||||||
|
`Version`, `Title`, `Priority`, `Origin`, ... (23 on mainframe). The list is as fresh
|
||||||
|
as the node's last `apt update` (Proxmox runs it daily); the call does not refresh it.
|
||||||
|
|
||||||
|
### `cli.py`
|
||||||
|
|
||||||
|
- `nodes() -> list[str]` - names of online nodes, sorted.
|
||||||
|
- `package_updates(node) -> list[dict]` - `get node packages <node> -o json`, sorted by
|
||||||
|
`Package`.
|
||||||
|
|
||||||
|
### `app.py` (UI contract)
|
||||||
|
|
||||||
|
- `UpdatesScreen(ModalScreen)` opened by `U`: frame `#updates_section` titled "Package
|
||||||
|
updates", `DataTable#updates`: Node, Package, Current (`OldVersion`), New
|
||||||
|
(`Version`), Origin, Priority; `Static#updates_note`: "No pending updates" when
|
||||||
|
empty, plus one line per node that failed ("<node>: unavailable: <summary>") and a
|
||||||
|
fixed hint that the list is as of the node's last apt update. Loaded in a thread
|
||||||
|
worker; `g` reloads; `escape` closes. A failing node does not hide the others.
|
||||||
|
- The main frame's bottom border adds "· N package updates" when N > 0, after the
|
||||||
|
backup count. The count is fetched in a worker on mount, after a context switch, and
|
||||||
|
every time the UpdatesScreen loads - not on every guest reload (one CLI call per node).
|
||||||
|
It follows the same generation guard as the guest list (a count from the old cluster
|
||||||
|
is dropped after a switch) and is cleared when a switch starts. Failures leave the
|
||||||
|
count unknown (nothing shown), no error notify on mount.
|
||||||
|
- Main footer shows `U Updates`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Search and filter in the guest table (issue #16)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- `/` on the main screen shows `Input#filter` (hidden until then) below the guest table
|
||||||
|
and focuses it. The table filters as you type.
|
||||||
|
- Filter text = space-separated terms, all must match (AND), case-insensitive:
|
||||||
|
- a plain term matches a substring of the name, the VMID or one of the tags
|
||||||
|
(`tags` from `get guests`, `;`-separated);
|
||||||
|
- `is:running` / `is:stopped` match the status;
|
||||||
|
- `type:lxc` / `type:ct` and `type:vm` / `type:qemu` match the guest type.
|
||||||
|
- Enter in the filter keeps the filter and moves focus to the table. Escape in the
|
||||||
|
filter clears it, hides the input and moves focus to the table.
|
||||||
|
- The frame's bottom border gets "· filter: <text> · N of M" while a filter is active,
|
||||||
|
after the context name and before the backup/package counts.
|
||||||
|
- Actions (s/S/x/r/t/Enter) act on the selected *visible* row only; the table only ever
|
||||||
|
contains the matching guests. A reload (g, after actions, context switch) keeps the
|
||||||
|
filter applied. A context switch keeps the filter text.
|
||||||
|
- Placeholder of the input shows the syntax: `name, vmid, tag · is:running · type:lxc`.
|
||||||
|
- No match: empty table, and the frame says "0 of M"; action keys do nothing.
|
||||||
|
|
||||||
|
### Code
|
||||||
|
|
||||||
|
- Pure function `guest_matches(guest: dict, query: str) -> bool` in `app.py` (or
|
||||||
|
`cli.py`-free module-level helper) so it can be tested without the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Autostart, startup order and tags (issue #18)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Shown on the info screen; edited with key `o` (options) there. LXC and VM.
|
||||||
|
|
||||||
|
CLI facts (read-only probes, PVE 9.2):
|
||||||
|
|
||||||
|
- Config keys: `onboot` (0/1, absent = 0), `startup` (`[[order=]N][,up=S][,down=S]`,
|
||||||
|
absent = none), `tags` (`;`-separated, e.g. `172.16.41.200;community-script;os`).
|
||||||
|
- Write with `set <kind> config <node> <vmid> --onboot 0|1 --startup <s> --tags <t>
|
||||||
|
[--delete startup,tags] --wait` - i.e. the existing `cli.set_net(guest, changes)` (one
|
||||||
|
call; `None` values go to `--delete`).
|
||||||
|
- DANGER: `delete ct|vm ... tags` exists but shares the guest-destroying `delete ct|vm
|
||||||
|
<node> <vmid>` form. Never use it; removing tags = `--delete tags` on `set config`.
|
||||||
|
|
||||||
|
### `cli.py`
|
||||||
|
|
||||||
|
- `startup_value(order, up, down) -> str | None` - ints >= 0 or None; builds
|
||||||
|
`order=N[,up=S][,down=S]` (only the given parts); all None -> None. ValueError for
|
||||||
|
negatives/non-ints. `parse_startup(s) -> (order, up, down)` (accepts a bare leading
|
||||||
|
number as order).
|
||||||
|
- `tags_value(text) -> str | None` - split on `;`, `,` or whitespace, drop empties and
|
||||||
|
duplicates (order kept); each tag must match `[A-Za-z0-9_][A-Za-z0-9_+.-]*` else
|
||||||
|
ValueError; joined with `;`; none -> None.
|
||||||
|
|
||||||
|
### `app.py` (UI contract)
|
||||||
|
|
||||||
|
- Info screen Resources table gets rows "Autostart" (`yes`/`no`), "Startup" (e.g.
|
||||||
|
`order 2, up 10 s, down 30 s` or `-`), "Tags" (`a, b, c` or `-`); In use `-`.
|
||||||
|
- `o`: `BootScreen` dialog (DialogScreen style, title "Boot and tags: <name> (<vmid>)"):
|
||||||
|
`Checkbox#onboot`, `Input#order`, `Input#up`, `Input#down` (empty = not set),
|
||||||
|
`Input#tags` (prefilled `a; b; c`), buttons `#apply`/`#cancel`, escape = cancel.
|
||||||
|
Apply: build changes for keys whose value differs (`onboot` "1"/"0", `startup` string
|
||||||
|
or None, `tags` string or None); invalid -> error notify, dialog stays, no call; no
|
||||||
|
change -> info notify; else `ConfirmScreen` titled "Boot and tags" listing old -> new
|
||||||
|
per key; yes -> busy guard, `cli.set_net(guest, changes)` via start_job, info reloads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# fstrim in a VM via the guest agent (issue #19)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Key `f` on the info screen, VMs only. Not run live (the issue asks for approval first);
|
||||||
|
tested with mocks.
|
||||||
|
|
||||||
|
CLI facts:
|
||||||
|
|
||||||
|
- `do vm agent ping <node> <vmid> -o json` -> `[{"key": "result", "value": "{}"}]` when the
|
||||||
|
agent answers (verified read-only on VM 108); non-zero exit when it does not.
|
||||||
|
- `do vm agent fstrim <node> <vmid> -o json` -> the same key/value shape; QEMU guest
|
||||||
|
agent's result is `{"paths": [{"path": "/", "trimmed": <bytes>, "minimum": 0}, ...]}`
|
||||||
|
(a path may carry `"error"` instead of `trimmed`). Not verified live.
|
||||||
|
- VM config `agent` = `enabled=1` (or `1`) when the agent is enabled.
|
||||||
|
|
||||||
|
### `cli.py`
|
||||||
|
|
||||||
|
- `agent_enabled(config) -> bool` - `agent` is `1` or starts with `1` / has `enabled=1`.
|
||||||
|
- `agent_ping(guest)` -> `do vm agent ping <node> <vmid> -o json`; CliError if the agent
|
||||||
|
does not answer.
|
||||||
|
- `fstrim(guest) -> list[dict]` -> `do vm agent fstrim <node> <vmid> -o json`; returns
|
||||||
|
the `paths` list parsed from the `result` value (`[]` if missing or unparseable).
|
||||||
|
|
||||||
|
### `app.py` (UI contract)
|
||||||
|
|
||||||
|
- `f` on the info screen:
|
||||||
|
- container -> info notify that containers have no guest agent (`pct fstrim` on the
|
||||||
|
node instead); nothing runs;
|
||||||
|
- VM not running, or `agent_enabled` false -> warning notify saying why; nothing runs;
|
||||||
|
- otherwise `ConfirmScreen` titled "fstrim" naming the VM and saying it frees unused
|
||||||
|
blocks on thin-provisioned storage and adds I/O load while it runs;
|
||||||
|
- yes -> busy guard, worker: `cli.agent_ping` then `cli.fstrim`; done notify with the
|
||||||
|
trimmed total in GiB/MiB and the number of paths (errors per path mentioned);
|
||||||
|
CliError -> error notify (ping failure says the agent did not answer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Restore a guest from a backup (issue #15)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
From the info screen's backup list, key `r` ("Restore backup"; `R` stays snapshot
|
||||||
|
rollback). Two targets: a new VMID (default, original untouched) or overwriting the
|
||||||
|
guest itself (destructive). Not run live: a live test would create a guest that then
|
||||||
|
needs deleting, which the issue reserves for the user's approval. Tested with mocks.
|
||||||
|
|
||||||
|
CLI facts (cv4pve-cli 2.4.0, read-only probes):
|
||||||
|
|
||||||
|
- `do restore ct|vm <node> <vmid> <volume> [--key value] -y --wait` - "creates/overwrites
|
||||||
|
the target vmid". API params: `storage` (target storage for the disks), `force`
|
||||||
|
(bool; required to overwrite an existing guest), `start` (bool), `unique` (VM: new
|
||||||
|
MACs).
|
||||||
|
- `get cluster nextid -o json` -> a JSON number, e.g. `105`.
|
||||||
|
- Backup entries carry `volid` (`truenas-backup:backup/vzdump-lxc-104-...tar.zst`),
|
||||||
|
`subtype` (`lxc`|`qemu`), `vmid`.
|
||||||
|
- Node storages: `content` lists `rootdir` (CT disks) and/or `images` (VM disks).
|
||||||
|
|
||||||
|
### `cli.py`
|
||||||
|
|
||||||
|
- `next_id() -> int`.
|
||||||
|
- `valid_volid(volid) -> str` - ValueError unless it matches
|
||||||
|
`^[A-Za-z0-9][A-Za-z0-9_.-]*:backup/vzdump-(lxc|qemu)-\d+-\S+$` (positional argument).
|
||||||
|
- `restore_storages(guest) -> list[str]` - enabled+active storages on the guest's node
|
||||||
|
whose `content` has `rootdir` (CT) or `images` (VM), API order.
|
||||||
|
- `restore(guest, volid, vmid, storage, force=False, start=False)` ->
|
||||||
|
`do restore <kind> <node> <vmid> <volid> --storage <storage> [--force 1] [--start 1]
|
||||||
|
-y --wait`; kind from the backup's guest kind (the guest dict's `type`); `vmid` a
|
||||||
|
positive int; volid validated; `force=True` only allowed when `vmid ==
|
||||||
|
guest["vmid"]` (ValueError otherwise - never overwrite some other guest).
|
||||||
|
|
||||||
|
### `app.py` (UI contract)
|
||||||
|
|
||||||
|
- `r` on the info screen: the backup under the cursor of `#backups` (the visible rows;
|
||||||
|
hidden earlier-guest backups are not restorable unless shown with `a`). No backup ->
|
||||||
|
info notify.
|
||||||
|
- `RestoreScreen` (DialogScreen, title "Restore <date> backup of <name> (<vmid>)"):
|
||||||
|
`RadioSet#target` with `RadioButton#new` (default, "New guest") and
|
||||||
|
`RadioButton#overwrite` ("Overwrite <name> (<vmid>)"); `Input#newid` prefilled with
|
||||||
|
`cli.next_id()` (used for "new"); `Select#storage` (`restore_storages`, preselected
|
||||||
|
to the storage of the guest's rootfs/first disk if listed); `Checkbox#start` (off);
|
||||||
|
`Input#confirm` (typed VMID, only needed for overwrite); `#apply`/`#cancel`; escape =
|
||||||
|
cancel. A note says a new copy keeps the original's network settings (same static
|
||||||
|
IP), so leave Start off until it is changed.
|
||||||
|
- Apply, new: `newid` must be a positive int not used by any guest in the current
|
||||||
|
listing (app.guests) -> else error notify, no call. Then `ConfirmScreen` titled
|
||||||
|
"Restore" summarising backup, target VMID, storage, start; yes -> busy guard on the
|
||||||
|
source guest, worker `cli.restore(guest, volid, newid, storage, force=False,
|
||||||
|
start=...)`, notify, main list reloads.
|
||||||
|
- Apply, overwrite: the guest must be stopped (live `cli.status` at apply time) -> else
|
||||||
|
error notify; `Input#confirm` must equal the VMID -> else error notify; then
|
||||||
|
`ConfirmScreen` titled "Overwrite" saying the guest's current disks and config are
|
||||||
|
replaced and cannot be undone; yes -> `cli.restore(guest, volid, guest vmid, storage,
|
||||||
|
force=True, start=...)`.
|
||||||
|
- All CliErrors -> error notify; busy released; markup-safe texts.
|
||||||
@@ -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.
|
||||||
@@ -14,6 +14,7 @@ dev = [
|
|||||||
"hypothesis>=6.170.14",
|
"hypothesis>=6.170.14",
|
||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
"pytest-asyncio>=1.0",
|
"pytest-asyncio>=1.0",
|
||||||
|
"ruff==0.17.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
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
|
||||||
+1294
-106
File diff suppressed because it is too large.
Load diff
+269
-5
@@ -5,6 +5,10 @@ import json
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
# Output formats this app parses (config list text, prune-backups as a JSON string, the CT
|
||||||
|
# ip-addresses JSON string, exit codes with --wait) were verified against this version.
|
||||||
|
TESTED_VERSION = "2.4.0"
|
||||||
|
|
||||||
|
|
||||||
class CliError(Exception):
|
class CliError(Exception):
|
||||||
"""cv4pve-cli failed; str() is its combined, stripped output."""
|
"""cv4pve-cli failed; str() is its combined, stripped output."""
|
||||||
@@ -24,7 +28,7 @@ class CliError(Exception):
|
|||||||
return str(self)
|
return str(self)
|
||||||
|
|
||||||
|
|
||||||
def run(*args: str) -> str:
|
def run(*args: str, timeout: int = 330) -> str:
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["cv4pve-cli", *args],
|
["cv4pve-cli", *args],
|
||||||
@@ -32,7 +36,7 @@ def run(*args: str) -> str:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
timeout=330,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
raise CliError(str(exc)) from exc
|
raise CliError(str(exc)) from exc
|
||||||
@@ -133,12 +137,17 @@ def pending_keys(guest: dict) -> list[str]:
|
|||||||
return [i["key"] for i in items if i.get("pending") is not None]
|
return [i["key"] for i in items if i.get("pending") is not None]
|
||||||
|
|
||||||
|
|
||||||
def status(guest: dict) -> str:
|
def current(guest: dict) -> dict:
|
||||||
"""Live status; get guests (/cluster/resources) lags a few seconds behind actions."""
|
"""status/current as a dict: live status plus cpu, mem, maxmem, disk, ..."""
|
||||||
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current"
|
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current"
|
||||||
return {
|
return {
|
||||||
i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json"))
|
i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json"))
|
||||||
}["status"]
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def status(guest: dict) -> str:
|
||||||
|
"""Live status; get guests (/cluster/resources) lags a few seconds behind actions."""
|
||||||
|
return current(guest)["status"]
|
||||||
|
|
||||||
|
|
||||||
_CONTEXT_LINE = re.compile(r"^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$")
|
_CONTEXT_LINE = re.compile(r"^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$")
|
||||||
@@ -171,6 +180,78 @@ def backup_storages(guest: dict) -> list[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def next_id() -> int:
|
||||||
|
return int(json.loads(run("get", "cluster", "nextid", "-o", "json")))
|
||||||
|
|
||||||
|
|
||||||
|
_STORAGE = r"[A-Za-z0-9][A-Za-z0-9_.-]*"
|
||||||
|
_VOLID = re.compile(rf"{_STORAGE}:backup/vzdump-(lxc|qemu)-[0-9]+-\S+")
|
||||||
|
|
||||||
|
|
||||||
|
def valid_volid(volid: str) -> str:
|
||||||
|
"""The volid is a positional argument: refuse anything that is not a vzdump volume."""
|
||||||
|
if not _VOLID.fullmatch(volid):
|
||||||
|
raise ValueError(f"not a backup volume id: {volid!r}")
|
||||||
|
return volid
|
||||||
|
|
||||||
|
|
||||||
|
def restore_storages(guest: dict) -> list[str]:
|
||||||
|
"""Enabled, active storages on the guest's node that hold its disks, in API order."""
|
||||||
|
content = "images" if guest["type"] == "qemu" else "rootdir"
|
||||||
|
items = json.loads(run("get", "node", "storages", guest["node"], "-o", "json"))
|
||||||
|
return [
|
||||||
|
s["storage"]
|
||||||
|
for s in items
|
||||||
|
if content in (s.get("content") or "").split(",")
|
||||||
|
and s.get("enabled")
|
||||||
|
and s.get("active")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def restore(
|
||||||
|
guest: dict,
|
||||||
|
volid: str,
|
||||||
|
vmid: int,
|
||||||
|
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.
|
||||||
|
|
||||||
|
`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(
|
||||||
|
"refusing to overwrite a guest other than the one restored from"
|
||||||
|
)
|
||||||
|
valid_volid(volid)
|
||||||
|
backup_type = re.search(r":backup/vzdump-(lxc|qemu)-", volid).group(1)
|
||||||
|
if backup_type != _api_type(guest): # a reused VMID can carry the other guest type
|
||||||
|
raise ValueError(
|
||||||
|
f"a {backup_type} backup cannot be restored as this {kind(guest)}"
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
) # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
def backup_jobs() -> list[dict]:
|
def backup_jobs() -> list[dict]:
|
||||||
jobs = json.loads(run("get", "cluster", "backup-jobs", "-o", "json"))
|
jobs = json.loads(run("get", "cluster", "backup-jobs", "-o", "json"))
|
||||||
for job in jobs: # the CLI returns prune-backups as a JSON string
|
for job in jobs: # the CLI returns prune-backups as a JSON string
|
||||||
@@ -227,6 +308,58 @@ def backup_now(guest: dict, storage: str) -> None:
|
|||||||
) # fmt: skip
|
) # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def agent_enabled(config: dict) -> bool:
|
||||||
|
"""VM option `agent`: `1`, `1,<opt>=...` or `enabled=1,...`."""
|
||||||
|
parts = str(config.get("agent") or "").split(",")
|
||||||
|
return parts[0] == "1" or "enabled=1" in parts
|
||||||
|
|
||||||
|
|
||||||
|
def agent_ping(guest: dict) -> None:
|
||||||
|
"""CliError if the guest agent does not answer."""
|
||||||
|
run("do", "vm", "agent", "ping", guest["node"], str(guest["vmid"]), "-o", "json")
|
||||||
|
|
||||||
|
|
||||||
|
def fstrim(guest: dict) -> list[dict]:
|
||||||
|
"""Trim the guest's filesystems; the per-path results, `[]` if they cannot be read.
|
||||||
|
|
||||||
|
Well-formed entries come back as the agent sent them. Odd ones are repaired, not
|
||||||
|
fatal - the trim has already happened when the output is parsed: non-dict entries
|
||||||
|
are dropped, a non-int `trimmed` becomes 0 and a missing `path` becomes "?".
|
||||||
|
"""
|
||||||
|
args = (
|
||||||
|
"do",
|
||||||
|
"vm",
|
||||||
|
"agent",
|
||||||
|
"fstrim",
|
||||||
|
guest["node"],
|
||||||
|
str(guest["vmid"]),
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
)
|
||||||
|
out = run(
|
||||||
|
*args, timeout=1800
|
||||||
|
) # a large disk can take longer than the default 330 s
|
||||||
|
try:
|
||||||
|
items = json.loads(out)
|
||||||
|
value = {x["key"]: x["value"] for x in items}.get("result") or "{}"
|
||||||
|
result = value if isinstance(value, dict) else json.loads(value)
|
||||||
|
paths = result.get("paths") if isinstance(result, dict) else None
|
||||||
|
except (ValueError, TypeError, KeyError, AttributeError):
|
||||||
|
return []
|
||||||
|
found = []
|
||||||
|
for p in paths if isinstance(paths, list) else []:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
p = dict(p)
|
||||||
|
if "trimmed" in p and (
|
||||||
|
not isinstance(p["trimmed"], int) or isinstance(p["trimmed"], bool)
|
||||||
|
):
|
||||||
|
p["trimmed"] = 0
|
||||||
|
p.setdefault("path", "?")
|
||||||
|
found.append(p)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def parse_net(value: str) -> dict:
|
def parse_net(value: str) -> dict:
|
||||||
"""`k=v,k=v` to an ordered dict; a VM's leading `<model>=<MAC>` is just the first pair."""
|
"""`k=v,k=v` to an ordered dict; a VM's leading `<model>=<MAC>` is just the first pair."""
|
||||||
return dict(item.partition("=")[::2] for item in value.split(",") if item)
|
return dict(item.partition("=")[::2] for item in value.split(",") if item)
|
||||||
@@ -379,3 +512,134 @@ def set_net(guest: dict, changes: dict[str, str | None]) -> None:
|
|||||||
run(
|
run(
|
||||||
"set", kind(guest), "config", guest["node"], str(guest["vmid"]), *opts, "--wait"
|
"set", kind(guest), "config", guest["node"], str(guest["vmid"]), *opts, "--wait"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def startup_value(order: int | None, up: int | None, down: int | None) -> str | None:
|
||||||
|
"""`order=N[,up=S][,down=S]` from the given parts; None when none is given."""
|
||||||
|
parts = []
|
||||||
|
for name, value in (("order", order), ("up", up), ("down", down)):
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if type(value) is not int or value < 0:
|
||||||
|
raise ValueError(f"startup {name} must be a whole number >= 0")
|
||||||
|
parts.append(f"{name}={value}")
|
||||||
|
return ",".join(parts) or None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_startup(text: str | None) -> tuple[int | None, int | None, int | None]:
|
||||||
|
"""(order, up, down) of a startup string; a bare leading number is the order."""
|
||||||
|
got: dict[str, int] = {}
|
||||||
|
for i, part in enumerate((text or "").split(",")):
|
||||||
|
key, sep, value = part.partition("=")
|
||||||
|
if not sep and i == 0 and key.strip().isdecimal():
|
||||||
|
key, value = "order", key
|
||||||
|
if key.strip() in ("order", "up", "down") and value.strip().isdecimal():
|
||||||
|
got[key.strip()] = int(value)
|
||||||
|
return got.get("order"), got.get("up"), got.get("down")
|
||||||
|
|
||||||
|
|
||||||
|
_TAG = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_+.-]*")
|
||||||
|
|
||||||
|
|
||||||
|
def tags_value(text: str) -> str | None:
|
||||||
|
"""Tags split on `;`, `,` or whitespace, deduplicated, `;`-joined; None when empty."""
|
||||||
|
tags = list(dict.fromkeys(re.split(r"[;,\s]+", text.strip())))
|
||||||
|
tags = [t for t in tags if t]
|
||||||
|
for tag in tags:
|
||||||
|
if not _TAG.fullmatch(tag):
|
||||||
|
raise ValueError(
|
||||||
|
f"invalid tag {tag!r}: letters, digits, _ + . - (not starting with + . -)"
|
||||||
|
)
|
||||||
|
return ";".join(tags) or None
|
||||||
|
|
||||||
|
|
||||||
|
def not_backed_up() -> set[int]:
|
||||||
|
"""VMIDs no backup job covers (Proxmox's own answer, incl. pool-based jobs)."""
|
||||||
|
items = json.loads(run("get", "cluster", "not-backed-up", "-o", "json"))
|
||||||
|
return {int(i["vmid"]) for i in items if i.get("vmid") is not None}
|
||||||
|
|
||||||
|
|
||||||
|
_SNAPNAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,39}")
|
||||||
|
|
||||||
|
|
||||||
|
def valid_snapname(name: str) -> str:
|
||||||
|
"""The name unchanged, or ValueError; `current` is Proxmox's pseudo entry."""
|
||||||
|
if not _SNAPNAME.fullmatch(name) or name.lower() == "current":
|
||||||
|
raise ValueError(
|
||||||
|
"snapshot name: a letter, then letters, digits, _ or - (max 40); not 'current'"
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def snapshots(guest: dict) -> list[dict]:
|
||||||
|
"""The guest's snapshots without the `current` pseudo entry, newest first."""
|
||||||
|
out = run(
|
||||||
|
"get", "guest", "snapshots", guest["node"], _api_type(guest),
|
||||||
|
str(guest["vmid"]), "-o", "json",
|
||||||
|
) # fmt: skip
|
||||||
|
found = [s for s in json.loads(out) if s["name"].lower() != "current"]
|
||||||
|
return sorted(found, key=lambda s: s.get("snaptime") or 0, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_description(text: str) -> str:
|
||||||
|
"""Passed as a positional argument: a leading '-' could be read as an option."""
|
||||||
|
if text.startswith("-"):
|
||||||
|
raise ValueError("description must not start with '-'")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def take_snapshot(
|
||||||
|
guest: dict, name: str, description: str = "", vmstate: bool = False
|
||||||
|
) -> None:
|
||||||
|
opts = ["--vmstate", "1"] if vmstate and kind(guest) == "vm" else []
|
||||||
|
run(
|
||||||
|
"create", kind(guest), "snapshot", guest["node"], str(guest["vmid"]),
|
||||||
|
valid_snapname(name), valid_description(description) or "cv4pve-tui", *opts, "--wait",
|
||||||
|
) # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_snapshot(guest: dict, name: str, start: bool = False) -> None:
|
||||||
|
opts = ["--start", "1"] if start else []
|
||||||
|
run(
|
||||||
|
"do", "rollback", kind(guest), guest["node"], str(guest["vmid"]),
|
||||||
|
valid_snapname(name), *opts, "-y", "--wait",
|
||||||
|
) # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def delete_snapshot(guest: dict, name: str) -> None:
|
||||||
|
"""DANGER: without the `snapshot` word `delete <kind> <node> <vmid> -y` destroys the guest."""
|
||||||
|
args = (
|
||||||
|
"delete", kind(guest), "snapshot", guest["node"], str(guest["vmid"]),
|
||||||
|
valid_snapname(name), "-y", "--wait",
|
||||||
|
) # fmt: skip
|
||||||
|
if args[2] != "snapshot" or args[4:6] != (str(guest["vmid"]), name):
|
||||||
|
raise RuntimeError("refusing to send a delete that is not a snapshot delete")
|
||||||
|
run(*args)
|
||||||
|
|
||||||
|
|
||||||
|
def tasks() -> list[dict]:
|
||||||
|
"""Recent cluster tasks, newest first; running ones lack endtime and status."""
|
||||||
|
items = json.loads(run("api", "get", "/cluster/tasks", "-o", "json"))
|
||||||
|
return sorted(items, key=lambda t: t.get("starttime") or 0, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def nodes() -> list[str]:
|
||||||
|
"""Names of the online nodes, sorted."""
|
||||||
|
items = json.loads(run("get", "nodes", "-o", "json"))
|
||||||
|
return sorted(n["node"] for n in items if n.get("status") == "online")
|
||||||
|
|
||||||
|
|
||||||
|
def package_updates(node: str) -> list[dict]:
|
||||||
|
"""Pending package updates of a node (as of its last `apt update`), by package."""
|
||||||
|
items = json.loads(run("get", "node", "packages", node, "-o", "json"))
|
||||||
|
return sorted(items, key=lambda p: p.get("Package", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def task_log(upid: str) -> str:
|
||||||
|
if not isinstance(upid, str) or not upid.startswith("UPID:"):
|
||||||
|
raise ValueError("not a task id (UPID)")
|
||||||
|
return run("task", "log", upid)
|
||||||
|
|
||||||
|
|
||||||
|
def version() -> str:
|
||||||
|
return run("--version").strip()
|
||||||
+3236
-10
File diff suppressed because it is too large.
Load diff
@@ -374,6 +374,53 @@ def test_status_reads_live_status_current(fake):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- current (issue #12; spec: "Live usage" > cli.py) ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_runs_status_current_for_lxc(fake):
|
||||||
|
fake.result = (json.dumps([{"key": "status", "value": "running"}]), "", 0)
|
||||||
|
cli.current(CT)
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "api", "get", "/nodes/mainframe/lxc/100/status/current", "-o", "json",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_runs_status_current_for_qemu(fake):
|
||||||
|
fake.result = (json.dumps([{"key": "status", "value": "running"}]), "", 0)
|
||||||
|
cli.current(VM)
|
||||||
|
assert fake.argv[3] == "/nodes/mainframe/qemu/108/status/current"
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_flattens_key_value_list_to_dict(fake):
|
||||||
|
fake.result = (
|
||||||
|
json.dumps([
|
||||||
|
{"key": "status", "value": "running"}, {"key": "cpu", "value": 0.0538},
|
||||||
|
{"key": "mem", "value": 932048896}, {"key": "maxmem", "value": 8589934592},
|
||||||
|
{"key": "swap", "value": 0},
|
||||||
|
]),
|
||||||
|
"", 0,
|
||||||
|
) # fmt: skip
|
||||||
|
assert cli.current(CT) == {
|
||||||
|
"status": "running", "cpu": 0.0538, "mem": 932048896,
|
||||||
|
"maxmem": 8589934592, "swap": 0,
|
||||||
|
} # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_cli_failure_raises_clierror(monkeypatch):
|
||||||
|
FakeRun(monkeypatch, "Error: nope", "", 5)
|
||||||
|
with pytest.raises(cli.CliError):
|
||||||
|
cli.current(CT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_still_returns_status_from_current_payload(fake):
|
||||||
|
fake.result = (
|
||||||
|
json.dumps([{"key": "cpu", "value": 0.5}, {"key": "status", "value": "running"}]),
|
||||||
|
"", 0,
|
||||||
|
) # fmt: skip
|
||||||
|
assert cli.status(VM) == "running"
|
||||||
|
assert fake.argv[3] == "/nodes/mainframe/qemu/108/status/current"
|
||||||
|
|
||||||
|
|
||||||
# --- contexts / use_context (spec: "Context switching" > cli.py) -------------
|
# --- contexts / use_context (spec: "Context switching" > cli.py) -------------
|
||||||
|
|
||||||
CONTEXT_LIST = (
|
CONTEXT_LIST = (
|
||||||
@@ -985,3 +1032,790 @@ def test_apply_ip_rejects_netmask_form_and_zero_prefix(cidr):
|
|||||||
family = 6 if ":" in cidr else 4
|
family = 6 if ":" in cidr else 4
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
cli.apply_ip({}, family, "static", cidr)
|
cli.apply_ip({}, family, "static", cidr)
|
||||||
|
|
||||||
|
|
||||||
|
# --- #9: guests not covered by any backup job -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_backed_up_returns_vmids(fake):
|
||||||
|
fake.result = (
|
||||||
|
json.dumps([{"vmid": 104, "name": "debian", "type": "lxc"}, {"vmid": "110"}]),
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
assert cli.not_backed_up() == {104, 110}
|
||||||
|
assert fake.argv == ["cv4pve-cli", "get", "cluster", "not-backed-up", "-o", "json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_backed_up_empty(fake):
|
||||||
|
fake.result = ("[]", "", 0)
|
||||||
|
assert cli.not_backed_up() == set()
|
||||||
|
|
||||||
|
|
||||||
|
# --- snapshots (issue #8); spec: docs/plans/guest-tui-mvp.md ("Snapshots in the info screen") ---
|
||||||
|
# Sections below name the spec bullet they cover.
|
||||||
|
|
||||||
|
GOOD_NAMES = [
|
||||||
|
"a",
|
||||||
|
"snap1",
|
||||||
|
"tui_20251102_1430",
|
||||||
|
"A-b_c",
|
||||||
|
"x" * 40,
|
||||||
|
"snapshot",
|
||||||
|
"currentx",
|
||||||
|
"curren",
|
||||||
|
]
|
||||||
|
BAD_NAMES = [
|
||||||
|
"",
|
||||||
|
"current",
|
||||||
|
"Current",
|
||||||
|
"CURRENT",
|
||||||
|
"1snap",
|
||||||
|
"_snap",
|
||||||
|
"-snap",
|
||||||
|
"my snap",
|
||||||
|
"a,b",
|
||||||
|
";rm",
|
||||||
|
"a;rm -rf",
|
||||||
|
"x" * 41,
|
||||||
|
"snäp",
|
||||||
|
"snap\n",
|
||||||
|
"snap ",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", GOOD_NAMES)
|
||||||
|
def test_valid_snapname_accepts_pattern(
|
||||||
|
name,
|
||||||
|
): # valid_snapname: [A-Za-z][A-Za-z0-9_-]{0,39}
|
||||||
|
assert cli.valid_snapname(name) == name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", BAD_NAMES)
|
||||||
|
def test_valid_snapname_rejects(
|
||||||
|
name,
|
||||||
|
): # valid_snapname: ValueError outside pattern / "current"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.valid_snapname(name)
|
||||||
|
|
||||||
|
|
||||||
|
@given(st.text())
|
||||||
|
def test_delete_snapshot_with_rejected_name_raises_and_never_runs(name):
|
||||||
|
# delete_snapshot: name validated first; no subprocess call for an invalid name
|
||||||
|
try:
|
||||||
|
cli.valid_snapname(name)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return # accepted names are covered by the argv tests
|
||||||
|
with pytest.MonkeyPatch.context() as m:
|
||||||
|
run = FakeRun(m)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.delete_snapshot(CT, name)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.delete_snapshot(VM, name)
|
||||||
|
assert run.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
SNAPS_RAW = [
|
||||||
|
{"name": "old", "description": "first", "snaptime": 1_000, "parent": None},
|
||||||
|
{"name": "undated", "description": "", "snaptime": None, "parent": "old"},
|
||||||
|
{
|
||||||
|
"name": "current",
|
||||||
|
"description": "You are here!",
|
||||||
|
"snaptime": None,
|
||||||
|
"parent": "new",
|
||||||
|
},
|
||||||
|
{"name": "new", "description": "second", "snaptime": 3_000, "parent": "old"},
|
||||||
|
{"name": "mid", "description": "", "snaptime": 2_000, "parent": "old"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshots_drops_current_sorts_newest_first_null_last(fake): # snapshots()
|
||||||
|
fake.result = (json.dumps(SNAPS_RAW), "", 0)
|
||||||
|
assert [s["name"] for s in cli.snapshots(CT)] == ["new", "mid", "old", "undated"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshots_drops_current_case_insensitively(
|
||||||
|
fake,
|
||||||
|
): # snapshots(): current is not a snapshot
|
||||||
|
fake.result = (
|
||||||
|
json.dumps([{"name": "Current", "snaptime": None}, SNAPS_RAW[0]]),
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
assert [s["name"] for s in cli.snapshots(CT)] == ["old"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshots_only_current_gives_empty_list(
|
||||||
|
fake,
|
||||||
|
): # snapshots(): "No snapshots" case
|
||||||
|
fake.result = (json.dumps([SNAPS_RAW[2]]), "", 0)
|
||||||
|
assert cli.snapshots(CT) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"guest,kind",
|
||||||
|
[(CT, "lxc"), (VM, "qemu")],
|
||||||
|
)
|
||||||
|
def test_snapshots_argv_uses_qemu_or_lxc(
|
||||||
|
fake, guest, kind
|
||||||
|
): # CLI facts: get guest snapshots
|
||||||
|
fake.result = ("[]", "", 0)
|
||||||
|
cli.snapshots(guest)
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "get", "guest", "snapshots", "mainframe", kind, str(guest["vmid"]), "-o", "json",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshots_failure_raises_clierror(
|
||||||
|
monkeypatch,
|
||||||
|
): # snapshots(): failure surfaces as CliError
|
||||||
|
FakeRun(monkeypatch, "Error: boom", returncode=1)
|
||||||
|
with pytest.raises(cli.CliError):
|
||||||
|
cli.snapshots(CT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_snapshot_ct_argv_has_no_vmstate_and_wait_last(fake): # take_snapshot
|
||||||
|
cli.take_snapshot(CT, "snap1", "hello")
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "create", "ct", "snapshot", "mainframe", "100", "snap1", "hello", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_snapshot_vm_without_vmstate(fake): # take_snapshot
|
||||||
|
cli.take_snapshot(VM, "snap1", "hello")
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "create", "vm", "snapshot", "mainframe", "108", "snap1", "hello", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_snapshot_vm_with_vmstate_before_wait(
|
||||||
|
fake,
|
||||||
|
): # take_snapshot: --vmstate 1 (VM only)
|
||||||
|
cli.take_snapshot(VM, "snap1", "hello", vmstate=True)
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "create", "vm", "snapshot", "mainframe", "108", "snap1", "hello",
|
||||||
|
"--vmstate", "1", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_snapshot_ct_ignores_vmstate(fake): # take_snapshot: vmstate only for a VM
|
||||||
|
cli.take_snapshot(CT, "snap1", "hello", vmstate=True)
|
||||||
|
assert "--vmstate" not in fake.argv and fake.argv[-1] == "--wait"
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_snapshot_default_description(
|
||||||
|
fake,
|
||||||
|
): # take_snapshot: description or "cv4pve-tui"
|
||||||
|
cli.take_snapshot(CT, "snap1")
|
||||||
|
assert fake.argv[7] == "cv4pve-tui"
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_snapshot_empty_description_falls_back(
|
||||||
|
fake,
|
||||||
|
): # take_snapshot: `description or "cv4pve-tui"`
|
||||||
|
cli.take_snapshot(VM, "snap1", "")
|
||||||
|
assert fake.argv[7] == "cv4pve-tui"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["", "current", "1x", "a b", ";rm"])
|
||||||
|
def test_take_snapshot_invalid_name_no_call(
|
||||||
|
fake, name
|
||||||
|
): # take_snapshot: name validated first
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.take_snapshot(VM, name, "d")
|
||||||
|
assert fake.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("guest,kind", [(CT, "ct"), (VM, "vm")])
|
||||||
|
def test_rollback_argv_without_start(fake, guest, kind): # rollback_snapshot
|
||||||
|
cli.rollback_snapshot(guest, "snap1")
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "do", "rollback", kind, "mainframe", str(guest["vmid"]), "snap1", "-y", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("guest,kind", [(CT, "ct"), (VM, "vm")])
|
||||||
|
def test_rollback_argv_with_start(
|
||||||
|
fake, guest, kind
|
||||||
|
): # rollback_snapshot: [--start 1] -y --wait
|
||||||
|
cli.rollback_snapshot(guest, "snap1", start=True)
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "do", "rollback", kind, "mainframe", str(guest["vmid"]), "snap1",
|
||||||
|
"--start", "1", "-y", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["", "current", "Current", "9", "a b", "a,b"])
|
||||||
|
def test_rollback_invalid_name_no_call(fake, name): # rollback_snapshot: name validated
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.rollback_snapshot(CT, name, start=True)
|
||||||
|
assert fake.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("guest,kind", [(CT, "ct"), (VM, "vm")])
|
||||||
|
def test_delete_snapshot_exact_argv(fake, guest, kind): # delete_snapshot
|
||||||
|
cli.delete_snapshot(guest, "snap1")
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "delete", kind, "snapshot", "mainframe", str(guest["vmid"]), "snap1", "-y", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
@given(
|
||||||
|
name=st.from_regex(r"[A-Za-z][A-Za-z0-9_-]{0,39}", fullmatch=True).filter(
|
||||||
|
lambda n: n.lower() != "current"
|
||||||
|
),
|
||||||
|
guest=st.sampled_from([CT, VM]),
|
||||||
|
)
|
||||||
|
def test_delete_never_produces_guest_destroying_form(name, guest):
|
||||||
|
# DANGER: `delete <kind> <node> <vmid> -y` destroys the guest; "snapshot" must sit at index 3
|
||||||
|
kind = {"lxc": "ct", "qemu": "vm"}[guest["type"]]
|
||||||
|
with pytest.MonkeyPatch.context() as m:
|
||||||
|
run = FakeRun(m)
|
||||||
|
cli.delete_snapshot(guest, name)
|
||||||
|
argv = run.argv
|
||||||
|
assert argv[1:3] == ["delete", kind]
|
||||||
|
assert argv[3] == "snapshot" # never the node directly after the kind
|
||||||
|
assert (
|
||||||
|
argv[4] == guest["node"] and argv[5] == str(guest["vmid"]) and argv[6] == name
|
||||||
|
)
|
||||||
|
assert argv[1:3] != ["delete", kind] or argv[3] != guest["node"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_with_snapshot_like_name_still_has_snapshot_at_index_3(fake):
|
||||||
|
# a valid name equal to the subcommand word must not shift the positions
|
||||||
|
cli.delete_snapshot(CT, "snapshot")
|
||||||
|
assert fake.argv[3:7] == ["snapshot", "mainframe", "100", "snapshot"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_snapshot_refuses_description_that_looks_like_an_option(fake):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.take_snapshot(
|
||||||
|
{"vmid": 104, "node": "mainframe", "type": "lxc"}, "snap1", "-y"
|
||||||
|
)
|
||||||
|
assert fake.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- issue #10: task log (spec: "Task log (issue #10)" > cli.py) ------------------------
|
||||||
|
|
||||||
|
UPID = "UPID:mainframe:0001:00A:68F:qmstart:108:root@pam:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_argv_is_cluster_tasks_json(fake): # tasks(): api get /cluster/tasks
|
||||||
|
fake.result = ("[]", "", 0)
|
||||||
|
assert cli.tasks() == []
|
||||||
|
assert fake.argv == ["cv4pve-cli", "api", "get", "/cluster/tasks", "-o", "json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_newest_first_including_running_without_endtime(
|
||||||
|
fake,
|
||||||
|
): # tasks(): newest first
|
||||||
|
raw = [
|
||||||
|
{"upid": "a", "starttime": 100, "endtime": 150, "status": "OK"},
|
||||||
|
{"upid": "c", "starttime": 300}, # running: no endtime, no status
|
||||||
|
{"upid": "b", "starttime": 200, "endtime": None},
|
||||||
|
]
|
||||||
|
fake.result = (json.dumps(raw), "", 0)
|
||||||
|
assert [t["upid"] for t in cli.tasks()] == ["c", "b", "a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_failure_raises_clierror(monkeypatch): # failure surfaces as CliError
|
||||||
|
FakeRun(monkeypatch, "Error: boom", returncode=1)
|
||||||
|
with pytest.raises(cli.CliError):
|
||||||
|
cli.tasks()
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_log_argv_and_returns_text(fake): # task_log(): task log <upid>
|
||||||
|
fake.result = ("starting\nTASK OK\n", "", 0)
|
||||||
|
out = cli.task_log(UPID)
|
||||||
|
assert fake.argv == ["cv4pve-cli", "task", "log", UPID]
|
||||||
|
assert "TASK OK" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["-y", "--help", "", "108", "x UPID:a"])
|
||||||
|
def test_task_log_rejects_non_upid_without_calling_subprocess(
|
||||||
|
fake, bad
|
||||||
|
): # ValueError unless UPID:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.task_log(bad)
|
||||||
|
assert fake.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- #13: nodes / package_updates (spec: "Node package updates (issue #13)") ---------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_nodes_argv_only_online_sorted(fake):
|
||||||
|
fake.result = (json.dumps([{"node": "zeta", "status": "online"},
|
||||||
|
{"node": "down", "status": "offline"},
|
||||||
|
{"node": "alpha", "status": "online"}]), "", 0) # fmt: skip
|
||||||
|
assert cli.nodes() == ["alpha", "zeta"]
|
||||||
|
assert fake.argv == ["cv4pve-cli", "get", "nodes", "-o", "json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_updates_argv_and_sorted_by_package(fake):
|
||||||
|
fake.result = (json.dumps([{"Package": "zsh", "Version": "2"},
|
||||||
|
{"Package": "bash", "Version": "1"},
|
||||||
|
{"Package": "curl", "Version": "3"}]), "", 0) # fmt: skip
|
||||||
|
got = cli.package_updates("mainframe")
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli",
|
||||||
|
"get",
|
||||||
|
"node",
|
||||||
|
"packages",
|
||||||
|
"mainframe",
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
]
|
||||||
|
assert [p["Package"] for p in got] == ["bash", "curl", "zsh"]
|
||||||
|
assert got[0] == {"Package": "bash", "Version": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_updates_failure_raises_clierror(monkeypatch):
|
||||||
|
FakeRun(monkeypatch, "Error: node unreachable\n", returncode=1)
|
||||||
|
with pytest.raises(cli.CliError):
|
||||||
|
cli.package_updates("mainframe")
|
||||||
|
|
||||||
|
|
||||||
|
# --- #14 ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_argv_and_strips(fake):
|
||||||
|
fake.result = ("2.4.0\n", "", 0)
|
||||||
|
assert cli.version() == "2.4.0"
|
||||||
|
assert fake.argv == ["cv4pve-cli", "--version"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tested_version_constant():
|
||||||
|
assert cli.TESTED_VERSION == "2.4.0"
|
||||||
|
|
||||||
|
|
||||||
|
# --- #18: startup order and tags (spec: "Autostart, startup order and tags") ----------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("args", "expected"),
|
||||||
|
[
|
||||||
|
((2, 10, 30), "order=2,up=10,down=30"),
|
||||||
|
((2, 10, None), "order=2,up=10"),
|
||||||
|
((2, None, None), "order=2"),
|
||||||
|
((None, 10, None), "up=10"),
|
||||||
|
((None, None, 30), "down=30"),
|
||||||
|
((None, 10, 30), "up=10,down=30"),
|
||||||
|
((0, 0, 0), "order=0,up=0,down=0"), # 0 is a value, not "unset"
|
||||||
|
((None, None, None), None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_startup_value_builds_only_the_given_parts(args, expected):
|
||||||
|
assert cli.startup_value(*args) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"args", [(-1, None, None), (None, -5, None), (None, None, -30), (1, 2, -1)]
|
||||||
|
)
|
||||||
|
def test_startup_value_rejects_negatives(args):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.startup_value(*args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["x", 1.5, [1]])
|
||||||
|
def test_startup_value_rejects_non_ints(bad):
|
||||||
|
for args in ((bad, None, None), (None, bad, None), (None, None, bad)):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.startup_value(*args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("text", "expected"),
|
||||||
|
[
|
||||||
|
("order=2,up=10,down=30", (2, 10, 30)),
|
||||||
|
("3", (3, None, None)), # a bare leading number is the order
|
||||||
|
("up=5", (None, 5, None)),
|
||||||
|
("order=2,up=10", (2, 10, None)),
|
||||||
|
("3,up=5", (3, 5, None)),
|
||||||
|
("down=7", (None, None, 7)),
|
||||||
|
(None, (None, None, None)),
|
||||||
|
("", (None, None, None)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_startup(text, expected):
|
||||||
|
assert cli.parse_startup(text) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@given(
|
||||||
|
st.tuples(*[st.one_of(st.none(), st.integers(min_value=0, max_value=10**6))] * 3)
|
||||||
|
)
|
||||||
|
def test_parse_startup_inverts_startup_value_property(parts):
|
||||||
|
assert cli.parse_startup(cli.startup_value(*parts)) == parts
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("text", "expected"),
|
||||||
|
[
|
||||||
|
("os", "os"),
|
||||||
|
("a;b;c", "a;b;c"),
|
||||||
|
("a,b", "a;b"),
|
||||||
|
("a b", "a;b"), # whitespace separates: two tags, not one with a space
|
||||||
|
("a;b, c d", "a;b;c;d"),
|
||||||
|
("a; b; c", "a;b;c"),
|
||||||
|
("a;b;a;c;b", "a;b;c"), # duplicates dropped, first occurrence wins
|
||||||
|
("c;a;b", "c;a;b"), # order kept, not sorted
|
||||||
|
(";;a;;,, b;", "a;b"), # empties dropped
|
||||||
|
("172.16.41.200", "172.16.41.200"), # IP-like tag is valid
|
||||||
|
("172.16.41.200;community-script;os", "172.16.41.200;community-script;os"),
|
||||||
|
("_x;a+b;a.b;a-b;9", "_x;a+b;a.b;a-b;9"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_tags_value_normalises(text, expected):
|
||||||
|
assert cli.tags_value(text) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["", " ", ";", " ; , ;", "\n\t"])
|
||||||
|
def test_tags_value_empty_is_none(text):
|
||||||
|
assert cli.tags_value(text) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad", ["-x", "a;b!", "!", "a;-b", ".a", "+a", "a/b", "a;b@c", "ä"]
|
||||||
|
)
|
||||||
|
def test_tags_value_rejects_invalid_tags(bad):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.tags_value(bad)
|
||||||
|
|
||||||
|
|
||||||
|
_TAG = st.from_regex(r"[A-Za-z0-9_][A-Za-z0-9_+.-]*", fullmatch=True)
|
||||||
|
_SEP = st.sampled_from([";", ",", " ", "; ", ", ", " ", ";;"])
|
||||||
|
|
||||||
|
|
||||||
|
@st.composite
|
||||||
|
def _valid_tag_text(draw):
|
||||||
|
tags = draw(st.lists(_TAG, max_size=6))
|
||||||
|
out = ""
|
||||||
|
for tag in tags:
|
||||||
|
out += tag + draw(_SEP)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@given(_valid_tag_text())
|
||||||
|
def test_tags_value_is_idempotent_property(text):
|
||||||
|
once = cli.tags_value(text)
|
||||||
|
if once is None:
|
||||||
|
assert text.replace(";", "").replace(",", "").strip() == ""
|
||||||
|
else:
|
||||||
|
assert cli.tags_value(once) == once
|
||||||
|
assert len(set(once.split(";"))) == len(once.split(";")) # no duplicates
|
||||||
|
|
||||||
|
|
||||||
|
# --- issue #19: guest-agent fstrim (spec: "fstrim in a VM via the guest agent") ----------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"agent,expected",
|
||||||
|
[
|
||||||
|
("enabled=1", True),
|
||||||
|
("1", True),
|
||||||
|
("1,fstrim_cloned_disks=1", True),
|
||||||
|
("enabled=0", False),
|
||||||
|
("0", False),
|
||||||
|
(None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_agent_enabled(agent, expected):
|
||||||
|
config = {} if agent is None else {"agent": agent}
|
||||||
|
assert cli.agent_enabled(config) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_ping_exact_argv(fake):
|
||||||
|
fake.result = (json.dumps([{"key": "result", "value": "{}"}]), "", 0)
|
||||||
|
cli.agent_ping(VMID108)
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "do", "vm", "agent", "ping", "mainframe", "108", "-o", "json",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_ping_nonzero_exit_raises_clierror(monkeypatch):
|
||||||
|
FakeRun(monkeypatch, "Error: QEMU guest agent is not running", returncode=5)
|
||||||
|
with pytest.raises(cli.CliError):
|
||||||
|
cli.agent_ping(VMID108)
|
||||||
|
|
||||||
|
|
||||||
|
FSTRIM_PATHS = [
|
||||||
|
{"path": "/", "trimmed": 1073741824, "minimum": 0},
|
||||||
|
{"path": "/boot", "error": "not supported"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fstrim_stdout(value):
|
||||||
|
return json.dumps([{"key": "result", "value": value}])
|
||||||
|
|
||||||
|
|
||||||
|
def test_fstrim_exact_argv_and_parses_paths(fake):
|
||||||
|
fake.result = (fstrim_stdout(json.dumps({"paths": FSTRIM_PATHS})), "", 0)
|
||||||
|
assert cli.fstrim(VMID108) == FSTRIM_PATHS
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "do", "vm", "agent", "fstrim", "mainframe", "108", "-o", "json",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"stdout",
|
||||||
|
[
|
||||||
|
"[]",
|
||||||
|
json.dumps([{"key": "other", "value": "{}"}]),
|
||||||
|
fstrim_stdout("not json at all"),
|
||||||
|
fstrim_stdout("{}"),
|
||||||
|
fstrim_stdout(""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_fstrim_missing_or_garbage_result_is_empty_list(fake, stdout):
|
||||||
|
fake.result = (stdout, "", 0)
|
||||||
|
assert cli.fstrim(VMID108) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fstrim_nonzero_exit_raises_clierror(monkeypatch):
|
||||||
|
FakeRun(monkeypatch, "Error: fstrim failed", returncode=5)
|
||||||
|
with pytest.raises(cli.CliError):
|
||||||
|
cli.fstrim(VMID108)
|
||||||
|
|
||||||
|
|
||||||
|
# --- #19 review findings ----------------------------------------------------------------
|
||||||
|
|
||||||
|
VM19 = {"vmid": 108, "node": "mainframe", "type": "qemu"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"stdout",
|
||||||
|
[
|
||||||
|
"garbage",
|
||||||
|
"",
|
||||||
|
json.dumps({"key": "result"}), # object instead of list
|
||||||
|
json.dumps([{"value": "{}"}]), # item without "key"
|
||||||
|
json.dumps([{"key": "result", "value": {"paths": "x"}}]), # already a dict
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_fstrim_unreadable_output_is_empty_not_an_error(fake, stdout):
|
||||||
|
fake.result = (stdout, "", 0)
|
||||||
|
assert cli.fstrim(VM19) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fstrim_normalises_odd_path_entries(fake):
|
||||||
|
paths = [
|
||||||
|
{"path": "/", "trimmed": "12"},
|
||||||
|
{"error": "x"},
|
||||||
|
"junk",
|
||||||
|
{"path": "/var", "trimmed": 5},
|
||||||
|
]
|
||||||
|
fake.result = (
|
||||||
|
json.dumps([{"key": "result", "value": json.dumps({"paths": paths})}]),
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
assert cli.fstrim(VM19) == [
|
||||||
|
{"path": "/", "trimmed": 0}, # non-int trimmed -> 0
|
||||||
|
{"error": "x", "path": "?"}, # missing path -> "?"; "junk" dropped
|
||||||
|
{"path": "/var", "trimmed": 5}, # well-formed: unchanged
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fstrim_gets_a_longer_timeout_than_other_calls(fake):
|
||||||
|
fake.result = (json.dumps([{"key": "result", "value": "{}"}]), "", 0)
|
||||||
|
cli.fstrim(VM19)
|
||||||
|
assert fake.calls[0][1]["timeout"] >= 1800 # a big disk can take longer than 330 s
|
||||||
|
|
||||||
|
|
||||||
|
# --- restore a guest from a backup (issue #15) ------------------------------
|
||||||
|
# spec: docs/plans/guest-tui-mvp.md ("Restore a guest from a backup (issue #15)" > cli.py)
|
||||||
|
|
||||||
|
CT_VOLID = "truenas-backup:backup/vzdump-lxc-104-2026_11_02-04_11_47.tar.zst"
|
||||||
|
VM_VOLID = "local:backup/vzdump-qemu-108-2026_11_02-04_11_47.vma.zst"
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_id_parses_json_number_and_runs_get_cluster_nextid(fake):
|
||||||
|
# spec: next_id() -> int; `get cluster nextid -o json` -> a JSON number
|
||||||
|
fake.result = ("105\n", "", 0)
|
||||||
|
assert cli.next_id() == 105
|
||||||
|
assert fake.argv == ["cv4pve-cli", "get", "cluster", "nextid", "-o", "json"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("volid", [CT_VOLID, VM_VOLID])
|
||||||
|
def test_valid_volid_accepts_real_volids_and_returns_them(volid):
|
||||||
|
# spec: valid_volid(volid) -> str
|
||||||
|
assert cli.valid_volid(volid) == volid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"volid",
|
||||||
|
[
|
||||||
|
"-y",
|
||||||
|
"",
|
||||||
|
"local:iso/x.iso",
|
||||||
|
"a b",
|
||||||
|
"x:backup/vzdump-lxc-104-",
|
||||||
|
"--force:backup/vzdump-lxc-104-x", # option-looking storage part
|
||||||
|
"local:backup/vzdump-lxc-104-x y", # whitespace in the file part
|
||||||
|
"local:backup/vzdump-vm-104-x.tar", # kind is lxc|qemu only
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_valid_volid_rejects_everything_else(volid):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.valid_volid(volid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_storages_ct_wants_rootdir_vm_wants_images(fake):
|
||||||
|
# spec: enabled+active storages whose content has rootdir (CT) or images (VM), API order
|
||||||
|
fake.result = (
|
||||||
|
json.dumps([
|
||||||
|
storage("zeta", "iso,rootdir,images"),
|
||||||
|
storage("only-backup", "backup"),
|
||||||
|
storage("vm-only", "images"),
|
||||||
|
storage("ct-only", "rootdir,vztmpl"),
|
||||||
|
storage("off", "rootdir,images", enabled=0),
|
||||||
|
storage("inactive", "rootdir,images", active=0),
|
||||||
|
storage("alpha", "images,rootdir"),
|
||||||
|
]),
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
) # fmt: skip
|
||||||
|
assert cli.restore_storages(CT) == ["zeta", "ct-only", "alpha"]
|
||||||
|
assert cli.restore_storages(VM) == ["zeta", "vm-only", "alpha"]
|
||||||
|
assert fake.calls[0][0] == [
|
||||||
|
"cv4pve-cli", "get", "node", "storages", "mainframe", "-o", "json",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_new_vm_exact_argv_without_force_or_start(fake):
|
||||||
|
# spec: do restore <kind> <node> <vmid> <volid> --storage <storage> -y --wait
|
||||||
|
cli.restore(VM, VM_VOLID, 109, "local-lvm")
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "do", "restore", "vm", "mainframe", "109", VM_VOLID,
|
||||||
|
"--storage", "local-lvm", "-y", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_new_ct_with_start_exact_argv(fake):
|
||||||
|
cli.restore(CT, CT_VOLID, 105, "zfs", start=True)
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "do", "restore", "ct", "mainframe", "105", CT_VOLID,
|
||||||
|
"--storage", "zfs", "--start", "1", "-y", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_overwrite_vm_exact_argv_with_force(fake):
|
||||||
|
cli.restore(VM, VM_VOLID, 108, "local-lvm", force=True)
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "do", "restore", "vm", "mainframe", "108", VM_VOLID,
|
||||||
|
"--storage", "local-lvm", "--force", "1", "-y", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_overwrite_ct_with_start_exact_argv(fake):
|
||||||
|
cli.restore(CT, CT_VOLID, 100, "zfs", force=True, start=True)
|
||||||
|
assert fake.argv == [
|
||||||
|
"cv4pve-cli", "do", "restore", "ct", "mainframe", "100", CT_VOLID,
|
||||||
|
"--storage", "zfs", "--force", "1", "--start", "1", "-y", "--wait",
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_force_on_another_vmid_raises_and_never_runs(fake):
|
||||||
|
# spec: force=True only when vmid == guest["vmid"] - never overwrite some other guest
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.restore(VM, VM_VOLID, 100, "local-lvm", force=True)
|
||||||
|
assert fake.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("vmid", [0, -1, -108])
|
||||||
|
@pytest.mark.parametrize("force", [False, True])
|
||||||
|
def test_restore_non_positive_vmid_raises_and_never_runs(fake, vmid, force):
|
||||||
|
# spec: vmid a positive int
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.restore(VM, VM_VOLID, vmid, "local-lvm", force=force)
|
||||||
|
assert fake.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("volid", ["-y", "", "local:iso/x.iso", "a b"])
|
||||||
|
def test_restore_rejected_volid_raises_and_never_runs(fake, volid):
|
||||||
|
# spec: volid validated (it is a positional argument)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.restore(VM, volid, 109, "local-lvm")
|
||||||
|
assert fake.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_failure_raises_clierror(monkeypatch):
|
||||||
|
FakeRun(monkeypatch, "Error: storage is full", returncode=5)
|
||||||
|
with pytest.raises(cli.CliError):
|
||||||
|
cli.restore(VM, VM_VOLID, 109, "local-lvm")
|
||||||
|
|
||||||
|
|
||||||
|
@given(vmid=st.integers(min_value=-10, max_value=10**9).filter(lambda n: n != 108))
|
||||||
|
def test_restore_force_never_runs_for_a_vmid_other_than_the_guests(vmid):
|
||||||
|
# DANGER property: --force 1 on any other guest would destroy it
|
||||||
|
with pytest.MonkeyPatch.context() as m:
|
||||||
|
run = FakeRun(m)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.restore(VM, VM_VOLID, vmid, "local-lvm", force=True)
|
||||||
|
assert run.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- #15 review findings ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("force", [False, True])
|
||||||
|
def test_restore_refuses_a_backup_of_the_other_guest_type(fake, force):
|
||||||
|
ct = {"vmid": 100, "node": "mainframe", "type": "lxc"}
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cli.restore(
|
||||||
|
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()) == []
|
||||||
@@ -24,6 +24,7 @@ dev = [
|
|||||||
{ name = "hypothesis" },
|
{ name = "hypothesis" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -34,6 +35,7 @@ dev = [
|
|||||||
{ name = "hypothesis", specifier = ">=6.170.14" },
|
{ name = "hypothesis", specifier = ">=6.170.14" },
|
||||||
{ name = "pytest", specifier = ">=8" },
|
{ name = "pytest", specifier = ">=8" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.0" },
|
||||||
|
{ name = "ruff", specifier = "==0.17.3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -251,6 +253,31 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.17.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sortedcontainers"
|
name = "sortedcontainers"
|
||||||
version = "2.4.0"
|
version = "2.4.0"
|
||||||
|
|||||||
Reference in new issue
Block a user