netbox-toolkit-plugin 0.1.0__py3-none-any.whl → 0.1.1__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.
Files changed (68) hide show
  1. netbox_toolkit_plugin/__init__.py +32 -0
  2. {netbox_toolkit → netbox_toolkit_plugin}/api/serializers.py +71 -35
  3. {netbox_toolkit → netbox_toolkit_plugin}/api/urls.py +3 -3
  4. {netbox_toolkit → netbox_toolkit_plugin}/config.py +80 -73
  5. {netbox_toolkit → netbox_toolkit_plugin}/connectors/factory.py +170 -111
  6. {netbox_toolkit → netbox_toolkit_plugin}/connectors/netmiko_connector.py +242 -179
  7. {netbox_toolkit → netbox_toolkit_plugin}/connectors/scrapli_connector.py +256 -172
  8. netbox_toolkit_plugin/migrations/0001_initial.py +108 -0
  9. netbox_toolkit_plugin/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +70 -0
  10. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0003_permission_system_update.py +26 -12
  11. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0004_remove_django_permissions.py +27 -29
  12. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0005_alter_command_options_and_more.py +7 -8
  13. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +7 -8
  14. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0007_alter_commandlog_parsing_template.py +6 -4
  15. {netbox_toolkit → netbox_toolkit_plugin}/models.py +31 -32
  16. {netbox_toolkit → netbox_toolkit_plugin}/navigation.py +6 -6
  17. {netbox_toolkit → netbox_toolkit_plugin}/services/command_service.py +188 -128
  18. {netbox_toolkit → netbox_toolkit_plugin}/services/rate_limiting_service.py +104 -97
  19. netbox_toolkit_plugin/tables.py +51 -0
  20. netbox_toolkit_plugin/templates/netbox_toolkit/command.html +108 -0
  21. netbox_toolkit_plugin/templates/netbox_toolkit/command_list.html +12 -0
  22. netbox_toolkit_plugin/templates/netbox_toolkit/commandlog.html +170 -0
  23. netbox_toolkit_plugin/templates/netbox_toolkit/device_toolkit.html +557 -0
  24. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command.html +5 -5
  25. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command_list.html +2 -2
  26. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/commandlog.html +2 -2
  27. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/device_toolkit.html +6 -6
  28. netbox_toolkit_plugin/urls.py +38 -0
  29. {netbox_toolkit → netbox_toolkit_plugin}/utils/logging.py +20 -19
  30. {netbox_toolkit → netbox_toolkit_plugin}/views.py +251 -169
  31. {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.1.dist-info}/METADATA +2 -2
  32. netbox_toolkit_plugin-0.1.1.dist-info/RECORD +60 -0
  33. netbox_toolkit_plugin-0.1.1.dist-info/entry_points.txt +2 -0
  34. netbox_toolkit_plugin-0.1.1.dist-info/top_level.txt +1 -0
  35. netbox_toolkit/__init__.py +0 -30
  36. netbox_toolkit/migrations/0001_initial.py +0 -54
  37. netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +0 -66
  38. netbox_toolkit/tables.py +0 -37
  39. netbox_toolkit/urls.py +0 -22
  40. netbox_toolkit_plugin-0.1.0.dist-info/RECORD +0 -56
  41. netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +0 -2
  42. netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt +0 -1
  43. {netbox_toolkit → netbox_toolkit_plugin}/admin.py +0 -0
  44. {netbox_toolkit → netbox_toolkit_plugin}/api/__init__.py +0 -0
  45. {netbox_toolkit → netbox_toolkit_plugin}/api/mixins.py +0 -0
  46. {netbox_toolkit → netbox_toolkit_plugin}/api/schemas.py +0 -0
  47. {netbox_toolkit → netbox_toolkit_plugin}/api/views/__init__.py +0 -0
  48. {netbox_toolkit → netbox_toolkit_plugin}/api/views/command_logs.py +0 -0
  49. {netbox_toolkit → netbox_toolkit_plugin}/api/views/commands.py +0 -0
  50. {netbox_toolkit → netbox_toolkit_plugin}/connectors/__init__.py +0 -0
  51. {netbox_toolkit → netbox_toolkit_plugin}/connectors/base.py +0 -0
  52. {netbox_toolkit → netbox_toolkit_plugin}/exceptions.py +0 -0
  53. {netbox_toolkit → netbox_toolkit_plugin}/filtersets.py +0 -0
  54. {netbox_toolkit → netbox_toolkit_plugin}/forms.py +0 -0
  55. {netbox_toolkit → netbox_toolkit_plugin}/migrations/__init__.py +0 -0
  56. {netbox_toolkit → netbox_toolkit_plugin}/search.py +0 -0
  57. {netbox_toolkit → netbox_toolkit_plugin}/services/__init__.py +0 -0
  58. {netbox_toolkit → netbox_toolkit_plugin}/services/device_service.py +0 -0
  59. {netbox_toolkit/static/netbox_toolkit → netbox_toolkit_plugin/static/netbox_toolkit_plugin}/css/toolkit.css +0 -0
  60. {netbox_toolkit/static/netbox_toolkit → netbox_toolkit_plugin/static/netbox_toolkit_plugin}/js/toolkit.js +0 -0
  61. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command_edit.html +0 -0
  62. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/commandlog_list.html +0 -0
  63. {netbox_toolkit → netbox_toolkit_plugin}/utils/__init__.py +0 -0
  64. {netbox_toolkit → netbox_toolkit_plugin}/utils/connection.py +0 -0
  65. {netbox_toolkit → netbox_toolkit_plugin}/utils/error_parser.py +0 -0
  66. {netbox_toolkit → netbox_toolkit_plugin}/utils/network.py +0 -0
  67. {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.1.dist-info}/WHEEL +0 -0
  68. {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.1.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 ..config import ToolkitConfig
11
- from ..exceptions import DeviceConnectionError, CommandExecutionError, UnsupportedPlatformError
11
+ from ..config 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 cleanup_connection_resources, validate_connection_health, wait_for_socket_cleanup
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
- 'cisco_ios': IOSXEDriver,
26
- 'cisco_nxos': NXOSDriver,
27
- 'cisco_iosxr': IOSXRDriver,
28
- 'cisco_xe': IOSXEDriver, # Alternative naming
29
- 'ios': IOSXEDriver, # Generic iOS
30
- 'nxos': NXOSDriver, # Shorter form
31
- 'iosxr': IOSXRDriver, # Shorter form
32
- 'ios-xe': IOSXEDriver, # Hyphenated form
33
- 'ios-xr': IOSXRDriver, # Hyphenated form
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 = ToolkitConfig.get_retry_config()
48
+ self._retry_config = ToolkitSettings.get_retry_config()
40
49
  self._error_parser = VendorErrorParser()
41
- self._fast_fail_mode = False # Flag for using reduced timeouts on initial attempts
42
-
43
- logger.debug(f"Initialized ScrapliConnector for {config.hostname} with platform '{config.platform}'")
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()) + ['generic']
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 'generic'
55
-
67
+ return "generic"
68
+
56
69
  normalized = platform_name.lower().strip()
