feat: report a dpkg left halfway and when the host's own automatic updates last succeeded
CI / test (3.10) (push) Successful in 1m59s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 39s
CI / test (3.11) (pull_request) Successful in 23s
CI / test (3.12) (pull_request) Successful in 27s
CI / test (3.10) (push) Successful in 1m59s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 39s
CI / test (3.11) (pull_request) Successful in 23s
CI / test (3.12) (pull_request) Successful in 27s
An unattended upgrade cut off while it built an initrd left a kernel package half-configured on an Ubuntu host. For three months apt refused every upgrade, while apt.systemd.daily exited quietly and the timers reported success (NetOrk/netork#738). HOST_STATUS_COMMAND now also reads, without root: - dpkg's state the way apt checks it: numbered files left in /var/lib/dpkg/updates, or a package half-installed, unpacked, half-configured, waiting for triggers or flagged for reinstallation. While dpkg, apt, aptitude or unattended-upgrade runs, packages are halfway on purpose, so the answer is then None. - /var/lib/apt/periodic/upgrade-stamp, which apt.systemd.daily touches only after unattended-upgrade succeeded, and the interval of APT::Periodic::Unattended-Upgrade in apt's units. update-success-stamp is not read: every apt-get update touches it, whoever runs it. HostStatusDict gains package_manager_interrupted, interrupted_packages, auto_updates_last_success and auto_updates_interval, all NotRequired.
This commit is contained in:
+133
-1
@@ -29,8 +29,12 @@ def _wire(
|
||||
modules=("6.8.0-139-generic", "6.8.0-142-generic"),
|
||||
needs_restarting=None,
|
||||
periodic=None,
|
||||
stamp=None,
|
||||
timer="enabled",
|
||||
dnf_timers=("not-found", "not-found"),
|
||||
dpkg=None,
|
||||
journal=(),
|
||||
busy=False,
|
||||
):
|
||||
lines = ["HSTAT_BEGIN"]
|
||||
if reboot_file:
|
||||
@@ -39,7 +43,11 @@ def _wire(
|
||||
lines += ["[needs-restarting]", str(needs_restarting)]
|
||||
lines += ["[kernel]", running, "[modules]", *modules]
|
||||
if periodic is not None:
|
||||
lines += ["[apt-config]", *periodic]
|
||||
lines += ["[apt-config]", *periodic, "[upgrade-stamp]", *([str(stamp)] if stamp else [])]
|
||||
if dpkg is not None:
|
||||
if busy:
|
||||
lines.append("[dpkg-busy]")
|
||||
lines += ["[dpkg-journal]", *journal, "[dpkg-audit]", *dpkg]
|
||||
lines += [
|
||||
"[timers]",
|
||||
f"apt-daily-upgrade.timer {timer}",
|
||||
@@ -243,3 +251,127 @@ class TestFreeBSD:
|
||||
assert "command -v freebsd-version" in HOST_STATUS_COMMAND
|
||||
assert "freebsd-version -k" in HOST_STATUS_COMMAND
|
||||
assert "freebsd-version -r" in HOST_STATUS_COMMAND
|
||||
|
||||
|
||||
class TestInterruptedDpkg:
|
||||
"""dpkg left halfway: apt refuses to install anything until
|
||||
``dpkg --configure -a`` has run, and the daily apt timers report success
|
||||
all the same (NetOrk/netork#738).
|
||||
|
||||
The fixture is aris (Ubuntu 24.04): unattended-upgrades was cut off on
|
||||
2026-07-03 while update-initramfs built the initrd for 6.8.0-134, and the
|
||||
kernel package stayed half-configured for three months.
|
||||
"""
|
||||
|
||||
ARIS = ("iF linux-image-6.8.0-134-generic",)
|
||||
|
||||
def test_a_half_configured_kernel_is_an_interrupted_dpkg(self):
|
||||
status = parse_host_status(_wire(dpkg=self.ARIS))
|
||||
|
||||
assert status["package_manager_interrupted"] is True
|
||||
assert status["interrupted_packages"] == [
|
||||
{"name": "linux-image-6.8.0-134-generic", "state": "half-configured"}
|
||||
]
|
||||
|
||||
def test_a_clean_dpkg_is_not_interrupted(self):
|
||||
status = parse_host_status(_wire(dpkg=()))
|
||||
|
||||
assert status["package_manager_interrupted"] is False
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "state"),
|
||||
[
|
||||
("iH base-files", "half-installed"),
|
||||
("iU base-files", "unpacked"),
|
||||
("iF base-files", "half-configured"),
|
||||
("iW base-files", "triggers-awaited"),
|
||||
("it base-files", "triggers-pending"),
|
||||
("iUR base-files", "reinstall-required"),
|
||||
("hF base-files", "half-configured"),
|
||||
],
|
||||
)
|
||||
def test_every_state_an_interrupted_run_leaves(self, line, state):
|
||||
status = parse_host_status(_wire(dpkg=(line,)))
|
||||
|
||||
assert status["interrupted_packages"] == [{"name": "base-files", "state": state}]
|
||||
|
||||
def test_a_journal_left_behind_is_interrupted_on_its_own(self):
|
||||
"""What apt itself checks: numbered files in /var/lib/dpkg/updates."""
|
||||
status = parse_host_status(_wire(dpkg=(), journal=("0000", "0001")))
|
||||
|
||||
assert status["package_manager_interrupted"] is True
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
def test_only_numbered_journal_files_count(self):
|
||||
"""apt ignores anything else there, such as dpkg's tmp.i."""
|
||||
status = parse_host_status(_wire(dpkg=(), journal=("tmp.i",)))
|
||||
|
||||
assert status["package_manager_interrupted"] is False
|
||||
|
||||
def test_a_running_package_manager_makes_it_unknown(self):
|
||||
"""Mid-upgrade, packages are unpacked and not yet configured, also
|
||||
between two of apt's dpkg calls. That is work in progress, not an
|
||||
interruption."""
|
||||
status = parse_host_status(_wire(dpkg=("iU base-files",), busy=True))
|
||||
|
||||
assert status["package_manager_interrupted"] is None
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
def test_without_dpkg_it_is_unknown(self):
|
||||
status = parse_host_status(_wire())
|
||||
|
||||
assert status["package_manager_interrupted"] is None
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
def test_the_command_asks_dpkg_only_where_it_exists(self):
|
||||
assert "command -v dpkg-query" in HOST_STATUS_COMMAND
|
||||
assert "/var/lib/dpkg/updates" in HOST_STATUS_COMMAND
|
||||
|
||||
def test_it_never_repairs(self):
|
||||
assert "--configure" not in HOST_STATUS_COMMAND
|
||||
|
||||
|
||||
class TestAutoUpdatesLastSuccess:
|
||||
"""apt.systemd.daily touches upgrade-stamp only after unattended-upgrade
|
||||
succeeded. update-success-stamp says nothing: netOrk's own daily
|
||||
``apt-get update`` touches it as well (NetOrk/netork#738)."""
|
||||
|
||||
def test_the_upgrade_stamp_is_the_last_successful_run(self):
|
||||
status = parse_host_status(_wire(periodic=UNATTENDED, stamp=1751525820))
|
||||
|
||||
assert status["auto_updates_last_success"] == 1751525820
|
||||
assert status["auto_updates_interval"] == 86400
|
||||
|
||||
def test_no_stamp_is_unknown(self):
|
||||
status = parse_host_status(_wire(periodic=UNATTENDED))
|
||||
|
||||
assert status["auto_updates_last_success"] is None
|
||||
|
||||
def test_without_apt_both_are_unknown(self):
|
||||
status = parse_host_status(_wire())
|
||||
|
||||
assert status["auto_updates_last_success"] is None
|
||||
assert status["auto_updates_interval"] is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "seconds"),
|
||||
[
|
||||
('"1"', 86400),
|
||||
('"7d"', 7 * 86400),
|
||||
('"12h"', 43200),
|
||||
('"30m"', 1800),
|
||||
('"90s"', 90),
|
||||
('"always"', 0),
|
||||
('"0"', None),
|
||||
('"weekly"', None),
|
||||
],
|
||||
)
|
||||
def test_the_interval_in_apts_units(self, value, seconds):
|
||||
periodic = [f"APT::Periodic::Unattended-Upgrade {value};"]
|
||||
|
||||
assert parse_host_status(_wire(periodic=periodic))["auto_updates_interval"] == seconds
|
||||
|
||||
def test_the_command_reads_the_upgrade_stamp_only(self):
|
||||
assert "/var/lib/apt/periodic/upgrade-stamp" in HOST_STATUS_COMMAND
|
||||
assert "update-success-stamp" not in HOST_STATUS_COMMAND
|
||||
|
||||
Reference in New Issue
Block a user