Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
549e8c01e0 | ||
|
|
d33739832b | ||
|
|
7faaafb7a3 | ||
|
|
799d1ce749 | ||
|
|
ce40299033 | ||
|
|
27027eec56 |
+98
-15
@@ -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.
|
||||||
|
|
||||||
@@ -126,6 +142,9 @@ class LinuxDriver(OSDriver):
|
|||||||
TYPE_LABEL = "Linux"
|
TYPE_LABEL = "Linux"
|
||||||
VENDOR = "Linux"
|
VENDOR = "Linux"
|
||||||
DRIVER_NAME = "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 = [
|
SNMP_FINGERPRINT = [
|
||||||
FingerprintRule("linux", weight=5.0),
|
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")
|
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"]
|
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 {
|
return {
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"fqdn": fqdn,
|
"fqdn": fqdn,
|
||||||
@@ -390,6 +413,7 @@ class LinuxDriver(OSDriver):
|
|||||||
"os_version": os_version,
|
"os_version": os_version,
|
||||||
"uptime": uptime_secs,
|
"uptime": uptime_secs,
|
||||||
"interface_list": interface_list,
|
"interface_list": interface_list,
|
||||||
|
"running_kernel": running_kernel,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _parse_uptime(self) -> int:
|
def _parse_uptime(self) -> int:
|
||||||
@@ -810,17 +834,20 @@ class LinuxDriver(OSDriver):
|
|||||||
|
|
||||||
def _get_packages_apt(self) -> list[PackageDict]:
|
def _get_packages_apt(self) -> list[PackageDict]:
|
||||||
out = self._send(
|
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] = []
|
packages: list[PackageDict] = []
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
parts = line.split("\t", 3)
|
parts = line.split("\t", 4)
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
continue
|
continue
|
||||||
name = parts[0].strip()
|
name = parts[0].strip()
|
||||||
version = parts[1].strip()
|
version = parts[1].strip()
|
||||||
size = int(parts[2].strip()) * 1024 if len(parts) > 2 and parts[2].strip().isdigit() else 0
|
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({
|
packages.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"version": version,
|
"version": version,
|
||||||
@@ -828,6 +855,7 @@ class LinuxDriver(OSDriver):
|
|||||||
"description": description,
|
"description": description,
|
||||||
"size": size,
|
"size": size,
|
||||||
"source": "apt",
|
"source": "apt",
|
||||||
|
"source_package": source_package,
|
||||||
})
|
})
|
||||||
return packages
|
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"))
|
success = not any(kw in low for kw in ("error:", "failed", "not found", "is not installed", "no packages"))
|
||||||
return {"success": success, "output": raw.strip()}
|
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":
|
if self._pkg_manager == "apt":
|
||||||
return self._get_updates_apt()
|
return self._get_updates_apt()
|
||||||
if self._pkg_manager in ("dnf", "yum"):
|
if self._pkg_manager in ("dnf", "yum"):
|
||||||
@@ -1032,10 +1060,6 @@ class LinuxDriver(OSDriver):
|
|||||||
f"Package manager '{self._pkg_manager}' is not supported"
|
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]]:
|
def get_device_warnings(self) -> List[dict[str, Any]]:
|
||||||
"""Return warning dicts for issues detected on this device.
|
"""Return warning dicts for issues detected on this device.
|
||||||
|
|
||||||
@@ -1529,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1548,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."""
|
||||||
@@ -1609,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():
|
||||||
@@ -1666,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(
|
||||||
@@ -1702,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)
|
||||||
|
|||||||
+13
-5
@@ -22,6 +22,11 @@ def driver():
|
|||||||
d._secret = "pass" # noqa: S105
|
d._secret = "pass" # noqa: S105
|
||||||
d._forced_pkg_manager = None
|
d._forced_pkg_manager = None
|
||||||
d._pkg_manager = "apt"
|
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.netmiko_optional_args = {}
|
||||||
d._device = MagicMock()
|
d._device = MagicMock()
|
||||||
return d
|
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"
|
driver._pkg_manager = "apt"
|
||||||
with patch.object(driver, "_send", side_effect=["", APT_UPGRADABLE]):
|
with patch.object(driver, "_send", return_value=APT_UPGRADABLE):
|
||||||
updates = driver.get_pending_updates()
|
updates = driver.get_available_updates()
|
||||||
assert len(updates) == 2
|
assert len(updates) == 2
|
||||||
assert updates[0]["name"] == "openssh-server"
|
assert updates[0]["name"] == "openssh-server"
|
||||||
assert updates[0]["current_version"] == "1:9.2p1-2+deb12u1"
|
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"
|
driver._pkg_manager = "apt"
|
||||||
sent_commands = []
|
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)
|
sent_commands.append(cmd)
|
||||||
return APT_UPGRADE_SUCCESS
|
return APT_UPGRADE_SUCCESS
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user