Compare commits

...
Author SHA1 Message Date
Christian ManivongandClaude Opus 5 549e8c01e0 fix(docker): keep a resolvable image reference when the tag has moved
`docker ps` reports a bare image ID instead of the tag as soon as that tag
points at a newer image — which is exactly what a pull without a recreate
does. That ID went straight into `docker buildx imagetools inspect`, which
cannot resolve an image ID, so the lookup failed and the image was silently
dropped from the outdated list. The check was blind in precisely the state
that means an update is waiting.

get_docker_info() now also reads `.Config.Image`, the reference the container
was created from, which never degrades. It compares each running container's
image ID against the ID its own tag currently resolves to, and reports
`image_ref`, `running_image_id`, `restart_pending` and `pending_version`.

get_docker_outdated() prefers `image_ref`, skips bare IDs with a log line
instead of asking the registry about them, and no longer swallows a failed
registry lookup — silence there was indistinguishable from "up to date".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 05:37:52 +02:00
Christian Manivong d33739832b feat: report running_kernel (uname -r) in get_facts 2026-08-23 19:06:10 +07:00
Christian Manivong 7faaafb7a3 feat: include Debian source package in apt get_packages
dpkg-query now also reports ${source:Package} as source_package for accurate
OSV vulnerability matching (binary -> source, e.g. libssl3 -> openssl).
2026-08-23 17:45:07 +07:00
Christian Manivong 799d1ce749 feat: declare REBOOT_SETTLE_SECONDS = 90
A general-purpose host runs through a full init sequence before it is worth
polling again. netOrk kept this in a hardcoded driver-name set duplicated across
two files (netork#113).

Worth knowing: the NAS drivers that inherit from here — OpenMediaVault and QNAP
— were not in that set and waited 45 seconds. They inherit 90 now, which is the
more honest number for a device that also brings storage up on boot.
2026-08-21 13:07:14 +07:00
Christian Manivong ce40299033 fix(tests): repair the fixture and doubles that made seven tests fail
Closes netork#110.

The seven failures had two causes, neither of them in the driver.

The `driver` fixture builds a LinuxDriver with `__new__`, bypassing `__init__`,
and never set `_sudo_password`. Every call through `_sudo()` therefore raised
AttributeError, which the callers' broad `except Exception` reported as
`{"success": False}` — so six apply_updates tests failed for a reason unrelated
to what they were asserting.

`test_apply_updates_apt_all_packages` had a second one: its `capture_send(cmd)`
double accepted no keyword arguments, while `_sudo()` passes `read_timeout`.

The seventh, the apt update listing test, supplied `side_effect=["",
APT_UPGRADABLE]` — two values for a cache refresh that never existed.
`_get_updates_apt` has made exactly one `_send` call since it was written in
2712389, so the empty first value was consumed and parsed as the package list.
Checked out that commit and ran it: the test failed there too. It was committed
red and never passed.

That settles the open question in netork#110: the implementation was not changed,
the tests were written against one that never existed. `get_available_updates`
reads the local apt cache deliberately — refreshing it needs sudo and would cost
a round trip on every poll — so there is no stale-cache bug behind the
`updates_available` warning.
2026-08-21 12:57:11 +07:00
Christian Manivong 27027eec56 refactor!: keep one name for pending updates, drop the alias
This driver implemented get_pending_updates and then carried
get_available_updates as a one-line alias, because netOrk's API only ever called
the latter. Two names for one thing, with the driver bridging the gap.

napalm-device-types v1.0 collapses them onto get_available_updates — the name
four drivers and every netOrk call site already used — so the alias has nothing
left to bridge.

BREAKING CHANGE: get_pending_updates is gone; call get_available_updates.

The seven pre-existing test failures in this repo are untouched and unrelated;
see netork#110.
2026-08-21 12:50:10 +07:00
2 changed files with 111 additions and 20 deletions
+98 -15
View File
@@ -116,6 +116,22 @@ def _arm_vendor_from_model(model: str) -> str:
return " ".join(brand)
#: A bare image ID (``d626d04934cd``), not a registry reference. ``docker ps``
#: falls back to this whenever the tag a container was created from has since
#: been moved to a newer image — i.e. exactly after a pull without a recreate.
_IMAGE_ID_RE = re.compile(r"^(sha256:)?[0-9a-f]{12,64}$")
def _looks_like_image_id(ref: str) -> bool:
"""True if *ref* is an image ID rather than something a registry can resolve."""
return bool(_IMAGE_ID_RE.match(ref.strip()))
def _short_image_id(raw: str) -> str:
"""Normalise ``sha256:<64hex>`` and ``<12hex>`` to a comparable 12-char form."""
return raw.strip().removeprefix("sha256:")[:12]
class LinuxDriver(OSDriver):
"""NAPALM driver for generic Linux systems.
@@ -126,6 +142,9 @@ class LinuxDriver(OSDriver):
TYPE_LABEL = "Linux"
VENDOR = "Linux"
DRIVER_NAME = "linux"
# A general-purpose host runs through a full init sequence; NAS derivatives
# (OpenMediaVault, QNAP) inherit this and are, if anything, slower.
REBOOT_SETTLE_SECONDS = 90
SNMP_FINGERPRINT = [
FingerprintRule("linux", weight=5.0),
]
@@ -381,6 +400,10 @@ class LinuxDriver(OSDriver):
iface_out = self._send("ip -o link show | awk -F': ' '{print $2}' | cut -d@ -f1")
interface_list = [i.strip() for i in iface_out.splitlines() if i.strip() and i.strip() != "lo"]
# Currently-booted kernel release, distinct from an installed-but-not-yet-
# booted newer kernel (used for kernel CVE relevance).
running_kernel = self._send("uname -r").strip()
return {
"hostname": hostname,
"fqdn": fqdn,
@@ -390,6 +413,7 @@ class LinuxDriver(OSDriver):
"os_version": os_version,
"uptime": uptime_secs,
"interface_list": interface_list,
"running_kernel": running_kernel,
}
def _parse_uptime(self) -> int:
@@ -810,17 +834,20 @@ class LinuxDriver(OSDriver):
def _get_packages_apt(self) -> list[PackageDict]:
out = self._send(
"dpkg-query -W -f='${Package}\\t${Version}\\t${Installed-Size}\\t${binary:Summary}\\n' 2>/dev/null"
"dpkg-query -W -f='${Package}\\t${Version}\\t${Installed-Size}"
"\\t${source:Package}\\t${binary:Summary}\\n' 2>/dev/null"
)
packages: list[PackageDict] = []
for line in out.splitlines():
parts = line.split("\t", 3)
parts = line.split("\t", 4)
if len(parts) < 2:
continue
name = parts[0].strip()
version = parts[1].strip()
size = int(parts[2].strip()) * 1024 if len(parts) > 2 and parts[2].strip().isdigit() else 0
description = parts[3].strip() if len(parts) > 3 else ""
# Debian source package (e.g. openssh-server → openssh) for OSV matching.
source_package = parts[3].strip() if len(parts) > 3 and parts[3].strip() else name
description = parts[4].strip() if len(parts) > 4 else ""
packages.append({
"name": name,
"version": version,
@@ -828,6 +855,7 @@ class LinuxDriver(OSDriver):
"description": description,
"size": size,
"source": "apt",
"source_package": source_package,
})
return packages
@@ -1019,7 +1047,7 @@ class LinuxDriver(OSDriver):
success = not any(kw in low for kw in ("error:", "failed", "not found", "is not installed", "no packages"))
return {"success": success, "output": raw.strip()}
def get_pending_updates(self) -> list[UpdateDict]:
def get_available_updates(self) -> list[UpdateDict]:
if self._pkg_manager == "apt":
return self._get_updates_apt()
if self._pkg_manager in ("dnf", "yum"):
@@ -1032,10 +1060,6 @@ class LinuxDriver(OSDriver):
f"Package manager '{self._pkg_manager}' is not supported"
)
def get_available_updates(self) -> list[UpdateDict]:
"""Alias for get_pending_updates(); called by the netork API backend."""
return self.get_pending_updates()
def get_device_warnings(self) -> List[dict[str, Any]]:
"""Return warning dicts for issues detected on this device.
@@ -1529,7 +1553,10 @@ class LinuxDriver(OSDriver):
"echo '---VOLUMES---'; "
f"{docker} volume ls --format '{{{{json .}}}}' 2>/dev/null; "
"echo '---NETWORKS---'; "
f"{docker} network ls --format '{{{{json .}}}}' 2>/dev/null",
f"{docker} network ls --format '{{{{json .}}}}' 2>/dev/null; "
"echo '---CONFIGIMAGES---'; "
f"{docker} ps -aq 2>/dev/null | xargs -r {docker} inspect "
f"--format '{{{{.Id}}}}|{{{{.Config.Image}}}}|{{{{.Image}}}}' 2>/dev/null",
read_timeout=60,
)
@@ -1548,7 +1575,8 @@ class LinuxDriver(OSDriver):
raw_containers = _section(combined, "---CONTAINERS---", "---IMAGES---")
raw_images = _section(combined, "---IMAGES---", "---VOLUMES---")
raw_volumes = _section(combined, "---VOLUMES---", "---NETWORKS---")
raw_networks = _section(combined, "---NETWORKS---", "\x00") # sentinel
raw_networks = _section(combined, "---NETWORKS---", "---CONFIGIMAGES---")
raw_cfgimages = _section(combined, "---CONFIGIMAGES---", "\x00") # sentinel
def _parse_labels(raw: Any) -> Dict[str, str]:
"""Parse Docker labels — may be a dict (JSON map) or comma-sep string."""
@@ -1609,6 +1637,44 @@ class LinuxDriver(OSDriver):
except Exception:
pass
# Stable image reference + restart-pending detection.
#
# ``docker ps`` only reports a usable tag while that tag still resolves to
# the running image. Pull a newer image without recreating the container and
# it degrades to a bare image ID — useless as a registry reference, and the
# very state in which an update is waiting. ``.Config.Image`` is the
# reference the container was created from and never degrades.
cfg_by_cid: dict[str, tuple] = {}
for line in raw_cfgimages.splitlines():
parts = line.strip().split("|")
if len(parts) != 3 or not parts[0]:
continue
cid, cfg_ref, run_id = parts
cfg_by_cid[cid[:12]] = (cfg_ref.strip(), run_id.strip())
tag_index: dict[str, tuple] = {}
for im in images:
repo, tag = im.get("repository", ""), im.get("tag", "")
if not repo or not tag or "<none>" in (repo, tag):
continue
tag_index[f"{repo}:{tag}"] = (_short_image_id(im.get("id", "")), im.get("version", ""))
for c in containers:
cfg_ref, run_id = cfg_by_cid.get(c.get("id", "")[:12], ("", ""))
if not cfg_ref:
continue
c["image_ref"] = cfg_ref
c["running_image_id"] = _short_image_id(run_id)
c["restart_pending"] = False
c["pending_version"] = ""
# A stopped container is not "pending a restart" in any useful sense.
if c.get("state") != "running":
continue
tag_id, tag_version = tag_index.get(cfg_ref, ("", ""))
if tag_id and c["running_image_id"] and tag_id != c["running_image_id"]:
c["restart_pending"] = True
c["pending_version"] = tag_version
# Volumes
volumes: List[dict[str, Any]] = []
for line in raw_volumes.splitlines():
@@ -1666,11 +1732,23 @@ class LinuxDriver(OSDriver):
Returns a list of image references that have a newer digest available.
"""
outdated_images: List[str] = []
candidate_images: List[str] = list({
c["image"] for c in containers
if c.get("image")
and "@sha256:" not in c.get("image", "") # skip digest-pinned
})
# Prefer the reference the container was created from. `image` is whatever
# `docker ps` displayed, which collapses to a bare image ID once the tag has
# moved on — and an image ID is not something a registry can resolve.
candidate_images: List[str] = []
for c in containers:
ref = (c.get("image_ref") or c.get("image") or "").strip()
if not ref or "@sha256:" in ref: # skip digest-pinned
continue
if _looks_like_image_id(ref):
logger.warning(
"container %s reports image ID %r instead of a tag — cannot ask the "
"registry about it; skipping update check",
c.get("name", "?"), ref,
)
continue
if ref not in candidate_images:
candidate_images.append(ref)
for img_name in candidate_images:
try:
local_raw = self._send(
@@ -1702,6 +1780,11 @@ class LinuxDriver(OSDriver):
remote_digest = _ls[7:].strip()
break
if not remote_digest or not remote_digest.startswith("sha256:"):
# Silence here is indistinguishable from "up to date" — say so.
logger.warning(
"no digest returned for %s; skipping update check. Registry said: %s",
img_name, remote_full[:200].replace("\n", " ") or "(nothing)",
)
continue
if local_digest != remote_digest:
outdated_images.append(img_name)
+13 -5
View File
@@ -22,6 +22,11 @@ def driver():
d._secret = "pass" # noqa: S105
d._forced_pkg_manager = None
d._pkg_manager = "apt"
# Set by __init__, which this fixture bypasses via __new__. Without it every
# call through _sudo() raises AttributeError, which the callers' broad
# `except Exception` turns into a plain {"success": False} -- so the tests
# failed for a reason that had nothing to do with what they were testing.
d._sudo_password = None
d.netmiko_optional_args = {}
d._device = MagicMock()
return d
@@ -166,7 +171,7 @@ def test_get_packages_apt(driver):
# ---------------------------------------------------------------------------
# get_pending_updates (apt)
# get_available_updates (apt)
# ---------------------------------------------------------------------------
@@ -177,10 +182,10 @@ APT_UPGRADABLE = (
)
def test_get_pending_updates_apt(driver):
def test_get_available_updates_apt(driver):
driver._pkg_manager = "apt"
with patch.object(driver, "_send", side_effect=["", APT_UPGRADABLE]):
updates = driver.get_pending_updates()
with patch.object(driver, "_send", return_value=APT_UPGRADABLE):
updates = driver.get_available_updates()
assert len(updates) == 2
assert updates[0]["name"] == "openssh-server"
assert updates[0]["current_version"] == "1:9.2p1-2+deb12u1"
@@ -312,7 +317,10 @@ def test_apply_updates_apt_all_packages(driver):
driver._pkg_manager = "apt"
sent_commands = []
def capture_send(cmd):
def capture_send(cmd, **kwargs):
# _sudo() passes read_timeout as a keyword; without **kwargs this raises
# TypeError, which the caller's `except Exception` reports as a failed
# upgrade rather than a broken test double.
sent_commands.append(cmd)
return APT_UPGRADE_SUCCESS