57
-
70
+
58
71
  # Handle common variations
59
- if normalized in ['cisco ios', 'ios', 'cisco_ios']:
60
- return 'cisco_ios'
61
- elif normalized in ['cisco nxos', 'nxos', 'nexus']:
62
- return 'cisco_nxos'
63
- elif normalized in ['cisco iosxr', 'iosxr', 'ios-xr']:
64
- return 'cisco_iosxr'
65
- elif normalized in ['cisco xe', 'ios-xe']:
66
- return 'cisco_xe'
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(f"Platform '{normalized_platform}' not in driver map, using GenericDriver")
93
+ logger.debug(
94
+ f"Platform '{normalized_platform}' not in driver map, using GenericDriver"
95
+ )
81
96
  else:
82
- logger.debug(f"Platform '{normalized_platform}' mapped to {driver_class.__name__}")
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 = ToolkitConfig.get_fast_test_timeouts()
91
- socket_timeout = fast_timeouts['socket']
92
- transport_timeout = fast_timeouts['transport']
93
- ops_timeout = fast_timeouts['ops']
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
- 'host': self.config.hostname,
102
- 'auth_username': self.config.username,
103
- 'auth_password': self.config.password,
104
- 'auth_strict_key': self.config.auth_strict_key,
105
- 'transport': self.config.transport,
106
- 'timeout_socket': socket_timeout,
107
- 'timeout_transport': transport_timeout,
108
- 'timeout_ops': ops_timeout,
109
- 'transport_options': self._get_transport_options()
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
- 'look_for_keys', 'use_keys', 'allow_agent', 'global_delay_factor',
117
- 'banner_timeout', 'auth_timeout', 'session_log', 'fast_cli',
118
- 'session_log_record_writes', 'session_log_file_mode', 'conn_timeout',
119
- 'read_timeout_override', 'auto_connect'
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 for k, v in self.config.extra_options.items()
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(f"Added filtered extra options: {list(filtered_options.keys())}")
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(filtered_options.keys())
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(f"Built connection params for {self.config.hostname}: transport={params['transport']}, "
136
- f"timeouts=[socket:{params['timeout_socket']}, transport:{params['timeout_transport']}, "
137
- f"ops:{params['timeout_ops']}]")
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 = ToolkitConfig.get_ssh_transport_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 == 'system':
149
- transport_options['system'] = {
150
- 'open_cmd': ['ssh'],
151
- 'auth_bypass': False,
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 'disabled_algorithms' in ssh_options:
156
- transport_options['disabled_algorithms'] = ssh_options['disabled_algorithms']
157
-
158
- if 'allowed_kex' in ssh_options:
159
- transport_options['kex_algorithms'] = ssh_options['allowed_kex']
160
-
161
- logger.debug(f"Transport options for {self.config.transport}: {transport_options}")
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(f"Attempting to connect to {self.config.hostname}:{self.config.port}")
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(f"Validating basic connectivity to {self.config.hostname}:{self.config.port}")
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(f"Pre-connection validation failed for {self.config.hostname}: {str(e)}")
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['retry_delay']
188
- max_retries = self._retry_config['max_retries']
189
-
190
- logger.debug(f"Starting connection attempts with max_retries={max_retries}, initial_delay={retry_delay}s")
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(f"Connection attempt {attempt + 1}/{max_retries + 1} after {retry_delay}s delay")
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("Switched to normal timeouts for subsequent attempts")
203
-
249
+ logger.debug(
250
+ "Switched to normal timeouts for subsequent attempts"
251
+ )
252
+
204
253
  # Adjust timeouts for SSH banner issues
205
- if "banner" in str(last_error).lower() or "timed out" in str(last_error).lower():
206
- logger.debug("Detected banner/timeout issue, increasing timeouts by 5s")
207
- conn_params['timeout_socket'] += 5
208
- conn_params['timeout_transport'] += 5
209
-
210
- retry_delay *= self._retry_config['backoff_multiplier']
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(f"Initial connection attempt to {self.config.hostname} (fast-fail mode)")
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(f"Successfully connected to {self.config.hostname} using {self._driver_class.__name__}")
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(f"Connection attempt {attempt + 1} failed for {self.config.hostname}: {error_msg}")
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 ToolkitConfig.should_fast_fail_to_netmiko(error_msg):
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(f"All connection attempts failed for {self.config.hostname}: {error_msg}")
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(f"Connection health validation: {'healthy' if is_healthy else 'unhealthy'}")
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(self, command: str, command_type: str = 'show') -> CommandResult:
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(f"Executing {command_type} command on {self.config.hostname}: {command}")
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(f"Connection validation failed before executing command: {command}")
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 == 'config':
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(f"Command completed in {execution_time:.2f}s, output length: {len(response.result)} chars")
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(response.result, self.config.platform)
419
+ parsed_error = self._error_parser.parse_command_output(
420
+ response.result, self.config.platform
421
+ )
346
422
  if parsed_error:
347
- logger.warning(f"Syntax error detected in command output: {parsed_error.error_type.value}")
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("Detected connection-related error, marking connection as invalid")
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(f"TextFSM parsing successful, parsed {len(parsed_data)} records")
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 = 'textfsm'
430
-
511
+ result.parsing_method = "textfsm"
512
+
431
513
  return result
432
514
  else:
433
- logger.debug("TextFSM parsing returned empty result (no matching template)")
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