Compare commits
36
Commits
3ca2ed72ed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a76b6d47f | ||
|
|
3333d8e8f5 | ||
|
|
fe29b507b0 | ||
|
|
51ee33eebe | ||
|
|
0f5e2a1d37 | ||
|
|
b7a13d5153 | ||
|
|
007590b9bf | ||
|
|
79e52f060e | ||
|
|
3571149639 | ||
|
|
60b56c37e0 | ||
|
|
e82f99df7b | ||
|
|
2fed2f73e2 | ||
|
|
b49acb8ed7 | ||
|
|
a6f9a17858 | ||
|
|
e31bc2a3bf | ||
|
|
84717ff53b | ||
|
|
31b8a37895 | ||
|
|
a6e5568e0b | ||
|
|
b6b1827f96 | ||
|
|
ac288823a7 | ||
|
|
b4e6bbf79f | ||
|
|
b45444c831 | ||
|
|
e8eadb46c6 | ||
|
|
55635ab551 | ||
|
|
549e8c01e0 | ||
|
|
d33739832b | ||
|
|
7faaafb7a3 | ||
|
|
799d1ce749 | ||
|
|
ce40299033 | ||
|
|
27027eec56 | ||
|
|
c8fc46c373 | ||
|
|
661d56074c | ||
|
|
2f049338b5 | ||
|
|
07dcdbfe50 | ||
|
|
1cee26823e | ||
|
|
8436013dcd |
@@ -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,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -48,7 +48,8 @@ with Driver(
|
||||
optional_args={
|
||||
# "port": 22,
|
||||
# "pkg_manager": "apt", # force package manager; auto-detected by default
|
||||
# "secret": "sudo-pass", # password for sudo / enable (defaults to login password)
|
||||
# "sudo_password": "sudo-pass", # for commands that need root; without it,
|
||||
# # `sudo -n` (passwordless sudo) is tried
|
||||
# "debugging": True, # enable verbose logging
|
||||
},
|
||||
) as dev:
|
||||
@@ -69,6 +70,10 @@ with Driver(
|
||||
|
||||
# Upgrade everything with pending updates
|
||||
result = dev.apply_updates([])
|
||||
|
||||
# Restart a service (start, stop, restart, enable, disable)
|
||||
result = dev.manage_service("cron", "restart")
|
||||
print(result) # {"success": True, "output": ""}
|
||||
```
|
||||
|
||||
## Supported NAPALM methods
|
||||
@@ -99,7 +104,8 @@ with Driver(
|
||||
| `get_packages()` | ✅ | apt, dnf, yum, apk, pacman |
|
||||
| `get_pending_updates()` | ✅ | apt, dnf, yum, apk, pacman |
|
||||
| `apply_updates(packages)` | ✅ | apt, dnf, yum, apk, pacman |
|
||||
| `get_services()` | ✅ | systemd (fallback: SysV `service`) |
|
||||
| `get_services()` | ✅ | systemd, one round trip (fallback: SysV `service`) |
|
||||
| `manage_service(name, action)` | ✅ | systemd: start, stop, restart, enable, disable |
|
||||
| `get_users()` | ✅ | `/etc/passwd` + `/etc/group` |
|
||||
| `get_processes()` | ✅ | `ps axo` |
|
||||
| `get_cron_jobs()` | ✅ | user crontabs + `/etc/cron.d/` |
|
||||
@@ -127,13 +133,25 @@ The SSH user needs read access to:
|
||||
| `/etc/passwd`, `/etc/group` | world-readable (default) |
|
||||
| `/proc/uptime`, `/sys/class/dmi/…` | world-readable (default) |
|
||||
| User crontabs (`/var/spool/cron/…`) | `root` or `sudo` required |
|
||||
| `systemctl is-enabled <unit>` | unprivileged on most distros |
|
||||
| `systemctl list-unit-files`, `systemctl show` | unprivileged |
|
||||
| `systemctl start/stop/restart/enable/disable` | `root`, or `sudo` (with `sudo_password`, or passwordless) |
|
||||
| `apt list --upgradable` | may require `apt-get update` (root) |
|
||||
| `dnf check-update` / `yum check-update` | unprivileged, but slower without cache |
|
||||
|
||||
For full functionality it is recommended to run as `root` or grant passwordless `sudo` for
|
||||
the above commands.
|
||||
|
||||
`get_services()` and `manage_service()` come from napalm-device-types'
|
||||
`SystemdServicesMixin`; this driver supplies only the transport. An action runs as
|
||||
`timeout 45 systemctl --no-ask-password <action> -- <unit>.service`, so a unit that hangs
|
||||
on its way up or down cannot hold the session, and only the exit status decides whether it
|
||||
succeeded. Without a sudo password it uses `sudo -n`, which fails at once instead of
|
||||
waiting for a password prompt.
|
||||
|
||||
On OpenMediaVault (napalm-openmediavault inherits this driver), enabling or disabling a
|
||||
unit that OMV manages itself — Samba, NFS, SSH — may be reverted the next time OMV applies
|
||||
its configuration.
|
||||
|
||||
## Tested distributions
|
||||
|
||||
| Distribution | Version | Package manager | Tested |
|
||||
|
||||
+425
-440
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "napalm-linux"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
description = "NAPALM driver for generic Linux systems via SSH"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -37,7 +37,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"napalm>=4.0",
|
||||
"napalm-device-types>=0.3.0",
|
||||
"napalm-device-types>=2.6.0",
|
||||
"netmiko>=4.0.0",
|
||||
"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")
|
||||
+925
-11
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user