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

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:
2026-10-08 16:07:55 +02:00
parent ba2f18ea7d
commit 7b44f689bc
5 changed files with 247 additions and 10 deletions
+133 -1
View File
@@ -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