Compare commits
21
Commits
b874996b1d
...
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
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.claude/scratch/
|
||||||
@@ -1,14 +1,35 @@
|
|||||||
# cv4pve-cli-tui
|
# cv4pve-cli-tui
|
||||||
|
|
||||||
|
[](https://git.duckboot.net/Duckboot/cv4pve-cli-tui/actions)
|
||||||
|
|
||||||
Textual TUI for [cv4pve-cli](https://github.com/Corsinvest/cv4pve-cli): list Proxmox VE
|
Textual TUI for [cv4pve-cli](https://github.com/Corsinvest/cv4pve-cli): list Proxmox VE
|
||||||
guests (VMs and LXC), start/shutdown/stop/reboot them, and change cores, memory and
|
guests (VMs and LXC), start/shutdown/stop/reboot them, and change cores, memory and
|
||||||
disk size.
|
disk size. The table shows CPU % and Mem % of running guests and the info screen shows
|
||||||
|
in-use values (refresh with `g`).
|
||||||
|
|
||||||
It shells out to `cv4pve-cli` and uses its current context (`cv4pve-cli config use <name>`);
|
It shells out to `cv4pve-cli` and uses its current context (`cv4pve-cli config use <name>`);
|
||||||
it stores no credentials itself. Tested with cv4pve-cli 2.4.0.
|
it stores no credentials itself. Tested with cv4pve-cli 2.4.0; with another version the app
|
||||||
|
warns at startup, because it parses output formats that are not a stable API.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Needs [uv](https://docs.astral.sh/uv/) and a working `cv4pve-cli` on `PATH`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv tool install git+https://git.duckboot.net/Duckboot/cv4pve-cli-tui
|
||||||
|
cv4pve-tui # from any directory
|
||||||
|
uv tool upgrade cv4pve-tui # later: get the newest main
|
||||||
|
uv tool uninstall cv4pve-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
From a local checkout instead: `uv tool install .` (add `--force` to reinstall after
|
||||||
|
changes).
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|
||||||
|
Installed: `cv4pve-tui`. From a checkout without installing:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv run cv4pve-tui
|
uv run cv4pve-tui
|
||||||
```
|
```
|
||||||
@@ -19,26 +40,76 @@ uv run cv4pve-tui
|
|||||||
| `S` | shutdown (confirm) |
|
| `S` | shutdown (confirm) |
|
||||||
| `x` | stop - hard power-off (confirm) |
|
| `x` | stop - hard power-off (confirm) |
|
||||||
| `r` | reboot (confirm) |
|
| `r` | reboot (confirm) |
|
||||||
| `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
| `Enter` | info: resources (CPU, memory, swap, disks), backups, backup job, network (config and runtime IPs) |
|
||||||
| `i` | info: backups, backup job, network (config and runtime IPs); `b` there = backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
|
| `Enter` then `e` | in info: edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
||||||
|
| `Enter` then `b` | in info: backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
|
||||||
|
| `Enter` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) |
|
||||||
|
| `Enter` then `o` | in info: edit autostart, startup order (order, up and down delay) and tags (one confirmed `set config` call; empty fields remove the setting) |
|
||||||
|
| `Enter` then `p` / `R` / `D` | in info: take a snapshot (name, description, VM: include RAM) / roll back to the snapshot under the cursor (confirms; starts the guest again if it is running) / delete the snapshot under the cursor (confirms) |
|
||||||
|
| `Enter` then `f` | in info: fstrim in a running VM with the guest agent enabled (confirms; containers: use `pct fstrim` on the node) |
|
||||||
|
| `Enter` then `r` | in info: restore the backup under the cursor, as a new VMID (default, the original stays untouched; a copy keeps the original's network settings, so leave Start off until they are changed) or overwriting the guest itself (stopped guests only; type the VMID, then confirm); container backups: optional smaller root disk, see *Shrinking a container's root disk* |
|
||||||
| `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) |
|
| `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) |
|
||||||
|
| `l` | tasks: recent cluster tasks (start, duration, node, type, VMID, user, status; `!` marks failures); `Enter` shows the log of the task under the cursor, `g` reloads. In info: `l` = only this guest's tasks |
|
||||||
|
| `U` | package updates: pending updates per online node (node, package, current, new, origin, priority), read-only; `g` reloads. The list is as of the node's last `apt update`. The frame's bottom border shows the count |
|
||||||
| `g` | refresh |
|
| `g` | refresh |
|
||||||
|
| `/` | filter the guest table as you type: space-separated terms, all must match (case-insensitive); plain term = part of name, VMID or a tag; `is:running` / `is:stopped`; `type:lxc` (or `ct`) / `type:vm` (or `qemu`). `Enter` keeps the filter and returns to the table, `Escape` clears it. The frame's bottom border shows `filter: <text> · N of M` |
|
||||||
| `c` | switch cv4pve-cli context (cluster) |
|
| `c` | switch cv4pve-cli context (cluster) |
|
||||||
| `q` | quit |
|
| `q` | quit |
|
||||||
|
|
||||||
|
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).
|
The shell key needs key-based root SSH to the PVE node (LXC only).
|
||||||
|
|
||||||
Switching context runs `cv4pve-cli config use`, which changes cv4pve-cli's global current
|
Switching context runs `cv4pve-cli config use`, which changes cv4pve-cli's global current
|
||||||
context: other shells and scripts follow, and it stays after the app exits.
|
context: other shells and scripts follow, and it stays after the app exits.
|
||||||
|
|
||||||
|
A `!` in the first column marks a guest that no backup job covers (Proxmox's
|
||||||
|
`not-backed-up` list); the frame's bottom border shows how many.
|
||||||
|
|
||||||
Empty fields in the edit dialog are left unchanged. If a CPU/memory change cannot be
|
Empty fields in the edit dialog are left unchanged. If a CPU/memory change cannot be
|
||||||
hot-plugged, the app tells you it applies at the next reboot.
|
hot-plugged, the app tells you it applies at the next reboot.
|
||||||
|
|
||||||
|
## Shrinking a container's root disk
|
||||||
|
|
||||||
|
Proxmox only grows disks. Two ways to get a smaller container root disk, both
|
||||||
|
**dangerous**; take a backup first and test on a throwaway container.
|
||||||
|
|
||||||
|
**In the TUI** (`Enter`, then `r` on a container backup): fill in *Root disk GiB*.
|
||||||
|
This restores the backup as a **new** guest with a new, smaller root disk
|
||||||
|
(Proxmox's "advanced restore mode"); overwriting the original with a smaller disk is
|
||||||
|
refused, because Proxmox deletes the old disks before it unpacks the backup. In this
|
||||||
|
mode every mount point (`mpN`) of the backup is dropped: files of mount points with
|
||||||
|
`backup=1` land inside the root disk, `backup=0` ones are not in the backup at all.
|
||||||
|
Raw `lxc.*` lines (bind mounts too) can come back if cv4pve-cli runs as root@pam, so
|
||||||
|
do not run both guests at once. A disk that is too small makes the restore fail; the
|
||||||
|
original is untouched.
|
||||||
|
|
||||||
|
**On the node** (root shell, container stopped, keeps the VMID):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/pct-shrink-rootfs <vmid> <new-size-GiB> # measure and print the plan
|
||||||
|
scripts/pct-shrink-rootfs --yes <vmid> <new-size-GiB> # do it
|
||||||
|
```
|
||||||
|
|
||||||
|
It adds a temporary `mp9` of the new size (Proxmox allocates and formats it), copies
|
||||||
|
the root disk into it with rsync, then swaps it into `rootfs` under Proxmox's config
|
||||||
|
lock. The old root disk stays as an `unusedK` volume until you delete it with the
|
||||||
|
exact `pct set <vmid> --delete unusedK` the script prints; the old config is copied to
|
||||||
|
`/root/pct-shrink-<vmid>-<timestamp>.conf`. It refuses containers with snapshots,
|
||||||
|
pending changes, a lock, template or protection flag, HA, replication, or any mount
|
||||||
|
point (`mpN`, `lxc.mount.entry`), and sizes the data does not fit with headroom
|
||||||
|
(5 % ext4 reserve, metadata, inodes). chattr flags (`+i`, `+a`) are not copied. Even
|
||||||
|
the dry run mounts the container briefly (`pct mount`). Design:
|
||||||
|
`docs/plans/lxc-shrink.md`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv sync
|
uv sync
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
uv run ruff check src tests && uv run ruff format --check src tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests mock `cv4pve-cli`; they never touch a cluster. Design: `docs/plans/guest-tui-mvp.md`.
|
Tests mock `cv4pve-cli`; they never touch a cluster. CI (`.gitea/workflows/ci.yaml`) runs
|
||||||
|
the same commands on every PR into `main`; ruff is pinned in the dev group so local runs
|
||||||
|
and CI check the same rules. Design: `docs/plans/guest-tui-mvp.md`.
|
||||||
@@ -346,3 +346,452 @@ Observed CLI behaviour (cv4pve-cli 2.4.0, all read-only probes):
|
|||||||
- [x] Read-only live check: open InfoScreen for CT 104 and VM 108, data shown.
|
- [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).
|
- [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).
|
- [ ] 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.
|
||||||
+6
-1
@@ -10,7 +10,12 @@ dependencies = ["textual>=8.2"]
|
|||||||
cv4pve-tui = "cv4pve_tui.app:main"
|
cv4pve-tui = "cv4pve_tui.app:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=8", "pytest-asyncio>=1.0"]
|
dev = [
|
||||||
|
"hypothesis>=6.170.14",
|
||||||
|
"pytest>=8",
|
||||||
|
"pytest-asyncio>=1.0",
|
||||||
|
"ruff==0.17.3",
|
||||||
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
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
|
||||||
+1540
-80
File diff suppressed because it is too large.
Load diff
+374
-5
@@ -1,9 +1,14 @@
|
|||||||
"""Thin subprocess wrapper around cv4pve-cli."""
|
"""Thin subprocess wrapper around cv4pve-cli."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
# Output formats this app parses (config list text, prune-backups as a JSON string, the CT
|
||||||
|
# ip-addresses JSON string, exit codes with --wait) were verified against this version.
|
||||||
|
TESTED_VERSION = "2.4.0"
|
||||||
|
|
||||||
|
|
||||||
class CliError(Exception):
|
class CliError(Exception):
|
||||||
"""cv4pve-cli failed; str() is its combined, stripped output."""
|
"""cv4pve-cli failed; str() is its combined, stripped output."""
|
||||||
@@ -23,7 +28,7 @@ class CliError(Exception):
|
|||||||
return str(self)
|
return str(self)
|
||||||
|
|
||||||
|
|
||||||
def run(*args: str) -> str:
|
def run(*args: str, timeout: int = 330) -> str:
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["cv4pve-cli", *args],
|
["cv4pve-cli", *args],
|
||||||
@@ -31,7 +36,7 @@ def run(*args: str) -> str:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
timeout=330,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
raise CliError(str(exc)) from exc
|
raise CliError(str(exc)) from exc
|
||||||
@@ -132,12 +137,17 @@ def pending_keys(guest: dict) -> list[str]:
|
|||||||
return [i["key"] for i in items if i.get("pending") is not None]
|
return [i["key"] for i in items if i.get("pending") is not None]
|
||||||
|
|
||||||
|
|
||||||
def status(guest: dict) -> str:
|
def current(guest: dict) -> dict:
|
||||||
"""Live status; get guests (/cluster/resources) lags a few seconds behind actions."""
|
"""status/current as a dict: live status plus cpu, mem, maxmem, disk, ..."""
|
||||||
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current"
|
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current"
|
||||||
return {
|
return {
|
||||||
i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json"))
|
i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json"))
|
||||||
}["status"]
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def status(guest: dict) -> str:
|
||||||
|
"""Live status; get guests (/cluster/resources) lags a few seconds behind actions."""
|
||||||
|
return current(guest)["status"]
|
||||||
|
|
||||||
|
|
||||||
_CONTEXT_LINE = re.compile(r"^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$")
|
_CONTEXT_LINE = re.compile(r"^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$")
|
||||||
@@ -170,6 +180,78 @@ def backup_storages(guest: dict) -> list[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def next_id() -> int:
|
||||||
|
return int(json.loads(run("get", "cluster", "nextid", "-o", "json")))
|
||||||
|
|
||||||
|
|
||||||
|
_STORAGE = r"[A-Za-z0-9][A-Za-z0-9_.-]*"
|
||||||
|
_VOLID = re.compile(rf"{_STORAGE}:backup/vzdump-(lxc|qemu)-[0-9]+-\S+")
|
||||||
|
|
||||||
|
|
||||||
|
def valid_volid(volid: str) -> str:
|
||||||
|
"""The volid is a positional argument: refuse anything that is not a vzdump volume."""
|
||||||
|
if not _VOLID.fullmatch(volid):
|
||||||
|
raise ValueError(f"not a backup volume id: {volid!r}")
|
||||||
|
return volid
|
||||||
|
|
||||||
|
|
||||||
|
def restore_storages(guest: dict) -> list[str]:
|
||||||
|
"""Enabled, active storages on the guest's node that hold its disks, in API order."""
|
||||||
|
content = "images" if guest["type"] == "qemu" else "rootdir"
|
||||||
|
items = json.loads(run("get", "node", "storages", guest["node"], "-o", "json"))
|
||||||
|
return [
|
||||||
|
s["storage"]
|
||||||
|
for s in items
|
||||||
|
if content in (s.get("content") or "").split(",")
|
||||||
|
and s.get("enabled")
|
||||||
|
and s.get("active")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def restore(
|
||||||
|
guest: dict,
|
||||||
|
volid: str,
|
||||||
|
vmid: int,
|
||||||
|
storage: str,
|
||||||
|
force: bool = False,
|
||||||
|
start: bool = False,
|
||||||
|
rootfs_gb: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Restore `volid` as `vmid`; `force` (overwrite) only for the guest's own VMID.
|
||||||
|
|
||||||
|
`rootfs_gb` (container, new guest only) restores onto a root disk of that size.
|
||||||
|
"""
|
||||||
|
_positive(vmid, "vmid")
|
||||||
|
if force and vmid != guest["vmid"]:
|
||||||
|
raise ValueError(
|
||||||
|
"refusing to overwrite a guest other than the one restored from"
|
||||||
|
)
|
||||||
|
valid_volid(volid)
|
||||||
|
backup_type = re.search(r":backup/vzdump-(lxc|qemu)-", volid).group(1)
|
||||||
|
if backup_type != _api_type(guest): # a reused VMID can carry the other guest type
|
||||||
|
raise ValueError(
|
||||||
|
f"a {backup_type} backup cannot be restored as this {kind(guest)}"
|
||||||
|
)
|
||||||
|
if not re.fullmatch(_STORAGE, storage):
|
||||||
|
raise ValueError(f"invalid storage name: {storage!r}")
|
||||||
|
if rootfs_gb is not None:
|
||||||
|
# 0 would make PVE allocate an unlimited subvol on ZFS
|
||||||
|
_positive(rootfs_gb, "root disk size")
|
||||||
|
if force or backup_type != "lxc":
|
||||||
|
raise ValueError("a smaller root disk needs a container and a new guest")
|
||||||
|
opts = ["--storage", storage]
|
||||||
|
if force:
|
||||||
|
opts += ["--force", "1"]
|
||||||
|
if start:
|
||||||
|
opts += ["--start", "1"]
|
||||||
|
if rootfs_gb is not None:
|
||||||
|
opts += ["--rootfs", f"{storage}:{rootfs_gb}"]
|
||||||
|
run(
|
||||||
|
"do", "restore", kind(guest), guest["node"], str(vmid), volid, *opts,
|
||||||
|
"-y", "--wait", timeout=3600,
|
||||||
|
) # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
def backup_jobs() -> list[dict]:
|
def backup_jobs() -> list[dict]:
|
||||||
jobs = json.loads(run("get", "cluster", "backup-jobs", "-o", "json"))
|
jobs = json.loads(run("get", "cluster", "backup-jobs", "-o", "json"))
|
||||||
for job in jobs: # the CLI returns prune-backups as a JSON string
|
for job in jobs: # the CLI returns prune-backups as a JSON string
|
||||||
@@ -226,6 +308,58 @@ def backup_now(guest: dict, storage: str) -> None:
|
|||||||
) # fmt: skip
|
) # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def agent_enabled(config: dict) -> bool:
|
||||||
|
"""VM option `agent`: `1`, `1,<opt>=...` or `enabled=1,...`."""
|
||||||
|
parts = str(config.get("agent") or "").split(",")
|
||||||
|
return parts[0] == "1" or "enabled=1" in parts
|
||||||
|
|
||||||
|
|
||||||
|
def agent_ping(guest: dict) -> None:
|
||||||
|
"""CliError if the guest agent does not answer."""
|
||||||
|
run("do", "vm", "agent", "ping", guest["node"], str(guest["vmid"]), "-o", "json")
|
||||||
|
|
||||||
|
|
||||||
|
def fstrim(guest: dict) -> list[dict]:
|
||||||
|
"""Trim the guest's filesystems; the per-path results, `[]` if they cannot be read.
|
||||||
|
|
||||||
|
Well-formed entries come back as the agent sent them. Odd ones are repaired, not
|
||||||
|
fatal - the trim has already happened when the output is parsed: non-dict entries
|
||||||
|
are dropped, a non-int `trimmed` becomes 0 and a missing `path` becomes "?".
|
||||||
|
"""
|
||||||
|
args = (
|
||||||
|
"do",
|
||||||
|
"vm",
|
||||||
|
"agent",
|
||||||
|
"fstrim",
|
||||||
|
guest["node"],
|
||||||
|
str(guest["vmid"]),
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
)
|
||||||
|
out = run(
|
||||||
|
*args, timeout=1800
|
||||||
|
) # a large disk can take longer than the default 330 s
|
||||||
|
try:
|
||||||
|
items = json.loads(out)
|
||||||
|
value = {x["key"]: x["value"] for x in items}.get("result") or "{}"
|
||||||
|
result = value if isinstance(value, dict) else json.loads(value)
|
||||||
|
paths = result.get("paths") if isinstance(result, dict) else None
|
||||||
|
except (ValueError, TypeError, KeyError, AttributeError):
|
||||||
|
return []
|
||||||
|
found = []
|
||||||
|
for p in paths if isinstance(paths, list) else []:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
p = dict(p)
|
||||||
|
if "trimmed" in p and (
|
||||||
|
not isinstance(p["trimmed"], int) or isinstance(p["trimmed"], bool)
|
||||||
|
):
|
||||||
|
p["trimmed"] = 0
|
||||||
|
p.setdefault("path", "?")
|
||||||
|
found.append(p)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def parse_net(value: str) -> dict:
|
def parse_net(value: str) -> dict:
|
||||||
"""`k=v,k=v` to an ordered dict; a VM's leading `<model>=<MAC>` is just the first pair."""
|
"""`k=v,k=v` to an ordered dict; a VM's leading `<model>=<MAC>` is just the first pair."""
|
||||||
return dict(item.partition("=")[::2] for item in value.split(",") if item)
|
return dict(item.partition("=")[::2] for item in value.split(",") if item)
|
||||||
@@ -274,3 +408,238 @@ def interfaces(guest: dict) -> list[dict]:
|
|||||||
)
|
)
|
||||||
found.append({"name": i["name"], "mac": i.get("hardware-address"), **addrs})
|
found.append({"name": i["name"], "mac": i.get("hardware-address"), **addrs})
|
||||||
return [i for i in found if i["name"] != "lo"]
|
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()
|
||||||
+3801
-8
File diff suppressed because it is too large.
Load diff
+1136
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()) == []
|
||||||
@@ -21,8 +21,10 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "hypothesis" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -30,8 +32,91 @@ requires-dist = [{ name = "textual", specifier = ">=8.2" }]
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "hypothesis", specifier = ">=6.170.14" },
|
||||||
{ name = "pytest", specifier = ">=8" },
|
{ name = "pytest", specifier = ">=8" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.0" },
|
||||||
|
{ name = "ruff", specifier = "==0.17.3" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
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]]
|
[[package]]
|
||||||
@@ -168,6 +253,40 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.17.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
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]]
|
[[package]]
|
||||||
name = "textual"
|
name = "textual"
|
||||||
version = "8.2.8"
|
version = "8.2.8"
|
||||||
|
|||||||
Reference in new issue
Block a user