Compare commits
29
Commits
6f009c6782
..
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
|
||||
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.claude/scratch/
|
||||
@@ -1,2 +1,115 @@
|
||||
# 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
|
||||
guests (VMs and LXC), start/shutdown/stop/reboot them, and change cores, memory and
|
||||
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 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
|
||||
|
||||
|
||||
Installed: `cv4pve-tui`. From a checkout without installing:
|
||||
|
||||
```sh
|
||||
uv run cv4pve-tui
|
||||
```
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `s` | start |
|
||||
| `S` | shutdown (confirm) |
|
||||
| `x` | stop - hard power-off (confirm) |
|
||||
| `r` | reboot (confirm) |
|
||||
| `Enter` | info: resources (CPU, memory, swap, disks), backups, backup job, network (config and runtime IPs) |
|
||||
| `Enter` then `e` | in info: edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
||||
| `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>`) |
|
||||
| `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 |
|
||||
| `/` | 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) |
|
||||
| `q` | quit |
|
||||
|
||||
Warning: link changes (bridge, VLAN, firewall) apply immediately on a running guest and can cut its network (and DNS for others if the guest is your DNS server). IP changes on a running container may need a reboot of the container (seen: static -> DHCP gave no IPv4 until a reboot).
|
||||
|
||||
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
|
||||
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
|
||||
hot-plugged, the app tells you it applies at the next reboot.
|
||||
|
||||
## Shrinking a container's root disk
|
||||
|
||||
Proxmox only grows disks. Two ways to get a smaller container root disk, both
|
||||
**dangerous**; take a backup first and test on a throwaway container.
|
||||
|
||||
**In the TUI** (`Enter`, then `r` on a container backup): fill in *Root disk GiB*.
|
||||
This restores the backup as a **new** guest with a new, smaller root disk
|
||||
(Proxmox's "advanced restore mode"); overwriting the original with a smaller disk is
|
||||
refused, because Proxmox deletes the old disks before it unpacks the backup. In this
|
||||
mode every mount point (`mpN`) of the backup is dropped: files of mount points with
|
||||
`backup=1` land inside the root disk, `backup=0` ones are not in the backup at all.
|
||||
Raw `lxc.*` lines (bind mounts too) can come back if cv4pve-cli runs as root@pam, so
|
||||
do not run both guests at once. A disk that is too small makes the restore fail; the
|
||||
original is untouched.
|
||||
|
||||
**On the node** (root shell, container stopped, keeps the VMID):
|
||||
|
||||
```sh
|
||||
scripts/pct-shrink-rootfs <vmid> <new-size-GiB> # measure and print the plan
|
||||
scripts/pct-shrink-rootfs --yes <vmid> <new-size-GiB> # do it
|
||||
```
|
||||
|
||||
It adds a temporary `mp9` of the new size (Proxmox allocates and formats it), copies
|
||||
the root disk into it with rsync, then swaps it into `rootfs` under Proxmox's config
|
||||
lock. The old root disk stays as an `unusedK` volume until you delete it with the
|
||||
exact `pct set <vmid> --delete unusedK` the script prints; the old config is copied to
|
||||
`/root/pct-shrink-<vmid>-<timestamp>.conf`. It refuses containers with snapshots,
|
||||
pending changes, a lock, template or protection flag, HA, replication, or any mount
|
||||
point (`mpN`, `lxc.mount.entry`), and sizes the data does not fit with headroom
|
||||
(5 % ext4 reserve, metadata, inodes). chattr flags (`+i`, `+a`) are not copied. Even
|
||||
the dry run mounts the container briefly (`pct mount`). Design:
|
||||
`docs/plans/lxc-shrink.md`.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
uv sync
|
||||
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. 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`.
|
||||
@@ -0,0 +1,797 @@
|
||||
# Guest TUI MVP
|
||||
|
||||
## Problem
|
||||
|
||||
Manage Proxmox guests (VMs and LXC containers) from the terminal without remembering
|
||||
`cv4pve-cli` syntax: list them, start/shutdown/stop/reboot, and change CPU cores,
|
||||
memory and disk size.
|
||||
|
||||
## Approach
|
||||
|
||||
Thin subprocess wrapper around `cv4pve-cli` (tested against 2.4.0) plus a Textual app.
|
||||
The app never handles credentials: it uses whatever context `cv4pve-cli config use`
|
||||
selected.
|
||||
|
||||
Observed CLI behaviour the design relies on:
|
||||
|
||||
- `get guests -o json` -> list of dicts with `vmid`, `type` (`qemu`|`lxc`), `node`,
|
||||
`name`, `status`, `maxcpu`, `maxmem` (bytes), `maxdisk` (bytes), ...
|
||||
- `show vm|ct <node> <vmid> -o json` -> list of `{"key": ..., "value": ...}`.
|
||||
- `api get /nodes/<node>/<qemu|lxc>/<vmid>/pending -o json` -> list of
|
||||
`{"key", "value", "pending", "delete"}`; `pending` non-null means not yet applied.
|
||||
- Async actions print a UPID and exit 0 **even when the task fails**. With `--wait`
|
||||
the CLI waits for the task and exits non-zero (5) with the error text on failure.
|
||||
Every mutating call therefore uses `--wait`.
|
||||
- `do stop` refuses to run without `-y` (verified); `-y` is accepted by all four
|
||||
lifecycle actions, so it is always passed.
|
||||
- Errors: non-zero exit, message on stdout/stderr. Sync failures are multi-line
|
||||
(`Error: ...` / `Call: ...` / `Alias: ...`). Success is judged by exit code only,
|
||||
never by parsing UPID text.
|
||||
- Each call costs ~0.5 s (.NET startup), so calls run in worker threads.
|
||||
- `get guests` (`/cluster/resources`) lags a few seconds behind actions; after an
|
||||
action the app reads the acted-on guest's live status from
|
||||
`api get /nodes/<node>/<qemu|lxc>/<vmid>/status/current` (key/value list).
|
||||
|
||||
### Module `cv4pve_tui/cli.py`
|
||||
|
||||
- `CliError(Exception)` - `str()` is the CLI's combined, stripped output;
|
||||
`.summary` is one line for the UI: the first non-empty line that is not a UPID,
|
||||
`Error:`-prefix stripped, plus the following indented detail line if the first
|
||||
line ends with `:`.
|
||||
- `run(*args) -> str` - `subprocess.run(["cv4pve-cli", *args], capture_output=True,
|
||||
text=True, stdin=DEVNULL, timeout=330)`; non-zero exit -> `CliError`.
|
||||
- `kind(guest) -> "vm" | "ct"` - from `type` (`qemu` -> `vm`, `lxc` -> `ct`).
|
||||
- `list_guests() -> list[dict]` - sorted by `vmid`.
|
||||
- `guest_config(guest) -> dict` - key/value list flattened to a dict.
|
||||
- `disks(config, kind) -> dict[str, str]` - resizable disks -> size string.
|
||||
VM: `scsiN|virtioN|sataN|ideN` with a `size=`, excluding `media=cdrom` and
|
||||
cloud-init. CT: `rootfs`, `mpN`.
|
||||
- `ACTIONS = ("start", "shutdown", "stop", "reboot")`;
|
||||
`action(name, guest)` -> `do <name> <kind> <node> <vmid> -y --wait`;
|
||||
unknown name -> `ValueError`.
|
||||
- `set_resources(guest, cores=None, memory_mb=None)` ->
|
||||
`set <kind> config <node> <vmid> [--cores N] [--memory M] --wait`; values must be
|
||||
positive ints; nothing to set -> `ValueError`.
|
||||
- `resize_disk(guest, disk, grow_gb)` -> `do resize <kind> disk <node> <vmid> <disk>
|
||||
+<grow_gb>G --wait`; `grow_gb` must be a positive int (growth only - Proxmox cannot
|
||||
shrink, and an increment cannot shrink by construction).
|
||||
- `pending_keys(guest) -> list[str]` - keys with a non-null `pending`.
|
||||
|
||||
### Module `cv4pve_tui/app.py`
|
||||
|
||||
- `DataTable` of guests: VMID, name, type, node, status, cores, memory (MiB),
|
||||
disk (GiB). Cursor row = selected guest.
|
||||
- Keys: `s` start, `S` shutdown, `x` stop (hard), `r` reboot, `e` edit,
|
||||
`g` refresh, `q` quit.
|
||||
- `shutdown`, `stop`, `reboot` open a confirm modal naming the guest; `stop` says it
|
||||
is a hard power-off. `start` needs no confirmation.
|
||||
- `e` opens an edit modal: cores, memory MiB, disk select + "grow by GiB".
|
||||
Empty field = unchanged. Disk growth gets its own confirm (irreversible).
|
||||
- After `set_resources`, if `pending_keys` contains `cores` or `memory`, notify that
|
||||
the change applies at next reboot.
|
||||
- All CLI calls in thread workers; results/errors via `notify` (errors show
|
||||
`CliError.summary`); table refreshes after each action.
|
||||
- A "working" notice is shown when an action starts (`--wait` can block minutes),
|
||||
and a second action on a guest that is already busy is refused.
|
||||
- Action keys on an empty table do nothing (no crash).
|
||||
|
||||
### UI contract (shared by tests and implementation)
|
||||
|
||||
- `app.py` does `from cv4pve_tui import cli` and always calls `cli.<function>(...)`,
|
||||
so tests monkeypatch `cv4pve_tui.cli.<function>`.
|
||||
- `class GuestApp(App)`; `def main()` runs it.
|
||||
- Guest table: `DataTable` with id `guests`, one row per guest, row key = str(vmid),
|
||||
in `list_guests()` order. Loaded on mount.
|
||||
- `ConfirmScreen(ModalScreen[bool])`: buttons `#yes` / `#no`; its text names the guest
|
||||
(name and vmid); for `stop` it contains the words "hard power-off".
|
||||
- `EditScreen(ModalScreen)`: `Input#cores`, `Input#memory`, `Select#disk`,
|
||||
`Input#grow`, buttons `#apply` / `#cancel`. Inputs are pre-filled with nothing
|
||||
(empty = unchanged); placeholders show current values. Disk options come from
|
||||
`cli.disks(cli.guest_config(guest), cli.kind(guest))`.
|
||||
On apply: if cores/memory given -> `cli.set_resources(guest, cores=..., memory_mb=...)`
|
||||
(only the given ones, as ints); if grow given -> `ConfirmScreen` first, then
|
||||
`cli.resize_disk(guest, disk, grow_gb)`. Non-integer or <= 0 input -> `notify`
|
||||
with severity "error" and no CLI call.
|
||||
- Thread workers (`@work(thread=True)` or `run_worker(..., thread=True)`); tests wait
|
||||
with `await app.workers.wait_for_complete()` then `await pilot.pause()`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Create/delete, snapshots, migration, console, backup, in-app context switching,
|
||||
sockets/vCPU/hotplug settings, live auto-refresh.
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Project skeleton (`uv`, `pyproject.toml`, entry point `cv4pve-tui`) -
|
||||
`uv run python -c "import cv4pve_tui"` succeeds.
|
||||
- [x] Tests for `cli.py` with `subprocess.run` mocked - fail before implementation.
|
||||
- [x] `cli.py` - tests pass.
|
||||
- [x] Tests for the app with Textual `run_test` and `cli` functions mocked.
|
||||
- [x] `app.py` - tests pass.
|
||||
- [x] Read-only smoke test against the real cluster (listing, edit dialog disk options,
|
||||
pending check).
|
||||
- [x] Live edit (cores/memory), shutdown, start, reboot on CT 104 (mainframe), restored
|
||||
to baseline afterwards. Disk resize not run live (irreversible).
|
||||
|
||||
## Open questions
|
||||
|
||||
None blocking.
|
||||
|
||||
---
|
||||
|
||||
# Context switching
|
||||
|
||||
## Problem
|
||||
|
||||
Several cv4pve-cli contexts (clusters) exist; switch between them inside the TUI
|
||||
instead of quitting and running `cv4pve-cli config use`.
|
||||
|
||||
## Decision
|
||||
|
||||
Option A: switching runs `cv4pve-cli config use <name>`, which changes the **global**
|
||||
current context (all shells and scripts follow) and stays after the app exits. cv4pve-cli
|
||||
2.4.0 has no per-call context flag or env var (verified), so there is no app-local option.
|
||||
|
||||
Observed CLI behaviour:
|
||||
|
||||
- `config list` has no `-o json`. Text output, one line per context; current one
|
||||
prefixed with `* `, others with two spaces:
|
||||
`* mainframe mainframe.iot.home.arpa:8006 (api-token)`
|
||||
` mainframe-edge mainframe-edge.home.arpa:8006 (api-token)`
|
||||
- `config use <name>` -> exit 0 on success; unknown name -> exit 3,
|
||||
`Error: Context '<name>' not found.`, current context unchanged.
|
||||
|
||||
### `cli.py`
|
||||
|
||||
- `contexts() -> list[dict]` - `run("config", "list")`, each line parsed to
|
||||
`{"name", "host", "auth", "current": bool}` (regex
|
||||
`^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$`); non-matching lines are ignored.
|
||||
- `use_context(name)` - `run("config", "use", name)`.
|
||||
|
||||
### `app.py` (UI contract)
|
||||
|
||||
- The active context is always visible: `app.sub_title == f"context: {name}"`
|
||||
(shown in the Header). Set on mount from `cli.contexts()` and after every switch.
|
||||
If `contexts()` fails: error notify, sub_title `context: ?`.
|
||||
- Key `c` opens `ContextScreen(ModalScreen[str | None])` with `OptionList#contexts`,
|
||||
one option per context, option id = context name, current one marked with `*`.
|
||||
Selecting an option dismisses with its name; `escape` dismisses with `None`.
|
||||
- `c` while any guest is busy -> warning notify, no screen (VMIDs collide across
|
||||
clusters, so the busy guard would point at the wrong cluster).
|
||||
- Selecting the current context or dismissing -> no CLI call.
|
||||
- A reload started before a switch must not refill the table (generation counter);
|
||||
`g` and `c` are refused while a switch runs; the post-action reload finishes before
|
||||
the guest stops being busy.
|
||||
- Selecting another context: table cleared immediately (old cluster's guests must not
|
||||
stay actionable), then in a worker `cli.use_context(name)`, sub_title updated, guests
|
||||
reloaded. On `CliError`: error notify with summary, sub_title re-read from
|
||||
`cli.contexts()`, guests reloaded.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Adding/editing/deleting contexts, `config verify`, restoring the original context on exit.
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Tests for `contexts`/`use_context` and the UI contract above - red first.
|
||||
- [x] Implementation - full suite green, ruff clean.
|
||||
- [x] Live: open the list, switch to `mainframe-edge` and back to `mainframe`
|
||||
(only `config use` and read-only listing; no guest actions); final context
|
||||
is `mainframe`.
|
||||
- [ ] Push `feat/guest-tui-mvp`, merge to `main` (user-approved).
|
||||
|
||||
---
|
||||
|
||||
# LXC shell
|
||||
|
||||
## Problem
|
||||
|
||||
Open an interactive shell in a running LXC container from the TUI. cv4pve-cli 2.4.0 has
|
||||
no console/shell/exec for LXC (verified: no command; the Proxmox API has no LXC exec),
|
||||
so the app goes around it: SSH to the PVE node as root and run `pct enter <vmid>`.
|
||||
|
||||
## Decision
|
||||
|
||||
Option 1 from the discussion: `ssh -t root@<node> pct enter <vmid>`. The node name is
|
||||
the SSH host (resolves via DNS here: `mainframe`, `mainframe-edge`); the user's key is in
|
||||
root's `authorized_keys` (verified: `ssh -o BatchMode=yes root@mainframe pct status 104`
|
||||
-> `status: running`). This bypasses cv4pve-cli and its API token.
|
||||
|
||||
## `app.py` (UI contract)
|
||||
|
||||
- Key `t` ("Shell") on the selected guest.
|
||||
- Only for `type == "lxc"` and `status == "running"`; otherwise a warning notify that
|
||||
says why (not a container / not running) and nothing runs. Empty table: nothing.
|
||||
- Busy guest (action running) -> warning notify, nothing runs.
|
||||
- Runs on the main thread inside `with self.suspend():` -
|
||||
`subprocess.run(["ssh", "-t", f"root@{node}", "pct", "enter", str(vmid)])`
|
||||
(no capture, no timeout: the user's terminal is handed over; `import subprocess` in
|
||||
app.py so tests patch `cv4pve_tui.app.subprocess.run`).
|
||||
- Exit 255 (ssh itself failed) -> error notify naming the guest and the code. Other
|
||||
exit codes are the container shell's last status (`exit` after a failed command
|
||||
gives 1) and are not errors.
|
||||
- Inside `suspend()`, right before ssh, the terminal gets a full reset (`ESC c`, what
|
||||
`reset` sends) so the container shell starts on a clean screen without leftover TUI
|
||||
terminal modes (user request). Done locally, not in the container: no dependency on
|
||||
`reset` existing there, and `pct enter` stays unchanged.
|
||||
- `OSError` from `subprocess.run` is caught *inside* the `suspend()` block: Textual
|
||||
8.2.8's `suspend()` has no try/finally, so an exception escaping it leaves the app
|
||||
suspended.
|
||||
- The guest table reloads after the shell returns (the user may have stopped or
|
||||
rebooted the container from inside).
|
||||
- `textual.app.SuspendNotSupported` (headless / unsupported driver) and `OSError`
|
||||
(no `ssh` binary) -> error notify, app keeps running.
|
||||
- Tests: `App.suspend` raises `SuspendNotSupported` under `run_test`, so tests replace
|
||||
`app.suspend` with a no-op context manager (e.g. `contextlib.nullcontext`) unless
|
||||
testing that error path.
|
||||
|
||||
## Out of scope
|
||||
|
||||
VM consoles (serial/VNC), configurable SSH user/host/options, termproxy/websocket.
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Tests for the UI contract above - red first.
|
||||
- [x] Implementation - full suite green, ruff clean. README key table gets `t`.
|
||||
- [x] Interactive test by the user: `t` on CT 104, `exit`, back in the table; screen
|
||||
clean after the reset.
|
||||
- [ ] Push `feat/lxc-shell`, merge to `main` (user-approved).
|
||||
|
||||
---
|
||||
|
||||
# Guest info screen: backups, backup now, network (read-only) - delivery A
|
||||
|
||||
## Problem
|
||||
|
||||
Per guest, see its latest backups and network setup, and start a backup now. Network
|
||||
*editing* is delivery B (separate, live-tested), not part of this.
|
||||
|
||||
## Decisions (user-approved)
|
||||
|
||||
- Backup now uses the scheduled job's settings: `--mode snapshot --compress zstd`.
|
||||
Storage is chosen from the node's backup-capable storages, preselected to the job's
|
||||
storage (`truenas-backup` here), `local` as alternative.
|
||||
- Manual backups get `--notes-template "{{guestname}} (manual)"` so they are
|
||||
recognisable. Note for the user: `truenas-backup` has storage prune `keep-all=1`, but
|
||||
the job has `keep-last 3` per guest; a manual backup there likely counts towards it at
|
||||
the job's next run (not verified).
|
||||
|
||||
Observed CLI behaviour (cv4pve-cli 2.4.0, all read-only probes):
|
||||
|
||||
- `get node storages <node> -o json` -> list of dicts: `storage`, `content`
|
||||
(comma list, e.g. `iso,snippets,import,backup,vztmpl`), `enabled`, `active`, `type`,
|
||||
`avail` (bytes).
|
||||
- `get cluster backup-jobs -o json` -> list of dicts: `id`, `enabled`, `storage`,
|
||||
`schedule` (e.g. `mon,wed,fri 04:00`), `mode`, `compress`, `all` (1 = all guests),
|
||||
`vmid` (comma list or null), `exclude` (comma list or null), `node` (null = all),
|
||||
`next-run` (epoch), `prune-backups` (e.g. `{"keep-last":"3"}`).
|
||||
- `get guest backups <node> <storage> <vmid> -o json` -> list of dicts: `volid`
|
||||
(`truenas-backup:backup/vzdump-lxc-104-2026_11_02-04_11_47.tar.zst`), `ctime` (epoch),
|
||||
`size` (bytes), `format`, `notes`, `protected`, `subtype` (`lxc`|`qemu`), `vmid`.
|
||||
- `do backup guest <node> <vmid> <storage> [--key value]` - no `--yes` option.
|
||||
vzdump params: `mode` snapshot|suspend|stop, `compress` 0|1|gzip|lzo|zstd,
|
||||
`notes-template`.
|
||||
- CT net config: `netN` = `name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=auto,type=veth`
|
||||
(optional `gw`, `gw6`, `tag`, `firewall`, `rate`, `mtu`, ...).
|
||||
- VM net config: `netN` = `virtio=02:24:A6:49:A8:25,bridge=vmbr0[,tag=..][,firewall=1]`
|
||||
(first item is `<model>=<MAC>`); IP settings live in `ipconfigN` (cloud-init), e.g.
|
||||
`ip=dhcp`.
|
||||
- Runtime IPs, CT (running): `get ct interfaces <node> <vmid> -o json` -> list of
|
||||
dicts: `name`, `hwaddr`, `inet` (e.g. `172.16.41.200/23`), `inet6` (may be absent/
|
||||
null), `ip-addresses` (JSON string).
|
||||
- Runtime IPs, VM (running, guest agent): `get vm agent network <node> <vmid> -o json`
|
||||
-> `[{"key": "result", "value": "<JSON string>"}]`; the JSON is a list of
|
||||
`{"name", "hardware-address", "ip-addresses": [{"ip-address", "prefix",
|
||||
"ip-address-type": "ipv4"|"ipv6"}]}`. Fails when the agent is not running.
|
||||
|
||||
### `cli.py`
|
||||
|
||||
- `backup_storages(guest) -> list[str]` - storages on the guest's node whose `content`
|
||||
contains `backup` and that are enabled and active, in API order.
|
||||
- `backup_jobs() -> list[dict]`.
|
||||
- `job_storage(jobs, guest) -> str | None` - storage of the first enabled job covering
|
||||
the guest: (`all` truthy and vmid not in `exclude`) or vmid in `vmid`; and `node` null
|
||||
or equal to the guest's node.
|
||||
- `backup_jobs()` parses `prune-backups` when the CLI returns it as a JSON string
|
||||
(real output: `'{"keep-last":"3"}'`).
|
||||
- `backups(guest, storages) -> list[dict]` - all backups of the guest on those
|
||||
storages, newest first (`ctime` desc). Each dict gets `storage` (from volid prefix).
|
||||
- `backup_now(guest, storage)` -> `do backup guest <node> <vmid> <storage> --mode
|
||||
snapshot --compress zstd --notes-template "{{guestname}} (manual)" --wait`.
|
||||
- `parse_net(value: str) -> dict` - `netN` string to an ordered dict of key -> value
|
||||
(insertion order kept, so B can re-serialise unchanged keys). VM: the first
|
||||
`<model>=<MAC>` item is kept as is (key = model).
|
||||
- `nets(config) -> dict[str, dict]` - `netN` keys (sorted by N) -> `parse_net(value)`.
|
||||
- `interfaces(guest) -> list[dict]` - runtime interfaces as
|
||||
`{"name", "mac", "ipv4": [cidr...], "ipv6": [cidr...]}`, loopback (`lo`) excluded.
|
||||
CT via `get ct interfaces` (addresses from its `ip-addresses` JSON string - `inet6`
|
||||
alone holds only one, e.g. link-local, and misses the global SLAAC address), VM via
|
||||
`get vm agent network`. Only called when the guest's live status is running.
|
||||
|
||||
### `app.py` (UI contract)
|
||||
|
||||
- Key `i` on a guest opens `InfoScreen(guest)` (a `Screen` or `ModalScreen`). Empty
|
||||
table: nothing.
|
||||
- InfoScreen loads in a thread worker: config -> `cli.nets`, `cli.interfaces` (only if
|
||||
status is `running`; a failure there is shown inline as "unavailable: <summary>", not
|
||||
as a fatal error), `cli.backup_storages`, `cli.backup_jobs`, `cli.backups`.
|
||||
- Shows:
|
||||
- `DataTable#backups`: Date (local time, `YYYY-MM-DD HH:MM`), Storage, Size (GiB, one
|
||||
decimal), Format, Notes. Newest first. A `Static#jobs` line naming the job(s) that
|
||||
cover the guest (schedule, storage, `keep-last` if present) or "No backup job
|
||||
covers this guest".
|
||||
- `DataTable#nets`: Iface (net0...), Bridge, VLAN (`tag` or `-`), MAC, IPv4 (CT `ip`,
|
||||
VM `ipconfigN` `ip`, or `-`), IPv6 (CT `ip6`, VM `ipconfigN` `ip6`, or `-`),
|
||||
Firewall (`yes`/`no`).
|
||||
- `DataTable#addresses`: Interface, MAC, IPv4, IPv6 (runtime), or the unavailable
|
||||
line.
|
||||
- VMIDs are reused: a deleted guest's backups keep the VMID and are listed by the API
|
||||
for the new guest. Backups whose notes (the job's `{{guestname}}`) are non-empty and
|
||||
are neither the current name nor start with `<name> ` (e.g. `debian (manual)`) are
|
||||
hidden by default; `Static#hidden` says how many; key `a` toggles showing them,
|
||||
marked `(earlier guest)`. Backups without notes are always shown.
|
||||
- Key `b` in InfoScreen: `BackupScreen` modal with `Select#storage` (options =
|
||||
backup storages, preselected `job_storage` or the first), text saying mode snapshot,
|
||||
zstd, and the guest; buttons `#yes` / `#no`; escape = no. On yes: guest marked busy
|
||||
in `app.busy` (refuse with a warning if already busy), "backup started" notify, worker
|
||||
runs `cli.backup_now`, then "backup done"/error notify, busy released, backup list
|
||||
reloaded if the InfoScreen is still open.
|
||||
- `escape` closes InfoScreen; `g` in InfoScreen reloads it.
|
||||
- Every CLI failure -> error notify with `_message(exc)`, app keeps running.
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Tests (cli + UI contract) - red first.
|
||||
- [x] Implementation - suite green, ruff clean, README keys `i` / `b`.
|
||||
- [x] Read-only live check: open InfoScreen for CT 104 and VM 108, data shown.
|
||||
- [x] User tests "Backup now" on CT 104 (`debian (manual)`, 2026-11-02 21:09).
|
||||
- [ ] Push `feat/guest-info`, merge to `main` (user-approved after the test).
|
||||
|
||||
---
|
||||
|
||||
# Network editing - delivery B
|
||||
|
||||
## Problem
|
||||
|
||||
Change a guest's network settings from the info screen: bridge, VLAN, firewall, IPv4
|
||||
and IPv6 (incl. SLAAC), without breaking what is not edited.
|
||||
|
||||
## Decisions (user-approved)
|
||||
|
||||
- VM IP settings go through cloud-init `ipconfigN`, only when the VM has a cloud-init
|
||||
drive; they apply at the next boot. VMs without cloud-init: IP fields disabled with a
|
||||
note (IP is configured inside the VM).
|
||||
- IPv4 modes: DHCP / Static (CIDR + optional gateway) / None.
|
||||
IPv6 modes: SLAAC / DHCP / Static (CIDR + optional gateway) / None.
|
||||
- Undo key `u`: restores the values from before the last network change made in this
|
||||
app session for that guest.
|
||||
- Out of scope: adding/removing interfaces, MAC, mtu, rate, trunks, DNS.
|
||||
|
||||
API facts (Proxmox VE 9.2, `api usage ... config set -v`):
|
||||
|
||||
- LXC `net[n]`: `name=<string>[,bridge][,firewall=<1|0>][,gw=<IPv4>][,gw6=<IPv6>]
|
||||
[,host-managed][,hwaddr][,ip=<IPv4/CIDR|dhcp|manual>][,ip6=<IPv6/CIDR|auto|dhcp|manual>]
|
||||
[,link_down][,mtu][,rate][,tag=<int>][,trunks][,type=veth]`. `ip6=auto` = SLAAC.
|
||||
Applied immediately on a running CT.
|
||||
- VM `net[n]`: `[model=]<enum>[,bridge][,firewall=<1|0>][,link_down][,macaddr][,mtu]
|
||||
[,queues][,rate][,tag=<int>][,trunks][,<model>=<macaddr>]`. Applied immediately.
|
||||
- VM `ipconfig[n]`: `[gw=<IPv4>][,gw6=<IPv6>][,ip=<IPv4/CIDR|dhcp>][,ip6=<IPv6/CIDR|dhcp|auto>]`
|
||||
(cloud-init; "no explicit gateway with dhcp"). Applies at the next boot.
|
||||
- `delete=<list>` removes settings (used when `ipconfigN` ends up empty).
|
||||
- `get node network <node> -o json` -> list of dicts with `iface`, `type`
|
||||
(`bridge`, `OVSBridge`, `eth`, ...), `active`.
|
||||
- Cloud-init drive: a disk value containing `cloudinit`
|
||||
(`ide2 = local-lvm:vm-108-cloudinit,media=cdrom`).
|
||||
|
||||
### `cli.py`
|
||||
|
||||
- `bridges(guest) -> list[str]` - `iface` of entries with type `bridge` or `OVSBridge`
|
||||
on the guest's node, sorted.
|
||||
- `format_net(d: dict) -> str` - inverse of `parse_net`: `k=v` joined by `,`, order
|
||||
kept. `format_net(parse_net(s)) == s` for the real samples.
|
||||
- `has_cloudinit(config) -> bool`.
|
||||
- `apply_link(net, bridge, tag, firewall) -> dict` - a copy of `net`: `bridge` set;
|
||||
`tag` int 1-4094 or None (None removes `tag`); `firewall` True -> `"1"`, False ->
|
||||
`firewall` removed. Existing keys keep their position; new keys are appended.
|
||||
- `ip_mode(cfg, family) -> tuple[str, str, str]` - `(mode, cidr, gw)` read from a
|
||||
parsed LXC net or VM ipconfig dict; family 4 uses `ip`/`gw`, 6 uses `ip6`/`gw6`.
|
||||
Modes: `dhcp`, `static`, `none`, and for 6 also `auto`; `manual` reads as `none`.
|
||||
- `apply_ip(cfg, family, mode, cidr="", gw="") -> dict` - copy with the family's keys
|
||||
set: `dhcp`/`auto` -> `ip[6]=dhcp|auto`, gateway removed; `static` -> `ip[6]=<cidr>`,
|
||||
`gw[6]` set if given else removed; `none` -> both removed. Validation (ValueError):
|
||||
static needs a CIDR of the right family with a `/prefix`
|
||||
(`ipaddress.ip_interface`); a gateway must be an address of the same family; `auto`
|
||||
only for family 6.
|
||||
- `set_net(guest, changes: dict[str, str | None])` -> one call:
|
||||
`set <kind> config <node> <vmid> [--<key> <value> ...] [--delete <k1,k2>] --wait`;
|
||||
`None` values go to `--delete`. Empty `changes` -> ValueError.
|
||||
|
||||
### `app.py` (UI contract)
|
||||
|
||||
- Key `n` in InfoScreen opens `NetScreen` for the guest (needs the loaded config;
|
||||
nothing if the guest has no `netN`).
|
||||
- `NetScreen(ModalScreen)`: `Select#iface` (netN keys), `Select#bridge`
|
||||
(`cli.bridges`), `Input#tag` (empty = none), `Checkbox#firewall`, `Select#ipv4`
|
||||
(`dhcp`/`static`/`none`), `Input#ipv4_cidr`, `Input#ipv4_gw`, `Select#ipv6`
|
||||
(`auto` labelled SLAAC / `dhcp` / `static` / `none`), `Input#ipv6_cidr`,
|
||||
`Input#ipv6_gw`, buttons `#apply` / `#cancel`; escape = cancel. Prefilled from the
|
||||
current values of the selected interface; changing `#iface` prefills again.
|
||||
VM without cloud-init: IP widgets disabled and `Static#ip_note` explains why.
|
||||
- Apply: build the new netN (and for VMs the new ipconfigN) with `apply_link` /
|
||||
`apply_ip`; ValueError -> error notify, screen stays open, no CLI call. No change ->
|
||||
info notify, nothing else. Otherwise `ConfirmScreen` listing each changed key with
|
||||
its old and new value (raw strings); for a running guest it says link changes apply
|
||||
immediately, and for VM ipconfig that it applies at next boot.
|
||||
- On yes: guest busy (refuse if busy), worker runs `cli.set_net(guest, changes)`,
|
||||
done/error notify, busy released, InfoScreen reloads. On success the old values
|
||||
(`None` for keys that did not exist) are stored as the guest's undo entry.
|
||||
- Live finding (CT 104): Proxmox applies a changed `ip` on a running CT, but static ->
|
||||
dhcp left the CT without IPv4 (no DHCP client restart) until the CT was rebooted.
|
||||
The confirm therefore says, for IP changes on a running container, that a reboot of
|
||||
the container may be needed. Proxmox re-orders keys when it stores `netN`.
|
||||
- Review fixes: undo entries are cleared on a context switch (VMIDs repeat across
|
||||
clusters); `has_cloudinit` only looks at disk keys; CIDR needs a decimal prefix
|
||||
1-32 / 1-128 (no netmask form, no /0); fields the user did not touch are not rewritten
|
||||
(e.g. `ip6=manual` stays).
|
||||
- Key `u` in InfoScreen: no undo entry -> info notify. Otherwise `ConfirmScreen`
|
||||
showing what will be restored, then `cli.set_net(guest, old_values)` the same way;
|
||||
the undo entry is cleared after success.
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Tests - red first.
|
||||
- [x] Implementation - suite green, ruff clean, README keys `n` / `u`.
|
||||
- [x] Live on CT 104 (approved), each step reverted: firewall off->on->off; VLAN
|
||||
none->99->none (undo key); IPv6 SLAAC->DHCP->SLAAC; IPv4 DHCP->static
|
||||
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.
|
||||
- [ ] 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.
|
||||
@@ -0,0 +1,26 @@
|
||||
[project]
|
||||
name = "cv4pve-tui"
|
||||
version = "0.1.0"
|
||||
description = "Textual TUI for cv4pve-cli: manage Proxmox VE guests"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["textual>=8.2"]
|
||||
|
||||
[project.scripts]
|
||||
cv4pve-tui = "cv4pve_tui.app:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"hypothesis>=6.170.14",
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=1.0",
|
||||
"ruff==0.17.3",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
"""Textual TUI for cv4pve-cli."""
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,645 @@
|
||||
"""Thin subprocess wrapper around cv4pve-cli."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
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):
|
||||
"""cv4pve-cli failed; str() is its combined, stripped output."""
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
"""One line for the UI: first non-UPID line, plus its indented detail line."""
|
||||
lines = str(self).splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
text = line.strip()
|
||||
if not text or text.startswith("UPID:"):
|
||||
continue
|
||||
text = text.removeprefix("Error:").strip()
|
||||
if text.endswith(":") and i + 1 < len(lines) and lines[i + 1][:1].isspace():
|
||||
text += " " + lines[i + 1].strip()
|
||||
return text
|
||||
return str(self)
|
||||
|
||||
|
||||
def run(*args: str, timeout: int = 330) -> str:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["cv4pve-cli", *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
timeout=timeout,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise CliError(str(exc)) from exc
|
||||
if proc.returncode != 0:
|
||||
raise CliError(
|
||||
"\n".join(s.strip() for s in (proc.stdout, proc.stderr) if s.strip())
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def kind(guest: dict) -> str:
|
||||
return "vm" if guest["type"] == "qemu" else "ct"
|
||||
|
||||
|
||||
def _api_type(guest: dict) -> str:
|
||||
return "qemu" if guest["type"] == "qemu" else "lxc"
|
||||
|
||||
|
||||
def list_guests() -> list[dict]:
|
||||
guests = json.loads(run("get", "guests", "-o", "json"))
|
||||
for g in guests:
|
||||
g["name"] = (
|
||||
g.get("name") or f"VM {g['vmid']}"
|
||||
) # unnamed guests have no/null name
|
||||
return sorted(guests, key=lambda g: g["vmid"])
|
||||
|
||||
|
||||
def guest_config(guest: dict) -> dict:
|
||||
out = run("show", kind(guest), guest["node"], str(guest["vmid"]), "-o", "json")
|
||||
return {item["key"]: item["value"] for item in json.loads(out)}
|
||||
|
||||
|
||||
_VM_DISK = re.compile(r"(scsi|virtio|sata|ide)\d+")
|
||||
_CT_DISK = re.compile(r"rootfs|mp\d+")
|
||||
|
||||
|
||||
def disks(config: dict, kind: str) -> dict[str, str]:
|
||||
pattern = _VM_DISK if kind == "vm" else _CT_DISK
|
||||
result = {}
|
||||
for key, value in config.items():
|
||||
if not pattern.fullmatch(key):
|
||||
continue
|
||||
value = str(value)
|
||||
size = re.search(r"size=(\w+)", value)
|
||||
if size and "media=cdrom" not in value and "cloudinit" not in value:
|
||||
result[key] = size.group(1)
|
||||
return result
|
||||
|
||||
|
||||
ACTIONS = ("start", "shutdown", "stop", "reboot")
|
||||
|
||||
|
||||
def action(name: str, guest: dict) -> None:
|
||||
if name not in ACTIONS:
|
||||
raise ValueError(f"unknown action: {name}")
|
||||
run("do", name, kind(guest), guest["node"], str(guest["vmid"]), "-y", "--wait")
|
||||
|
||||
|
||||
def _positive(value, what: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
||||
raise ValueError(f"{what} must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def set_resources(
|
||||
guest: dict, cores: int | None = None, memory_mb: int | None = None
|
||||
) -> None:
|
||||
opts = []
|
||||
if cores is not None:
|
||||
opts += ["--cores", str(_positive(cores, "cores"))]
|
||||
if memory_mb is not None:
|
||||
opts += ["--memory", str(_positive(memory_mb, "memory"))]
|
||||
if not opts:
|
||||
raise ValueError("nothing to set")
|
||||
run(
|
||||
"set", kind(guest), "config", guest["node"], str(guest["vmid"]), *opts, "--wait"
|
||||
)
|
||||
|
||||
|
||||
def resize_disk(guest: dict, disk: str, grow_gb: int) -> None:
|
||||
_positive(grow_gb, "grow")
|
||||
run(
|
||||
"do",
|
||||
"resize",
|
||||
kind(guest),
|
||||
"disk",
|
||||
guest["node"],
|
||||
str(guest["vmid"]),
|
||||
disk,
|
||||
f"+{grow_gb}G",
|
||||
"--wait",
|
||||
)
|
||||
|
||||
|
||||
def pending_keys(guest: dict) -> list[str]:
|
||||
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/pending"
|
||||
items = json.loads(run("api", "get", path, "-o", "json"))
|
||||
return [i["key"] for i in items if i.get("pending") is not None]
|
||||
|
||||
|
||||
def current(guest: dict) -> dict:
|
||||
"""status/current as a dict: live status plus cpu, mem, maxmem, disk, ..."""
|
||||
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current"
|
||||
return {
|
||||
i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json"))
|
||||
}
|
||||
|
||||
|
||||
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*$")
|
||||
|
||||
|
||||
def contexts() -> list[dict]:
|
||||
"""Parse `config list` (text only); lines that do not look like a context are skipped."""
|
||||
found = (_CONTEXT_LINE.match(line) for line in run("config", "list").splitlines())
|
||||
return [
|
||||
{"name": m[2], "host": m[3], "auth": m[4], "current": bool(m[1])}
|
||||
for m in found
|
||||
if m
|
||||
]
|
||||
|
||||
|
||||
def use_context(name: str) -> None:
|
||||
"""Changes cv4pve-cli's GLOBAL current context."""
|
||||
run("config", "use", name)
|
||||
|
||||
|
||||
def backup_storages(guest: dict) -> list[str]:
|
||||
"""Enabled, active storages on the guest's node that accept backups, in API order."""
|
||||
items = json.loads(run("get", "node", "storages", guest["node"], "-o", "json"))
|
||||
return [
|
||||
s["storage"]
|
||||
for s in items
|
||||
if "backup" in (s.get("content") or "").split(",")
|
||||
and s.get("enabled")
|
||||
and s.get("active")
|
||||
]
|
||||
|
||||
|
||||
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]:
|
||||
jobs = json.loads(run("get", "cluster", "backup-jobs", "-o", "json"))
|
||||
for job in jobs: # the CLI returns prune-backups as a JSON string
|
||||
if isinstance(job.get("prune-backups"), str):
|
||||
job["prune-backups"] = json.loads(job["prune-backups"])
|
||||
return jobs
|
||||
|
||||
|
||||
def _ids(value) -> list[str]:
|
||||
return str(value).split(",") if value else []
|
||||
|
||||
|
||||
def covering_jobs(jobs: list[dict], guest: dict) -> list[dict]:
|
||||
"""Enabled jobs that back up the guest (job order)."""
|
||||
vmid = str(guest["vmid"])
|
||||
return [
|
||||
j
|
||||
for j in jobs
|
||||
if j.get("enabled")
|
||||
and (
|
||||
j.get("all")
|
||||
and vmid not in _ids(j.get("exclude"))
|
||||
or vmid in _ids(j.get("vmid"))
|
||||
)
|
||||
and j.get("node") in (None, guest["node"])
|
||||
]
|
||||
|
||||
|
||||
def job_storage(jobs: list[dict], guest: dict) -> str | None:
|
||||
covering = covering_jobs(jobs, guest)
|
||||
return covering[0]["storage"] if covering else None
|
||||
|
||||
|
||||
def backups(guest: dict, storages: list[str]) -> list[dict]:
|
||||
"""The guest's backups on the given storages, newest first, each with its `storage`."""
|
||||
found = []
|
||||
for storage in storages:
|
||||
out = run(
|
||||
"get", "guest", "backups", guest["node"], storage, str(guest["vmid"]),
|
||||
"-o", "json",
|
||||
) # fmt: skip
|
||||
for item in json.loads(out):
|
||||
item["storage"] = item["volid"].split(":", 1)[0]
|
||||
found.append(item)
|
||||
return sorted(found, key=lambda b: b["ctime"], reverse=True)
|
||||
|
||||
|
||||
def backup_now(guest: dict, storage: str) -> None:
|
||||
"""Same settings as the scheduled job; no --yes option exists for this command."""
|
||||
run(
|
||||
"do", "backup", "guest", guest["node"], str(guest["vmid"]), storage,
|
||||
"--mode", "snapshot", "--compress", "zstd",
|
||||
"--notes-template", "{{guestname}} (manual)", "--wait",
|
||||
) # 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:
|
||||
"""`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)
|
||||
|
||||
|
||||
def nets(config: dict) -> dict[str, dict]:
|
||||
keys = sorted(
|
||||
(k for k in config if re.fullmatch(r"net\d+", k)), key=lambda k: int(k[3:])
|
||||
)
|
||||
return {k: parse_net(str(config[k])) for k in keys}
|
||||
|
||||
|
||||
def _addresses(listed: list[dict], types: dict[str, str]) -> dict[str, list[str]]:
|
||||
"""ip-addresses entries -> {"ipv4": [cidr], "ipv6": [cidr]}; unknown types ignored."""
|
||||
addrs = {"ipv4": [], "ipv6": []}
|
||||
for a in listed:
|
||||
family = types.get(a.get("ip-address-type"))
|
||||
if family:
|
||||
addrs[family].append(f"{a['ip-address']}/{a['prefix']}")
|
||||
return addrs
|
||||
|
||||
|
||||
def interfaces(guest: dict) -> list[dict]:
|
||||
"""Runtime interfaces of a running guest, without loopback."""
|
||||
args = (guest["node"], str(guest["vmid"]), "-o", "json")
|
||||
if kind(guest) == "ct":
|
||||
found = []
|
||||
for i in json.loads(run("get", "ct", "interfaces", *args)):
|
||||
listed = i.get("ip-addresses") # all addresses; inet6 alone holds only one
|
||||
if isinstance(listed, str):
|
||||
listed = json.loads(listed)
|
||||
if listed:
|
||||
addrs = _addresses(listed, {"inet": "ipv4", "inet6": "ipv6"})
|
||||
else:
|
||||
addrs = {"ipv4": [i["inet"]] if i.get("inet") else [],
|
||||
"ipv6": [i["inet6"]] if i.get("inet6") else []} # fmt: skip
|
||||
found.append({"name": i["name"], "mac": i.get("hwaddr"), **addrs})
|
||||
else:
|
||||
items = json.loads(run("get", "vm", "agent", "network", *args))
|
||||
found = []
|
||||
for i in json.loads(
|
||||
{x["key"]: x["value"] for x in items}.get("result") or "[]"
|
||||
):
|
||||
addrs = _addresses(
|
||||
i.get("ip-addresses") or [], {"ipv4": "ipv4", "ipv6": "ipv6"}
|
||||
)
|
||||
found.append({"name": i["name"], "mac": i.get("hardware-address"), **addrs})
|
||||
return [i for i in found if i["name"] != "lo"]
|
||||
|
||||
|
||||
def bridges(guest: dict) -> list[str]:
|
||||
items = json.loads(run("get", "node", "network", guest["node"], "-o", "json"))
|
||||
return sorted(i["iface"] for i in items if i.get("type") in ("bridge", "OVSBridge"))
|
||||
|
||||
|
||||
def format_net(d: dict) -> str:
|
||||
return ",".join(
|
||||
f"{k}={v}" if v else k for k, v in d.items()
|
||||
) # bare flags stay bare
|
||||
|
||||
|
||||
def has_cloudinit(config: dict) -> bool:
|
||||
"""A cloud-init drive: a disk (not a name or description) whose volume says so."""
|
||||
return any(
|
||||
_VM_DISK.fullmatch(k) and "cloudinit" in str(v) for k, v in config.items()
|
||||
)
|
||||
|
||||
|
||||
def apply_link(net: dict, bridge: str, tag: int | None, firewall: bool) -> dict:
|
||||
"""Copy of `net` with bridge, VLAN tag and firewall set; other keys stay in place."""
|
||||
if tag is not None and not 1 <= tag <= 4094:
|
||||
raise ValueError("VLAN tag must be 1-4094")
|
||||
out = dict(net)
|
||||
out["bridge"] = bridge
|
||||
if tag is None:
|
||||
out.pop("tag", None)
|
||||
else:
|
||||
out["tag"] = str(tag)
|
||||
if firewall:
|
||||
out["firewall"] = "1"
|
||||
else:
|
||||
out.pop("firewall", None)
|
||||
return out
|
||||
|
||||
|
||||
def ip_mode(cfg: dict, family: int) -> tuple[str, str, str]:
|
||||
"""`(mode, cidr, gw)` of one family from an LXC net or VM ipconfig dict."""
|
||||
ip, gw = cfg.get(_IP_KEYS[family][0], ""), cfg.get(_IP_KEYS[family][1], "")
|
||||
if ip in ("dhcp", "auto"):
|
||||
return ip, "", ""
|
||||
if ip in ("", "manual"):
|
||||
return "none", "", ""
|
||||
return "static", ip, gw
|
||||
|
||||
|
||||
_IP_KEYS = {4: ("ip", "gw"), 6: ("ip6", "gw6")}
|
||||
|
||||
|
||||
def apply_ip(cfg: dict, family: int, mode: str, cidr: str = "", gw: str = "") -> dict:
|
||||
"""Copy of `cfg` with one family's address keys set; ValueError on bad input."""
|
||||
ip_key, gw_key = _IP_KEYS[family]
|
||||
out = dict(cfg)
|
||||
if mode == "static":
|
||||
try:
|
||||
prefix = cidr.partition("/")[2]
|
||||
if not (
|
||||
prefix.isdecimal() and 1 <= int(prefix) <= (32 if family == 4 else 128)
|
||||
):
|
||||
raise ValueError # CIDR only: no netmask form, no /0
|
||||
if ipaddress.ip_interface(cidr).version != family:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(f"IPv{family} needs an address like 10.0.0.5/24") from None
|
||||
try:
|
||||
if gw and ipaddress.ip_address(gw).version != family:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"IPv{family} gateway must be an IPv{family} address"
|
||||
) from None
|
||||
out[ip_key] = cidr
|
||||
if gw:
|
||||
out[gw_key] = gw
|
||||
else:
|
||||
out.pop(gw_key, None)
|
||||
elif mode in ("dhcp", "auto"):
|
||||
if mode == "auto" and family != 6:
|
||||
raise ValueError("SLAAC (auto) exists only for IPv6")
|
||||
out[ip_key] = mode
|
||||
out.pop(gw_key, None)
|
||||
elif mode == "none":
|
||||
out.pop(ip_key, None)
|
||||
out.pop(gw_key, None)
|
||||
else:
|
||||
raise ValueError(f"unknown mode: {mode}")
|
||||
return out
|
||||
|
||||
|
||||
def set_net(guest: dict, changes: dict[str, str | None]) -> None:
|
||||
"""Set the given keys; `None` values are deleted. One call."""
|
||||
if not changes:
|
||||
raise ValueError("nothing to set")
|
||||
opts = []
|
||||
for key, value in changes.items():
|
||||
if value is not None:
|
||||
opts += [f"--{key}", value]
|
||||
gone = [k for k, v in changes.items() if v is None]
|
||||
if gone:
|
||||
opts += ["--delete", ",".join(gone)]
|
||||
run(
|
||||
"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()
|
||||
+5111
File diff suppressed because it is too large.
Load diff
+1821
File diff suppressed because it is too large.
Load diff
@@ -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()) == []
|
||||
@@ -0,0 +1,314 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cv4pve-tui"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "textual" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "hypothesis" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "textual", specifier = ">=8.2" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "hypothesis", specifier = ">=6.170.14" },
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.0" },
|
||||
{ name = "ruff", specifier = "==0.17.3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.170.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/0f/10b2076a1314c554eee58c26e1dbe630fd26a32773df2a63bd5dba5d9947/hypothesis-6.170.14.tar.gz", hash = "sha256:8272913eb34c4399a88cf8a9ecdb351c9ef8f621c061f2ac323362d4a66b755d", size = 521490, upload-time = "2026-10-30T06:51:55.943Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d6/eae1191f3398b8cc9f10b20c9b93dd1ceae037565e94ababf521730e59a4/hypothesis-6.170.14-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b1c4990f9fafad83cdac33bc564d41066f5824a92ddd45ded7e0e84277a3ec0e", size = 800123, upload-time = "2026-10-30T06:50:33.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/78/434915150cecd5b9ba41837106a3da2782f515a9aed583d25860bfa6d71c/hypothesis-6.170.14-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ab414b45a56853fcdd6b602d9c5033e3c7d127ee8b8e7e3397223b746440b37c", size = 795278, upload-time = "2026-10-30T06:50:22.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/6b/07f5e5e667b4b3e90bea16b4db09367b5bb1dfae63eb75a7152b053b0c50/hypothesis-6.170.14-cp311-abi3-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:cceb1b3c19ea0a146c7bb2fb2b44e3ef231bcecd42c97d95154e5d233e209572", size = 1162031, upload-time = "2026-10-30T06:50:08.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ba/51e0dc10befcc4ca9b7fb81b76ca6aef4ffc9c7a741191a8b41e878ee2f2/hypothesis-6.170.14-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:376dbf5ffa1e3ad71af2a1af0877176faabe3e63d345324a33c82e408dbfd604", size = 1124181, upload-time = "2026-10-30T06:51:51.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/37/77834f102b0bca702f723dd519b8f590e83ec6392e59565384cfdc5bbec7/hypothesis-6.170.14-cp311-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5f0f6f6666fa15ec13ca1d8430f70660fce8b3f4f133e25fdf674be6ad77295e", size = 1154664, upload-time = "2026-10-30T06:51:50.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/76/83edc11f33319a2febd025fb504aa1ed8e245b7e25905da80513615e4b5e/hypothesis-6.170.14-cp311-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c4ff70fbb94f54cd35045a40e5a77dd8f876d4cc6a39dcdc4ac1af6f8f5cc6fe", size = 1148576, upload-time = "2026-10-30T06:50:18.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/35/d4086d546c0abe406f0ece17e33eca7051e87dc7501485a696385aed34fd/hypothesis-6.170.14-cp311-abi3-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8ecec6be3a02e22c70bc7dfb15afeeeb708aeccb994c9d57db438199cad9bfbb", size = 1201336, upload-time = "2026-10-30T06:50:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/85/117b0dde708240854ce0f1ad95d43bc132f59e6bffc82c2a20e76b984020/hypothesis-6.170.14-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3bbf06c4f8e3aab37c144fe3bc1d89a051a2dfbefef8c2a25160b11da854f949", size = 1167592, upload-time = "2026-10-30T06:50:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/bb/105ee91bf92e3b7a5bc388827e774b7c5744f39ce36f50f94918b584813c/hypothesis-6.170.14-cp311-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:07869d66bf1e92e39447808fcc1977bfd10bfdff4970d6969a49e89a84cf04a7", size = 1123748, upload-time = "2026-10-30T06:51:07.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/9b/c60e868ba0f93ff5bc7cec3e413c5e824863bbd9bd45dcc86bc18adb4266/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0ce9ddf0d0e99c532c2cf00d97b63316830810d18f11c80c7bcf0f771969f3a4", size = 1299121, upload-time = "2026-10-30T06:51:00.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/f6/a2b9630ddc70aa7642931ac6c353fc57df38c6de655718a50337b5b7d4e0/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:420b4a77157e94f124498fff639284d514b60926f61925dd7aafb75411e7ce92", size = 1427779, upload-time = "2026-10-30T06:50:15.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/36/b3e7d3fef4ce3f44f70a61399a3549c02a71e430122f688880a24336e9a6/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:2a8ed08715a9a27d3a4730f48c99133ef25b3561d8fe992d1eda91e22cf6be99", size = 1381908, upload-time = "2026-10-30T06:50:58.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/52/b044ccaf1cb9d89f319b737f1fee7cc83e16c2fd086e1bf1ce149092eb44/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:71081b46714e95e9a10d922bedd08d5a6e49835bbc2ef37a81d30d62f8596829", size = 1277802, upload-time = "2026-10-30T06:51:53.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/2f/989ef755683c7cefd195670861a2fbd72b6b7952c124256152e94660e62c/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:ce563cb34dedc9163811b03ce05a955eededd6f429db7c80c5c31d5836a9c05a", size = 1293571, upload-time = "2026-10-30T06:50:03.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/00/7426641c28dfc52e0a0d32748a1e47d3c189f9cf7eef81c0ecb7e90eb4cc/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06a752e3e879f1b69dda9ea203d4e8e1562cf72f694513f01c8c99d2e5e3f3cc", size = 1333329, upload-time = "2026-10-30T06:50:50.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/55/88307f535bb0914824b0eb2c6b58aa4a482b483c8d25d8022e7b4f641f8c/hypothesis-6.170.14-cp311-abi3-win32.whl", hash = "sha256:0f418c6bff7cd8a117dd0f526c20f100c32df1ff9f7bc8e01a5eeb3855278039", size = 687305, upload-time = "2026-10-30T06:50:43.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/61/ba5bb3e8593afa9212fad51dc70f219ef6da7527d806c62a42c1cf12247b/hypothesis-6.170.14-cp311-abi3-win_amd64.whl", hash = "sha256:1e9f7619dff3072685e4f36e8ae7b134a0c1ca41074fc54ade81e5355c72cd8c", size = 693378, upload-time = "2026-10-30T06:51:36.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/72/482a68ae0e94b24acd964196cca830d1dd65efbf3d4821599d1c9527f8e2/hypothesis-6.170.14-cp311-abi3-win_arm64.whl", hash = "sha256:7dbaec9a62b7792b0b04b7ebc933aeafd3ff019b81d67d763c36ebcf4d56f864", size = 691696, upload-time = "2026-10-30T06:50:32.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/41/aa2c8ffc1490f02ec2b0e283e0856a0822c2b0707a83831e286493f888f5/hypothesis-6.170.14-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:76b03ded37a7eb14d01b03edca02b9a6ce6b8ab0ad8d2f9ac5f2e9de6769a33a", size = 801893, upload-time = "2026-10-30T06:51:10.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/45/a82212e5773214d3ad1e0cee626098c1068adc93cdb37449e3c0629aa591/hypothesis-6.170.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a6055172afdca41cda869652bd2f2c24ec984005cd16bbec5a8f38483f23b9bc", size = 793892, upload-time = "2026-10-30T06:50:55.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cc/1889082d37e946e006845163a91e4091d1cff4f1692e3580729454cb02f4/hypothesis-6.170.14-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6490c64811a2176a5af9fe5a5d59ab2aa20d35d691328cbb336f6fb398355c4", size = 1123577, upload-time = "2026-10-30T06:50:11.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/7a/311a4ec14dbde1ce82cbd4d8957665c4d68e9139524b10e0a622db98bc4e/hypothesis-6.170.14-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cde7e98694a9baa1d9761c1b595b3d617467c876200c0798a40f2016a0e85304", size = 1167206, upload-time = "2026-10-30T06:50:05.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/0d/1afb5e9f470898cf296f10545cfbf76e6b8d4d7025e0c41a491bbe969a22/hypothesis-6.170.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e39d46bdebb3853fb3000aab3c354e4d780705dc8b2674773ffcdfd04fd5122", size = 1298455, upload-time = "2026-10-30T06:51:30.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/f6/ff7e9568f7a7d9ec375c68636c66e1b1a3baa06f44652535ff434110f6cd/hypothesis-6.170.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1309925a71a2baacb85ed5c20291ddb4b2a4665bd33fb594697463d457374a1b", size = 1332876, upload-time = "2026-10-30T06:50:57.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/73/fd330eec15026dc30d0ec8c658517c99703774098305632efb3e523b2f38/hypothesis-6.170.14-cp312-cp312-win_amd64.whl", hash = "sha256:ac98e833bdefbcbebb9094180a7431a8946f1acc91a5b779c814aecca71c7345", size = 691003, upload-time = "2026-10-30T06:50:36.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/95/9729cc49645d07211ce8158f0bb51860873b3b99fb1650c2dad78bd2e86c/hypothesis-6.170.14-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d5e5affa9e69b8673f15bef86421740945e74f3c31d0817141ba020c7e00705", size = 801888, upload-time = "2026-10-30T06:50:06.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ac/687277e019d68b96d2c33b979ad8a955b5747e279891f8c335bbf58cd9bc/hypothesis-6.170.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7ae9ff3220659c4721acb58d34e72227a0af7747fc903ae177dfdb0323796b4", size = 793879, upload-time = "2026-10-30T06:51:09.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/1a/b46a2959b41ff9409af8b43fd63b07c6ed51958db0e9d314db9ee6012f5a/hypothesis-6.170.14-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:857932ff6bba183af0236f659b042023ab8b4ad58725b9024bcaba941c8cd5d0", size = 1123483, upload-time = "2026-10-30T06:50:47.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/2a/6735b2edff23d045441b5917f35c01a4c139a186604847ca19bbd63e81e2/hypothesis-6.170.14-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8126e8208acadfc3c92d0aedb8011cf724fb59e5523ceaca3f4612dcc409e632", size = 1167158, upload-time = "2026-10-30T06:51:26.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f0/b5b4273568620080db24dda2008236bd6c64599ab03210e46977b44b54d5/hypothesis-6.170.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d28ab5c5b08b1de11bc022d576b20f9f585f7e7758778295461f80b9222d7a18", size = 1298440, upload-time = "2026-10-30T06:50:10.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/c7/38427a9803dac9bdd06581061a84672107d821f6d91b833dec467c37f5d5/hypothesis-6.170.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:033aca79fd3f9a6e15402dfddbc988141d43eae38d26a5fc554f6879f7e0e5f9", size = 1332909, upload-time = "2026-10-30T06:51:17.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/4f/47c060c96d077750c8a7b83b2e50b1add2050b391e77f49126828c125264/hypothesis-6.170.14-cp313-cp313-win_amd64.whl", hash = "sha256:03831d833b161b9b7a32f0b607205293954f3a651196c80138804b9479845d71", size = 690983, upload-time = "2026-10-30T06:51:04.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/3a/7ce36c91aafb192e08d990b3a8fd632be113eeb43973a94fddaa4172c570/hypothesis-6.170.14-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a2410c5a6dcc011338e53cd381bd32d979b6f06d799c5cf01d76b923f0a0670b", size = 802098, upload-time = "2026-10-30T06:51:48.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/dc/17bc4ba00a48976944918bb5d7207519ed206fdf98af0d0319e5ba33afd2/hypothesis-6.170.14-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcf6303c65e73acf134b7854cbdfec8f2108cfdc84806fa180de04d7946aab0c", size = 793921, upload-time = "2026-10-30T06:50:07.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/c3/252db9362dab21e3c3351abb89e715fa1ef598ebaf30abb9c9b1dd3b3aab/hypothesis-6.170.14-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67d17e8b91d3913e7aa2b1179957df4e0634caea3868b5ba4fe2bf1945334134", size = 1123996, upload-time = "2026-10-30T06:51:05.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/41/573468979e9579eb6df937e6a96c1a944dfe2765c73624156571e3cc4b34/hypothesis-6.170.14-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:96f9f8a684c88ad2280129b28bc609adb6b31cd63b1b83b397b86d552391e947", size = 1167330, upload-time = "2026-10-30T06:51:23.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1c/d1b790ec3247966ceb3d5729bb79f810e24b1684b49b6290c9ad5be05e33/hypothesis-6.170.14-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c42d504d9bc0aef71fa88877c68ce202c02242c84f05791be2785e01e3a8699", size = 1298940, upload-time = "2026-10-30T06:51:27.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/a3/2fb6f7e825c9d943c5c35e0d9ef968e416267db2ef710e6d2d3cf7f36ab0/hypothesis-6.170.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ad84ca98f38e9f385b0f616df04bea4a9c6849333e221ce9289e9566ccd41058", size = 1333102, upload-time = "2026-10-30T06:51:20.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/09/b533e4ac06dc317314d574dd0e3b548bac9cffe05e7e7a32f7b25aa7f052/hypothesis-6.170.14-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f569bd9745835f9864d53c853e401c2021e796fdbb385497fdae633fe950b6ba", size = 633715, upload-time = "2026-10-30T06:50:25.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/c6/9c47f183e5a8a4059d7c1c39e85f7b140f4416961b5ed0dac5d35dc6e1f7/hypothesis-6.170.14-cp314-cp314-win_amd64.whl", hash = "sha256:31c6a4015c02c45ba0285ff48538f4eb2c2de7e836f313bb8832fd8d86f71d62", size = 690702, upload-time = "2026-10-30T06:50:41.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/47/623b189cc8d9e360bfbb69828fb88ca4d0d4996b3975f3191b49a50f04ce/hypothesis-6.170.14-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:744c587791b2c5e38d1da2eaca617b1a82073832dfcf42975ddb03e93f187919", size = 800266, upload-time = "2026-10-30T06:50:29.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/af/7ec2697869df2c7f5d1c414b1a0a21746aa0298d41d7358e45fb7ed83992/hypothesis-6.170.14-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d0daded6c2f3e05f6bd989059bb798e570722802e05eb3543c6f5d7a1e7e6500", size = 792500, upload-time = "2026-10-30T06:50:51.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/cf/2c7b13b5e56cd42789dbef4948f21e685c4a4db7dd6daffa253f261a9193/hypothesis-6.170.14-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:986eaec8ca5a22710b1086b88734035c7d7ffcdb4ce02341b9f7069cc1bdfc5e", size = 1122469, upload-time = "2026-10-30T06:51:31.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/17/aab31d83a3195edf21ff0b05ea8fabf1fcf723517a6ba7bd1aee3b2713e3/hypothesis-6.170.14-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44c481115770bb3df5014b0369ea1aaf7319b81b3346a744a9265fc933190c3d", size = 1166369, upload-time = "2026-10-30T06:50:54.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ce/88ab370d5c1e06c74b87995381c28f5c1456bcd832db339bb93b6b15cae5/hypothesis-6.170.14-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:261ae20a5573cf59e5dbf931c7fb9abcb012695adccb61fdab108cbba1772ee5", size = 1297198, upload-time = "2026-10-30T06:51:22.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/03/5c8d82f48d3a7adb593d1c141fdae597ef874e27dabd94ede9edd5c36c29/hypothesis-6.170.14-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:833f7e6d93c03f3c00583a126e61f631d6b91a84e157a30da2d1c786ce5658b7", size = 1332114, upload-time = "2026-10-30T06:51:34.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/24/b08d06042af15e1a5fd2fe378feaa2336983f4ecc2bf645b38c6accf7d7d/hypothesis-6.170.14-cp314-cp314t-win_amd64.whl", hash = "sha256:224cad0e7ba8bfc6ef708652a3cc2148161e3157d191eb77ecc85dc5d16afb70", size = 690815, upload-time = "2026-10-30T06:51:43.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/72/bbd7a5341e23b56759937cef4fe83ea9c907f15ffccee1c2e8e2c8937546/hypothesis-6.170.14-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:e76877e7c7a5cf8c4fbf98280dc6582f5d6450a73494490e8b48b7d03fb5e12f", size = 799866, upload-time = "2026-10-30T06:50:12.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f0/be3b05a4c57bbb91b0048c37a5f3a278accb1ec51e853bb87037379c3f93/hypothesis-6.170.14-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:5a613867bd2675101dda8fda380c3ce6be47842bed5640e1ba7b6b6fd0ffe8a3", size = 792315, upload-time = "2026-10-30T06:50:37.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/2b/f0a1509d5d4ad2e3b3c834bebd8a4b1f430d01d350710f45338342756b91/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:a2249f2428ea94c47bccceb9fb8beada242c577dd4b3cd2edef5fb7160d1b71b", size = 1158319, upload-time = "2026-10-30T06:50:46.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/5c/eea759905d4b7d4d9a750b9b61a419120742143288c48c7d0ba633efc5f1/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:20df34cca1265c1683ccdcab5872cf0a023f40235a3092a96d4bd81e4fc57986", size = 1121221, upload-time = "2026-10-30T06:50:28.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/b0/3c90a9e734aa0a4aef0fa9004dd7c2bcd27a6e66bc32a8690647639cba88/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:4a7f8023efbec61296fd20edb35d638ec89ff761d3f9d3dc8128ac8f204652ec", size = 1152108, upload-time = "2026-10-30T06:50:35.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/fd/4b7e7f69ee248547f6fe69475197ffbb5ae9563ab49c1430f020bd1d5493/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3faa2b45f30320fb57ce1c30f1bf9f08b1fd1cc1c606ae2a3e1efd0256fd7c77", size = 1145869, upload-time = "2026-10-30T06:51:54.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/de/dc96bc0646e8543386e3c539fea154075b2d0f40e6b3fb19022c43058dac/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5be7a2239adde6c72001eb42cdb924d3c3856b1ee80342049961efdd6c72d064", size = 1199697, upload-time = "2026-10-30T06:51:40.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/fd/363472210e3ed2a356b7bc6ca8ca405c0dae6531ad43c8f76ca20a946884/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1a7aa883cd30c6c0c7259f34c1e3f37617c0d52fa6718e93c9926cc49b8047d6", size = 1165533, upload-time = "2026-10-30T06:50:14.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/3f/989ab6da164efe574399debdf64370d153f17d4cfda51c8d7e14781dc92b/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:79360d1dd671e602d2327e26fb9179645511adcfb9ec9d28c5d6bda313920aa2", size = 1119844, upload-time = "2026-10-30T06:51:08.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/c4/7758868c29d3fbcdd0face967a2d7e3af221a1c8d57802c7ac6cb49becd9/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:e4c45885e931a55c5097ebf0a9b65459878ad5e1c69c0448ae37e03fd764ddfc", size = 1296008, upload-time = "2026-10-30T06:50:40.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/b2/dc88869b6f8508fbfb290c4a9b03328142743d041ae4a144870b904b69bd/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:a6d09601401c019366826effa98ae64393dd149b50d7d6ea202ad472784278a8", size = 1425148, upload-time = "2026-10-30T06:51:18.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/f2/f6dde97e8a1d017d9663164f48f600f65e9d2ade37c43447421f6a369cbe/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:05e2a645b94fd43107a3360fe0aaf6db268199c80a43cf375fac5f1f9047745f", size = 1378730, upload-time = "2026-10-30T06:51:16.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/42/e2aa93586527428b51c019a4cedf28e1d2f10dfec5f9acfa3e1a22cd2e86/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cb54946f70ae5008ea8cd58ce9a27b2126506732b53b83a6d117371d7c755362", size = 1274346, upload-time = "2026-10-30T06:51:14.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/22/17ee8d38c5cb0a948ba9bb6cd7be2351a021cf1ea6686aa4fac16c29c45a/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:b72805cf56012a3d96f4a1fbb869f178d448f242b4f64310956134a3c952113f", size = 1291604, upload-time = "2026-10-30T06:50:53.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c8/c97cdaa1191519fadcc06bc118f271910d7d7972caeecb70b7003a62303c/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:a17500680ec2c3cf006c12d3832a6c8daba8a18386f93b3d1828de07b07910aa", size = 1331507, upload-time = "2026-10-30T06:51:02.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c7/1007bdaf517b086df3c54d06de6c47f099704fd8bd6c2b5013e749833ee4/hypothesis-6.170.14-cp315-abi3.abi3t-win32.whl", hash = "sha256:b1d9baaef909100fd1a6ba5457ecffa4a184e820c7f0bd3d4589f14870f5cade", size = 684627, upload-time = "2026-10-30T06:50:49.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/d1/68539ecbe240de4afc03c5fa120ecf0875690708f2a97914352ae3172d36/hypothesis-6.170.14-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a725b854c5df088c5a579f1c323657258abe5d8e32e16cd07beb48960f31c070", size = 690617, upload-time = "2026-10-30T06:51:42.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d7/9671c708cdab1c49ff96344f6730ccd76a7e1c2bdbbb2bed93bc9d6a18b4/hypothesis-6.170.14-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:75f60ef38c010742339a9b2ec2a44cb0aed6c6c8f1455ad6a6de48f5216a72b2", size = 688595, upload-time = "2026-10-30T06:51:37.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ae/822ac5f03f0f13e96b4a7b380ede8fa4ae2a46730f4e5c6d7eca016147bb/hypothesis-6.170.14-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:c7b5dee8b87f8a78c1786eb1f96a6c0c91fcc2e10b2e9d876dcbc8e3f65dffc3", size = 801000, upload-time = "2026-10-30T06:50:44.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b2/e4a0901adb647ca375a794cbf1a23e5e49e59c77d191d1922e3d3c4ce6b6/hypothesis-6.170.14-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:e99d1bc7d8e8fc17cb67a38e003cbf4a17f41f7851c1f205efd35b6f3ba93da5", size = 792896, upload-time = "2026-10-30T06:51:25.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/9c/ac50eed9becb0de590d8af7bd1b67546e58b84dda4abb89ca563eed1babe/hypothesis-6.170.14-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d66dab3b79eda78964665ab4f9d7a269137c14cd6ec7ea4fb27002a8786452b9", size = 1122853, upload-time = "2026-10-30T06:51:33.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/7f/bc41905c23c74c3db4620a5382977b168156f357e8a421c2b9f66f764979/hypothesis-6.170.14-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d829c29907e7d86f8b1d33041bb73814062a3681ec51dcb231f64874f88b14", size = 1167065, upload-time = "2026-10-30T06:51:45.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/17/b28b4348a95694562357dc0c8ecb521dd607051dddd7af158c754b895710/hypothesis-6.170.14-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:90d4e85f22c3154a59df851bb2c59c89c129fba3155125d98ebad80bf69aad26", size = 692119, upload-time = "2026-10-30T06:51:19.8Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify-it-py"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
linkify = [
|
||||
{ name = "linkify-it-py" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdit-py-plugins"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/886dc7c9d21e2d95aceb696f1e31271742033aeef3661f6e9c8921493592/platformdirs-4.14.3.tar.gz", hash = "sha256:021add1ab113483edbcd4694f5fbb2d688b893ea30d35e1155bb371cf4be0be8", size = 71124, upload-time = "2026-11-01T16:27:32.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/41/f0aa003909ee77a9e7e55f8659abb635ca23ab0d022a449bbb18da87a1ef/platformdirs-4.14.3-py3-none-any.whl", hash = "sha256:cba2d05fe5e18b26b1fa6f376b8e6234c012abe1f8696c486fface7d0054e931", size = 35276, upload-time = "2026-11-01T16:27:31.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.1.post1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/7f/c60a4338420b33453d4f814ac0c930abd399f162b9bdd21af4b9e363037f/pytest_asyncio-1.4.1.post1.tar.gz", hash = "sha256:f582d9ea4a1c082195abe8bd29a1fe106aeb1cd435e3dad2d3bc2749935c36a2", size = 145360, upload-time = "2026-11-01T18:26:03.732Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/de/adbc7578959cfab07e5e7c05cf6d54224a51e7667ea2c07458b6bc571e75/pytest_asyncio-1.4.1.post1-py3-none-any.whl", hash = "sha256:0755e488cc0a2b9bd2eab7292cc6390d4cbb8390f1020745647bae5a708e0165", size = 17140, upload-time = "2026-11-01T18:26:02.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sortedcontainers"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textual"
|
||||
version = "8.2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||
{ name = "mdit-py-plugins" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pygments" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
Reference in new issue
Block a user