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
+94 -10
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.
@@ -384,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,
@@ -393,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:
@@ -813,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,
@@ -831,6 +855,7 @@ class LinuxDriver(OSDriver):
"description": description,
"size": size,
"source": "apt",
"source_package": source_package,
})
return packages
@@ -1528,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,
)
@@ -1547,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."""
@@ -1608,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():
@@ -1665,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(
@@ -1701,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)