From 549e8c01e0086d6aab17565d118925109d6c83f0 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Tue, 1 Sep 2026 05:37:52 +0200 Subject: [PATCH] fix(docker): keep a resolvable image reference when the tag has moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- napalm_linux/linux.py | 89 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/napalm_linux/linux.py b/napalm_linux/linux.py index e1d3daa..941e1ee 100644 --- a/napalm_linux/linux.py +++ b/napalm_linux/linux.py @@ -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. @@ -1537,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, ) @@ -1556,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.""" @@ -1617,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 "" 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(): @@ -1674,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( @@ -1710,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)