netbox-toolkit-plugin 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_toolkit_plugin/__init__.py +32 -0
- {netbox_toolkit → netbox_toolkit_plugin}/api/serializers.py +71 -35
- {netbox_toolkit → netbox_toolkit_plugin}/api/urls.py +3 -3
- {netbox_toolkit → netbox_toolkit_plugin}/connectors/factory.py +170 -111
- {netbox_toolkit → netbox_toolkit_plugin}/connectors/netmiko_connector.py +242 -179
- {netbox_toolkit → netbox_toolkit_plugin}/connectors/scrapli_connector.py +256 -172
- netbox_toolkit_plugin/migrations/0001_initial.py +108 -0
- netbox_toolkit_plugin/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +70 -0
- {netbox_toolkit → netbox_toolkit_plugin}/migrations/0003_permission_system_update.py +26 -12
- {netbox_toolkit → netbox_toolkit_plugin}/migrations/0004_remove_django_permissions.py +27 -29
- {netbox_toolkit → netbox_toolkit_plugin}/migrations/0005_alter_command_options_and_more.py +7 -8
- {netbox_toolkit → netbox_toolkit_plugin}/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +7 -8
- {netbox_toolkit → netbox_toolkit_plugin}/migrations/0007_alter_commandlog_parsing_template.py +6 -4
- {netbox_toolkit → netbox_toolkit_plugin}/models.py +31 -32
- {netbox_toolkit → netbox_toolkit_plugin}/navigation.py +6 -6
- {netbox_toolkit → netbox_toolkit_plugin}/services/command_service.py +188 -128
- {netbox_toolkit → netbox_toolkit_plugin}/services/rate_limiting_service.py +104 -97
- netbox_toolkit_plugin/settings.py +176 -0
- netbox_toolkit_plugin/tables.py +51 -0
- netbox_toolkit_plugin/templates/netbox_toolkit/command.html +108 -0
- netbox_toolkit_plugin/templates/netbox_toolkit/command_list.html +12 -0
- netbox_toolkit_plugin/templates/netbox_toolkit/commandlog.html +170 -0
- netbox_toolkit_plugin/templates/netbox_toolkit/device_toolkit.html +557 -0
- {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command.html +5 -5
- {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command_list.html +2 -2
- {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/commandlog.html +2 -2
- {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/device_toolkit.html +6 -6
- netbox_toolkit_plugin/urls.py +38 -0
- {netbox_toolkit → netbox_toolkit_plugin}/utils/logging.py +20 -19
- {netbox_toolkit → netbox_toolkit_plugin}/views.py +251 -169
- {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.2.dist-info}/METADATA +2 -2
- netbox_toolkit_plugin-0.1.2.dist-info/RECORD +60 -0
- netbox_toolkit_plugin-0.1.2.dist-info/entry_points.txt +2 -0
- netbox_toolkit_plugin-0.1.2.dist-info/top_level.txt +1 -0
- netbox_toolkit/__init__.py +0 -30
- netbox_toolkit/config.py +0 -159
- netbox_toolkit/migrations/0001_initial.py +0 -54
- netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +0 -66
- netbox_toolkit/tables.py +0 -37
- netbox_toolkit/urls.py +0 -22
- netbox_toolkit_plugin-0.1.0.dist-info/RECORD +0 -56
- netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +0 -2
- netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt +0 -1
- {netbox_toolkit → netbox_toolkit_plugin}/admin.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/api/__init__.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/api/mixins.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/api/schemas.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/api/views/__init__.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/api/views/command_logs.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/api/views/commands.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/connectors/__init__.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/connectors/base.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/exceptions.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/filtersets.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/forms.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/migrations/__init__.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/search.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/services/__init__.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/services/device_service.py +0 -0
- {netbox_toolkit/static/netbox_toolkit → netbox_toolkit_plugin/static/netbox_toolkit_plugin}/css/toolkit.css +0 -0
- {netbox_toolkit/static/netbox_toolkit → netbox_toolkit_plugin/static/netbox_toolkit_plugin}/js/toolkit.js +0 -0
- {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command_edit.html +0 -0
- {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/commandlog_list.html +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/utils/__init__.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/utils/connection.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/utils/error_parser.py +0 -0
- {netbox_toolkit → netbox_toolkit_plugin}/utils/network.py +0 -0
- {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.2.dist-info}/WHEEL +0 -0
- {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,4 +1,5 @@
|
|
1
1
|
"""Scrapli-based device connector implementation."""
|
2
|
+
|
2
3
|
import time
|
3
4
|
from typing import Dict, Any, Type, Optional
|
4
5
|
|
@@ -7,10 +8,18 @@ from scrapli.driver.generic import GenericDriver
|
|
7
8
|
from scrapli.exceptions import ScrapliException
|
8
9
|
|
9
10
|
from .base import BaseDeviceConnector, ConnectionConfig, CommandResult
|
10
|
-
from ..
|
11
|
-
from ..exceptions import
|
11
|
+
from ..settings import ToolkitSettings
|
12
|
+
from ..exceptions import (
|
13
|
+
DeviceConnectionError,
|
14
|
+
CommandExecutionError,
|
15
|
+
UnsupportedPlatformError,
|
16
|
+
)
|
12
17
|
from ..utils.network import validate_device_connectivity
|
13
|
-
from ..utils.connection import
|
18
|
+
from ..utils.connection import (
|
19
|
+
cleanup_connection_resources,
|
20
|
+
validate_connection_health,
|
21
|
+
wait_for_socket_cleanup,
|
22
|
+
)
|
14
23
|
from ..utils.error_parser import VendorErrorParser
|
15
24
|
from ..utils.logging import get_toolkit_logger
|
16
25
|
|
@@ -19,219 +28,272 @@ logger = get_toolkit_logger(__name__)
|
|
19
28
|
|
20
29
|
class ScrapliConnector(BaseDeviceConnector):
|
21
30
|
"""Scrapli-based implementation of device connector."""
|
22
|
-
|
31
|
+
|
23
32
|
# Platform to driver mapping - expanded for better NetBox platform support
|
24
33
|
DRIVER_MAP = {
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
+
"cisco_ios": IOSXEDriver,
|
35
|
+
"cisco_nxos": NXOSDriver,
|
36
|
+
"cisco_iosxr": IOSXRDriver,
|
37
|
+
"cisco_xe": IOSXEDriver, # Alternative naming
|
38
|
+
"ios": IOSXEDriver, # Generic iOS
|
39
|
+
"nxos": NXOSDriver, # Shorter form
|
40
|
+
"iosxr": IOSXRDriver, # Shorter form
|
41
|
+
"ios-xe": IOSXEDriver, # Hyphenated form
|
42
|
+
"ios-xr": IOSXRDriver, # Hyphenated form
|
34
43
|
}
|
35
|
-
|
44
|
+
|
36
45
|
def __init__(self, config: ConnectionConfig):
|
37
46
|
super().__init__(config)
|
38
47
|
self._driver_class = self._get_driver_class()
|
39
|
-
self._retry_config =
|
48
|
+
self._retry_config = ToolkitSettings.get_retry_config()
|
40
49
|
self._error_parser = VendorErrorParser()
|
41
|
-
self._fast_fail_mode =
|
42
|
-
|
43
|
-
|
44
|
-
|
50
|
+
self._fast_fail_mode = (
|
51
|
+
False # Flag for using reduced timeouts on initial attempts
|
52
|
+
)
|
53
|
+
|
54
|
+
logger.debug(
|
55
|
+
f"Initialized ScrapliConnector for {config.hostname} with platform '{config.platform}'"
|
56
|
+
)
|
57
|
+
|
45
58
|
@classmethod
|
46
59
|
def get_supported_platforms(cls) -> list:
|
47
60
|
"""Get list of supported platform names."""
|
48
|
-
return list(cls.DRIVER_MAP.keys()) + [
|
49
|
-
|
61
|
+
return list(cls.DRIVER_MAP.keys()) + ["generic"]
|
62
|
+
|
50
63
|
@classmethod
|
51
64
|
def normalize_platform_name(cls, platform_name: str) -> str:
|
52
65
|
"""Normalize platform name for consistent mapping."""
|
53
66
|
if not platform_name:
|
54
|
-
return
|
55
|
-
|
67
|
+
return "generic"
|
68
|
+
|
56
69
|
normalized = platform_name.lower().strip()
|
57
|
-
|
70
|
+
|
58
71
|
# Handle common variations
|
59
|
-
if normalized in [
|
60
|
-
return
|
61
|
-
elif normalized in [
|
62
|
-
return
|
63
|
-
elif normalized in [
|
64
|
-
return
|
65
|
-
elif normalized in [
|
66
|
-
return
|
67
|
-
|
72
|
+
if normalized in ["cisco ios", "ios", "cisco_ios"]:
|
73
|
+
return "cisco_ios"
|
74
|
+
elif normalized in ["cisco nxos", "nxos", "nexus"]:
|
75
|
+
return "cisco_nxos"
|
76
|
+
elif normalized in ["cisco iosxr", "iosxr", "ios-xr"]:
|
77
|
+
return "cisco_iosxr"
|
78
|
+
elif normalized in ["cisco xe", "ios-xe"]:
|
79
|
+
return "cisco_xe"
|
80
|
+
|
68
81
|
return normalized
|
69
|
-
|
82
|
+
|
70
83
|
def _get_driver_class(self) -> Type:
|
71
84
|
"""Get the appropriate Scrapli driver class for the platform."""
|
72
85
|
if not self.config.platform:
|
73
86
|
logger.debug("No platform specified, using GenericDriver")
|
74
87
|
return GenericDriver
|
75
|
-
|
88
|
+
|
76
89
|
normalized_platform = self.normalize_platform_name(self.config.platform)
|
77
90
|
driver_class = self.DRIVER_MAP.get(normalized_platform, GenericDriver)
|
78
|
-
|
91
|
+
|
79
92
|
if driver_class == GenericDriver:
|
80
|
-
logger.debug(
|
93
|
+
logger.debug(
|
94
|
+
f"Platform '{normalized_platform}' not in driver map, using GenericDriver"
|
95
|
+
)
|
81
96
|
else:
|
82
|
-
logger.debug(
|
83
|
-
|
97
|
+
logger.debug(
|
98
|
+
f"Platform '{normalized_platform}' mapped to {driver_class.__name__}"
|
99
|
+
)
|
100
|
+
|
84
101
|
return driver_class
|
85
|
-
|
102
|
+
|
86
103
|
def _build_connection_params(self) -> Dict[str, Any]:
|
87
104
|
"""Build connection parameters for Scrapli."""
|
88
105
|
# Use fast test timeouts for initial attempts if in fast fail mode
|
89
106
|
if self._fast_fail_mode:
|
90
|
-
fast_timeouts =
|
91
|
-
socket_timeout = fast_timeouts[
|
92
|
-
transport_timeout = fast_timeouts[
|
93
|
-
ops_timeout = fast_timeouts[
|
107
|
+
fast_timeouts = ToolkitSettings.get_fast_test_timeouts()
|
108
|
+
socket_timeout = fast_timeouts["socket"]
|
109
|
+
transport_timeout = fast_timeouts["transport"]
|
110
|
+
ops_timeout = fast_timeouts["ops"]
|
94
111
|
logger.debug("Using fast test timeouts for initial connection attempt")
|
95
112
|
else:
|
96
113
|
socket_timeout = self.config.timeout_socket
|
97
114
|
transport_timeout = self.config.timeout_transport
|
98
115
|
ops_timeout = self.config.timeout_ops
|
99
|
-
|
116
|
+
|
100
117
|
params = {
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
118
|
+
"host": self.config.hostname,
|
119
|
+
"auth_username": self.config.username,
|
120
|
+
"auth_password": self.config.password,
|
121
|
+
"auth_strict_key": self.config.auth_strict_key,
|
122
|
+
"transport": self.config.transport,
|
123
|
+
"timeout_socket": socket_timeout,
|
124
|
+
"timeout_transport": transport_timeout,
|
125
|
+
"timeout_ops": ops_timeout,
|
126
|
+
"transport_options": self._get_transport_options(),
|
110
127
|
}
|
111
|
-
|
128
|
+
|
112
129
|
# Add any extra options (but filter out Netmiko-specific ones)
|
113
130
|
if self.config.extra_options:
|
114
131
|
# Filter out Netmiko-specific parameters that Scrapli doesn't understand
|
115
132
|
netmiko_only_params = {
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
133
|
+
"look_for_keys",
|
134
|
+
"use_keys",
|
135
|
+
"allow_agent",
|
136
|
+
"global_delay_factor",
|
137
|
+
"banner_timeout",
|
138
|
+
"auth_timeout",
|
139
|
+
"session_log",
|
140
|
+
"fast_cli",
|
141
|
+
"session_log_record_writes",
|
142
|
+
"session_log_file_mode",
|
143
|
+
"conn_timeout",
|
144
|
+
"read_timeout_override",
|
145
|
+
"auto_connect",
|
120
146
|
}
|
121
|
-
|
147
|
+
|
122
148
|
filtered_options = {
|
123
|
-
k: v
|
149
|
+
k: v
|
150
|
+
for k, v in self.config.extra_options.items()
|
124
151
|
if k not in netmiko_only_params
|
125
152
|
}
|
126
|
-
|
153
|
+
|
127
154
|
if filtered_options:
|
128
155
|
params.update(filtered_options)
|
129
|
-
logger.debug(
|
130
|
-
|
156
|
+
logger.debug(
|
157
|
+
f"Added filtered extra options: {list(filtered_options.keys())}"
|
158
|
+
)
|
159
|
+
|
131
160
|
if len(filtered_options) != len(self.config.extra_options):
|
132
|
-
excluded = set(self.config.extra_options.keys()) - set(
|
161
|
+
excluded = set(self.config.extra_options.keys()) - set(
|
162
|
+
filtered_options.keys()
|
163
|
+
)
|
133
164
|
logger.debug(f"Excluded Netmiko-specific options: {excluded}")
|
134
|
-
|
135
|
-
logger.debug(
|
136
|
-
|
137
|
-
|
138
|
-
|
165
|
+
|
166
|
+
logger.debug(
|
167
|
+
f"Built connection params for {self.config.hostname}: transport={params['transport']}, "
|
168
|
+
f"timeouts=[socket:{params['timeout_socket']}, transport:{params['timeout_transport']}, "
|
169
|
+
f"ops:{params['timeout_ops']}]"
|
170
|
+
)
|
171
|
+
|
139
172
|
return params
|
140
|
-
|
173
|
+
|
141
174
|
def _get_transport_options(self) -> Dict[str, Any]:
|
142
175
|
"""Get transport-specific options for Scrapli."""
|
143
|
-
ssh_options =
|
144
|
-
|
176
|
+
ssh_options = ToolkitSettings.get_ssh_transport_options()
|
177
|
+
|
145
178
|
transport_options = {}
|
146
|
-
|
179
|
+
|
147
180
|
# Add system transport options (Scrapli's native transport)
|
148
|
-
if self.config.transport ==
|
149
|
-
transport_options[
|
150
|
-
|
151
|
-
|
181
|
+
if self.config.transport == "system":
|
182
|
+
transport_options["system"] = {
|
183
|
+
"open_cmd": ["ssh"],
|
184
|
+
"auth_bypass": False,
|
152
185
|
}
|
153
|
-
|
186
|
+
|
154
187
|
# Add SSH algorithm configurations
|
155
|
-
if
|
156
|
-
transport_options[
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
188
|
+
if "disabled_algorithms" in ssh_options:
|
189
|
+
transport_options["disabled_algorithms"] = ssh_options[
|
190
|
+
"disabled_algorithms"
|
191
|
+
]
|
192
|
+
|
193
|
+
if "allowed_kex" in ssh_options:
|
194
|
+
transport_options["kex_algorithms"] = ssh_options["allowed_kex"]
|
195
|
+
|
196
|
+
logger.debug(
|
197
|
+
f"Transport options for {self.config.transport}: {transport_options}"
|
198
|
+
)
|
162
199
|
return transport_options
|
163
|
-
|
200
|
+
|
164
201
|
def connect(self) -> None:
|
165
202
|
"""Establish connection to the device with retry logic and fast-fail detection."""
|
166
|
-
logger.debug(
|
167
|
-
|
203
|
+
logger.debug(
|
204
|
+
f"Attempting to connect to {self.config.hostname}:{self.config.port}"
|
205
|
+
)
|
206
|
+
|
168
207
|
# Clean up any existing connection first
|
169
208
|
if self._connection:
|
170
209
|
logger.debug("Cleaning up existing connection before reconnecting")
|
171
210
|
self.disconnect()
|
172
|
-
|
211
|
+
|
173
212
|
# First validate basic connectivity
|
174
213
|
try:
|
175
|
-
logger.debug(
|
214
|
+
logger.debug(
|
215
|
+
f"Validating basic connectivity to {self.config.hostname}:{self.config.port}"
|
216
|
+
)
|
176
217
|
validate_device_connectivity(self.config.hostname, self.config.port)
|
177
218
|
except Exception as e:
|
178
|
-
logger.error(
|
219
|
+
logger.error(
|
220
|
+
f"Pre-connection validation failed for {self.config.hostname}: {str(e)}"
|
221
|
+
)
|
179
222
|
raise DeviceConnectionError(f"Pre-connection validation failed: {str(e)}")
|
180
|
-
|
223
|
+
|
181
224
|
# Use fast-fail mode for first attempt to quickly detect incompatible scenarios
|
182
225
|
self._fast_fail_mode = True
|
183
226
|
conn_params = self._build_connection_params()
|
184
|
-
|
227
|
+
|
185
228
|
# Attempt connection with retry logic
|
186
229
|
last_error = None
|
187
|
-
retry_delay = self._retry_config[
|
188
|
-
max_retries = self._retry_config[
|
189
|
-
|
190
|
-
logger.debug(
|
191
|
-
|
230
|
+
retry_delay = self._retry_config["retry_delay"]
|
231
|
+
max_retries = self._retry_config["max_retries"]
|
232
|
+
|
233
|
+
logger.debug(
|
234
|
+
f"Starting connection attempts with max_retries={max_retries}, initial_delay={retry_delay}s"
|
235
|
+
)
|
236
|
+
|
192
237
|
for attempt in range(max_retries + 1):
|
193
238
|
try:
|
194
239
|
if attempt > 0:
|
195
|
-
logger.debug(
|
240
|
+
logger.debug(
|
241
|
+
f"Connection attempt {attempt + 1}/{max_retries + 1} after {retry_delay}s delay"
|
242
|
+
)
|
196
243
|
time.sleep(retry_delay)
|
197
|
-
|
244
|
+
|
198
245
|
# Switch to normal timeouts after first attempt
|
199
246
|
if attempt == 1:
|
200
247
|
self._fast_fail_mode = False
|
201
248
|
conn_params = self._build_connection_params()
|
202
|
-
logger.debug(
|
203
|
-
|
249
|
+
logger.debug(
|
250
|
+
"Switched to normal timeouts for subsequent attempts"
|
251
|
+
)
|
252
|
+
|
204
253
|
# Adjust timeouts for SSH banner issues
|
205
|
-
if
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
254
|
+
if (
|
255
|
+
"banner" in str(last_error).lower()
|
256
|
+
or "timed out" in str(last_error).lower()
|
257
|
+
):
|
258
|
+
logger.debug(
|
259
|
+
"Detected banner/timeout issue, increasing timeouts by 5s"
|
260
|
+
)
|
261
|
+
conn_params["timeout_socket"] += 5
|
262
|
+
conn_params["timeout_transport"] += 5
|
263
|
+
|
264
|
+
retry_delay *= self._retry_config["backoff_multiplier"]
|
211
265
|
else:
|
212
|
-
logger.debug(
|
213
|
-
|
266
|
+
logger.debug(
|
267
|
+
f"Initial connection attempt to {self.config.hostname} (fast-fail mode)"
|
268
|
+
)
|
269
|
+
|
214
270
|
# Create and open connection
|
215
271
|
logger.debug(f"Creating {self._driver_class.__name__} instance")
|
216
272
|
self._connection = self._driver_class(**conn_params)
|
217
|
-
|
273
|
+
|
218
274
|
logger.debug("Opening connection to device")
|
219
275
|
self._connection.open()
|
220
|
-
|
221
|
-
logger.info(
|
276
|
+
|
277
|
+
logger.info(
|
278
|
+
f"Successfully connected to {self.config.hostname} using {self._driver_class.__name__}"
|
279
|
+
)
|
222
280
|
return
|
223
|
-
|
281
|
+
|
224
282
|
except Exception as e:
|
225
283
|
last_error = e
|
226
284
|
error_msg = str(e)
|
227
|
-
logger.warning(
|
228
|
-
|
285
|
+
logger.warning(
|
286
|
+
f"Connection attempt {attempt + 1} failed for {self.config.hostname}: {error_msg}"
|
287
|
+
)
|
288
|
+
|
229
289
|
# Check for fast-fail patterns on first attempt
|
230
|
-
if attempt == 0 and
|
290
|
+
if attempt == 0 and ToolkitSettings.should_fast_fail_to_netmiko(
|
291
|
+
error_msg
|
292
|
+
):
|
231
293
|
logger.info(f"Fast-fail pattern detected: {error_msg}")
|
232
294
|
logger.info("Triggering immediate fallback to Netmiko")
|
233
295
|
raise DeviceConnectionError(f"Fast-fail to Netmiko: {error_msg}")
|
234
|
-
|
296
|
+
|
235
297
|
# Clean up failed connection attempt
|
236
298
|
if self._connection:
|
237
299
|
try:
|
@@ -240,12 +302,14 @@ class ScrapliConnector(BaseDeviceConnector):
|
|
240
302
|
except Exception:
|
241
303
|
pass
|
242
304
|
self._connection = None
|
243
|
-
|
305
|
+
|
244
306
|
if attempt >= max_retries:
|
245
307
|
error_msg = self._format_connection_error(e)
|
246
|
-
logger.error(
|
308
|
+
logger.error(
|
309
|
+
f"All connection attempts failed for {self.config.hostname}: {error_msg}"
|
310
|
+
)
|
247
311
|
raise DeviceConnectionError(error_msg)
|
248
|
-
|
312
|
+
|
249
313
|
def disconnect(self) -> None:
|
250
314
|
"""Close connection to the device with proper socket cleanup."""
|
251
315
|
if self._connection:
|
@@ -264,13 +328,13 @@ class ScrapliConnector(BaseDeviceConnector):
|
|
264
328
|
logger.debug("Socket cleanup wait completed")
|
265
329
|
else:
|
266
330
|
logger.debug("No active connection to disconnect")
|
267
|
-
|
331
|
+
|
268
332
|
def is_connected(self) -> bool:
|
269
333
|
"""Check if connection is active with proper error handling."""
|
270
334
|
if not self._connection:
|
271
335
|
logger.debug("No connection object exists")
|
272
336
|
return False
|
273
|
-
|
337
|
+
|
274
338
|
try:
|
275
339
|
# Check if connection object exists and is alive
|
276
340
|
is_alive = self._connection.isalive()
|
@@ -282,46 +346,54 @@ class ScrapliConnector(BaseDeviceConnector):
|
|
282
346
|
# Clean up the bad connection
|
283
347
|
self._connection = None
|
284
348
|
return False
|
285
|
-
|
349
|
+
|
286
350
|
def _validate_and_recover_connection(self) -> bool:
|
287
351
|
"""Validate connection and attempt recovery if needed."""
|
288
352
|
try:
|
289
353
|
if not self._connection:
|
290
354
|
logger.debug("No connection to validate")
|
291
355
|
return False
|
292
|
-
|
356
|
+
|
293
357
|
# Use the robust validation utility
|
294
358
|
is_healthy = validate_connection_health(self._connection)
|
295
|
-
logger.debug(
|
359
|
+
logger.debug(
|
360
|
+
f"Connection health validation: {'healthy' if is_healthy else 'unhealthy'}"
|
361
|
+
)
|
296
362
|
return is_healthy
|
297
|
-
|
363
|
+
|
298
364
|
except Exception as e:
|
299
365
|
logger.warning(f"Connection validation failed: {str(e)}")
|
300
366
|
self._connection = None
|
301
367
|
return False
|
302
368
|
|
303
|
-
def execute_command(
|
369
|
+
def execute_command(
|
370
|
+
self, command: str, command_type: str = "show"
|
371
|
+
) -> CommandResult:
|
304
372
|
"""Execute a command on the device with robust error handling.
|
305
|
-
|
373
|
+
|
306
374
|
Args:
|
307
375
|
command: The command string to execute
|
308
376
|
command_type: Type of command ('show' or 'config') for proper scrapli method selection
|
309
|
-
|
377
|
+
|
310
378
|
Returns:
|
311
379
|
CommandResult with execution details
|
312
380
|
"""
|
313
|
-
logger.debug(
|
314
|
-
|
381
|
+
logger.debug(
|
382
|
+
f"Executing {command_type} command on {self.config.hostname}: {command}"
|
383
|
+
)
|
384
|
+
|
315
385
|
# Validate connection first
|
316
386
|
if not self._validate_and_recover_connection():
|
317
|
-
logger.error(
|
387
|
+
logger.error(
|
388
|
+
f"Connection validation failed before executing command: {command}"
|
389
|
+
)
|
318
390
|
raise DeviceConnectionError("Connection is not available or has been lost")
|
319
|
-
|
391
|
+
|
320
392
|
start_time = time.time()
|
321
|
-
|
393
|
+
|
322
394
|
try:
|
323
395
|
# Use appropriate scrapli method based on command type
|
324
|
-
if command_type ==
|
396
|
+
if command_type == "config":
|
325
397
|
logger.debug("Using send_config method for configuration command")
|
326
398
|
# Use send_config for configuration commands - automatically handles config mode
|
327
399
|
response = self._connection.send_config(command)
|
@@ -329,46 +401,52 @@ class ScrapliConnector(BaseDeviceConnector):
|
|
329
401
|
logger.debug("Using send_command method for show/operational command")
|
330
402
|
# Use send_command for show/operational commands
|
331
403
|
response = self._connection.send_command(command)
|
332
|
-
|
404
|
+
|
333
405
|
execution_time = time.time() - start_time
|
334
|
-
logger.debug(
|
335
|
-
|
406
|
+
logger.debug(
|
407
|
+
f"Command completed in {execution_time:.2f}s, output length: {len(response.result)} chars"
|
408
|
+
)
|
409
|
+
|
336
410
|
# Create initial result
|
337
411
|
result = CommandResult(
|
338
412
|
command=command,
|
339
413
|
output=response.result,
|
340
414
|
success=True,
|
341
|
-
execution_time=execution_time
|
415
|
+
execution_time=execution_time,
|
342
416
|
)
|
343
|
-
|
417
|
+
|
344
418
|
# Check for syntax errors in the output even if command executed successfully
|
345
|
-
parsed_error = self._error_parser.parse_command_output(
|
419
|
+
parsed_error = self._error_parser.parse_command_output(
|
420
|
+
response.result, self.config.platform
|
421
|
+
)
|
346
422
|
if parsed_error:
|
347
|
-
logger.warning(
|
423
|
+
logger.warning(
|
424
|
+
f"Syntax error detected in command output: {parsed_error.error_type.value}"
|
425
|
+
)
|
348
426
|
# Update result with syntax error information
|
349
427
|
result.has_syntax_error = True
|
350
428
|
result.syntax_error_type = parsed_error.error_type.value
|
351
429
|
result.syntax_error_vendor = parsed_error.vendor
|
352
430
|
result.syntax_error_guidance = parsed_error.guidance
|
353
|
-
|
431
|
+
|
354
432
|
# Enhance the output with error information
|
355
|
-
enhanced_output = response.result + "\n\n" + "="*50 + "\n"
|
356
|
-
enhanced_output += "SYNTAX ERROR DETECTED\n" + "="*50 + "\n"
|
433
|
+
enhanced_output = response.result + "\n\n" + "=" * 50 + "\n"
|
434
|
+
enhanced_output += "SYNTAX ERROR DETECTED\n" + "=" * 50 + "\n"
|
357
435
|
enhanced_output += f"Error Type: {parsed_error.error_type.value.replace('_', ' ').title()}\n"
|
358
436
|
enhanced_output += f"Vendor: {self._error_parser._get_vendor_display_name(parsed_error.vendor)}\n"
|
359
437
|
enhanced_output += f"Confidence: {parsed_error.confidence:.0%}\n\n"
|
360
438
|
enhanced_output += parsed_error.enhanced_message + "\n\n"
|
361
439
|
enhanced_output += parsed_error.guidance
|
362
|
-
|
440
|
+
|
363
441
|
result.output = enhanced_output
|
364
|
-
|
442
|
+
|
365
443
|
# Attempt to parse command output using TextFSM (only for successful commands without syntax errors)
|
366
444
|
if result.success and not result.has_syntax_error:
|
367
445
|
logger.debug("Attempting to parse command output with TextFSM")
|
368
446
|
result = self._attempt_parsing(result, response)
|
369
|
-
|
447
|
+
|
370
448
|
return result
|
371
|
-
|
449
|
+
|
372
450
|
except OSError as e:
|
373
451
|
# Handle socket-related errors specifically
|
374
452
|
execution_time = time.time() - start_time
|
@@ -380,79 +458,85 @@ class ScrapliConnector(BaseDeviceConnector):
|
|
380
458
|
else:
|
381
459
|
logger.error(f"OS error during command execution: {str(e)}")
|
382
460
|
error_msg = f"OS error during command execution: {str(e)}"
|
383
|
-
|
461
|
+
|
384
462
|
return CommandResult(
|
385
463
|
command=command,
|
386
464
|
output="",
|
387
465
|
success=False,
|
388
466
|
error_message=error_msg,
|
389
|
-
execution_time=execution_time
|
467
|
+
execution_time=execution_time,
|
390
468
|
)
|
391
|
-
|
469
|
+
|
392
470
|
except Exception as e:
|
393
471
|
execution_time = time.time() - start_time
|
394
472
|
error_msg = f"Command execution failed: {str(e)}"
|
395
473
|
logger.error(f"Command execution failed for '{command}': {str(e)}")
|
396
|
-
|
474
|
+
|
397
475
|
# Check if this is a connection-related error
|
398
476
|
if "connection" in str(e).lower() or "socket" in str(e).lower():
|
399
|
-
logger.warning(
|
477
|
+
logger.warning(
|
478
|
+
"Detected connection-related error, marking connection as invalid"
|
479
|
+
)
|
400
480
|
self._connection = None # Mark connection as invalid
|
401
|
-
|
481
|
+
|
402
482
|
return CommandResult(
|
403
483
|
command=command,
|
404
484
|
output="",
|
405
485
|
success=False,
|
406
486
|
error_message=error_msg,
|
407
|
-
execution_time=execution_time
|
487
|
+
execution_time=execution_time,
|
408
488
|
)
|
409
489
|
|
410
490
|
def _attempt_parsing(self, result: CommandResult, response) -> CommandResult:
|
411
491
|
"""Attempt to parse command output using available parsers.
|
412
|
-
|
492
|
+
|
413
493
|
Args:
|
414
494
|
result: The current CommandResult
|
415
495
|
response: The scrapli response object
|
416
|
-
|
496
|
+
|
417
497
|
Returns:
|
418
498
|
Updated CommandResult with parsing information
|
419
499
|
"""
|
420
500
|
# Try TextFSM parsing first (most comprehensive template library)
|
421
501
|
try:
|
422
502
|
parsed_data = response.textfsm_parse_output()
|
423
|
-
|
503
|
+
|
424
504
|
if parsed_data:
|
425
505
|
# TextFSM parsing successful
|
426
|
-
logger.debug(
|
506
|
+
logger.debug(
|
507
|
+
f"TextFSM parsing successful, parsed {len(parsed_data)} records"
|
508
|
+
)
|
427
509
|
result.parsed_output = parsed_data
|
428
510
|
result.parsing_success = True
|
429
|
-
result.parsing_method =
|
430
|
-
|
511
|
+
result.parsing_method = "textfsm"
|
512
|
+
|
431
513
|
return result
|
432
514
|
else:
|
433
|
-
logger.debug(
|
515
|
+
logger.debug(
|
516
|
+
"TextFSM parsing returned empty result (no matching template)"
|
517
|
+
)
|
434
518
|
pass # TextFSM parsing returned empty result
|
435
|
-
|
519
|
+
|
436
520
|
except Exception as e:
|
437
521
|
# TextFSM parsing failed - this is common for commands without templates
|
438
522
|
error_msg = str(e)
|
439
523
|
logger.debug(f"TextFSM parsing failed: {error_msg}")
|
440
|
-
|
524
|
+
|
441
525
|
# Store parsing error for debugging (but don't fail the command)
|
442
526
|
result.parsing_error = f"TextFSM: {error_msg}"
|
443
|
-
|
527
|
+
|
444
528
|
# Could add other parsers here in the future (Genie, TTP)
|
445
529
|
# For now, we only attempt TextFSM
|
446
|
-
|
530
|
+
|
447
531
|
return result
|
448
532
|
|
449
533
|
def _format_connection_error(self, error: Exception) -> str:
|
450
534
|
"""Format connection error with helpful troubleshooting information."""
|
451
535
|
error_message = str(error)
|
452
|
-
|
536
|
+
|
453
537
|
# Base error message
|
454
538
|
formatted_msg = f"Failed to connect to {self.config.hostname}: {error_message}"
|
455
|
-
|
539
|
+
|
456
540
|
# Add specific guidance for common SSH errors
|
457
541
|
if "No matching key exchange" in error_message:
|
458
542
|
formatted_msg += (
|
@@ -482,5 +566,5 @@ class ScrapliConnector(BaseDeviceConnector):
|
|
482
566
|
"\n- The account is not locked"
|
483
567
|
"\n- The device allows the authentication method being used"
|
484
568
|
)
|
485
|
-
|
569
|
+
|
486
570
|
return formatted_msg
|