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

+117
View File
@@ -1504,3 +1504,120 @@ def test_tags_value_is_idempotent_property(text):
else:
assert cli.tags_value(once) == once
assert len(set(once.split(";"))) == len(once.split(";")) # no duplicates
# --- issue #19: guest-agent fstrim (spec: "fstrim in a VM via the guest agent") ----------
@pytest.mark.parametrize(
"agent,expected",
[
("enabled=1", True),
("1", True),
("1,fstrim_cloned_disks=1", True),
("enabled=0", False),
("0", False),
(None, False),
],
)
def test_agent_enabled(agent, expected):
config = {} if agent is None else {"agent": agent}
assert cli.agent_enabled(config) is expected
def test_agent_ping_exact_argv(fake):
fake.result = (json.dumps([{"key": "result", "value": "{}"}]), "", 0)
cli.agent_ping(VMID108)
assert fake.argv == [
"cv4pve-cli", "do", "vm", "agent", "ping", "mainframe", "108", "-o", "json",
] # fmt: skip
def test_agent_ping_nonzero_exit_raises_clierror(monkeypatch):
FakeRun(monkeypatch, "Error: QEMU guest agent is not running", returncode=5)
with pytest.raises(cli.CliError):
cli.agent_ping(VMID108)
FSTRIM_PATHS = [
{"path": "/", "trimmed": 1073741824, "minimum": 0},
{"path": "/boot", "error": "not supported"},
]
def fstrim_stdout(value):
return json.dumps([{"key": "result", "value": value}])
def test_fstrim_exact_argv_and_parses_paths(fake):
fake.result = (fstrim_stdout(json.dumps({"paths": FSTRIM_PATHS})), "", 0)
assert cli.fstrim(VMID108) == FSTRIM_PATHS
assert fake.argv == [
"cv4pve-cli", "do", "vm", "agent", "fstrim", "mainframe", "108", "-o", "json",
] # fmt: skip
@pytest.mark.parametrize(
"stdout",
[
"[]",
json.dumps([{"key": "other", "value": "{}"}]),
fstrim_stdout("not json at all"),
fstrim_stdout("{}"),
fstrim_stdout(""),
],
)
def test_fstrim_missing_or_garbage_result_is_empty_list(fake, stdout):
fake.result = (stdout, "", 0)
assert cli.fstrim(VMID108) == []
def test_fstrim_nonzero_exit_raises_clierror(monkeypatch):
FakeRun(monkeypatch, "Error: fstrim failed", returncode=5)
with pytest.raises(cli.CliError):
cli.fstrim(VMID108)
# --- #19 review findings ----------------------------------------------------------------
VM19 = {"vmid": 108, "node": "mainframe", "type": "qemu"}
@pytest.mark.parametrize(
"stdout",
[
"garbage",
"",
json.dumps({"key": "result"}), # object instead of list
json.dumps([{"value": "{}"}]), # item without "key"
json.dumps([{"key": "result", "value": {"paths": "x"}}]), # already a dict
],
)
def test_fstrim_unreadable_output_is_empty_not_an_error(fake, stdout):
fake.result = (stdout, "", 0)
assert cli.fstrim(VM19) == []
def test_fstrim_normalises_odd_path_entries(fake):
paths = [
{"path": "/", "trimmed": "12"},
{"error": "x"},
"junk",
{"path": "/var", "trimmed": 5},
]
fake.result = (
json.dumps([{"key": "result", "value": json.dumps({"paths": paths})}]),
"",
0,
)
assert cli.fstrim(VM19) == [
{"path": "/", "trimmed": 0}, # non-int trimmed -> 0
{"error": "x", "path": "?"}, # missing path -> "?"; "junk" dropped
{"path": "/var", "trimmed": 5}, # well-formed: unchanged
]
def test_fstrim_gets_a_longer_timeout_than_other_calls(fake):
fake.result = (json.dumps([{"key": "result", "value": "{}"}]), "", 0)
cli.fstrim(VM19)
assert fake.calls[0][1]["timeout"] >= 1800 # a big disk can take longer than 330 s