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)