Run fstrim in a VM via the guest agent
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:
1 parent
f5f3effce5
commit
262441916c
6 files changed
+540
-2
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user