Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb7432ea5a | ||
|
|
0268019743 | ||
|
|
a78daccb56 | ||
|
|
3a76b6d47f | ||
|
|
3333d8e8f5 | ||
|
|
fe29b507b0 | ||
|
|
51ee33eebe | ||
|
|
0f5e2a1d37 | ||
|
|
b7a13d5153 | ||
|
|
007590b9bf | ||
|
|
79e52f060e | ||
|
|
3571149639 |
No files matched your search
@@ -0,0 +1,48 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install package with dev extras
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# napalm-device-types lives in git.netork.io/NAPALM, not on PyPI: without this
|
||||||
|
# pip looks there, finds an unrelated 0.1.0 and the job dies before any test.
|
||||||
|
python -m pip install "napalm-device-types @ git+https://git.netork.io/NAPALM/napalm-device-types.git"
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
python -m pytest -q --tb=short
|
||||||
|
|
||||||
|
- name: Build wheel and sdist
|
||||||
|
run: |
|
||||||
|
python -m pip install build
|
||||||
|
python -m build
|
||||||
|
|
||||||
|
- name: Upload dist artifacts
|
||||||
|
# v4 refuses to run on Gitea ("not currently supported on GHES").
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: dist-${{ matrix.python-version }}
|
||||||
|
path: dist/*
|
||||||
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] – 2026-10-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `get_virtual_ips()` through napalm-device-types' `VirtualIpsMixin` (4.2.0): the
|
||||||
|
addresses vip-manager and keepalived configuration lets float onto this host,
|
||||||
|
whether or not it holds them right now (NetOrk/netork#829). The command goes on
|
||||||
|
the exec channel (`run_command`): with its awk filter it is about 4 kB, past the
|
||||||
|
line the PTY reliably takes. Root first, then without it.
|
||||||
|
|
||||||
|
## [0.3.0] – 2026-10-08
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `get_docker_info()`, `get_docker_outdated()`, `reconstruct_docker_run()` and
|
||||||
|
the `_docker_bin()` hook, with the image-ID helpers they used. netOrk reads
|
||||||
|
and handles containers itself over the Engine API, through
|
||||||
|
`open_container_engine()` (NetOrk/netork#765), and no longer calls them.
|
||||||
|
`run_device_action("fix_docker_permissions")` stays: letting the login user
|
||||||
|
use Docker is an OS action.
|
||||||
|
|
||||||
|
## [0.2.0] – 2026-10-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `run_command()` and `open_stream()`, the public command channel from
|
||||||
|
napalm-device-types 2.6.0: an exec channel on the existing SSH transport, so
|
||||||
|
no PTY, separate stderr and a real exit code. `privileged=True` runs as root
|
||||||
|
directly, through `sudo -S` with the password on stdin (never on the command
|
||||||
|
line), or through `sudo -n`, which fails instead of prompting.
|
||||||
|
- `ContainerEngineMixin`: `container_engines()` and `open_container_engine()`,
|
||||||
|
whose `open_api()` streams the Docker Engine API over `docker system
|
||||||
|
dial-stdio` (NetOrk/netork#765). The existing Docker methods are unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Requires napalm-device-types >= 2.6.0.
|
||||||
|
|
||||||
## [0.1.0] – 2026-05-29
|
## [0.1.0] – 2026-05-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+84
-433
@@ -34,21 +34,28 @@ from napalm.base.netmiko_helpers import netmiko_args
|
|||||||
from napalm_device_types import (
|
from napalm_device_types import (
|
||||||
APT_UPGRADABLE_COMMAND,
|
APT_UPGRADABLE_COMMAND,
|
||||||
DNF_SECURITY_COMMAND,
|
DNF_SECURITY_COMMAND,
|
||||||
|
ByteStream,
|
||||||
|
CommandResult,
|
||||||
|
ContainerEngineMixin,
|
||||||
FingerprintRule,
|
FingerprintRule,
|
||||||
HostStatusMixin,
|
HostStatusMixin,
|
||||||
KernelFactsMixin,
|
KernelFactsMixin,
|
||||||
|
ListeningSocketsMixin,
|
||||||
|
OpenSshServerMixin,
|
||||||
OSDriver,
|
OSDriver,
|
||||||
SystemdServicesMixin,
|
SystemdServicesMixin,
|
||||||
SystemdUnavailable,
|
SystemdUnavailable,
|
||||||
|
VirtualIpsMixin,
|
||||||
parse_apt_upgradable,
|
parse_apt_upgradable,
|
||||||
|
open_stream_on_transport,
|
||||||
parse_dnf_security,
|
parse_dnf_security,
|
||||||
|
run_on_transport,
|
||||||
strip_terminal_codes,
|
strip_terminal_codes,
|
||||||
)
|
)
|
||||||
from napalm_device_types.models import (
|
from napalm_device_types.models import (
|
||||||
ApplyUpdatesResultDict,
|
ApplyUpdatesResultDict,
|
||||||
CronJobDict,
|
CronJobDict,
|
||||||
DeviceActionResultDict,
|
DeviceActionResultDict,
|
||||||
DockerInfoDict,
|
|
||||||
PackageDict,
|
PackageDict,
|
||||||
ProcessDict,
|
ProcessDict,
|
||||||
ServiceDict,
|
ServiceDict,
|
||||||
@@ -198,23 +205,16 @@ 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``
|
class LinuxDriver(
|
||||||
#: falls back to this whenever the tag a container was created from has since
|
KernelFactsMixin,
|
||||||
#: been moved to a newer image — i.e. exactly after a pull without a recreate.
|
ListeningSocketsMixin,
|
||||||
_IMAGE_ID_RE = re.compile(r"^(sha256:)?[0-9a-f]{12,64}$")
|
VirtualIpsMixin,
|
||||||
|
OpenSshServerMixin,
|
||||||
|
SystemdServicesMixin,
|
||||||
def _looks_like_image_id(ref: str) -> bool:
|
HostStatusMixin,
|
||||||
"""True if *ref* is an image ID rather than something a registry can resolve."""
|
ContainerEngineMixin,
|
||||||
return bool(_IMAGE_ID_RE.match(ref.strip()))
|
OSDriver,
|
||||||
|
):
|
||||||
|
|
||||||
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(KernelFactsMixin, SystemdServicesMixin, HostStatusMixin, OSDriver):
|
|
||||||
"""NAPALM driver for generic Linux systems.
|
"""NAPALM driver for generic Linux systems.
|
||||||
|
|
||||||
Connects via SSH (netmiko ``linux`` device type) and auto-detects the
|
Connects via SSH (netmiko ``linux`` device type) and auto-detects the
|
||||||
@@ -394,6 +394,50 @@ class LinuxDriver(KernelFactsMixin, SystemdServicesMixin, HostStatusMixin, OSDri
|
|||||||
return self._sudo(command, read_timeout=timeout)
|
return self._sudo(command, read_timeout=timeout)
|
||||||
return self._send(f"sudo -n {command}", read_timeout=timeout)
|
return self._send(f"sudo -n {command}", read_timeout=timeout)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Command channel (napalm-device-types CommandChannelMixin)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _transport(self) -> Any:
|
||||||
|
"""The SSH transport netmiko already holds, for exec channels next to its PTY."""
|
||||||
|
if not self._device:
|
||||||
|
raise ConnectionClosedException("Not connected")
|
||||||
|
return self._device.remote_conn_pre.get_transport()
|
||||||
|
|
||||||
|
def _privileged(self, command: str, privileged: bool) -> tuple[str, bytes | None]:
|
||||||
|
"""The command line that runs *command* with the privileges asked for,
|
||||||
|
and what has to reach its stdin first.
|
||||||
|
|
||||||
|
Root runs it directly. With a sudo password, ``sudo -S`` reads it from
|
||||||
|
stdin, so it never appears in a process list. Without one, ``sudo -n``
|
||||||
|
fails at once where a prompt would hang. The command goes to ``sh -c``
|
||||||
|
as one argument, so the privilege covers every part of it.
|
||||||
|
"""
|
||||||
|
if not privileged or self._is_root():
|
||||||
|
return command, None
|
||||||
|
if self._sudo_password:
|
||||||
|
line = f"sudo -S -p '' sh -c {_shlex_quote(command)}"
|
||||||
|
return line, f"{self._sudo_password}\n".encode()
|
||||||
|
return f"sudo -n sh -c {_shlex_quote(command)}", None
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
privileged: bool = False,
|
||||||
|
timeout: float = 60,
|
||||||
|
stdin: bytes | None = None,
|
||||||
|
) -> CommandResult:
|
||||||
|
"""Run *command* on an exec channel: no PTY, stderr apart, a real exit code."""
|
||||||
|
line, prefix = self._privileged(command, privileged)
|
||||||
|
data = (prefix or b"") + (stdin or b"") if (prefix or stdin) else None
|
||||||
|
return run_on_transport(self._transport(), line, stdin=data, timeout=timeout)
|
||||||
|
|
||||||
|
def open_stream(self, command: str, *, privileged: bool = False) -> ByteStream:
|
||||||
|
"""Start *command* on an exec channel and return a stream to it."""
|
||||||
|
line, prefix = self._privileged(command, privileged)
|
||||||
|
return open_stream_on_transport(self._transport(), line, stdin_prefix=prefix)
|
||||||
|
|
||||||
def reboot_host(self) -> None:
|
def reboot_host(self) -> None:
|
||||||
"""Restart the host (``HostRebootMixin``); returns once the restart is under way.
|
"""Restart the host (``HostRebootMixin``); returns once the restart is under way.
|
||||||
|
|
||||||
@@ -969,6 +1013,28 @@ class LinuxDriver(KernelFactsMixin, SystemdServicesMixin, HostStatusMixin, OSDri
|
|||||||
"""The transport for ``KernelFactsMixin.get_kernel_facts``: read-only, no sudo."""
|
"""The transport for ``KernelFactsMixin.get_kernel_facts``: read-only, no sudo."""
|
||||||
return self._send(command, read_timeout=60)
|
return self._send(command, read_timeout=60)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ListeningSocketsMixin – the transport for get_listening_sockets
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _run_listening_sockets_command(self, command: str, *, privileged: bool) -> str:
|
||||||
|
"""The transport for ``ListeningSocketsMixin.get_listening_sockets``.
|
||||||
|
|
||||||
|
Read-only either way; root only so that ``ss`` names every process.
|
||||||
|
"""
|
||||||
|
if privileged:
|
||||||
|
return self._run_privileged(command, 60)
|
||||||
|
return self._send(command, read_timeout=60)
|
||||||
|
|
||||||
|
def _run_virtual_ips_command(self, command: str, *, privileged: bool) -> str:
|
||||||
|
"""The transport for ``VirtualIpsMixin.get_virtual_ips``: an exec channel.
|
||||||
|
|
||||||
|
The command carries its awk program and is about 4 kB, past the line the
|
||||||
|
PTY netmiko holds reliably takes. Read-only either way; root only because
|
||||||
|
the configuration may be root's alone.
|
||||||
|
"""
|
||||||
|
return self.run_command(command, privileged=privileged, timeout=60).stdout
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# OSDriver – package management
|
# OSDriver – package management
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1709,421 +1775,6 @@ class LinuxDriver(KernelFactsMixin, SystemdServicesMixin, HostStatusMixin, OSDri
|
|||||||
job["description"] = comment
|
job["description"] = comment
|
||||||
return job
|
return job
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 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:
|
|
||||||
"""Return information about the local Docker environment.
|
|
||||||
|
|
||||||
Uses a single SSH call to collect all Docker data at once, eliminating
|
|
||||||
per-section round-trip overhead. Labels from ``docker images`` are used
|
|
||||||
directly for the OCI version field — no separate ``docker image inspect``
|
|
||||||
needed.
|
|
||||||
|
|
||||||
Returns a dict with keys:
|
|
||||||
- ``available`` (bool) — False if docker is not installed/accessible
|
|
||||||
- ``version`` (str) — Docker Engine version string
|
|
||||||
- ``containers`` (list) — list of container dicts
|
|
||||||
- ``images`` (list) — list of image dicts
|
|
||||||
- ``volumes`` (list) — list of volume dicts
|
|
||||||
- ``networks`` (list) — list of network dicts
|
|
||||||
"""
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
docker = self._docker_bin()
|
|
||||||
|
|
||||||
# Check docker binary first (docker --version doesn't need socket access)
|
|
||||||
if not self._send(f"command -v {docker} 2>/dev/null").strip():
|
|
||||||
return {"available": False}
|
|
||||||
|
|
||||||
# Verify socket access — docker ps is cheaper and fails immediately on permission errors
|
|
||||||
ps_check = self._send(f"{docker} ps 2>&1")
|
|
||||||
if "permission denied" in ps_check.lower() or "cannot connect" in ps_check.lower():
|
|
||||||
return {"available": False, "permission_denied": True}
|
|
||||||
|
|
||||||
version = self._send(f"{docker} --version 2>/dev/null").strip()
|
|
||||||
|
|
||||||
combined = self._send(
|
|
||||||
"echo '---CONTAINERS---'; "
|
|
||||||
f"{docker} ps -a --format '{{{{json .}}}}' 2>/dev/null; "
|
|
||||||
"echo '---IMAGES---'; "
|
|
||||||
f"{docker} images --format '{{{{json .}}}}' 2>/dev/null; "
|
|
||||||
"echo '---VOLUMES---'; "
|
|
||||||
f"{docker} volume ls --format '{{{{json .}}}}' 2>/dev/null; "
|
|
||||||
"echo '---NETWORKS---'; "
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "---CONTAINERS---" not in combined:
|
|
||||||
return {"available": False}
|
|
||||||
|
|
||||||
# Split into sections
|
|
||||||
def _section(text: str, marker: str, next_marker: str) -> str:
|
|
||||||
start = text.find(marker)
|
|
||||||
if start == -1:
|
|
||||||
return ""
|
|
||||||
start += len(marker)
|
|
||||||
end = text.find(next_marker, start)
|
|
||||||
return text[start:end] if end != -1 else text[start:]
|
|
||||||
|
|
||||||
raw_containers = _section(combined, "---CONTAINERS---", "---IMAGES---")
|
|
||||||
raw_images = _section(combined, "---IMAGES---", "---VOLUMES---")
|
|
||||||
raw_volumes = _section(combined, "---VOLUMES---", "---NETWORKS---")
|
|
||||||
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."""
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return {str(k): str(v) for k, v in raw.items()}
|
|
||||||
if isinstance(raw, str) and raw:
|
|
||||||
result: Dict[str, str] = {}
|
|
||||||
for part in raw.split(","):
|
|
||||||
if "=" in part:
|
|
||||||
k, _, v = part.partition("=")
|
|
||||||
result[k.strip()] = v.strip()
|
|
||||||
return result
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Containers
|
|
||||||
containers: List[dict[str, Any]] = []
|
|
||||||
for line in raw_containers.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
obj = _json.loads(line)
|
|
||||||
labels = _parse_labels(obj.get("Labels", ""))
|
|
||||||
containers.append({
|
|
||||||
"id": obj.get("ID", ""),
|
|
||||||
"name": obj.get("Names", ""),
|
|
||||||
"image": obj.get("Image", ""),
|
|
||||||
"image_version": labels.get("org.opencontainers.image.version", ""),
|
|
||||||
"command": obj.get("Command", ""),
|
|
||||||
"created": obj.get("CreatedAt", ""),
|
|
||||||
"status": obj.get("Status", ""),
|
|
||||||
"ports": obj.get("Ports", ""),
|
|
||||||
"state": obj.get("State", ""),
|
|
||||||
"compose_project": labels.get("com.docker.compose.project", ""),
|
|
||||||
"compose_service": labels.get("com.docker.compose.service", ""),
|
|
||||||
"compose_file": labels.get("com.docker.compose.project.config_files", ""),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Images — OCI version comes from Labels, no separate inspect needed
|
|
||||||
images: List[dict[str, Any]] = []
|
|
||||||
for line in raw_images.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
obj = _json.loads(line)
|
|
||||||
labels = _parse_labels(obj.get("Labels", ""))
|
|
||||||
images.append({
|
|
||||||
"id": obj.get("ID", ""),
|
|
||||||
"repository": obj.get("Repository", ""),
|
|
||||||
"tag": obj.get("Tag", ""),
|
|
||||||
"size": obj.get("Size", ""),
|
|
||||||
"created": obj.get("CreatedAt", ""),
|
|
||||||
"version": labels.get("org.opencontainers.image.version", ""),
|
|
||||||
})
|
|
||||||
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 "<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: List[dict[str, Any]] = []
|
|
||||||
for line in raw_volumes.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
obj = _json.loads(line)
|
|
||||||
volumes.append({
|
|
||||||
"name": obj.get("Name", ""),
|
|
||||||
"driver": obj.get("Driver", ""),
|
|
||||||
"mountpoint": obj.get("Mountpoint", ""),
|
|
||||||
"scope": obj.get("Scope", ""),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Networks
|
|
||||||
networks: List[dict[str, Any]] = []
|
|
||||||
for line in raw_networks.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
obj = _json.loads(line)
|
|
||||||
networks.append({
|
|
||||||
"id": obj.get("ID", ""),
|
|
||||||
"name": obj.get("Name", ""),
|
|
||||||
"driver": obj.get("Driver", ""),
|
|
||||||
"scope": obj.get("Scope", ""),
|
|
||||||
"ipv6": obj.get("IPv6", ""),
|
|
||||||
"internal": obj.get("Internal", ""),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"available": True,
|
|
||||||
"version": version,
|
|
||||||
"containers": containers,
|
|
||||||
"images": images,
|
|
||||||
"volumes": volumes,
|
|
||||||
"networks": networks,
|
|
||||||
"outdated_images": [], # populated by separate check_docker_outdated task
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_docker_outdated(self, containers: List[Dict]) -> List[str]:
|
|
||||||
"""Check registry for available updates for all container images.
|
|
||||||
|
|
||||||
Runs ``docker buildx imagetools inspect`` (metadata-only, no download)
|
|
||||||
for each unique image referenced by a container. Intended to be called
|
|
||||||
from a separate Celery task on a long interval (e.g. every 3 hours) so
|
|
||||||
it never blocks the main device poll.
|
|
||||||
|
|
||||||
Returns a list of image references that have a newer digest available.
|
|
||||||
"""
|
|
||||||
outdated_images: List[str] = []
|
|
||||||
# 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(
|
|
||||||
f"{self._docker_bin()} inspect {img_name!r} "
|
|
||||||
f"--format '{{{{index .RepoDigests 0}}}}' 2>/dev/null",
|
|
||||||
read_timeout=5,
|
|
||||||
).strip()
|
|
||||||
if not local_raw or "@" not in local_raw:
|
|
||||||
continue # locally built or not yet pulled
|
|
||||||
local_digest = local_raw.split("@", 1)[1]
|
|
||||||
|
|
||||||
remote_full = self._send(
|
|
||||||
f"{self._docker_bin()} buildx imagetools inspect {img_name!r} 2>&1",
|
|
||||||
read_timeout=30,
|
|
||||||
).strip()
|
|
||||||
if ("429" in remote_full
|
|
||||||
or "Too Many Requests" in remote_full
|
|
||||||
or "toomanyrequests" in remote_full):
|
|
||||||
logger.warning(
|
|
||||||
"Docker Hub rate limit hit for %s — run "
|
|
||||||
"'docker login' on the device to avoid this",
|
|
||||||
img_name,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
remote_digest = ""
|
|
||||||
for _line in remote_full.splitlines():
|
|
||||||
_ls = _line.strip()
|
|
||||||
if _ls.startswith("Digest:"):
|
|
||||||
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)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("image update check for %s: %s", img_name, exc)
|
|
||||||
return outdated_images
|
|
||||||
|
|
||||||
def reconstruct_docker_run(self, container_id: str) -> dict | None:
|
|
||||||
"""Return the information needed to recreate a standalone container.
|
|
||||||
|
|
||||||
Parses ``docker inspect`` JSON and returns a dict with:
|
|
||||||
- ``name`` — container name (without leading slash)
|
|
||||||
- ``image`` — current image reference
|
|
||||||
- ``run_args`` — list of CLI args for ``docker run`` (without image/cmd)
|
|
||||||
- ``cmd`` — command override (may be empty list)
|
|
||||||
- ``entrypoint`` — entrypoint override (may be empty list)
|
|
||||||
|
|
||||||
Returns None if the container does not exist or inspect fails.
|
|
||||||
"""
|
|
||||||
import json as _json
|
|
||||||
import shlex as _shlex
|
|
||||||
|
|
||||||
raw = self._send(
|
|
||||||
f"{self._docker_bin()} inspect {_shlex.quote(container_id)} 2>/dev/null",
|
|
||||||
read_timeout=10,
|
|
||||||
).strip()
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
data = _json.loads(raw)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
c = data[0]
|
|
||||||
|
|
||||||
name = c.get("Name", "").lstrip("/")
|
|
||||||
cfg = c.get("Config", {})
|
|
||||||
hcfg = c.get("HostConfig", {})
|
|
||||||
net_settings = c.get("NetworkSettings", {})
|
|
||||||
|
|
||||||
args: List[str] = ["--name", name]
|
|
||||||
|
|
||||||
# Restart policy
|
|
||||||
rp = hcfg.get("RestartPolicy", {})
|
|
||||||
rp_name = rp.get("Name", "no")
|
|
||||||
if rp_name and rp_name != "no":
|
|
||||||
max_retry = rp.get("MaximumRetryCount", 0)
|
|
||||||
if rp_name == "on-failure" and max_retry:
|
|
||||||
args += ["--restart", f"on-failure:{max_retry}"]
|
|
||||||
else:
|
|
||||||
args += ["--restart", rp_name]
|
|
||||||
|
|
||||||
# Hostname
|
|
||||||
hostname = cfg.get("Hostname", "")
|
|
||||||
if hostname and hostname != name[:12]:
|
|
||||||
args += ["--hostname", hostname]
|
|
||||||
|
|
||||||
# Environment (skip vars that look like Docker-injected metadata)
|
|
||||||
_skip_prefixes = ("PATH=", "HOME=", "TERM=", "HOSTNAME=")
|
|
||||||
for env in cfg.get("Env") or []:
|
|
||||||
if not any(env.startswith(p) for p in _skip_prefixes):
|
|
||||||
args += ["-e", env]
|
|
||||||
|
|
||||||
# Volume binds
|
|
||||||
for bind in hcfg.get("Binds") or []:
|
|
||||||
args += ["-v", bind]
|
|
||||||
|
|
||||||
# Port bindings
|
|
||||||
for container_port, host_bindings in (hcfg.get("PortBindings") or {}).items():
|
|
||||||
for hb in (host_bindings or []):
|
|
||||||
host_ip = hb.get("HostIp", "")
|
|
||||||
host_port = hb.get("HostPort", "")
|
|
||||||
if host_ip:
|
|
||||||
args += ["-p", f"{host_ip}:{host_port}:{container_port}"]
|
|
||||||
else:
|
|
||||||
args += ["-p", f"{host_port}:{container_port}"]
|
|
||||||
|
|
||||||
# Network mode
|
|
||||||
net_mode = hcfg.get("NetworkMode", "default")
|
|
||||||
if net_mode not in ("default", "bridge"):
|
|
||||||
args += ["--network", net_mode]
|
|
||||||
else:
|
|
||||||
# Check for custom networks from NetworkSettings
|
|
||||||
for net_name in (net_settings.get("Networks") or {}):
|
|
||||||
if net_name not in ("bridge", "host", "none"):
|
|
||||||
args += ["--network", net_name]
|
|
||||||
break
|
|
||||||
|
|
||||||
# Privileged
|
|
||||||
if hcfg.get("Privileged"):
|
|
||||||
args.append("--privileged")
|
|
||||||
|
|
||||||
# Cap-add
|
|
||||||
for cap in hcfg.get("CapAdd") or []:
|
|
||||||
args += ["--cap-add", cap]
|
|
||||||
|
|
||||||
# Devices
|
|
||||||
for dev in hcfg.get("Devices") or []:
|
|
||||||
host_p = dev.get("PathOnHost", "")
|
|
||||||
ctr_p = dev.get("PathInContainer", "")
|
|
||||||
perms = dev.get("CgroupPermissions", "rwm")
|
|
||||||
if host_p:
|
|
||||||
args += ["--device", f"{host_p}:{ctr_p}:{perms}"]
|
|
||||||
|
|
||||||
# Extra hosts
|
|
||||||
for eh in hcfg.get("ExtraHosts") or []:
|
|
||||||
args += ["--add-host", eh]
|
|
||||||
|
|
||||||
# DNS
|
|
||||||
for dns in hcfg.get("Dns") or []:
|
|
||||||
args += ["--dns", dns]
|
|
||||||
|
|
||||||
# Labels (skip Docker-internal labels)
|
|
||||||
_skip_label_prefixes = ("com.docker.compose.", "org.opencontainers.")
|
|
||||||
for k, v in (cfg.get("Labels") or {}).items():
|
|
||||||
if not any(k.startswith(p) for p in _skip_label_prefixes):
|
|
||||||
args += ["--label", f"{k}={v}"]
|
|
||||||
|
|
||||||
# Detach always
|
|
||||||
args.append("-d")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"image": cfg.get("Image", ""),
|
|
||||||
"run_args": args,
|
|
||||||
"cmd": cfg.get("Cmd") or [],
|
|
||||||
"entrypoint": cfg.get("Entrypoint") or [],
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Device actions ────────────────────────────────────────────────────────
|
# ── Device actions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_snmp_config(self) -> Optional[SNMPConfigDict]:
|
def get_snmp_config(self) -> Optional[SNMPConfigDict]:
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "napalm-linux"
|
name = "napalm-linux"
|
||||||
version = "0.1.0"
|
version = "0.5.0"
|
||||||
description = "NAPALM driver for generic Linux systems via SSH"
|
description = "NAPALM driver for generic Linux systems via SSH"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -37,7 +37,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"napalm>=4.0",
|
"napalm>=4.0",
|
||||||
"napalm-device-types>=2.3.0",
|
"napalm-device-types>=4.5.0",
|
||||||
"netmiko>=4.0.0",
|
"netmiko>=4.0.0",
|
||||||
"paramiko>=5.0.0", # CVE-2026-44405
|
"paramiko>=5.0.0", # CVE-2026-44405
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""The public command channel and container engine access (napalm-device-types 2.6.0).
|
||||||
|
|
||||||
|
netOrk used to reach a Linux host's shell through the private ``_send``: an
|
||||||
|
interactive PTY, stdout and stderr merged, no exit code. ``run_command`` and
|
||||||
|
``open_stream`` go through an exec channel on the same SSH transport instead,
|
||||||
|
and ``open_container_engine`` builds on them (NetOrk/netork#765). Privileges
|
||||||
|
work as they do everywhere else in this driver: root runs directly, a sudo
|
||||||
|
password goes to ``sudo -S`` on stdin and never onto a command line, and
|
||||||
|
without one ``sudo -n`` fails at once instead of hanging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from napalm.base.exceptions import ConnectionClosedException
|
||||||
|
|
||||||
|
from napalm_linux import LinuxDriver
|
||||||
|
|
||||||
|
|
||||||
|
class FakeChannel:
|
||||||
|
def __init__(self):
|
||||||
|
self.command = None
|
||||||
|
self.sent = b""
|
||||||
|
|
||||||
|
def exec_command(self, command):
|
||||||
|
self.command = command
|
||||||
|
|
||||||
|
def settimeout(self, timeout):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def sendall(self, data):
|
||||||
|
self.sent += data
|
||||||
|
|
||||||
|
def shutdown_write(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def recv_ready(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def recv_stderr_ready(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def exit_status_ready(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def recv_exit_status(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
def __init__(self):
|
||||||
|
self.channels = []
|
||||||
|
|
||||||
|
def open_session(self):
|
||||||
|
self.channels.append(FakeChannel())
|
||||||
|
return self.channels[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _driver(*, root=False, sudo_password=None):
|
||||||
|
driver = LinuxDriver("h", "u", "p", optional_args={"sudo_password": sudo_password})
|
||||||
|
transport = FakeTransport()
|
||||||
|
driver._device = SimpleNamespace(remote_conn_pre=SimpleNamespace(get_transport=lambda: transport))
|
||||||
|
driver._root = root
|
||||||
|
return driver, transport
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unprivileged_command_runs_as_given():
|
||||||
|
driver, transport = _driver()
|
||||||
|
|
||||||
|
result = driver.run_command("docker version", timeout=5)
|
||||||
|
|
||||||
|
assert transport.channels[-1].command == "docker version"
|
||||||
|
assert transport.channels[-1].sent == b""
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_privileged_command_with_a_sudo_password_reads_it_from_stdin():
|
||||||
|
driver, transport = _driver(sudo_password="s3cr3t")
|
||||||
|
|
||||||
|
driver.run_command("usermod -aG docker u", privileged=True, timeout=5)
|
||||||
|
|
||||||
|
channel = transport.channels[-1]
|
||||||
|
assert channel.command == "sudo -S -p '' sh -c 'usermod -aG docker u'"
|
||||||
|
assert channel.sent == b"s3cr3t\n"
|
||||||
|
assert "s3cr3t" not in channel.command
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_privileged_command_without_a_password_fails_fast_instead_of_prompting():
|
||||||
|
driver, transport = _driver()
|
||||||
|
|
||||||
|
driver.run_command("id", privileged=True, timeout=5)
|
||||||
|
|
||||||
|
assert transport.channels[-1].command == "sudo -n sh -c id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_root_login_needs_no_sudo():
|
||||||
|
driver, transport = _driver(root=True, sudo_password="s3cr3t")
|
||||||
|
|
||||||
|
driver.run_command("id", privileged=True, timeout=5)
|
||||||
|
|
||||||
|
assert transport.channels[-1].command == "id"
|
||||||
|
assert transport.channels[-1].sent == b""
|
||||||
|
|
||||||
|
|
||||||
|
def test_stdin_follows_the_sudo_password():
|
||||||
|
driver, transport = _driver(sudo_password="pw")
|
||||||
|
|
||||||
|
driver.run_command("tee /etc/x", privileged=True, stdin=b"data", timeout=5)
|
||||||
|
|
||||||
|
assert transport.channels[-1].sent == b"pw\ndata"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_privileged_stream_gets_the_password_first():
|
||||||
|
driver, transport = _driver(sudo_password="pw")
|
||||||
|
|
||||||
|
driver.open_stream("cat > /tmp/x", privileged=True)
|
||||||
|
|
||||||
|
channel = transport.channels[-1]
|
||||||
|
assert channel.command == "sudo -S -p '' sh -c 'cat > /tmp/x'"
|
||||||
|
assert channel.sent == b"pw\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_container_engine_api_is_a_stream_over_dial_stdio():
|
||||||
|
driver, transport = _driver()
|
||||||
|
|
||||||
|
driver.open_container_engine("docker").open_api()
|
||||||
|
|
||||||
|
assert transport.channels[-1].command == "docker system dial-stdio"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_channel_needs_an_open_connection():
|
||||||
|
driver = LinuxDriver("h", "u", "p")
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionClosedException):
|
||||||
|
driver.run_command("true")
|
||||||
+190
-52
@@ -866,60 +866,18 @@ class TestRunDeviceActionDispatch:
|
|||||||
mock_action.assert_called_once()
|
mock_action.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestDockerBinHook:
|
class TestNoDockerOfItsOwn:
|
||||||
"""Where the docker binary lives is device-specific; what to do with it is not.
|
"""Containers are read and handled by netOrk over the Engine API, through
|
||||||
|
``open_container_engine`` (NetOrk/netork#765). The old CLI methods are gone;
|
||||||
|
only the OS action that lets the login user use Docker stays."""
|
||||||
|
|
||||||
QTS puts Container Station's docker under /share/<pool>/.qpkg/ and not on
|
def test_the_cli_methods_are_gone(self):
|
||||||
PATH. Rather than duplicating the whole Docker surface in the QNAP driver,
|
for name in ("get_docker_info", "get_docker_outdated", "reconstruct_docker_run", "_docker_bin"):
|
||||||
the path is a one-method hook here and the logic stays generic. See
|
assert not hasattr(LinuxDriver, name), name
|
||||||
docs/ARCHITECTURE.md §4.4, "Generisch vs. treiberspezifisch".
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_defaults_to_docker_on_path(self, driver):
|
def test_the_engine_access_and_the_permission_fix_stay(self):
|
||||||
assert driver._docker_bin() == "docker"
|
assert hasattr(LinuxDriver, "open_container_engine")
|
||||||
|
assert hasattr(LinuxDriver, "_action_fix_docker_permissions")
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# uninstall_package – purge, and getting out of `install ok unpacked`
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestUninstallPackage:
|
class TestUninstallPackage:
|
||||||
@@ -1555,3 +1513,183 @@ class TestRebootHost:
|
|||||||
|
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
driver.reboot_host()
|
driver.reboot_host()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Listening sockets: ss and the processes' cgroups, as root when it can
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SOCKETS = (
|
||||||
|
"SOCK_BEGIN\n[ss]\n"
|
||||||
|
'tcp LISTEN 0 128 0.0.0.0:5432 0.0.0.0:* users:(("postgres",pid=812,fd=6))\n'
|
||||||
|
"__SS_RC=0\n[cgroups]\n"
|
||||||
|
"812 0::/system.slice/system-postgresql.slice/postgresql@16-main.service\n"
|
||||||
|
"SOCK_END\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetListeningSockets:
|
||||||
|
def _sent(self, driver) -> list[str]:
|
||||||
|
return [c[0][0] for c in driver._device.send_command.call_args_list]
|
||||||
|
|
||||||
|
def test_it_reads_with_the_shared_command(self, driver):
|
||||||
|
from napalm_device_types import ListeningSocketsMixin
|
||||||
|
|
||||||
|
assert isinstance(driver, ListeningSocketsMixin)
|
||||||
|
driver._root = True
|
||||||
|
driver._device.send_command.return_value = _SOCKETS
|
||||||
|
|
||||||
|
reading = driver.get_listening_sockets()
|
||||||
|
|
||||||
|
assert reading["attributed"] is True
|
||||||
|
[socket] = reading["sockets"]
|
||||||
|
assert (socket["port"], socket["unit"]) == (5432, "postgresql@16-main")
|
||||||
|
assert self._sent(driver)[0].startswith("sh -c '")
|
||||||
|
|
||||||
|
def test_without_a_sudo_password_the_whole_script_runs_under_sudo_n(self, driver):
|
||||||
|
driver._root = False
|
||||||
|
driver._device.send_command.return_value = _SOCKETS
|
||||||
|
|
||||||
|
driver.get_listening_sockets()
|
||||||
|
|
||||||
|
assert self._sent(driver)[0].startswith("sudo -n sh -c '")
|
||||||
|
|
||||||
|
def test_with_a_sudo_password_it_goes_through_sudo(self, driver):
|
||||||
|
driver._root = False
|
||||||
|
driver._sudo_password = "pw" # noqa: S105
|
||||||
|
driver._device.send_command.return_value = _SOCKETS
|
||||||
|
|
||||||
|
driver.get_listening_sockets()
|
||||||
|
|
||||||
|
sent = self._sent(driver)[0]
|
||||||
|
assert sent.startswith("echo pw | sudo -S")
|
||||||
|
assert "sh -c '" in sent
|
||||||
|
|
||||||
|
def test_when_sudo_refuses_it_reads_what_the_user_may_see(self, driver):
|
||||||
|
driver._root = False
|
||||||
|
driver._device.send_command.side_effect = ["sudo: a password is required", _SOCKETS]
|
||||||
|
|
||||||
|
reading = driver.get_listening_sockets()
|
||||||
|
|
||||||
|
assert reading["attributed"] is False
|
||||||
|
refused, plain = self._sent(driver)
|
||||||
|
assert refused.startswith("sudo -n sh -c '")
|
||||||
|
assert plain.startswith("sh -c '")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_virtual_ips (napalm-device-types VirtualIpsMixin)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_VIPS = (
|
||||||
|
"VIPS_BEGIN\n[systemd]\n[vip-manager vip-manager.service]\nactive active\n"
|
||||||
|
"cfg ip 10.7.224.10\ncfg netmask 24\ncfg interface ens7\nVIPS_END\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetVirtualIps:
|
||||||
|
"""The command is about 4 kB -- past the line a terminal takes -- so it goes
|
||||||
|
on an exec channel, never typed into the PTY netmiko holds."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _exec(driver, *answers):
|
||||||
|
from napalm_device_types import CommandResult
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
replies = iter(answers)
|
||||||
|
|
||||||
|
def run_command(command, *, privileged=False, timeout=60, stdin=None):
|
||||||
|
calls.append((command, privileged))
|
||||||
|
return CommandResult(*next(replies))
|
||||||
|
|
||||||
|
driver.run_command = run_command
|
||||||
|
return calls
|
||||||
|
|
||||||
|
def test_it_reads_with_the_shared_command_on_an_exec_channel(self, driver):
|
||||||
|
from napalm_device_types import VirtualIpsMixin
|
||||||
|
|
||||||
|
assert isinstance(driver, VirtualIpsMixin)
|
||||||
|
calls = self._exec(driver, (_VIPS, "", 0))
|
||||||
|
|
||||||
|
reading = driver.get_virtual_ips()
|
||||||
|
|
||||||
|
assert reading["vip_manager"][0]["address"] == "10.7.224.10"
|
||||||
|
[(command, privileged)] = calls
|
||||||
|
assert privileged is True
|
||||||
|
assert command.startswith("sh -c '")
|
||||||
|
driver._device.send_command.assert_not_called()
|
||||||
|
|
||||||
|
def test_when_sudo_refuses_it_reads_what_the_user_may_see(self, driver):
|
||||||
|
calls = self._exec(driver, ("", "sudo: a password is required\n", 1), (_VIPS, "", 0))
|
||||||
|
|
||||||
|
reading = driver.get_virtual_ips()
|
||||||
|
|
||||||
|
assert reading["vip_manager"][0]["interface"] == "ens7"
|
||||||
|
assert [privileged for _, privileged in calls] == [True, False]
|
||||||
|
|
||||||
|
def test_without_a_sudo_password_the_whole_script_runs_under_sudo_n(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
channels = []
|
||||||
|
|
||||||
|
class Channel:
|
||||||
|
def __init__(self):
|
||||||
|
channels.append(self)
|
||||||
|
|
||||||
|
def exec_command(self, command):
|
||||||
|
self.command = command
|
||||||
|
|
||||||
|
def settimeout(self, timeout):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def shutdown_write(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def recv_ready(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def recv_stderr_ready(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def exit_status_ready(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def recv_exit_status(self):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
transport = SimpleNamespace(open_session=Channel)
|
||||||
|
driver = LinuxDriver("h", "u", "p", optional_args={})
|
||||||
|
driver._device = SimpleNamespace(
|
||||||
|
remote_conn_pre=SimpleNamespace(get_transport=lambda: transport)
|
||||||
|
)
|
||||||
|
driver._root = False
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
driver.get_virtual_ips()
|
||||||
|
|
||||||
|
privileged, plain = (c.command for c in channels)
|
||||||
|
assert privileged.startswith("sudo -n sh -c 'sh -c '")
|
||||||
|
assert plain.startswith("sh -c '")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHardensItsSshServer:
|
||||||
|
"""A stock Linux host runs a stock OpenSSH: the hardening netOrk offers for
|
||||||
|
ssh-audit findings comes from napalm-device-types, carried over run_command
|
||||||
|
(NetOrk/netork#1027)."""
|
||||||
|
|
||||||
|
def test_mixes_in_the_openssh_hardening(self):
|
||||||
|
from napalm_device_types import OpenSshServerMixin
|
||||||
|
|
||||||
|
assert issubclass(LinuxDriver, OpenSshServerMixin)
|
||||||
|
assert callable(getattr(LinuxDriver, "harden_ssh_server", None))
|
||||||
|
|
||||||
|
def test_reads_the_algorithms_as_root_over_the_command_channel(self, driver):
|
||||||
|
from napalm_device_types import CommandResult
|
||||||
|
|
||||||
|
out = "kexalgorithms a\nhostkeyalgorithms b\nciphers c\nmacs d\n"
|
||||||
|
with patch.object(driver, "run_command", return_value=CommandResult(out, "", 0)) as run:
|
||||||
|
assert driver.get_ssh_server_algorithms()["mac"] == ["d"]
|
||||||
|
assert run.call_args.kwargs["privileged"] is True
|
||||||
Reference in new issue
Block a user