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
Christian Manivong 799d1ce749 feat: declare REBOOT_SETTLE_SECONDS = 90
A general-purpose host runs through a full init sequence before it is worth
polling again. netOrk kept this in a hardcoded driver-name set duplicated across
two files (netork#113).

Worth knowing: the NAS drivers that inherit from here — OpenMediaVault and QNAP
— were not in that set and waited 45 seconds. They inherit 90 now, which is the
more honest number for a device that also brings storage up on boot.
2026-08-21 13:07:14 +07:00
Christian Manivong ce40299033 fix(tests): repair the fixture and doubles that made seven tests fail
Closes netork#110.

The seven failures had two causes, neither of them in the driver.

The `driver` fixture builds a LinuxDriver with `__new__`, bypassing `__init__`,
and never set `_sudo_password`. Every call through `_sudo()` therefore raised
AttributeError, which the callers' broad `except Exception` reported as
`{"success": False}` — so six apply_updates tests failed for a reason unrelated
to what they were asserting.

`test_apply_updates_apt_all_packages` had a second one: its `capture_send(cmd)`
double accepted no keyword arguments, while `_sudo()` passes `read_timeout`.

The seventh, the apt update listing test, supplied `side_effect=["",
APT_UPGRADABLE]` — two values for a cache refresh that never existed.
`_get_updates_apt` has made exactly one `_send` call since it was written in
2712389, so the empty first value was consumed and parsed as the package list.
Checked out that commit and ran it: the test failed there too. It was committed
red and never passed.

That settles the open question in netork#110: the implementation was not changed,
the tests were written against one that never existed. `get_available_updates`
reads the local apt cache deliberately — refreshing it needs sudo and would cost
a round trip on every poll — so there is no stale-cache bug behind the
`updates_available` warning.
2026-08-21 12:57:11 +07:00
Christian Manivong 27027eec56 refactor!: keep one name for pending updates, drop the alias
This driver implemented get_pending_updates and then carried
get_available_updates as a one-line alias, because netOrk's API only ever called
the latter. Two names for one thing, with the driver bridging the gap.

napalm-device-types v1.0 collapses them onto get_available_updates — the name
four drivers and every netOrk call site already used — so the alias has nothing
left to bridge.

BREAKING CHANGE: get_pending_updates is gone; call get_available_updates.

The seven pre-existing test failures in this repo are untouched and unrelated;
see netork#110.
2026-08-21 12:50:10 +07:00
christianmanivong c8fc46c373 feat: make the docker binary path a hook
Where docker lives is device-specific; what to do with it is not. QNAP's
Container Station installs docker under /share/<pool>/.qpkg/ and never
puts it on PATH, so a QTS driver inheriting this class found no docker at
all.

Rather than reimplementing the Docker surface in the vendor driver, the
path becomes a single overridable method and every call site goes through
it. Per docs/ARCHITECTURE.md 4.4, generic logic belongs to the shared
driver and only the device-specific mechanics belong to the vendor one.

A test asserts that *every* docker call site uses the hook — a half
converted set would let detection find the binary while the actual
queries still missed it, which only shows up against real hardware.
2026-08-21 10:28:12 +07:00
Christian Manivong 661d56074c refactor(warnings): report raw signal only, no severity/presentation
get_device_warnings() now returns only {code, meta} — severity, title,
message, and action are resolved centrally by netork's
WARNING_CATALOG (netork/core/device_warnings.py), not by the driver.
Keeps this driver independent of netork and avoids per-vendor drift in
how the same warning code is presented.
2026-07-20 09:47:39 +02:00
Christian Manivong 2f049338b5 Merge fix/apt-full-upgrade: use full-upgrade to resolve all pending updates 2026-07-08 17:31:20 +02:00
Christian Manivong 07dcdbfe50 fix(linux): apt_update_upgrade left dependency-driven updates pending
Plain "apt-get upgrade" refuses to install or remove packages even when
a newer version requires it, silently holding those updates back —
switched to "apt-get full-upgrade" so VM-provisioning bootstrap actually
finishes with nothing left to update.
2026-07-08 17:31:16 +02:00
2 changed files with 194 additions and 36 deletions
+127 -30
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.
@@ -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.
@@ -1052,8 +1076,6 @@ class LinuxDriver(OSDriver):
if updates: if updates:
warnings.append({ warnings.append({
"code": "updates_available", "code": "updates_available",
"severity": "warning",
"action": None,
"meta": { "meta": {
"count": len(updates), "count": len(updates),
"packages": [u.get("name", "") for u in updates], "packages": [u.get("name", "") for u in updates],
@@ -1065,8 +1087,6 @@ class LinuxDriver(OSDriver):
if self._apt_proxy_url not in current: if self._apt_proxy_url not in current:
warnings.append({ warnings.append({
"code": "apt_proxy_missing", "code": "apt_proxy_missing",
"severity": "warning",
"action": "fix_apt_proxy",
"meta": {"expected_url": self._apt_proxy_url}, "meta": {"expected_url": self._apt_proxy_url},
}) })
except Exception as exc: except Exception as exc:
@@ -1484,6 +1504,16 @@ class LinuxDriver(OSDriver):
# Docker # Docker
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _docker_bin(self) -> str:
"""Path to the docker binary.
A hook rather than a literal because the Docker *logic* is the same
everywhere while the *location* is not: QTS ships Container Station's
docker under /share/<pool>/.qpkg/ and never puts it on PATH. Subclasses
override this one method instead of reimplementing the surface.
"""
return "docker"
def get_docker_info(self) -> DockerInfoDict: def get_docker_info(self) -> DockerInfoDict:
"""Return information about the local Docker environment. """Return information about the local Docker environment.
@@ -1502,26 +1532,31 @@ class LinuxDriver(OSDriver):
""" """
import json as _json import json as _json
docker = self._docker_bin()
# Check docker binary first (docker --version doesn't need socket access) # Check docker binary first (docker --version doesn't need socket access)
if not self._send("command -v docker 2>/dev/null").strip(): if not self._send(f"command -v {docker} 2>/dev/null").strip():
return {"available": False} return {"available": False}
# Verify socket access — docker ps is cheaper and fails immediately on permission errors # Verify socket access — docker ps is cheaper and fails immediately on permission errors
ps_check = self._send("docker ps 2>&1") ps_check = self._send(f"{docker} ps 2>&1")
if "permission denied" in ps_check.lower() or "cannot connect" in ps_check.lower(): if "permission denied" in ps_check.lower() or "cannot connect" in ps_check.lower():
return {"available": False, "permission_denied": True} return {"available": False, "permission_denied": True}
version = self._send("docker --version 2>/dev/null").strip() version = self._send(f"{docker} --version 2>/dev/null").strip()
combined = self._send( combined = self._send(
"echo '---CONTAINERS---'; " "echo '---CONTAINERS---'; "
"docker ps -a --format '{{json .}}' 2>/dev/null; " f"{docker} ps -a --format '{{{{json .}}}}' 2>/dev/null; "
"echo '---IMAGES---'; " "echo '---IMAGES---'; "
"docker images --format '{{json .}}' 2>/dev/null; " f"{docker} images --format '{{{{json .}}}}' 2>/dev/null; "
"echo '---VOLUMES---'; " "echo '---VOLUMES---'; "
"docker volume ls --format '{{json .}}' 2>/dev/null; " f"{docker} volume ls --format '{{{{json .}}}}' 2>/dev/null; "
"echo '---NETWORKS---'; " "echo '---NETWORKS---'; "
"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,
) )
@@ -1540,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."""
@@ -1601,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():
@@ -1658,15 +1732,28 @@ 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(
f"docker inspect {img_name!r} --format '{{{{index .RepoDigests 0}}}}' 2>/dev/null", f"{self._docker_bin()} inspect {img_name!r} "
f"--format '{{{{index .RepoDigests 0}}}}' 2>/dev/null",
read_timeout=5, read_timeout=5,
).strip() ).strip()
if not local_raw or "@" not in local_raw: if not local_raw or "@" not in local_raw:
@@ -1674,7 +1761,7 @@ class LinuxDriver(OSDriver):
local_digest = local_raw.split("@", 1)[1] local_digest = local_raw.split("@", 1)[1]
remote_full = self._send( remote_full = self._send(
f"docker buildx imagetools inspect {img_name!r} 2>&1", f"{self._docker_bin()} buildx imagetools inspect {img_name!r} 2>&1",
read_timeout=30, read_timeout=30,
).strip() ).strip()
if ("429" in remote_full if ("429" in remote_full
@@ -1693,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)
@@ -1716,7 +1808,7 @@ class LinuxDriver(OSDriver):
import shlex as _shlex import shlex as _shlex
raw = self._send( raw = self._send(
f"docker inspect {_shlex.quote(container_id)} 2>/dev/null", f"{self._docker_bin()} inspect {_shlex.quote(container_id)} 2>/dev/null",
read_timeout=10, read_timeout=10,
).strip() ).strip()
if not raw: if not raw:
@@ -1889,7 +1981,12 @@ class LinuxDriver(OSDriver):
raise NotImplementedError(f"Unknown action: {action!r}") raise NotImplementedError(f"Unknown action: {action!r}")
def _action_apt_update_upgrade(self) -> DeviceActionResultDict: def _action_apt_update_upgrade(self) -> DeviceActionResultDict:
"""Refresh the apt cache and upgrade all packages (apt-based systems only).""" """Refresh the apt cache and fully upgrade all packages (apt-based systems only).
Uses full-upgrade (not plain upgrade) — plain "apt-get upgrade" refuses
to install/remove packages even when required to satisfy a newer
version's dependencies, silently leaving those updates pending.
"""
if self._pkg_manager != "apt": if self._pkg_manager != "apt":
return { return {
"success": True, "success": True,
@@ -1913,7 +2010,7 @@ class LinuxDriver(OSDriver):
out = self._sudo("apt-get update -y 2>&1", read_timeout=90) out = self._sudo("apt-get update -y 2>&1", read_timeout=90)
lines.append(f"[update] {out.strip()[-300:]}") lines.append(f"[update] {out.strip()[-300:]}")
out = self._sudo( out = self._sudo(
"DEBIAN_FRONTEND=noninteractive apt-get upgrade -y 2>&1", read_timeout=240 "DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y 2>&1", read_timeout=240
) )
lines.append(f"[upgrade] {out.strip()[-300:]}") lines.append(f"[upgrade] {out.strip()[-300:]}")
return {"success": True, "output": "\n".join(lines)} return {"success": True, "output": "\n".join(lines)}
+67 -6
View File
@@ -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
@@ -776,7 +784,9 @@ class TestActionAptUpdateUpgrade:
assert "[upgrade]" in result["output"] assert "[upgrade]" in result["output"]
update_call, upgrade_call = mock_sudo.call_args_list update_call, upgrade_call = mock_sudo.call_args_list
assert "apt-get update" in update_call.args[0] assert "apt-get update" in update_call.args[0]
assert "apt-get upgrade" in upgrade_call.args[0] # full-upgrade (not plain upgrade) — plain upgrade silently holds back
# packages whose newer version needs to install/remove dependencies.
assert "apt-get full-upgrade" in upgrade_call.args[0]
def test_exception_during_upgrade_returns_failure(self, driver): def test_exception_during_upgrade_returns_failure(self, driver):
driver._pkg_manager = "apt" driver._pkg_manager = "apt"
@@ -800,3 +810,54 @@ class TestRunDeviceActionDispatch:
) as mock_action: ) as mock_action:
driver.run_device_action("apt_update_upgrade") driver.run_device_action("apt_update_upgrade")
mock_action.assert_called_once() mock_action.assert_called_once()
class TestDockerBinHook:
"""Where the docker binary lives is device-specific; what to do with it is not.
QTS puts Container Station's docker under /share/<pool>/.qpkg/ and not on
PATH. Rather than duplicating the whole Docker surface in the QNAP driver,
the path is a one-method hook here and the logic stays generic. See
docs/ARCHITECTURE.md §4.4, "Generisch vs. treiberspezifisch".
"""
def test_defaults_to_docker_on_path(self, driver):
assert driver._docker_bin() == "docker"
def test_detection_uses_the_hook(self, driver):
"""A subclass pointing elsewhere must not be probed for a PATH docker."""
sent = []
def _record(cmd, **kwargs):
sent.append(cmd)
return ""
with patch.object(driver, "_docker_bin", return_value="/opt/cs/docker"):
with patch.object(driver, "_send", side_effect=_record):
result = driver.get_docker_info()
assert result == {"available": False}
assert any("/opt/cs/docker" in cmd for cmd in sent)
assert not any("command -v docker " in cmd for cmd in sent)
def test_all_docker_subcommands_use_the_hook(self, driver):
"""Half-converted call sites are the failure mode here: detection would
find the binary and the actual queries would still miss it."""
sent = []
def _record(cmd, **kwargs):
sent.append(cmd)
if "command -v" in cmd:
return "/opt/cs/docker"
if "---CONTAINERS---" in cmd:
return "---CONTAINERS---\n---IMAGES---\n---VOLUMES---\n---NETWORKS---\n"
return ""
with patch.object(driver, "_docker_bin", return_value="/opt/cs/docker"):
with patch.object(driver, "_send", side_effect=_record):
driver.get_docker_info()
docker_cmds = [c for c in sent if "docker" in c]
assert docker_cmds
for cmd in docker_cmds:
assert "/opt/cs/docker" in cmd, f"unconverted call site: {cmd}"