r on the info screen restores the backup under the cursor of the backup list. - New guest (default): the next free VMID from `get cluster nextid`, editable. The original is left untouched. An id already used by a listed guest is refused. - Overwrite: replaces the guest itself. The guest must be stopped; its live status is read at apply and again right before the call. The VMID must be typed to confirm, then an "Overwrite" confirm follows. --storage is always passed. It is preselected to the storage of the guest's rootfs or first disk, and the choices are the node's storages that can hold CT (rootdir) or VM (images) disks. Start after restore is off by default, because a copy keeps the original's MAC address and IP. Safety: - cli.restore refuses force (overwrite) for any VMID other than the guest's own (explicit raise). A hypothesis property checks that no other VMID ever reaches the CLI with force. - The volid and storage are validated before they become arguments. - A backup of the other guest type is refused. A reused VMID can carry a qemu backup for a CT or the other way round, and whether PVE rejects a mismatched archive before removing the old guest is not verified. - An earlier guest's backup (visible with a) is labelled with that guest's name in the dialog and the confirm. - r before the info screen has loaded says "still loading". - Restore gets a 1-hour CLI timeout. Not run live: a live test would create a guest that then has to be deleted, which the issue reserves for the user's approval. A read-only check opened the dialog for CT 104 (next id 105, local-lvm preselected) and closed it without any call. Closes #15 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
798 lines
41 KiB
Markdown
798 lines
41 KiB
Markdown
# Guest TUI MVP
|
|
|
|
## Problem
|
|
|
|
Manage Proxmox guests (VMs and LXC containers) from the terminal without remembering
|
|
`cv4pve-cli` syntax: list them, start/shutdown/stop/reboot, and change CPU cores,
|
|
memory and disk size.
|
|
|
|
## Approach
|
|
|
|
Thin subprocess wrapper around `cv4pve-cli` (tested against 2.4.0) plus a Textual app.
|
|
The app never handles credentials: it uses whatever context `cv4pve-cli config use`
|
|
selected.
|
|
|
|
Observed CLI behaviour the design relies on:
|
|
|
|
- `get guests -o json` -> list of dicts with `vmid`, `type` (`qemu`|`lxc`), `node`,
|
|
`name`, `status`, `maxcpu`, `maxmem` (bytes), `maxdisk` (bytes), ...
|
|
- `show vm|ct <node> <vmid> -o json` -> list of `{"key": ..., "value": ...}`.
|
|
- `api get /nodes/<node>/<qemu|lxc>/<vmid>/pending -o json` -> list of
|
|
`{"key", "value", "pending", "delete"}`; `pending` non-null means not yet applied.
|
|
- Async actions print a UPID and exit 0 **even when the task fails**. With `--wait`
|
|
the CLI waits for the task and exits non-zero (5) with the error text on failure.
|
|
Every mutating call therefore uses `--wait`.
|
|
- `do stop` refuses to run without `-y` (verified); `-y` is accepted by all four
|
|
lifecycle actions, so it is always passed.
|
|
- Errors: non-zero exit, message on stdout/stderr. Sync failures are multi-line
|
|
(`Error: ...` / `Call: ...` / `Alias: ...`). Success is judged by exit code only,
|
|
never by parsing UPID text.
|
|
- Each call costs ~0.5 s (.NET startup), so calls run in worker threads.
|
|
- `get guests` (`/cluster/resources`) lags a few seconds behind actions; after an
|
|
action the app reads the acted-on guest's live status from
|
|
`api get /nodes/<node>/<qemu|lxc>/<vmid>/status/current` (key/value list).
|
|
|
|
### Module `cv4pve_tui/cli.py`
|
|
|
|
- `CliError(Exception)` - `str()` is the CLI's combined, stripped output;
|
|
`.summary` is one line for the UI: the first non-empty line that is not a UPID,
|
|
`Error:`-prefix stripped, plus the following indented detail line if the first
|
|
line ends with `:`.
|
|
- `run(*args) -> str` - `subprocess.run(["cv4pve-cli", *args], capture_output=True,
|
|
text=True, stdin=DEVNULL, timeout=330)`; non-zero exit -> `CliError`.
|
|
- `kind(guest) -> "vm" | "ct"` - from `type` (`qemu` -> `vm`, `lxc` -> `ct`).
|
|
- `list_guests() -> list[dict]` - sorted by `vmid`.
|
|
- `guest_config(guest) -> dict` - key/value list flattened to a dict.
|
|
- `disks(config, kind) -> dict[str, str]` - resizable disks -> size string.
|
|
VM: `scsiN|virtioN|sataN|ideN` with a `size=`, excluding `media=cdrom` and
|
|
cloud-init. CT: `rootfs`, `mpN`.
|
|
- `ACTIONS = ("start", "shutdown", "stop", "reboot")`;
|
|
`action(name, guest)` -> `do <name> <kind> <node> <vmid> -y --wait`;
|
|
unknown name -> `ValueError`.
|
|
- `set_resources(guest, cores=None, memory_mb=None)` ->
|
|
`set <kind> config <node> <vmid> [--cores N] [--memory M] --wait`; values must be
|
|
positive ints; nothing to set -> `ValueError`.
|
|
- `resize_disk(guest, disk, grow_gb)` -> `do resize <kind> disk <node> <vmid> <disk>
|
|
+<grow_gb>G --wait`; `grow_gb` must be a positive int (growth only - Proxmox cannot
|
|
shrink, and an increment cannot shrink by construction).
|
|
- `pending_keys(guest) -> list[str]` - keys with a non-null `pending`.
|
|
|
|
### Module `cv4pve_tui/app.py`
|
|
|
|
- `DataTable` of guests: VMID, name, type, node, status, cores, memory (MiB),
|
|
disk (GiB). Cursor row = selected guest.
|
|
- Keys: `s` start, `S` shutdown, `x` stop (hard), `r` reboot, `e` edit,
|
|
`g` refresh, `q` quit.
|
|
- `shutdown`, `stop`, `reboot` open a confirm modal naming the guest; `stop` says it
|
|
is a hard power-off. `start` needs no confirmation.
|
|
- `e` opens an edit modal: cores, memory MiB, disk select + "grow by GiB".
|
|
Empty field = unchanged. Disk growth gets its own confirm (irreversible).
|
|
- After `set_resources`, if `pending_keys` contains `cores` or `memory`, notify that
|
|
the change applies at next reboot.
|
|
- All CLI calls in thread workers; results/errors via `notify` (errors show
|
|
`CliError.summary`); table refreshes after each action.
|
|
- A "working" notice is shown when an action starts (`--wait` can block minutes),
|
|
and a second action on a guest that is already busy is refused.
|
|
- Action keys on an empty table do nothing (no crash).
|
|
|
|
### UI contract (shared by tests and implementation)
|
|
|
|
- `app.py` does `from cv4pve_tui import cli` and always calls `cli.<function>(...)`,
|
|
so tests monkeypatch `cv4pve_tui.cli.<function>`.
|
|
- `class GuestApp(App)`; `def main()` runs it.
|
|
- Guest table: `DataTable` with id `guests`, one row per guest, row key = str(vmid),
|
|
in `list_guests()` order. Loaded on mount.
|
|
- `ConfirmScreen(ModalScreen[bool])`: buttons `#yes` / `#no`; its text names the guest
|
|
(name and vmid); for `stop` it contains the words "hard power-off".
|
|
- `EditScreen(ModalScreen)`: `Input#cores`, `Input#memory`, `Select#disk`,
|
|
`Input#grow`, buttons `#apply` / `#cancel`. Inputs are pre-filled with nothing
|
|
(empty = unchanged); placeholders show current values. Disk options come from
|
|
`cli.disks(cli.guest_config(guest), cli.kind(guest))`.
|
|
On apply: if cores/memory given -> `cli.set_resources(guest, cores=..., memory_mb=...)`
|
|
(only the given ones, as ints); if grow given -> `ConfirmScreen` first, then
|
|
`cli.resize_disk(guest, disk, grow_gb)`. Non-integer or <= 0 input -> `notify`
|
|
with severity "error" and no CLI call.
|
|
- Thread workers (`@work(thread=True)` or `run_worker(..., thread=True)`); tests wait
|
|
with `await app.workers.wait_for_complete()` then `await pilot.pause()`.
|
|
|
|
## Out of scope
|
|
|
|
Create/delete, snapshots, migration, console, backup, in-app context switching,
|
|
sockets/vCPU/hotplug settings, live auto-refresh.
|
|
|
|
## Steps
|
|
|
|
- [x] Project skeleton (`uv`, `pyproject.toml`, entry point `cv4pve-tui`) -
|
|
`uv run python -c "import cv4pve_tui"` succeeds.
|
|
- [x] Tests for `cli.py` with `subprocess.run` mocked - fail before implementation.
|
|
- [x] `cli.py` - tests pass.
|
|
- [x] Tests for the app with Textual `run_test` and `cli` functions mocked.
|
|
- [x] `app.py` - tests pass.
|
|
- [x] Read-only smoke test against the real cluster (listing, edit dialog disk options,
|
|
pending check).
|
|
- [x] Live edit (cores/memory), shutdown, start, reboot on CT 104 (mainframe), restored
|
|
to baseline afterwards. Disk resize not run live (irreversible).
|
|
|
|
## Open questions
|
|
|
|
None blocking.
|
|
|
|
---
|
|
|
|
# Context switching
|
|
|
|
## Problem
|
|
|
|
Several cv4pve-cli contexts (clusters) exist; switch between them inside the TUI
|
|
instead of quitting and running `cv4pve-cli config use`.
|
|
|
|
## Decision
|
|
|
|
Option A: switching runs `cv4pve-cli config use <name>`, which changes the **global**
|
|
current context (all shells and scripts follow) and stays after the app exits. cv4pve-cli
|
|
2.4.0 has no per-call context flag or env var (verified), so there is no app-local option.
|
|
|
|
Observed CLI behaviour:
|
|
|
|
- `config list` has no `-o json`. Text output, one line per context; current one
|
|
prefixed with `* `, others with two spaces:
|
|
`* mainframe mainframe.iot.home.arpa:8006 (api-token)`
|
|
` mainframe-edge mainframe-edge.home.arpa:8006 (api-token)`
|
|
- `config use <name>` -> exit 0 on success; unknown name -> exit 3,
|
|
`Error: Context '<name>' not found.`, current context unchanged.
|
|
|
|
### `cli.py`
|
|
|
|
- `contexts() -> list[dict]` - `run("config", "list")`, each line parsed to
|
|
`{"name", "host", "auth", "current": bool}` (regex
|
|
`^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$`); non-matching lines are ignored.
|
|
- `use_context(name)` - `run("config", "use", name)`.
|
|
|
|
### `app.py` (UI contract)
|
|
|
|
- The active context is always visible: `app.sub_title == f"context: {name}"`
|
|
(shown in the Header). Set on mount from `cli.contexts()` and after every switch.
|
|
If `contexts()` fails: error notify, sub_title `context: ?`.
|
|
- Key `c` opens `ContextScreen(ModalScreen[str | None])` with `OptionList#contexts`,
|
|
one option per context, option id = context name, current one marked with `*`.
|
|
Selecting an option dismisses with its name; `escape` dismisses with `None`.
|
|
- `c` while any guest is busy -> warning notify, no screen (VMIDs collide across
|
|
clusters, so the busy guard would point at the wrong cluster).
|
|
- Selecting the current context or dismissing -> no CLI call.
|
|
- A reload started before a switch must not refill the table (generation counter);
|
|
`g` and `c` are refused while a switch runs; the post-action reload finishes before
|
|
the guest stops being busy.
|
|
- Selecting another context: table cleared immediately (old cluster's guests must not
|
|
stay actionable), then in a worker `cli.use_context(name)`, sub_title updated, guests
|
|
reloaded. On `CliError`: error notify with summary, sub_title re-read from
|
|
`cli.contexts()`, guests reloaded.
|
|
|
|
## Out of scope
|
|
|
|
Adding/editing/deleting contexts, `config verify`, restoring the original context on exit.
|
|
|
|
## Steps
|
|
|
|
- [x] Tests for `contexts`/`use_context` and the UI contract above - red first.
|
|
- [x] Implementation - full suite green, ruff clean.
|
|
- [x] Live: open the list, switch to `mainframe-edge` and back to `mainframe`
|
|
(only `config use` and read-only listing; no guest actions); final context
|
|
is `mainframe`.
|
|
- [ ] Push `feat/guest-tui-mvp`, merge to `main` (user-approved).
|
|
|
|
---
|
|
|
|
# LXC shell
|
|
|
|
## Problem
|
|
|
|
Open an interactive shell in a running LXC container from the TUI. cv4pve-cli 2.4.0 has
|
|
no console/shell/exec for LXC (verified: no command; the Proxmox API has no LXC exec),
|
|
so the app goes around it: SSH to the PVE node as root and run `pct enter <vmid>`.
|
|
|
|
## Decision
|
|
|
|
Option 1 from the discussion: `ssh -t root@<node> pct enter <vmid>`. The node name is
|
|
the SSH host (resolves via DNS here: `mainframe`, `mainframe-edge`); the user's key is in
|
|
root's `authorized_keys` (verified: `ssh -o BatchMode=yes root@mainframe pct status 104`
|
|
-> `status: running`). This bypasses cv4pve-cli and its API token.
|
|
|
|
## `app.py` (UI contract)
|
|
|
|
- Key `t` ("Shell") on the selected guest.
|
|
- Only for `type == "lxc"` and `status == "running"`; otherwise a warning notify that
|
|
says why (not a container / not running) and nothing runs. Empty table: nothing.
|
|
- Busy guest (action running) -> warning notify, nothing runs.
|
|
- Runs on the main thread inside `with self.suspend():` -
|
|
`subprocess.run(["ssh", "-t", f"root@{node}", "pct", "enter", str(vmid)])`
|
|
(no capture, no timeout: the user's terminal is handed over; `import subprocess` in
|
|
app.py so tests patch `cv4pve_tui.app.subprocess.run`).
|
|
- Exit 255 (ssh itself failed) -> error notify naming the guest and the code. Other
|
|
exit codes are the container shell's last status (`exit` after a failed command
|
|
gives 1) and are not errors.
|
|
- Inside `suspend()`, right before ssh, the terminal gets a full reset (`ESC c`, what
|
|
`reset` sends) so the container shell starts on a clean screen without leftover TUI
|
|
terminal modes (user request). Done locally, not in the container: no dependency on
|
|
`reset` existing there, and `pct enter` stays unchanged.
|
|
- `OSError` from `subprocess.run` is caught *inside* the `suspend()` block: Textual
|
|
8.2.8's `suspend()` has no try/finally, so an exception escaping it leaves the app
|
|
suspended.
|
|
- The guest table reloads after the shell returns (the user may have stopped or
|
|
rebooted the container from inside).
|
|
- `textual.app.SuspendNotSupported` (headless / unsupported driver) and `OSError`
|
|
(no `ssh` binary) -> error notify, app keeps running.
|
|
- Tests: `App.suspend` raises `SuspendNotSupported` under `run_test`, so tests replace
|
|
`app.suspend` with a no-op context manager (e.g. `contextlib.nullcontext`) unless
|
|
testing that error path.
|
|
|
|
## Out of scope
|
|
|
|
VM consoles (serial/VNC), configurable SSH user/host/options, termproxy/websocket.
|
|
|
|
## Steps
|
|
|
|
- [x] Tests for the UI contract above - red first.
|
|
- [x] Implementation - full suite green, ruff clean. README key table gets `t`.
|
|
- [x] Interactive test by the user: `t` on CT 104, `exit`, back in the table; screen
|
|
clean after the reset.
|
|
- [ ] Push `feat/lxc-shell`, merge to `main` (user-approved).
|
|
|
|
---
|
|
|
|
# Guest info screen: backups, backup now, network (read-only) - delivery A
|
|
|
|
## Problem
|
|
|
|
Per guest, see its latest backups and network setup, and start a backup now. Network
|
|
*editing* is delivery B (separate, live-tested), not part of this.
|
|
|
|
## Decisions (user-approved)
|
|
|
|
- Backup now uses the scheduled job's settings: `--mode snapshot --compress zstd`.
|
|
Storage is chosen from the node's backup-capable storages, preselected to the job's
|
|
storage (`truenas-backup` here), `local` as alternative.
|
|
- Manual backups get `--notes-template "{{guestname}} (manual)"` so they are
|
|
recognisable. Note for the user: `truenas-backup` has storage prune `keep-all=1`, but
|
|
the job has `keep-last 3` per guest; a manual backup there likely counts towards it at
|
|
the job's next run (not verified).
|
|
|
|
Observed CLI behaviour (cv4pve-cli 2.4.0, all read-only probes):
|
|
|
|
- `get node storages <node> -o json` -> list of dicts: `storage`, `content`
|
|
(comma list, e.g. `iso,snippets,import,backup,vztmpl`), `enabled`, `active`, `type`,
|
|
`avail` (bytes).
|
|
- `get cluster backup-jobs -o json` -> list of dicts: `id`, `enabled`, `storage`,
|
|
`schedule` (e.g. `mon,wed,fri 04:00`), `mode`, `compress`, `all` (1 = all guests),
|
|
`vmid` (comma list or null), `exclude` (comma list or null), `node` (null = all),
|
|
`next-run` (epoch), `prune-backups` (e.g. `{"keep-last":"3"}`).
|
|
- `get guest backups <node> <storage> <vmid> -o json` -> list of dicts: `volid`
|
|
(`truenas-backup:backup/vzdump-lxc-104-2026_11_02-04_11_47.tar.zst`), `ctime` (epoch),
|
|
`size` (bytes), `format`, `notes`, `protected`, `subtype` (`lxc`|`qemu`), `vmid`.
|
|
- `do backup guest <node> <vmid> <storage> [--key value]` - no `--yes` option.
|
|
vzdump params: `mode` snapshot|suspend|stop, `compress` 0|1|gzip|lzo|zstd,
|
|
`notes-template`.
|
|
- CT net config: `netN` = `name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=auto,type=veth`
|
|
(optional `gw`, `gw6`, `tag`, `firewall`, `rate`, `mtu`, ...).
|
|
- VM net config: `netN` = `virtio=02:24:A6:49:A8:25,bridge=vmbr0[,tag=..][,firewall=1]`
|
|
(first item is `<model>=<MAC>`); IP settings live in `ipconfigN` (cloud-init), e.g.
|
|
`ip=dhcp`.
|
|
- Runtime IPs, CT (running): `get ct interfaces <node> <vmid> -o json` -> list of
|
|
dicts: `name`, `hwaddr`, `inet` (e.g. `172.16.41.200/23`), `inet6` (may be absent/
|
|
null), `ip-addresses` (JSON string).
|
|
- Runtime IPs, VM (running, guest agent): `get vm agent network <node> <vmid> -o json`
|
|
-> `[{"key": "result", "value": "<JSON string>"}]`; the JSON is a list of
|
|
`{"name", "hardware-address", "ip-addresses": [{"ip-address", "prefix",
|
|
"ip-address-type": "ipv4"|"ipv6"}]}`. Fails when the agent is not running.
|
|
|
|
### `cli.py`
|
|
|
|
- `backup_storages(guest) -> list[str]` - storages on the guest's node whose `content`
|
|
contains `backup` and that are enabled and active, in API order.
|
|
- `backup_jobs() -> list[dict]`.
|
|
- `job_storage(jobs, guest) -> str | None` - storage of the first enabled job covering
|
|
the guest: (`all` truthy and vmid not in `exclude`) or vmid in `vmid`; and `node` null
|
|
or equal to the guest's node.
|
|
- `backup_jobs()` parses `prune-backups` when the CLI returns it as a JSON string
|
|
(real output: `'{"keep-last":"3"}'`).
|
|
- `backups(guest, storages) -> list[dict]` - all backups of the guest on those
|
|
storages, newest first (`ctime` desc). Each dict gets `storage` (from volid prefix).
|
|
- `backup_now(guest, storage)` -> `do backup guest <node> <vmid> <storage> --mode
|
|
snapshot --compress zstd --notes-template "{{guestname}} (manual)" --wait`.
|
|
- `parse_net(value: str) -> dict` - `netN` string to an ordered dict of key -> value
|
|
(insertion order kept, so B can re-serialise unchanged keys). VM: the first
|
|
`<model>=<MAC>` item is kept as is (key = model).
|
|
- `nets(config) -> dict[str, dict]` - `netN` keys (sorted by N) -> `parse_net(value)`.
|
|
- `interfaces(guest) -> list[dict]` - runtime interfaces as
|
|
`{"name", "mac", "ipv4": [cidr...], "ipv6": [cidr...]}`, loopback (`lo`) excluded.
|
|
CT via `get ct interfaces` (addresses from its `ip-addresses` JSON string - `inet6`
|
|
alone holds only one, e.g. link-local, and misses the global SLAAC address), VM via
|
|
`get vm agent network`. Only called when the guest's live status is running.
|
|
|
|
### `app.py` (UI contract)
|
|
|
|
- Key `i` on a guest opens `InfoScreen(guest)` (a `Screen` or `ModalScreen`). Empty
|
|
table: nothing.
|
|
- InfoScreen loads in a thread worker: config -> `cli.nets`, `cli.interfaces` (only if
|
|
status is `running`; a failure there is shown inline as "unavailable: <summary>", not
|
|
as a fatal error), `cli.backup_storages`, `cli.backup_jobs`, `cli.backups`.
|
|
- Shows:
|
|
- `DataTable#backups`: Date (local time, `YYYY-MM-DD HH:MM`), Storage, Size (GiB, one
|
|
decimal), Format, Notes. Newest first. A `Static#jobs` line naming the job(s) that
|
|
cover the guest (schedule, storage, `keep-last` if present) or "No backup job
|
|
covers this guest".
|
|
- `DataTable#nets`: Iface (net0...), Bridge, VLAN (`tag` or `-`), MAC, IPv4 (CT `ip`,
|
|
VM `ipconfigN` `ip`, or `-`), IPv6 (CT `ip6`, VM `ipconfigN` `ip6`, or `-`),
|
|
Firewall (`yes`/`no`).
|
|
- `DataTable#addresses`: Interface, MAC, IPv4, IPv6 (runtime), or the unavailable
|
|
line.
|
|
- VMIDs are reused: a deleted guest's backups keep the VMID and are listed by the API
|
|
for the new guest. Backups whose notes (the job's `{{guestname}}`) are non-empty and
|
|
are neither the current name nor start with `<name> ` (e.g. `debian (manual)`) are
|
|
hidden by default; `Static#hidden` says how many; key `a` toggles showing them,
|
|
marked `(earlier guest)`. Backups without notes are always shown.
|
|
- Key `b` in InfoScreen: `BackupScreen` modal with `Select#storage` (options =
|
|
backup storages, preselected `job_storage` or the first), text saying mode snapshot,
|
|
zstd, and the guest; buttons `#yes` / `#no`; escape = no. On yes: guest marked busy
|
|
in `app.busy` (refuse with a warning if already busy), "backup started" notify, worker
|
|
runs `cli.backup_now`, then "backup done"/error notify, busy released, backup list
|
|
reloaded if the InfoScreen is still open.
|
|
- `escape` closes InfoScreen; `g` in InfoScreen reloads it.
|
|
- Every CLI failure -> error notify with `_message(exc)`, app keeps running.
|
|
|
|
## Steps
|
|
|
|
- [x] Tests (cli + UI contract) - red first.
|
|
- [x] Implementation - suite green, ruff clean, README keys `i` / `b`.
|
|
- [x] Read-only live check: open InfoScreen for CT 104 and VM 108, data shown.
|
|
- [x] User tests "Backup now" on CT 104 (`debian (manual)`, 2026-11-02 21:09).
|
|
- [ ] Push `feat/guest-info`, merge to `main` (user-approved after the test).
|
|
|
|
---
|
|
|
|
# Network editing - delivery B
|
|
|
|
## Problem
|
|
|
|
Change a guest's network settings from the info screen: bridge, VLAN, firewall, IPv4
|
|
and IPv6 (incl. SLAAC), without breaking what is not edited.
|
|
|
|
## Decisions (user-approved)
|
|
|
|
- VM IP settings go through cloud-init `ipconfigN`, only when the VM has a cloud-init
|
|
drive; they apply at the next boot. VMs without cloud-init: IP fields disabled with a
|
|
note (IP is configured inside the VM).
|
|
- IPv4 modes: DHCP / Static (CIDR + optional gateway) / None.
|
|
IPv6 modes: SLAAC / DHCP / Static (CIDR + optional gateway) / None.
|
|
- Undo key `u`: restores the values from before the last network change made in this
|
|
app session for that guest.
|
|
- Out of scope: adding/removing interfaces, MAC, mtu, rate, trunks, DNS.
|
|
|
|
API facts (Proxmox VE 9.2, `api usage ... config set -v`):
|
|
|
|
- LXC `net[n]`: `name=<string>[,bridge][,firewall=<1|0>][,gw=<IPv4>][,gw6=<IPv6>]
|
|
[,host-managed][,hwaddr][,ip=<IPv4/CIDR|dhcp|manual>][,ip6=<IPv6/CIDR|auto|dhcp|manual>]
|
|
[,link_down][,mtu][,rate][,tag=<int>][,trunks][,type=veth]`. `ip6=auto` = SLAAC.
|
|
Applied immediately on a running CT.
|
|
- VM `net[n]`: `[model=]<enum>[,bridge][,firewall=<1|0>][,link_down][,macaddr][,mtu]
|
|
[,queues][,rate][,tag=<int>][,trunks][,<model>=<macaddr>]`. Applied immediately.
|
|
- VM `ipconfig[n]`: `[gw=<IPv4>][,gw6=<IPv6>][,ip=<IPv4/CIDR|dhcp>][,ip6=<IPv6/CIDR|dhcp|auto>]`
|
|
(cloud-init; "no explicit gateway with dhcp"). Applies at the next boot.
|
|
- `delete=<list>` removes settings (used when `ipconfigN` ends up empty).
|
|
- `get node network <node> -o json` -> list of dicts with `iface`, `type`
|
|
(`bridge`, `OVSBridge`, `eth`, ...), `active`.
|
|
- Cloud-init drive: a disk value containing `cloudinit`
|
|
(`ide2 = local-lvm:vm-108-cloudinit,media=cdrom`).
|
|
|
|
### `cli.py`
|
|
|
|
- `bridges(guest) -> list[str]` - `iface` of entries with type `bridge` or `OVSBridge`
|
|
on the guest's node, sorted.
|
|
- `format_net(d: dict) -> str` - inverse of `parse_net`: `k=v` joined by `,`, order
|
|
kept. `format_net(parse_net(s)) == s` for the real samples.
|
|
- `has_cloudinit(config) -> bool`.
|
|
- `apply_link(net, bridge, tag, firewall) -> dict` - a copy of `net`: `bridge` set;
|
|
`tag` int 1-4094 or None (None removes `tag`); `firewall` True -> `"1"`, False ->
|
|
`firewall` removed. Existing keys keep their position; new keys are appended.
|
|
- `ip_mode(cfg, family) -> tuple[str, str, str]` - `(mode, cidr, gw)` read from a
|
|
parsed LXC net or VM ipconfig dict; family 4 uses `ip`/`gw`, 6 uses `ip6`/`gw6`.
|
|
Modes: `dhcp`, `static`, `none`, and for 6 also `auto`; `manual` reads as `none`.
|
|
- `apply_ip(cfg, family, mode, cidr="", gw="") -> dict` - copy with the family's keys
|
|
set: `dhcp`/`auto` -> `ip[6]=dhcp|auto`, gateway removed; `static` -> `ip[6]=<cidr>`,
|
|
`gw[6]` set if given else removed; `none` -> both removed. Validation (ValueError):
|
|
static needs a CIDR of the right family with a `/prefix`
|
|
(`ipaddress.ip_interface`); a gateway must be an address of the same family; `auto`
|
|
only for family 6.
|
|
- `set_net(guest, changes: dict[str, str | None])` -> one call:
|
|
`set <kind> config <node> <vmid> [--<key> <value> ...] [--delete <k1,k2>] --wait`;
|
|
`None` values go to `--delete`. Empty `changes` -> ValueError.
|
|
|
|
### `app.py` (UI contract)
|
|
|
|
- Key `n` in InfoScreen opens `NetScreen` for the guest (needs the loaded config;
|
|
nothing if the guest has no `netN`).
|
|
- `NetScreen(ModalScreen)`: `Select#iface` (netN keys), `Select#bridge`
|
|
(`cli.bridges`), `Input#tag` (empty = none), `Checkbox#firewall`, `Select#ipv4`
|
|
(`dhcp`/`static`/`none`), `Input#ipv4_cidr`, `Input#ipv4_gw`, `Select#ipv6`
|
|
(`auto` labelled SLAAC / `dhcp` / `static` / `none`), `Input#ipv6_cidr`,
|
|
`Input#ipv6_gw`, buttons `#apply` / `#cancel`; escape = cancel. Prefilled from the
|
|
current values of the selected interface; changing `#iface` prefills again.
|
|
VM without cloud-init: IP widgets disabled and `Static#ip_note` explains why.
|
|
- Apply: build the new netN (and for VMs the new ipconfigN) with `apply_link` /
|
|
`apply_ip`; ValueError -> error notify, screen stays open, no CLI call. No change ->
|
|
info notify, nothing else. Otherwise `ConfirmScreen` listing each changed key with
|
|
its old and new value (raw strings); for a running guest it says link changes apply
|
|
immediately, and for VM ipconfig that it applies at next boot.
|
|
- On yes: guest busy (refuse if busy), worker runs `cli.set_net(guest, changes)`,
|
|
done/error notify, busy released, InfoScreen reloads. On success the old values
|
|
(`None` for keys that did not exist) are stored as the guest's undo entry.
|
|
- Live finding (CT 104): Proxmox applies a changed `ip` on a running CT, but static ->
|
|
dhcp left the CT without IPv4 (no DHCP client restart) until the CT was rebooted.
|
|
The confirm therefore says, for IP changes on a running container, that a reboot of
|
|
the container may be needed. Proxmox re-orders keys when it stores `netN`.
|
|
- Review fixes: undo entries are cleared on a context switch (VMIDs repeat across
|
|
clusters); `has_cloudinit` only looks at disk keys; CIDR needs a decimal prefix
|
|
1-32 / 1-128 (no netmask form, no /0); fields the user did not touch are not rewritten
|
|
(e.g. `ip6=manual` stays).
|
|
- Key `u` in InfoScreen: no undo entry -> info notify. Otherwise `ConfirmScreen`
|
|
showing what will be restored, then `cli.set_net(guest, old_values)` the same way;
|
|
the undo entry is cleared after success.
|
|
|
|
## Steps
|
|
|
|
- [x] Tests - red first.
|
|
- [x] Implementation - suite green, ruff clean, README keys `n` / `u`.
|
|
- [x] Live on CT 104 (approved), each step reverted: firewall off->on->off; VLAN
|
|
none->99->none (undo key); IPv6 SLAAC->DHCP->SLAAC; IPv4 DHCP->static
|
|
172.16.41.200/23 gw 172.16.40.1->DHCP. Bridge change cannot be tested live: the
|
|
node has a single bridge (`vmbr0`). Final net0 string identical to the baseline.
|
|
- [ ] Push `feat/network-edit`, merge to `main` (ask first).
|
|
|
|
---
|
|
|
|
# Snapshots in the info screen (issue #8)
|
|
|
|
## Decision (user-approved)
|
|
|
|
Info screen section "Snapshots"; keys `p` take, `R` roll back, `D` delete. LXC and VM.
|
|
|
|
CLI facts (cv4pve-cli 2.4.0, read-only probes):
|
|
|
|
- `get guest snapshots <node> <qemu|lxc> <vmid> -o json` -> list of dicts: `name`,
|
|
`description`, `snaptime` (epoch or null), `parent`, VM also `vmstate`. Always
|
|
contains a pseudo entry `name == "current"` ("You are here!") - not a snapshot.
|
|
- `create ct|vm snapshot <node> <vmid> <snapname> <descr>` (+ `--vmstate 1` for VM RAM),
|
|
no `--yes`; with `--wait`.
|
|
- `do rollback ct|vm <node> <vmid> <snapname>` needs `-y`; API param `start` (bool)
|
|
starts the guest after a successful rollback.
|
|
- `delete ct|vm snapshot <node> <vmid> <snapname>` needs `-y`.
|
|
DANGER: `delete ct|vm <node> <vmid> -y` (without `snapshot`) DESTROYS THE WHOLE GUEST.
|
|
|
|
### `cli.py`
|
|
|
|
- `valid_snapname(name) -> str` - ValueError unless it matches
|
|
`[A-Za-z][A-Za-z0-9_-]{0,39}` and is not `current` (case-insensitive).
|
|
- `snapshots(guest) -> list[dict]` - without `current`, newest first (`snaptime`
|
|
desc, null last).
|
|
- `take_snapshot(guest, name, description="", vmstate=False)` ->
|
|
`create <kind> snapshot <node> <vmid> <name> <description or "cv4pve-tui">
|
|
[--vmstate 1 (VM only)] --wait`; name validated first.
|
|
- `rollback_snapshot(guest, name, start=False)` ->
|
|
`do rollback <kind> <node> <vmid> <name> [--start 1] -y --wait`; name validated.
|
|
- `delete_snapshot(guest, name)` ->
|
|
`delete <kind> snapshot <node> <vmid> <name> -y --wait`; name validated first, and
|
|
the argv is asserted to contain `snapshot` right after the kind and the name right
|
|
after the vmid (defence against ever sending the guest-destroying form).
|
|
|
|
### `app.py` (UI contract)
|
|
|
|
- InfoScreen gets `Vertical#snapshots_section` (border_title "Snapshots") with
|
|
`DataTable#snapshots` (Name, Date `YYYY-MM-DD HH:MM` or `-`, Description, and for VMs
|
|
RAM `yes`/`no`) and `Static#snap_note`. Loaded with the rest; a failure of
|
|
`cli.snapshots` is shown in `#snap_note` ("unavailable: <summary>"), the rest of the
|
|
screen still works. Empty list -> `#snap_note` "No snapshots".
|
|
- `p`: `SnapshotScreen` dialog (DialogScreen style, title "Take snapshot: <name>
|
|
(<vmid>)"): `Input#snapname` prefilled `tui_YYYYMMDD_HHMM` (local time),
|
|
`Input#snapdesc`, `Checkbox#vmstate` (VMs only; not shown for CTs), buttons
|
|
`#yes`/`#no`, escape = no. Invalid name -> error notify, dialog stays, no call.
|
|
Yes -> busy guard, worker `cli.take_snapshot`, done/error notify, info reloads.
|
|
- `R`: roll back the snapshot under the cursor of `#snapshots`. `ConfirmScreen`
|
|
titled "Roll back" naming snapshot and date and saying everything changed since then
|
|
is lost; if the guest is running (live status) it says it will be started again and
|
|
`start=True` is passed. Yes -> busy guard, worker, notify, info reloads.
|
|
- `D`: delete the snapshot under the cursor. `ConfirmScreen` titled "Delete snapshot"
|
|
naming it. Yes -> busy guard, worker `cli.delete_snapshot`, notify, info reloads.
|
|
- `R`/`D` with no snapshot -> info notify, no dialog.
|
|
|
|
## Steps
|
|
|
|
- [x] Tests - red first. - [x] Implementation - suite green, ruff clean, README keys.
|
|
- Review fixes: rollback reads the live status right before rolling back (if it cannot
|
|
be read, no rollback); the info screen scrolls; a description starting with `-` is
|
|
refused (positional argument).
|
|
- [x] Live on CT 104: take `tui_live_test`, listed; roll back (CT running -> started
|
|
again); delete; list back to the baseline (no snapshots); CT running.
|
|
- [ ] PR with `Closes #8`, merge after user OK.
|
|
|
|
---
|
|
|
|
# Live usage: CPU %, memory used, disk used (issue #12)
|
|
|
|
## Decision (user-approved)
|
|
|
|
Usage in the main table and in the info screen's Resources section. Values refresh with
|
|
the listing (`g`, after actions); no auto-refresh and no rrddata trend (out of scope).
|
|
|
|
CLI facts (read-only probes):
|
|
|
|
- `get guests -o json` per guest: `cpu` (fraction of the guest's CPUs, 0..1; the web UI
|
|
shows `cpu*100` %), `mem`, `maxmem` (bytes), `disk`, `maxdisk` (bytes). Stopped guests
|
|
have 0s. VMs report `disk: 0` (no guest-agent disk info) - shown as `-`, not 0.
|
|
- `api get /nodes/<node>/<qemu|lxc>/<vmid>/status/current -o json` -> key/value list:
|
|
`status`, `cpu`, `cpus`, `mem`, `maxmem`, `disk`, `maxdisk`, `uptime`; CTs also
|
|
`swap`, `maxswap`; VMs also `freemem`, `balloon`.
|
|
|
|
### `cli.py`
|
|
|
|
- `current(guest) -> dict` - the status/current key/value list as a dict.
|
|
- `status(guest)` keeps its behaviour (returns `current(guest)["status"]`), argv
|
|
unchanged.
|
|
|
|
### `app.py`
|
|
|
|
- Main table: two new columns at the end, "CPU %" and "Mem %": `cpu*100` and
|
|
`mem/maxmem*100`, one decimal; `-` for a guest that is not running or lacks the field.
|
|
- Info screen Resources table gets a header and a third column: Resource | Configured |
|
|
In use. The load uses `cli.current(guest)` (one call) for both the live status and the
|
|
usage. In use, only when running:
|
|
- CPU: `<cpu*100:.1f> %`
|
|
- Memory: `<mem MiB> MiB (<pct:.0f> %)`
|
|
- Swap (CT): `<swap MiB> MiB`
|
|
- Disk rootfs (CT): `<disk GiB:.1f> GiB (<pct:.0f> %)`; other disks and all VM disks `-`
|
|
- otherwise `-`.
|
|
A failure of `cli.current` keeps today's behaviour (inline "unavailable", status
|
|
unknown -> not running).
|
|
|
|
---
|
|
|
|
# Task log (issue #10)
|
|
|
|
## Decision (user-approved)
|
|
|
|
`l` on the main screen: all recent cluster tasks. `l` on the info screen: the tasks of
|
|
that guest. Enter on a task shows its log.
|
|
|
|
CLI facts (read-only probes):
|
|
|
|
- `task list -o json` lacks start/end times. `api get /cluster/tasks -o json` has them:
|
|
list of dicts `upid`, `type` (e.g. `vzdump`, `qmstart`, `vzsnapshot`), `id` (VMID as a
|
|
string, `""` for node tasks), `node`, `user`, `starttime`, `endtime` (epoch; missing
|
|
or null while running), `status` (`OK`, an error text, or missing while running),
|
|
`saved`, `tokenid`.
|
|
- `task log <upid>` prints the log as plain text lines (e.g. `TASK OK` at the end).
|
|
|
|
### `cli.py`
|
|
|
|
- `tasks() -> list[dict]` - `/cluster/tasks`, newest first by `starttime`.
|
|
- `task_log(upid) -> str` - `task log <upid>`; ValueError unless `upid` starts with
|
|
`UPID:` (positional argument).
|
|
|
|
### `app.py` (UI contract)
|
|
|
|
- `TasksScreen(ModalScreen)`, opened by `l` on the main screen (all tasks) and on the
|
|
info screen (`vmid` filter: only tasks whose `id == str(vmid)`). Frame `#tasks_section`
|
|
titled "Tasks" or "Tasks: <name> (<vmid>)". `DataTable#tasks` (row cursor): Start
|
|
(`YYYY-MM-DD HH:MM` local), Duration (`1h 2m`, `3m 12s`, `45s`; `running` without
|
|
endtime), Node, Type, VMID (`-` for node tasks), User, Status (`OK`, the error text, or
|
|
`running`). A failed task (status present and not `OK`) gets a `!` in a leading
|
|
column. `Static#tasks_note` says "No tasks" when empty. Loaded in a thread worker;
|
|
failure -> error notify, screen stays. Keys: `enter` open the log, `g` reload,
|
|
`escape` close.
|
|
- `TaskLogScreen` (DialogScreen style, wide box), title "Log: <type> <vmid or node>",
|
|
a read-only plain-text log widget `#tasklog` (no markup parsing), loaded in a worker;
|
|
failure -> error notify. `escape` closes.
|
|
- Main-screen footer shows `l Tasks`.
|
|
|
|
---
|
|
|
|
# Node package updates (issue #13)
|
|
|
|
## Decision (user-approved)
|
|
|
|
`U` on the main screen shows pending package updates per node; the frame's bottom
|
|
border shows the count. Read-only: running `apt update`/`upgrade` stays out of scope.
|
|
|
|
CLI facts (read-only probes):
|
|
|
|
- `get nodes -o json` -> list of dicts with `node`, `status` (`online`/`offline`), ...
|
|
- `get node packages <node> -o json` -> list of dicts with `Package`, `OldVersion`,
|
|
`Version`, `Title`, `Priority`, `Origin`, ... (23 on mainframe). The list is as fresh
|
|
as the node's last `apt update` (Proxmox runs it daily); the call does not refresh it.
|
|
|
|
### `cli.py`
|
|
|
|
- `nodes() -> list[str]` - names of online nodes, sorted.
|
|
- `package_updates(node) -> list[dict]` - `get node packages <node> -o json`, sorted by
|
|
`Package`.
|
|
|
|
### `app.py` (UI contract)
|
|
|
|
- `UpdatesScreen(ModalScreen)` opened by `U`: frame `#updates_section` titled "Package
|
|
updates", `DataTable#updates`: Node, Package, Current (`OldVersion`), New
|
|
(`Version`), Origin, Priority; `Static#updates_note`: "No pending updates" when
|
|
empty, plus one line per node that failed ("<node>: unavailable: <summary>") and a
|
|
fixed hint that the list is as of the node's last apt update. Loaded in a thread
|
|
worker; `g` reloads; `escape` closes. A failing node does not hide the others.
|
|
- The main frame's bottom border adds "· N package updates" when N > 0, after the
|
|
backup count. The count is fetched in a worker on mount, after a context switch, and
|
|
every time the UpdatesScreen loads - not on every guest reload (one CLI call per node).
|
|
It follows the same generation guard as the guest list (a count from the old cluster
|
|
is dropped after a switch) and is cleared when a switch starts. Failures leave the
|
|
count unknown (nothing shown), no error notify on mount.
|
|
- Main footer shows `U Updates`.
|
|
|
|
---
|
|
|
|
# Search and filter in the guest table (issue #16)
|
|
|
|
## Decision
|
|
|
|
- `/` on the main screen shows `Input#filter` (hidden until then) below the guest table
|
|
and focuses it. The table filters as you type.
|
|
- Filter text = space-separated terms, all must match (AND), case-insensitive:
|
|
- a plain term matches a substring of the name, the VMID or one of the tags
|
|
(`tags` from `get guests`, `;`-separated);
|
|
- `is:running` / `is:stopped` match the status;
|
|
- `type:lxc` / `type:ct` and `type:vm` / `type:qemu` match the guest type.
|
|
- Enter in the filter keeps the filter and moves focus to the table. Escape in the
|
|
filter clears it, hides the input and moves focus to the table.
|
|
- The frame's bottom border gets "· filter: <text> · N of M" while a filter is active,
|
|
after the context name and before the backup/package counts.
|
|
- Actions (s/S/x/r/t/Enter) act on the selected *visible* row only; the table only ever
|
|
contains the matching guests. A reload (g, after actions, context switch) keeps the
|
|
filter applied. A context switch keeps the filter text.
|
|
- Placeholder of the input shows the syntax: `name, vmid, tag · is:running · type:lxc`.
|
|
- No match: empty table, and the frame says "0 of M"; action keys do nothing.
|
|
|
|
### Code
|
|
|
|
- Pure function `guest_matches(guest: dict, query: str) -> bool` in `app.py` (or
|
|
`cli.py`-free module-level helper) so it can be tested without the UI.
|
|
|
|
---
|
|
|
|
# Autostart, startup order and tags (issue #18)
|
|
|
|
## Decision
|
|
|
|
Shown on the info screen; edited with key `o` (options) there. LXC and VM.
|
|
|
|
CLI facts (read-only probes, PVE 9.2):
|
|
|
|
- Config keys: `onboot` (0/1, absent = 0), `startup` (`[[order=]N][,up=S][,down=S]`,
|
|
absent = none), `tags` (`;`-separated, e.g. `172.16.41.200;community-script;os`).
|
|
- Write with `set <kind> config <node> <vmid> --onboot 0|1 --startup <s> --tags <t>
|
|
[--delete startup,tags] --wait` - i.e. the existing `cli.set_net(guest, changes)` (one
|
|
call; `None` values go to `--delete`).
|
|
- DANGER: `delete ct|vm ... tags` exists but shares the guest-destroying `delete ct|vm
|
|
<node> <vmid>` form. Never use it; removing tags = `--delete tags` on `set config`.
|
|
|
|
### `cli.py`
|
|
|
|
- `startup_value(order, up, down) -> str | None` - ints >= 0 or None; builds
|
|
`order=N[,up=S][,down=S]` (only the given parts); all None -> None. ValueError for
|
|
negatives/non-ints. `parse_startup(s) -> (order, up, down)` (accepts a bare leading
|
|
number as order).
|
|
- `tags_value(text) -> str | None` - split on `;`, `,` or whitespace, drop empties and
|
|
duplicates (order kept); each tag must match `[A-Za-z0-9_][A-Za-z0-9_+.-]*` else
|
|
ValueError; joined with `;`; none -> None.
|
|
|
|
### `app.py` (UI contract)
|
|
|
|
- Info screen Resources table gets rows "Autostart" (`yes`/`no`), "Startup" (e.g.
|
|
`order 2, up 10 s, down 30 s` or `-`), "Tags" (`a, b, c` or `-`); In use `-`.
|
|
- `o`: `BootScreen` dialog (DialogScreen style, title "Boot and tags: <name> (<vmid>)"):
|
|
`Checkbox#onboot`, `Input#order`, `Input#up`, `Input#down` (empty = not set),
|
|
`Input#tags` (prefilled `a; b; c`), buttons `#apply`/`#cancel`, escape = cancel.
|
|
Apply: build changes for keys whose value differs (`onboot` "1"/"0", `startup` string
|
|
or None, `tags` string or None); invalid -> error notify, dialog stays, no call; no
|
|
change -> info notify; else `ConfirmScreen` titled "Boot and tags" listing old -> new
|
|
per key; yes -> busy guard, `cli.set_net(guest, changes)` via start_job, info reloads.
|
|
|
|
---
|
|
|
|
# fstrim in a VM via the guest agent (issue #19)
|
|
|
|
## Decision
|
|
|
|
Key `f` on the info screen, VMs only. Not run live (the issue asks for approval first);
|
|
tested with mocks.
|
|
|
|
CLI facts:
|
|
|
|
- `do vm agent ping <node> <vmid> -o json` -> `[{"key": "result", "value": "{}"}]` when the
|
|
agent answers (verified read-only on VM 108); non-zero exit when it does not.
|
|
- `do vm agent fstrim <node> <vmid> -o json` -> the same key/value shape; QEMU guest
|
|
agent's result is `{"paths": [{"path": "/", "trimmed": <bytes>, "minimum": 0}, ...]}`
|
|
(a path may carry `"error"` instead of `trimmed`). Not verified live.
|
|
- VM config `agent` = `enabled=1` (or `1`) when the agent is enabled.
|
|
|
|
### `cli.py`
|
|
|
|
- `agent_enabled(config) -> bool` - `agent` is `1` or starts with `1` / has `enabled=1`.
|
|
- `agent_ping(guest)` -> `do vm agent ping <node> <vmid> -o json`; CliError if the agent
|
|
does not answer.
|
|
- `fstrim(guest) -> list[dict]` -> `do vm agent fstrim <node> <vmid> -o json`; returns
|
|
the `paths` list parsed from the `result` value (`[]` if missing or unparseable).
|
|
|
|
### `app.py` (UI contract)
|
|
|
|
- `f` on the info screen:
|
|
- container -> info notify that containers have no guest agent (`pct fstrim` on the
|
|
node instead); nothing runs;
|
|
- VM not running, or `agent_enabled` false -> warning notify saying why; nothing runs;
|
|
- otherwise `ConfirmScreen` titled "fstrim" naming the VM and saying it frees unused
|
|
blocks on thin-provisioned storage and adds I/O load while it runs;
|
|
- yes -> busy guard, worker: `cli.agent_ping` then `cli.fstrim`; done notify with the
|
|
trimmed total in GiB/MiB and the number of paths (errors per path mentioned);
|
|
CliError -> error notify (ping failure says the agent did not answer).
|
|
|
|
---
|
|
|
|
# Restore a guest from a backup (issue #15)
|
|
|
|
## Decision
|
|
|
|
From the info screen's backup list, key `r` ("Restore backup"; `R` stays snapshot
|
|
rollback). Two targets: a new VMID (default, original untouched) or overwriting the
|
|
guest itself (destructive). Not run live: a live test would create a guest that then
|
|
needs deleting, which the issue reserves for the user's approval. Tested with mocks.
|
|
|
|
CLI facts (cv4pve-cli 2.4.0, read-only probes):
|
|
|
|
- `do restore ct|vm <node> <vmid> <volume> [--key value] -y --wait` - "creates/overwrites
|
|
the target vmid". API params: `storage` (target storage for the disks), `force`
|
|
(bool; required to overwrite an existing guest), `start` (bool), `unique` (VM: new
|
|
MACs).
|
|
- `get cluster nextid -o json` -> a JSON number, e.g. `105`.
|
|
- Backup entries carry `volid` (`truenas-backup:backup/vzdump-lxc-104-...tar.zst`),
|
|
`subtype` (`lxc`|`qemu`), `vmid`.
|
|
- Node storages: `content` lists `rootdir` (CT disks) and/or `images` (VM disks).
|
|
|
|
### `cli.py`
|
|
|
|
- `next_id() -> int`.
|
|
- `valid_volid(volid) -> str` - ValueError unless it matches
|
|
`^[A-Za-z0-9][A-Za-z0-9_.-]*:backup/vzdump-(lxc|qemu)-\d+-\S+$` (positional argument).
|
|
- `restore_storages(guest) -> list[str]` - enabled+active storages on the guest's node
|
|
whose `content` has `rootdir` (CT) or `images` (VM), API order.
|
|
- `restore(guest, volid, vmid, storage, force=False, start=False)` ->
|
|
`do restore <kind> <node> <vmid> <volid> --storage <storage> [--force 1] [--start 1]
|
|
-y --wait`; kind from the backup's guest kind (the guest dict's `type`); `vmid` a
|
|
positive int; volid validated; `force=True` only allowed when `vmid ==
|
|
guest["vmid"]` (ValueError otherwise - never overwrite some other guest).
|
|
|
|
### `app.py` (UI contract)
|
|
|
|
- `r` on the info screen: the backup under the cursor of `#backups` (the visible rows;
|
|
hidden earlier-guest backups are not restorable unless shown with `a`). No backup ->
|
|
info notify.
|
|
- `RestoreScreen` (DialogScreen, title "Restore <date> backup of <name> (<vmid>)"):
|
|
`RadioSet#target` with `RadioButton#new` (default, "New guest") and
|
|
`RadioButton#overwrite` ("Overwrite <name> (<vmid>)"); `Input#newid` prefilled with
|
|
`cli.next_id()` (used for "new"); `Select#storage` (`restore_storages`, preselected
|
|
to the storage of the guest's rootfs/first disk if listed); `Checkbox#start` (off);
|
|
`Input#confirm` (typed VMID, only needed for overwrite); `#apply`/`#cancel`; escape =
|
|
cancel. A note says a new copy keeps the original's network settings (same static
|
|
IP), so leave Start off until it is changed.
|
|
- Apply, new: `newid` must be a positive int not used by any guest in the current
|
|
listing (app.guests) -> else error notify, no call. Then `ConfirmScreen` titled
|
|
"Restore" summarising backup, target VMID, storage, start; yes -> busy guard on the
|
|
source guest, worker `cli.restore(guest, volid, newid, storage, force=False,
|
|
start=...)`, notify, main list reloads.
|
|
- Apply, overwrite: the guest must be stopped (live `cli.status` at apply time) -> else
|
|
error notify; `Input#confirm` must equal the VMID -> else error notify; then
|
|
`ConfirmScreen` titled "Overwrite" saying the guest's current disks and config are
|
|
replaced and cannot be undone; yes -> `cli.restore(guest, volid, guest vmid, storage,
|
|
force=True, start=...)`.
|
|
- All CliErrors -> error notify; busy released; markup-safe texts.
|