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>
This commit was merged in pull request #31.
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-03 01:08:30 +01:00
1 parent f5f3effce5
commit 262441916c
6 files changed
+540 -2

No files matched your search

+64
View File
@@ -718,6 +718,7 @@ class InfoScreen(ModalScreen):
("R", "rollback", "Roll back"),
("D", "delete_snapshot", "Delete snapshot"),
("l", "tasks", "Tasks"),
("f", "fstrim", "fstrim"),
]
DEFAULT_CSS = """
InfoScreen DataTable { height: auto; max-height: 12; }
@@ -1064,6 +1065,64 @@ class InfoScreen(ModalScreen):
),
)
def action_fstrim(self) -> None:
who = f"{self.guest['name']} ({self.guest['vmid']})"
if not self.config: # running/agent are only known once the screen has loaded
self.app.notify("Info is still loading; try again in a moment")
return
if cli.kind(self.guest) == "ct":
self.app.notify(
"Containers have no guest agent; run pct fstrim on the node instead"
)
return
if not self.running:
self.app.notify(
f"{who} is not running; fstrim needs a running VM", severity="warning"
)
return
if not cli.agent_enabled(self.config):
self.app.notify(
f"The guest agent is not enabled for {who}", severity="warning"
)
return
def job() -> None:
try:
cli.agent_ping(self.guest)
except cli.CliError as exc:
raise cli.CliError(
f"the guest agent did not answer: {_message(exc)}"
) from exc
paths = cli.fstrim(self.guest)
if not paths:
self.app.call_from_thread(
self.app.notify,
f"fstrim finished in {who}; the agent reported no results",
)
return
total = sum(p.get("trimmed") or 0 for p in paths)
size = (
f"{total / 2**30:.1f} GiB"
if total >= 2**30
else f"{total / 2**20:.1f} MiB"
)
errs = "".join(
f"; {p.get('path', '?')}: {p['error']}" for p in paths if p.get("error")
)
self.app.call_from_thread(
self.app.notify,
f"Trimmed {size} in {len(paths)} path{'s' if len(paths) != 1 else ''}{errs}",
)
self.app.push_screen(
ConfirmScreen(
f"Run fstrim in {who}? It frees unused blocks on thin-provisioned "
"storage and adds I/O load while it runs.",
"fstrim",
),
lambda ok: self.app.start_job(self.guest, "fstrim", job) if ok else None,
)
def _when(epoch: int | None) -> str:
return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M") if epoch else "-"
@@ -1163,6 +1222,11 @@ class GuestApp(App):
#filter { display: none; }
"""
def notify(self, message: str, **kwargs) -> None:
# messages carry guest names, paths and CLI/agent errors: never parse them as markup
kwargs.setdefault("markup", False)
super().notify(message, **kwargs)
def compose(self) -> ComposeResult:
yield Header()
with Vertical(id="guests_section") as section:
+54 -2
View File
@@ -28,7 +28,7 @@ class CliError(Exception):
return str(self)
def run(*args: str) -> str:
def run(*args: str, timeout: int = 330) -> str:
try:
proc = subprocess.run(
["cv4pve-cli", *args],
@@ -36,7 +36,7 @@ def run(*args: str) -> str:
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=330,
timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CliError(str(exc)) from exc
@@ -236,6 +236,58 @@ def backup_now(guest: dict, storage: str) -> None:
) # fmt: skip
def agent_enabled(config: dict) -> bool:
"""VM option `agent`: `1`, `1,<opt>=...` or `enabled=1,...`."""
parts = str(config.get("agent") or "").split(",")
return parts[0] == "1" or "enabled=1" in parts
def agent_ping(guest: dict) -> None:
"""CliError if the guest agent does not answer."""
run("do", "vm", "agent", "ping", guest["node"], str(guest["vmid"]), "-o", "json")
def fstrim(guest: dict) -> list[dict]:
"""Trim the guest's filesystems; the per-path results, `[]` if they cannot be read.
Well-formed entries come back as the agent sent them. Odd ones are repaired, not
fatal - the trim has already happened when the output is parsed: non-dict entries
are dropped, a non-int `trimmed` becomes 0 and a missing `path` becomes "?".
"""
args = (
"do",
"vm",
"agent",
"fstrim",
guest["node"],
str(guest["vmid"]),
"-o",
"json",
)
out = run(
*args, timeout=1800
) # a large disk can take longer than the default 330 s
try:
items = json.loads(out)
value = {x["key"]: x["value"] for x in items}.get("result") or "{}"
result = value if isinstance(value, dict) else json.loads(value)
paths = result.get("paths") if isinstance(result, dict) else None
except (ValueError, TypeError, KeyError, AttributeError):
return []
found = []
for p in paths if isinstance(paths, list) else []:
if not isinstance(p, dict):
continue
p = dict(p)
if "trimmed" in p and (
not isinstance(p["trimmed"], int) or isinstance(p["trimmed"], bool)
):
p["trimmed"] = 0
p.setdefault("path", "?")
found.append(p)
return found
def parse_net(value: str) -> dict:
"""`k=v,k=v` to an ordered dict; a VM's leading `<model>=<MAC>` is just the first pair."""
return dict(item.partition("=")[::2] for item in value.split(",") if item)