feat: purge, and a way out of install ok unpacked

Both cases come from a fleet-wide Wazuh rollback. Of thirteen hosts
carrying the agent, seven sat at `install ok unpacked` with the unit
failed — an upgrade whose postinst could not reach a manager that had
been decommissioned.

`apt-get remove` cannot help there. apt configures a package before
removing it, and configuring is precisely what was broken. On one host
only `dpkg --purge --force-all` got it out.

So `uninstall_package` takes `purge: bool = False`, and falls back to a
forced dpkg purge **after apt has failed** — never as a routine second
step. Forcing dpkg past its own consistency checks is a bigger hammer
than apt, and a caller who reaches for it every time will eventually
break something apt would rightly have refused.

`purge` is off by default: configuration somebody may want back is not
this function's to delete unless asked. It matters for more than
tidiness — a package's apt source survives a plain remove, so the
repository keeps being fetched on every update long after the package
is gone, which is what the agent left behind on all thirteen.

Found while writing the fallback test, and older than this change: the
success check read apt's commonest failure as a success.
`E: Sub-process /usr/bin/dpkg returned an error code (1)` contains
neither "error:" nor "failed", so a removal that did not happen was
reported as one that did — and the caller then records the package as
gone. `_uninstall_failed` now also treats a line starting with `e: ` as
failure, matched at line start because "note: " ends in "e: ".

Reading success out of prose stays guesswork; the exit status is the real
answer and `_sudo`'s `|| true` throws it away before anyone can read it.
That is netork#267, deliberately not fixed here.

apk and pacman have no separate purge. Asking for one there is not an
error, it simply has nothing extra to do.
This commit is contained in:
Christian Manivong
2026-09-20 22:37:23 +02:00
parent b45444c831
commit b4e6bbf79f
2 changed files with 132 additions and 6 deletions
+72
View File
@@ -912,3 +912,75 @@ class TestDockerBinHook:
assert docker_cmds
for cmd in docker_cmds:
assert "/opt/cs/docker" in cmd, f"unconverted call site: {cmd}"
# ---------------------------------------------------------------------------
# uninstall_package – purge, and getting out of `install ok unpacked`
# ---------------------------------------------------------------------------
class TestUninstallPackage:
"""Removing a package that does not want to go.
Both cases here were found during a fleet-wide Wazuh rollback. Of thirteen
hosts carrying the agent, seven sat at `install ok unpacked` with the unit
failed — an upgrade whose postinst could not reach a manager that no longer
existed. `apt-get remove` cannot help there: apt configures a package before
removing it, and configuring is exactly what was broken.
And `remove` leaves the configuration behind by design, which for the Wazuh
agent means its apt source keeps being fetched on every update, long after
the package is gone.
"""
def test_remove_is_still_the_default(self, driver):
"""Callers that did not ask for a purge must not get one: configuration
somebody may want back is not this function's to delete."""
_mock_send(driver, "Removing wazuh-agent ...")
driver.uninstall_package("wazuh-agent")
sent = driver._device.send_command.call_args[0][0]
assert "apt-get remove" in sent
assert "purge" not in sent
def test_purge_is_asked_for_explicitly(self, driver):
_mock_send(driver, "Purging configuration files for wazuh-agent ...")
driver.uninstall_package("wazuh-agent", purge=True)
assert "apt-get purge" in driver._device.send_command.call_args[0][0]
def test_a_half_configured_package_falls_back_to_dpkg(self, driver):
"""`install ok unpacked` is the state apt cannot get out of. On one host
only `dpkg --purge --force-all` removed it."""
driver._device.send_command.side_effect = [
"E: Sub-process /usr/bin/dpkg returned an error code (1)",
"Removing wazuh-agent (4.14.7-1) ...",
]
result = driver.uninstall_package("wazuh-agent", purge=True)
assert result["success"] is True
second = driver._device.send_command.call_args_list[1][0][0]
assert "dpkg --purge --force-all" in second
def test_the_fallback_is_not_tried_when_the_first_pass_worked(self, driver):
"""A forced dpkg purge is a bigger hammer than apt and must stay a last
resort, not a routine second step."""
_mock_send(driver, "Removing wazuh-agent ...")
driver.uninstall_package("wazuh-agent", purge=True)
assert driver._device.send_command.call_count == 1
def test_a_package_manager_without_purge_still_removes(self, driver):
"""apk and pacman have no separate purge; asking for one must not turn
into a failure or a command they do not understand."""
driver._pkg_manager = "apk"
_mock_send(driver, "(1/1) Purging wazuh-agent")
result = driver.uninstall_package("wazuh-agent", purge=True)
assert result["success"] is True
assert "apk del" in driver._device.send_command.call_args[0][0]