25 Commits
Author SHA1 Message Date
DuckbootandClaude Opus 5.5 890b6b115b Fix the inode count in pct-shrink-rootfs
CI / pytest and ruff (push) Successful in 5m6s
GNU df refuses -i together with --output, so the script stopped right
after mounting the container ("options -i and --output are mutually
exclusive"). The iused field needs no -i: use df --output=iused.

The df stub in the tests accepted the combination and hid the bug; it now
rejects it the way GNU df does.

Generated with AI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:10:42 +01:00
DuckbootandClaude Opus 5.5 792667897a Add a node script that shrinks a container's root disk
CI / pytest and ruff (push) Successful in 5m19s
scripts/pct-shrink-rootfs runs as root on the Proxmox node and keeps
the VMID. Without --yes it only measures and prints the plan.

It adds a temporary mp9 of the new size (Proxmox allocates and formats
it), rsyncs the root disk into it, and swaps it into rootfs under
Proxmox's config flock. The config is written like
file_set_contents does on pmxcfs (tmp file, then rename), after
checking that rootfs and mp9 did not change during the copy. The old
volume is kept as the first free unusedK and the exact delete command
is printed; pct rescan is not used, so other orphans of the VMID are
not pulled in.

It refuses snapshots and pending sections, a lock, template,
protection, HA, replication, any mount point, and sizes the data does
not fit (10 % headroom on top of the ext4 reserve and metadata, and an
inode check). On an rsync failure the new volume is freed by its volid.

Tests run the real script against stub pct, df, rsync, flock,
ha-manager, pvesr and id. README documents both shrink routes.

Design: docs/plans/lxc-shrink.md (section B).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:58:02 +01:00
DuckbootandClaude Opus 5.5 82902edfc8 Restore a container backup with a smaller root disk
Proxmox only grows disks. The restore dialog gets an optional "Root
disk GiB" field for container backups; with a size, the backup is
restored as a new guest with --rootfs <storage>:<N> (Proxmox's advanced
restore mode).

- A smaller disk is only allowed for a new VMID, checked in the dialog
  and again in cli.restore(): with force, Proxmox deletes the old disks
  before it unpacks, and a failed unpack then removes the guest.
- The size must be a positive integer (0 gives an unlimited subvol on
  ZFS/dir storage) and above the used root disk space of a running
  guest.
- The confirm screen is a separate DANGER screen with a red border: it
  says mount points are dropped and their backed-up files land in the
  root disk, raw lxc.* lines can come back under root@pam, root disk
  options are not carried over, and the used space is no fit check.
- Never --unique or ignore-unpack-errors.

Design: docs/plans/lxc-shrink.md (section A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:58:02 +01:00
DuckbootandClaude Opus 5.5 9b472e8cf6 Wrap the key bindings bar over several lines
CI / pytest and ruff (pull_request) Successful in 4m43s
CI / pytest and ruff (push) Successful in 4m43s
Textual's Footer is a single horizontal row, so on a narrow terminal the
keys at the end were cut off. KeyBar replaces it on every screen: it reads
the same active bindings, packs them greedily into rows that fit the width,
and grows in height as needed.

Generated with AI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:35:03 +01:00
DuckbootandClaude Opus 5.5 0df928ab11 Restore a guest from a backup in the info screen
CI / pytest and ruff (pull_request) Successful in 7m25s
CI / pytest and ruff (push) Successful in 6m56s
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>
2026-11-03 01:32:05 +01:00
DuckbootandClaude Opus 5.5 262441916c Run fstrim in a VM via the guest agent
CI / pytest and ruff (pull_request) Successful in 5m56s
CI / pytest and ruff (push) Successful in 6m14s
f on the info screen runs fstrim in a running VM with the guest agent
enabled. A container gets an info note (no guest agent; use pct
fstrim on the node). A stopped VM or one without an agent gets a
warning, and nothing runs. Otherwise a confirm comes first, then
agent ping and fstrim. The result reports the trimmed total
(GiB/MiB), the number of paths and any per-path errors.

Review fixes:
- Odd agent output no longer turns a finished trim into "fstrim
  failed". Unreadable output gives "no results". Odd entries are
  repaired: non-dict entries are dropped, a non-int trimmed becomes 0
  and a missing path becomes "?". Well-formed entries are returned
  unchanged.
- fstrim gets a 30-minute CLI timeout instead of 330 s, because a big
  disk can take longer.
- Notifications are never parsed as markup (GuestApp.notify defaults
  to markup=False). Guest names, paths and CLI or agent errors are
  untrusted text, so a "[/b]" in them could break the toast.
- f before the info screen has loaded says "still loading" instead of
  a wrong "not running".
- "1 path" in the singular.

Not run live: the issue asks for approval before fstrim on VM 108.
Only the harmless agent ping was run there, and it answered.

Closes #19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:08:30 +01:00
DuckbootandClaude Opus 5.5 f5f3effce5 Edit autostart, startup order and tags from the info screen
CI / pytest and ruff (pull_request) Successful in 5m31s
CI / pytest and ruff (push) Has been cancelled
The info screen's Resources table shows Autostart (yes/no), Startup
(order, up and down delays) and Tags. o opens a dialog to change them.
Only keys that differ are sent, in one `set <kind> config` call
through the existing set_net. A confirm lists the old and new value
of each key.

Tags are removed with `--delete tags` on set config. The CLI also has
a `delete ct|vm ... tags` command, but it shares the form that
destroys the whole guest, so the app never uses it; nothing in the
code builds a delete ct|vm argv. Inputs are validated before sending:
startup values must be ints >= 0, and tags must match
[A-Za-z0-9_][A-Za-z0-9_+.-]*. They are split on ; , or whitespace and
deduplicated.

Live test on CT 104: onboot 1 -> 0, startup order=5,up=10, tag
tui-test added, then all set back through the dialog. The config
ended identical to the baseline.

Closes #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:55:02 +01:00
DuckbootandClaude Opus 5.5 d97ad22c78 Add search and filter to the guest table
CI / pytest and ruff (pull_request) Successful in 4m48s
CI / pytest and ruff (push) Has been cancelled
/ opens a filter box under the guest table, and the table filters as
you type. Terms are separated by spaces, all must match, and case is
ignored:

- a plain term matches part of the name, the VMID or a tag
- is:running and is:stopped match the status
- type:lxc/ct and type:vm/qemu match the guest type

Enter keeps the filter and goes back to the table. Escape clears it,
from the box or from the table. The frame shows
"· filter: <text> · N of M". Actions only ever see the visible rows,
and the filter survives reloads and context switches.

Review fixes:
- Typing "[/b]" or "[/]" crashed the app, because the filter text went
  into the border subtitle, which Textual parses as markup. The
  subtitle is now plain text, so "[red]" is also shown literally.
- Escape on the table now clears a filter that was applied with Enter.
- guest_matches tolerates a None status or type.

Read-only live check on mainframe: "debian" gives 104, "type:vm" gives
108, "community-script is:running" gives 14 of 19, and "is:stopped"
gives 0 of 19.

Closes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:42:13 +01:00
DuckbootandClaude Opus 5.5 e7569a7bf7 Warn on an untested cv4pve-cli version at startup
CI / pytest and ruff (pull_request) Successful in 4m42s
CI / pytest and ruff (push) Has been cancelled
At startup the app runs `cv4pve-cli --version`. If it differs from
cli.TESTED_VERSION (2.4.0), a warning says which version was found
and which one was tested. The app parses output formats that are not
a stable API: the config list text, prune-backups as a JSON string,
the CT ip-addresses JSON string and the exit codes with --wait. A
newer cv4pve-cli could break them in confusing ways.

If cv4pve-cli cannot be run at all, an error says so once, instead of
every later call failing without explanation. The README notes the
tested version.

Closes #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:30:14 +01:00
DuckbootandClaude Opus 5.5 364f8e2137 Show pending node package updates (read-only)
CI / pytest and ruff (pull_request) Successful in 4m47s
CI / pytest and ruff (push) Successful in 4m22s
U opens "Package updates": the pending apt updates of every online
node, with node, package, current and new version, origin and
priority. A node that fails is shown as "<node>: unavailable", and the
other nodes are still listed. A hint says the list is as of the
node's last apt update; this call does not refresh it.

The main frame's bottom border shows "· N package updates" after the
backup count, with a singular for 1. The count is fetched on mount,
after a context switch and whenever the updates screen loads, not on
every guest reload, since it costs one CLI call per node. It uses the
same generation guard as the guest list, so a count from the old
cluster is dropped after a switch.

Running apt update or upgrade stays out of scope; it is a write.

Read-only live check: "mainframe · 23 package updates", and 23 rows
in the screen.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:14:07 +01:00
DuckbootandClaude Opus 5.5 1ed5e5069b Add the task log: recent tasks with status, Enter shows the log
CI / pytest and ruff (pull_request) Successful in 4m10s
CI / pytest and ruff (push) Successful in 4m32s
l on the main screen opens "Tasks", the recent cluster tasks newest
first. l on the info screen shows only that guest's tasks. Columns:
start, duration (or "running"), node, type, VMID ("-" for node
tasks), user and status. A failed task gets a "!" in the leading
column. Enter opens the task's log in a plain-text widget, so the
output is never parsed as markup.

`task list -o json` has no start or end times, so the list comes from
`/cluster/tasks`. The upid is validated (it must start with "UPID:")
before it goes in as a positional argument.

Review fixes:
- A finished task without a start time no longer crashes the app; its
  duration shows "-".
- "running" now means there is no endtime. A finished task with an
  empty status shows "unknown" and is flagged.

Read-only live check: 25 tasks listed, and the log of the newest
(vzdelsnapshot 104) ends with TASK OK.

Closes #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:03:58 +01:00
DuckbootandClaude Opus 5.5 eec65e0585 Show live usage: CPU % and Mem % in the table, in-use on the info screen
CI / pytest and ruff (pull_request) Successful in 3m49s
CI / pytest and ruff (push) Has been cancelled
The guest table gets two last columns, CPU % (cpu * 100) and Mem %
(mem / maxmem), each with one decimal. They show "-" for a guest that
is not running or lacks the field.

The info screen's Resources table gets a header and an "In use"
column: CPU %, memory MiB with %, swap for containers, and rootfs used
GiB with % for containers. VM disks show "-", because the API reports
0 without guest-agent disk info. The info screen reads status and
usage in one status/current call (new cli.current).

After an action, the acted guest's status and usage both come from
status/current, so a guest that has just started no longer shows the
lagging listing's 0.0 %.

One blind-written test expected a VM's CPU cell to read "4 cores". VMs
have always shown sockets ("4 cores × 1 socket", as the resources
spec and its tests define), so that expectation was corrected.

Closes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:52:32 +01:00
DuckbootandClaude Opus 5.5 ec35a4c996 Add snapshots to the info screen: take, roll back, delete
A "Snapshots" section on the info screen lists the snapshots, newest
first, without Proxmox's "current" pseudo entry. Keys:

- p takes a snapshot. The name defaults to tui_YYYYMMDD_HHMM, the
  description is optional, and VMs can include RAM.
- R rolls back to the snapshot under the cursor. The confirm names the
  snapshot and its date. A running guest is started again; its status
  is read right before the rollback, and if it cannot be read the
  rollback does not run.
- D deletes the snapshot under the cursor, after a confirm.

`delete ct|vm <node> <vmid> -y` without the snapshot subcommand
destroys the whole guest. delete_snapshot therefore validates the name
first (letter first, then [A-Za-z0-9_-], not "current"), writes
"snapshot" into the argv as a literal, and checks the argv shape
before running it. A property test covers rejected names.

The info screen now scrolls, because four sections do not fit short
terminals. A description starting with "-" is refused, since it is a
positional argument and could be read as an option.

Live test on CT 104: take, roll back (started again) and delete. The
snapshot list, status, config and IPv4/IPv6 match the baseline.

Closes #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:26:49 +01:00
DuckbootandClaude Opus 5.5 520de16cc6 Flag guests that no backup job covers
The guest table marks with "!" each guest that Proxmox's not-backed-up
list returns. The frame's bottom border shows the count, e.g.
"mainframe · 2 without backup job". This uses Proxmox's own answer, so
it also counts jobs that select guests by pool.

If the coverage call fails, the guests are still listed and a warning
says the coverage is unknown.

Closes #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:02:35 +01:00
DuckbootandClaude Opus 5.5 5a3ca5a216 Show configured resources on the info screen
A new "Resources" section at the top of the info screen shows CPU
cores (with sockets for VMs), memory, swap for containers, and each
disk with its size. The disks are the same ones the edit dialog
offers, so cloud-init and CD drives are left out. A container without
a cores limit shows "unlimited".

The open info screen reloads after any guest job, so values edited
with e show up straight away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:42:38 +01:00
DuckbootandClaude Opus 5.5 0c3669271c Open the info screen with Enter and move edit into it
Enter on a guest now opens its info screen, replacing i. The guest
table shows Enter as "Info" in the footer. A priority binding at app
level would have taken Enter away from buttons and lists in the
dialogs, so the hint comes from the table's own binding instead.

Editing cores, memory and disk moved from the main screen into the
info screen (key e). When the edit dialog closes, you are back on the
info screen. Test helpers that pressed i or e on the main screen now
go through Enter, because the requirement changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:34:13 +01:00
DuckbootandClaude Opus 5.5 9ff00e425f Show all dialogs and the guest table as titled boxes
The info screen's style now applies everywhere. The guest table sits
in a "Guests" frame with the active context in its bottom border.
Every dialog is a centred box with a title, labelled fields and the
buttons bottom right: confirm (titled by the action), edit resources,
switch context, back up and edit network.

Fixed:
- The network dialog hid most of its fields. In Horizontal rows
  Textual gave Select a width of 1 and Input 100 %, which pushed
  bridge, firewall, the IP mode selects and the gateways off-screen.
  Every field in a row now has an explicit width.
- A guest name or note containing markup, such as "a[/b]", crashed the
  guest table and the dialogs, because DataTable cells and border
  titles were parsed as markup. They are now plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:18:10 +01:00
DuckbootandClaude Opus 5.5 62490af913 Separate backups and network on the info screen
Put backups and network in their own bordered sections titled
"Backups" and "Network". Inside Network, the configured settings and
the addresses in use are labelled "Configured" and "In use". The
"unavailable" note is hidden when it is empty, so it no longer leaves
a blank line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:53:00 +01:00
DuckbootandClaude Opus 5.5 5038148686 Add network editing with VLAN, firewall, IPv4/IPv6 and undo
Press n in the info screen to edit one interface: bridge, VLAN tag,
firewall, IPv4 (DHCP, static or none) and IPv6 (SLAAC, DHCP, static or
none). The netN string is parsed, the chosen fields are changed and
the string is written back, so hwaddr, model=MAC, mtu, rate, trunks
and type are kept. For VMs, IP settings go to cloud-init ipconfigN and
only when the VM has a cloud-init drive.

A confirm shows the old and new raw values before anything is sent.
Press u to restore the values from before the last change to that
guest in this session.

Review fixes:
- Undo entries are cleared on a context switch, because VMIDs repeat
  across clusters.
- has_cloudinit only looks at disks.
- CIDR needs a decimal prefix: no netmask form and no /0.
- Fields you did not touch are not rewritten.

Live test on CT 104: firewall, VLAN 99, IPv6 DHCP and static IPv4 were
each applied and undone, and net0 ended identical to the baseline.
Going from static back to DHCP left the CT without IPv4 until a
reboot, so the confirm now warns that IP changes on a running
container may need one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:45:06 +01:00
DuckbootandClaude Opus 5.5 3efd028708 Hide backups of earlier guests that reused the VMID
Proxmox ties backups to the VMID only, so a deleted guest's backups
were listed under the new guest with that VMID. CT 104 showed planka
backups next to debian's, and VM 108 showed vaultwarden backups.

Backups whose notes name another guest are now hidden by default. The
notes come from the job's {{guestname}}, so "debian (manual)" still
counts as debian. A line says how many are hidden, and the a key shows
them, marked "(earlier guest)". Backups without notes are always
shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:15:49 +01:00
DuckbootandClaude Opus 5.5 32809b6743 Add guest info screen with backups, backup now and network
Press i on a guest to see its backups and network. Backups from every
backup-capable storage are listed newest first, with the backup jobs
that cover the guest. The network part shows the netN settings
(bridge, VLAN, MAC, IPv4/IPv6 setting, firewall) and the addresses in
use.

Press b in the info screen to back the guest up now: snapshot mode and
zstd, like the scheduled job, to a storage you pick (the job's storage
is preselected). It asks for confirmation, marks the guest busy and
uses --wait.

Fixed after review and a read-only live check:
- prune-backups arrives as a JSON string; parsing it as a dict
  crashed the app.
- CT addresses come from ip-addresses. inet6 holds only the
  link-local address, so the global SLAAC address was missing.
- The info screen uses the guest's live status, and odd API data
  (null size, unknown address types) no longer kills the app.
- The test fake now copies guest dicts; a shared constant was being
  mutated across tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:06:22 +01:00
DuckbootandClaude Opus 5.5 cccdaf7209 Reset the terminal before opening the LXC shell
Write ESC c, the sequence `reset` sends, inside suspend() right before
ssh. The container shell then starts on a clean screen with no
leftover TUI terminal modes. It runs locally, so the container does
not need `reset` installed and `pct enter` is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:41:24 +01:00
DuckbootandClaude Opus 5.5 40feb125b4 Add LXC shell via ssh and pct enter
Press t on a running container to suspend the TUI and run
`ssh -t root@<node> pct enter <vmid>`. cv4pve-cli has no shell or exec
for LXC, so this goes around it and needs key-based root SSH to the
PVE node.

- VMs, stopped containers and busy guests get a warning instead.
- Only exit code 255 (ssh failed) is reported as an error. Other codes
  are the last status of the container's shell.
- OSError is caught inside suspend(). Textual's suspend() has no
  try/finally, so an exception escaping it would leave the app
  suspended.
- The guest table reloads when the shell returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:35:22 +01:00
DuckbootandClaude Opus 5.5 62b79ab29e Add context listing and switching to the TUI
Press c to list cv4pve-cli contexts and switch between them. The
active context is always shown in the header.

- Switching runs `cv4pve-cli config use`, which changes the global
  current context. cv4pve-cli 2.4.0 has no per-call context option.
- `config list` has no JSON output, so its text lines are parsed.
- The guest table is cleared as soon as another context is chosen, so
  the old cluster's guests cannot be acted on.
- A generation counter drops guest listings that started before a
  switch, and g and c are refused while a switch runs. Without this,
  a reload already in flight could refill the table with the old
  cluster's guests, and VMIDs collide across clusters.

103 tests pass. The switch was tested live from mainframe to
mainframe-edge and back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:22:53 +01:00
DuckbootandClaude Opus 5.5 f51aff3db5 Add Textual TUI for managing Proxmox guests via cv4pve-cli
List VMs and LXC containers from the current cv4pve-cli context and
start, shutdown, stop and reboot them, change cores and memory, and
grow disks. The app shells out to `cv4pve-cli -o json` and stores no
credentials.

- Every mutating call uses --wait: without it cv4pve-cli exits 0 even
  when the task fails.
- shutdown, stop and reboot need confirmation; disk growth is
  increment-only and asks again because it cannot be undone.
- After an action the acted-on guest's status is read from
  status/current, because /cluster/resources lags a few seconds.
- Notifies when a cores/memory change is pending until reboot.

Tests mock cv4pve-cli (85 tests). Live-tested edit, shutdown, start
and reboot on CT 104 (mainframe), restored afterwards; disk resize
was not run live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:12:05 +01:00