3 Commits
Author SHA1 Message Date
christianmanivong 661f6da3f7 Merge pull request 'fix: keep systemd's OSC 3008 shell context out of command output (#41)' (#42) from fix/osc-context-41 into main
driver / lint (push) Successful in 17s
driver / standard (push) Successful in 18s
driver / test (3.10) (push) Successful in 1m13s
driver / types (push) Successful in 3m1s
driver / test (3.11) (push) Successful in 2m16s
driver / test (3.13) (push) Successful in 46s
driver / lowest (push) Successful in 25s
driver / test (3.12) (push) Successful in 1m43s
driver / build (push) Successful in 24s
driver / security (push) Successful in 56s
CI / driver (push) Successful in 5m29s
driver / publish (push) Skipped
2026-10-23 21:10:04 +00:00
Christian Manivong 42e93f02dd refactor: prepare the interactive shell in its own module
driver / standard (pull_request) Successful in 40s
driver / lint (pull_request) Successful in 27s
driver / test (3.10) (pull_request) Successful in 3m57s
driver / test (3.11) (pull_request) Successful in 1m7s
driver / test (3.12) (pull_request) Successful in 1m9s
driver / types (pull_request) Successful in 19m18s
driver / lowest (pull_request) Successful in 43s
driver / security (pull_request) Successful in 1m3s
driver / build (pull_request) Successful in 58s
driver / test (3.13) (pull_request) Successful in 16m1s
CI / driver (pull_request) Successful in 23m9s
driver / publish (pull_request) Skipped
The prompt-hook step (#41) pushed linux.py past its line count in the
quality baseline. The three steps that make netmiko's fresh shell fit to
read - history off, prompt hooks off, no line wrap - and the prompt
pattern move to napalm_linux/_shell.py, under strict mypy. linux.py
shrinks below where it was; the baseline follows.
2026-10-23 22:46:36 +02:00
Christian Manivong 4159612a33 fix: keep systemd's OSC 3008 shell context out of command output (#41)
driver / standard (pull_request) Failing after 17s
driver / lint (pull_request) Successful in 28s
driver / types (pull_request) Successful in 51s
driver / test (3.10) (pull_request) Successful in 1m3s
driver / test (3.11) (pull_request) Successful in 1m4s
driver / test (3.12) (pull_request) Successful in 1m58s
driver / test (3.13) (pull_request) Successful in 2m1s
driver / lowest (pull_request) Successful in 1m19s
driver / build (pull_request) Successful in 23s
driver / security (pull_request) Successful in 4m12s
CI / driver (pull_request) Failing after 8m24s
driver / publish (pull_request) Skipped
systemd 258 (Ubuntu 26.04) ships /etc/profile.d/80-systemd-osc-context.sh:
an interactive bash wraps every command in OSC 3008 sequences from PS0 and
PROMPT_COMMAND, each with a fresh UUID. They ended up in every value read
through netmiko's shell, and because the prompt never looked the same twice,
the prompt line stayed in the output too. os_version grew past 255
characters and netOrk could not store the poll.

The session now unsets PS0 and PROMPT_COMMAND right after switching the
history off and reads its prompt anew; _send() strips terminal codes from
what it returns.

Version 0.15.1.
2026-10-23 18:58:21 +02:00
7 changed files with 156 additions and 31 deletions

No files matched your search

+10
View File
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.15.1] - 2026-10-23
### Fixed
- Ubuntu 26.04 hosts could not be polled (#41). systemd 258's
`/etc/profile.d/80-systemd-osc-context.sh` wraps every command of an interactive bash
in OSC 3008 sequences, so they ended up in every value read through the shell, the
prompt line with them: `os_version` grew past 255 characters. The session now unsets
`PS0` and `PROMPT_COMMAND` right after switching the history off and reads its prompt
anew, and `_send()` strips terminal codes from what it returns.
## [0.15.0] - 2026-10-23 ## [0.15.0] - 2026-10-23
The first release on PyPI. It collects everything since 0.4.0, which was the last version recorded here. The first release on PyPI. It collects everything since 0.4.0, which was the last version recorded here.
+54
View File
@@ -0,0 +1,54 @@
"""The interactive shell netmiko opens, made fit to be read before the first command."""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
#: The end of a command's output when nothing better is known: a line that looks
#: like a shell prompt.
PROMPT_RE = r"[#$\>]\s*$"
#: Sent before anything else in the interactive session (NetOrk/netork#1039):
#: the shell writes no history file and records nothing more. The leading space
#: keeps this line itself out of a history under ``HISTCONTROL=ignorespace``;
#: a shell without ``set -o history`` (dash) only complains, to /dev/null.
HISTORY_OFF = " unset HISTFILE; set +o history 2>/dev/null; true"
#: Sent right after ``HISTORY_OFF`` (#41): systemd 258's
#: ``/etc/profile.d/80-systemd-osc-context.sh`` wraps every command in OSC 3008
#: sequences from ``PS0`` and ``PROMPT_COMMAND``, each with a fresh UUID. It appends
#: to ``PROMPT_COMMAND`` as an array, so only ``unset`` removes its hook.
PROMPT_HOOKS_OFF = " unset PS0 PROMPT_COMMAND; true"
#: Keeps the PTY from wrapping long output lines (e.g. docker JSON).
NO_LINE_WRAP = "stty cols 10000 2>/dev/null || true"
def prepare_shell(device: Any, hostname: str) -> None:
"""Make netmiko's fresh shell *device* fit to read, one step at a time.
A step the shell refuses is logged and skipped: none of them is worth
failing the connection for.
"""
# No history before anything else is typed (NetOrk/netork#1039).
try:
device.send_command(HISTORY_OFF, expect_string=PROMPT_RE)
except Exception:
logger.debug("Could not switch the shell history off on %s", hostname, exc_info=True)
# No systemd OSC 3008 context around every command (#41). netmiko learned
# the prompt with those sequences in front, and a prompt carrying a fresh
# UUID never matches again, so it reads the prompt anew once they are gone.
try:
device.send_command(PROMPT_HOOKS_OFF, expect_string=PROMPT_RE)
device.set_base_prompt()
except Exception:
logger.debug("Could not switch the prompt hooks off on %s", hostname, exc_info=True)
try:
device.send_command(NO_LINE_WRAP, expect_string=PROMPT_RE)
except Exception:
logger.debug("%s: ignored", hostname, exc_info=True)
+8 -27
View File
@@ -74,6 +74,9 @@ from netmiko.exceptions import (
NetmikoTimeoutException, NetmikoTimeoutException,
) )
from napalm_linux._shell import PROMPT_RE as _PROMPT_RE
from napalm_linux._shell import prepare_shell
logger = logging.getLogger("napalm_linux") logger = logging.getLogger("napalm_linux")
# Package managers in detection order # Package managers in detection order
@@ -86,9 +89,6 @@ _RC_MARKER = "__NETORK_RC="
_RC_MARKER_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE) _RC_MARKER_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE)
#: The end of a command's output when nothing better is known: a line that looks
#: like a shell prompt.
_PROMPT_RE = r"[#$\>]\s*$"
#: A command ending in ``echo __NAME=$?`` reports its exit status on a line of its #: A command ending in ``echo __NAME=$?`` reports its exit status on a line of its
#: own: ``_RC_MARKER`` here, ``__APT_RC=`` and ``__SVC_RC=`` in napalm-device-types. #: own: ``_RC_MARKER`` here, ``__APT_RC=`` and ``__SVC_RC=`` in napalm-device-types.
_STATUS_ECHO_RE = re.compile(r"echo\s+(__[A-Z_]+=)\$\?") _STATUS_ECHO_RE = re.compile(r"echo\s+(__[A-Z_]+=)\$\?")
@@ -159,12 +159,6 @@ _PRIVILEGED_STDIN = (
"mktemp, cat) and have root install that file." "mktemp, cat) and have root install that file."
) )
#: Sent before anything else in the interactive session (NetOrk/netork#1039):
#: the shell writes no history file and records nothing more. The leading space
#: keeps this line itself out of a history under ``HISTCONTROL=ignorespace``;
#: a shell without ``set -o history`` (dash) only complains, to /dev/null.
_HISTORY_OFF = " unset HISTFILE; set +o history 2>/dev/null; true"
#: First in every script ``_sudo`` runs: a sudo that does not prompt (NOPASSWD, #: First in every script ``_sudo`` runs: a sudo that does not prompt (NOPASSWD,
#: a cached timestamp) leaves the password on stdin, and nothing may read it. #: a cached timestamp) leaves the password on stdin, and nothing may read it.
_NO_STDIN = "exec </dev/null; " _NO_STDIN = "exec </dev/null; "
@@ -413,21 +407,7 @@ class LinuxDriver(
raise ConnectionException(str(exc)) from exc raise ConnectionException(str(exc)) from exc
self._host_key.observe(self._device.remote_conn_pre.get_transport()) self._host_key.observe(self._device.remote_conn_pre.get_transport())
# No history before anything else is typed (NetOrk/netork#1039). prepare_shell(self._device, self.hostname)
try:
self._device.send_command(_HISTORY_OFF, expect_string=_PROMPT_RE)
except Exception:
logger.debug(
"Could not switch the shell history off on %s", self.hostname, exc_info=True
)
# Prevent PTY from wrapping long output lines (e.g. docker JSON).
try:
self._device.send_command(
"stty cols 10000 2>/dev/null || true", expect_string=r"[#$>]\s*$"
)
except Exception:
logger.debug("%s: ignored", self.hostname, exc_info=True)
self._pkg_manager = self._forced_pkg_manager or self._detect_pkg_manager() self._pkg_manager = self._forced_pkg_manager or self._detect_pkg_manager()
logger.debug("Connected to %s, pkg_manager=%s", self.hostname, self._pkg_manager) logger.debug("Connected to %s, pkg_manager=%s", self.hostname, self._pkg_manager)
@@ -455,15 +435,16 @@ class LinuxDriver(
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _send(self, command: str, read_timeout: float = 100) -> str: def _send(self, command: str, read_timeout: float = 100) -> str:
"""Send a command and return stripped output.""" """Send a command and return its output without terminal codes, stripped."""
if not self._device: if not self._device:
raise ConnectionClosedException("Not connected") raise ConnectionClosedException("Not connected")
return self._device.send_command( output = self._device.send_command(
command, command,
read_timeout=read_timeout, read_timeout=read_timeout,
cmd_verify=False, cmd_verify=False,
expect_string=_expect_for(command), expect_string=_expect_for(command),
).strip() )
return strip_terminal_codes(output).strip()
def _sudo(self, command: str, read_timeout: float = 100) -> str: def _sudo(self, command: str, read_timeout: float = 100) -> str:
"""Run *command* as root and return its output, stdout and stderr together. """Run *command* as root and return its output, stdout and stderr together.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "napalm-linux" name = "napalm-linux"
version = "0.15.0" version = "0.15.1"
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.10" requires-python = ">=3.10"
+1 -1
View File
@@ -22,7 +22,7 @@
"napalm_linux.linux" "napalm_linux.linux"
], ],
"python_file_lines": { "python_file_lines": {
"napalm_linux/linux.py": 2307 "napalm_linux/linux.py": 2288
}, },
"untyped_public": [] "untyped_public": []
} }
+3 -2
View File
@@ -21,7 +21,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from napalm_linux import LinuxDriver from napalm_linux import LinuxDriver
from napalm_linux.linux import _HISTORY_OFF from napalm_linux._shell import HISTORY_OFF as _HISTORY_OFF
_PW = "s3cr3t 'q' $x" _PW = "s3cr3t 'q' $x"
@@ -236,7 +236,8 @@ class TestHistoryOff:
def test_a_shell_that_refuses_it_does_not_stop_the_connection(self): def test_a_shell_that_refuses_it_does_not_stop_the_connection(self):
driver = LinuxDriver("h", "admin", "p") driver = LinuxDriver("h", "admin", "p")
device = MagicMock() device = MagicMock()
device.send_command.side_effect = [Exception("no prompt"), "", "/usr/bin/apt"] # history off (refused), prompt hooks off (#41), stty, package manager
device.send_command.side_effect = [Exception("no prompt"), "", "", "/usr/bin/apt"]
with patch("napalm_linux.linux.ConnectHandler", return_value=device): with patch("napalm_linux.linux.ConnectHandler", return_value=device):
driver.open() driver.open()
+79
View File
@@ -0,0 +1,79 @@
"""systemd's shell context sequences stay out of every command's output (#41).
systemd 258 (Ubuntu 26.04) ships ``/etc/profile.d/80-systemd-osc-context.sh``.
An interactive bash then wraps every command in OSC 3008 sequences, from ``PS0``
before it runs and from ``PROMPT_COMMAND`` before the next prompt, each with a
fresh UUID. netmiko's interactive shell is such a bash: the sequences ended up in
every parsed value, and since the prompt never looked the same twice, netmiko
left the prompt line in the output as well. ``os_version`` grew past 255
characters and netOrk could not store the poll.
"""
from unittest.mock import MagicMock, patch
from napalm_linux._shell import HISTORY_OFF, PROMPT_HOOKS_OFF
from napalm_linux.linux import LinuxDriver
#: What ``cat /etc/os-release | grep PRETTY_NAME ...`` returned through netmiko on
#: an Ubuntu 26.04.1 host (2026-10-23), the prompt line already gone.
_UBUNTU_2604_OS_RELEASE = (
"\x1b]3008;end=87c2e1c5-f850-4429-9f06-749e9be783e6;exit=success\x1b\\"
"\x1b]3008;start=bc3bc23b-2652-4826-8192-be447f97dd6f;type=command;"
"machineid=35cf42f3beb446bbb9aa95b937a0cf48;user=netork;hostname=ubuntu;"
"bootid=d32bd88c-dfce-40fe-9869-257bf50ef0e0;pid=20059;cwd=/home/netork\x1b\\"
"Ubuntu 26.04.1 LTS\n"
)
def _opened(device: MagicMock) -> LinuxDriver:
driver = LinuxDriver("h", "admin", "p")
with patch("napalm_linux.linux.ConnectHandler", return_value=device):
driver.open()
return driver
class TestPromptHooksOff:
def test_the_session_drops_the_hooks_right_after_the_history(self):
device = MagicMock()
device.send_command.return_value = "/usr/bin/apt"
_opened(device)
sent = [c.args[0] for c in device.send_command.call_args_list]
assert sent[:2] == [HISTORY_OFF, PROMPT_HOOKS_OFF]
def test_it_unsets_both_hooks_including_an_array_prompt_command(self):
# The script appends to PROMPT_COMMAND as an array; assigning the empty
# string would only clear element 0 and leave systemd's hook running.
assert PROMPT_HOOKS_OFF.startswith(" ")
assert "unset PS0 PROMPT_COMMAND" in PROMPT_HOOKS_OFF
def test_the_prompt_is_read_again_once_the_hooks_are_gone(self):
# netmiko learned the prompt with the sequences in front of it; a prompt
# carrying a UUID never matches again, so strip_prompt would keep failing.
device = MagicMock()
device.send_command.return_value = "/usr/bin/apt"
_opened(device)
calls = [(c[0], c.args[0] if c.args else None) for c in device.mock_calls]
hooks_off = calls.index(("send_command", PROMPT_HOOKS_OFF))
assert "set_base_prompt" in [name for name, _ in calls[hooks_off + 1 :]]
def test_a_shell_that_refuses_it_does_not_stop_the_connection(self):
device = MagicMock()
device.send_command.side_effect = ["", Exception("no prompt"), "", "/usr/bin/apt"]
driver = _opened(device)
assert driver._device is device
class TestSendStripsTerminalCodes:
def test_os_version_comes_back_without_the_context_sequences(self):
device = MagicMock()
device.send_command.return_value = _UBUNTU_2604_OS_RELEASE
driver = LinuxDriver("h", "admin", "p")
driver._device = device
assert driver._send("cat /etc/os-release") == "Ubuntu 26.04.1 LTS"