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>
This commit is contained in:
Christian Manivong
2026-09-01 05:37:52 +02:00
co-authored by Claude Opus 5
parent d33739832b
commit 549e8c01e0
+82 -7
View File
@@ -116,6 +116,22 @@ def _arm_vendor_from_model(model: str) -> str:
return " ".join(brand) 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): class LinuxDriver(OSDriver):
"""NAPALM driver for generic Linux systems. """NAPALM driver for generic Linux systems.
@@ -1537,7 +1553,10 @@ class LinuxDriver(OSDriver):
"echo '---VOLUMES---'; " "echo '---VOLUMES---'; "
f"{docker} volume ls --format '{{{{json .}}}}' 2>/dev/null; " f"{docker} volume ls --format '{{{{json .}}}}' 2>/dev/null; "
"echo '---NETWORKS---'; " "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, read_timeout=60,
) )
@@ -1556,7 +1575,8 @@ class LinuxDriver(OSDriver):
raw_containers = _section(combined, "---CONTAINERS---", "---IMAGES---") raw_containers = _section(combined, "---CONTAINERS---", "---IMAGES---")
raw_images = _section(combined, "---IMAGES---", "---VOLUMES---") raw_images = _section(combined, "---IMAGES---", "---VOLUMES---")
raw_volumes = _section(combined, "---VOLUMES---", "---NETWORKS---") 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]: def _parse_labels(raw: Any) -> Dict[str, str]:
"""Parse Docker labels — may be a dict (JSON map) or comma-sep string.""" """Parse Docker labels — may be a dict (JSON map) or comma-sep string."""
@@ -1617,6 +1637,44 @@ class LinuxDriver(OSDriver):
except Exception: except Exception:
pass 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
volumes: List[dict[str, Any]] = [] volumes: List[dict[str, Any]] = []
for line in raw_volumes.splitlines(): for line in raw_volumes.splitlines():
@@ -1674,11 +1732,23 @@ class LinuxDriver(OSDriver):
Returns a list of image references that have a newer digest available. Returns a list of image references that have a newer digest available.
""" """
outdated_images: List[str] = [] outdated_images: List[str] = []
candidate_images: List[str] = list({ # Prefer the reference the container was created from. `image` is whatever
c["image"] for c in containers # `docker ps` displayed, which collapses to a bare image ID once the tag has
if c.get("image") # moved on — and an image ID is not something a registry can resolve.
and "@sha256:" not in c.get("image", "") # skip digest-pinned 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: for img_name in candidate_images:
try: try:
local_raw = self._send( local_raw = self._send(
@@ -1710,6 +1780,11 @@ class LinuxDriver(OSDriver):
remote_digest = _ls[7:].strip() remote_digest = _ls[7:].strip()
break break
if not remote_digest or not remote_digest.startswith("sha256:"): 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 continue
if local_digest != remote_digest: if local_digest != remote_digest:
outdated_images.append(img_name) outdated_images.append(img_name)