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.
- 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}/config.py +80 -73
- {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/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.1.dist-info}/METADATA +2 -2
- netbox_toolkit_plugin-0.1.1.dist-info/RECORD +60 -0
- netbox_toolkit_plugin-0.1.1.dist-info/entry_points.txt +2 -0
- netbox_toolkit_plugin-0.1.1.dist-info/top_level.txt +1 -0
- netbox_toolkit/__init__.py +0 -30
- 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.1.dist-info}/WHEEL +0 -0
- {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,19 +1,24 @@
|
|
1
1
|
"""Netmiko-based device connector implementation."""
|
2
|
+
|
2
3
|
import time
|
3
4
|
from typing import Dict, Any, Optional, Tuple
|
4
5
|
|
5
6
|
from netmiko import ConnectHandler, SSHDetect
|
6
7
|
from netmiko.exceptions import (
|
7
|
-
NetmikoTimeoutException,
|
8
|
+
NetmikoTimeoutException,
|
8
9
|
NetmikoAuthenticationException,
|
9
10
|
NetmikoBaseException,
|
10
11
|
ConnectionException,
|
11
|
-
ConfigInvalidException
|
12
|
+
ConfigInvalidException,
|
12
13
|
)
|
13
14
|
|
14
15
|
from .base import BaseDeviceConnector, ConnectionConfig, CommandResult
|
15
|
-
from ..config import
|
16
|
-
from ..exceptions import
|
16
|
+
from ..config import ToolkitSettings
|
17
|
+
from ..exceptions import (
|
18
|
+
DeviceConnectionError,
|
19
|
+
CommandExecutionError,
|
20
|
+
UnsupportedPlatformError,
|
21
|
+
)
|
17
22
|
from ..utils.network import validate_device_connectivity
|
18
23
|
from ..utils.error_parser import VendorErrorParser
|
19
24
|
from ..utils.logging import get_toolkit_logger
|
@@ -23,239 +28,285 @@ logger = get_toolkit_logger(__name__)
|
|
23
28
|
|
24
29
|
class NetmikoConnector(BaseDeviceConnector):
|
25
30
|
"""Netmiko-based implementation of device connector for legacy/fallback support."""
|
26
|
-
|
31
|
+
|
27
32
|
# NetBox platform to Netmiko device_type mapping
|
28
33
|
DEVICE_TYPE_MAP = {
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
34
|
+
"cisco_ios": "cisco_ios",
|
35
|
+
"cisco_nxos": "cisco_nxos",
|
36
|
+
"cisco_iosxr": "cisco_xr",
|
37
|
+
"cisco_xe": "cisco_ios",
|
38
|
+
"cisco_asa": "cisco_asa",
|
39
|
+
"arista_eos": "arista_eos",
|
40
|
+
"juniper_junos": "juniper_junos",
|
41
|
+
"hp_procurve": "hp_procurve",
|
42
|
+
"hp_comware": "hp_comware",
|
43
|
+
"dell_os10": "dell_os10",
|
44
|
+
"dell_powerconnect": "dell_powerconnect",
|
45
|
+
"linux": "linux",
|
46
|
+
"paloalto_panos": "paloalto_panos",
|
47
|
+
"fortinet": "fortinet",
|
48
|
+
"mikrotik_routeros": "mikrotik_routeros",
|
49
|
+
"ubiquiti_edge": "ubiquiti_edge",
|
45
50
|
# Generic fallback
|
46
|
-
|
47
|
-
|
51
|
+
"generic": "generic_termserver",
|
52
|
+
"autodetect": "autodetect",
|
48
53
|
}
|
49
|
-
|
54
|
+
|
50
55
|
def __init__(self, config: ConnectionConfig):
|
51
56
|
super().__init__(config)
|
52
57
|
self._error_parser = VendorErrorParser()
|
53
|
-
self._retry_config =
|
54
|
-
|
55
|
-
# Use config from extra_options if available, otherwise get from
|
58
|
+
self._retry_config = ToolkitSettings.get_retry_config()
|
59
|
+
|
60
|
+
# Use config from extra_options if available, otherwise get from ToolkitSettings
|
56
61
|
if config.extra_options:
|
57
62
|
self._netmiko_config = config.extra_options.copy()
|
58
|
-
logger.debug(
|
63
|
+
logger.debug(
|
64
|
+
f"Using Netmiko config from extra_options: {list(self._netmiko_config.keys())}"
|
65
|
+
)
|
59
66
|
else:
|
60
|
-
self._netmiko_config =
|
61
|
-
logger.debug("Using default Netmiko config from
|
62
|
-
|
63
|
-
logger.debug(
|
64
|
-
|
67
|
+
self._netmiko_config = ToolkitSettings.get_netmiko_config()
|
68
|
+
logger.debug("Using default Netmiko config from ToolkitSettings")
|
69
|
+
|
70
|
+
logger.debug(
|
71
|
+
f"Initialized NetmikoConnector for {config.hostname} with platform '{config.platform}'"
|
72
|
+
)
|
73
|
+
|
65
74
|
def _filter_valid_netmiko_params(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
66
75
|
"""Filter parameters to only include those supported by Netmiko."""
|
67
76
|
# Define valid Netmiko connection parameters
|
68
77
|
valid_netmiko_params = {
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
78
|
+
"device_type",
|
79
|
+
"host",
|
80
|
+
"username",
|
81
|
+
"password",
|
82
|
+
"port",
|
83
|
+
"timeout",
|
84
|
+
"banner_timeout",
|
85
|
+
"auth_timeout",
|
86
|
+
"global_delay_factor",
|
87
|
+
"use_keys",
|
88
|
+
"key_file",
|
89
|
+
"allow_agent",
|
90
|
+
"session_log",
|
91
|
+
"session_log_record_writes",
|
92
|
+
"session_log_file_mode",
|
93
|
+
"fast_cli",
|
94
|
+
"secret",
|
95
|
+
"blocking_timeout",
|
96
|
+
"verbose",
|
97
|
+
"conn_timeout",
|
98
|
+
"read_timeout",
|
99
|
+
"keepalive",
|
74
100
|
}
|
75
|
-
|
101
|
+
|
76
102
|
filtered_params = {}
|
77
103
|
for key, value in params.items():
|
78
104
|
if key in valid_netmiko_params:
|
79
105
|
filtered_params[key] = value
|
80
106
|
else:
|
81
107
|
logger.debug(f"Filtering out unsupported Netmiko parameter: {key}")
|
82
|
-
|
108
|
+
|
83
109
|
return filtered_params
|
84
|
-
|
110
|
+
|
85
111
|
@classmethod
|
86
112
|
def get_supported_platforms(cls) -> list:
|
87
113
|
"""Get list of supported platform names."""
|
88
114
|
return list(cls.DEVICE_TYPE_MAP.keys())
|
89
|
-
|
115
|
+
|
90
116
|
@classmethod
|
91
117
|
def normalize_platform_name(cls, platform_name: str) -> str:
|
92
118
|
"""Normalize platform name for consistent mapping."""
|
93
119
|
if not platform_name:
|
94
|
-
return
|
95
|
-
|
120
|
+
return "autodetect"
|
121
|
+
|
96
122
|
normalized = platform_name.lower().strip()
|
97
|
-
|
123
|
+
|
98
124
|
# Handle common variations
|
99
125
|
platform_mappings = {
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
126
|
+
"ios": "cisco_ios",
|
127
|
+
"nxos": "cisco_nxos",
|
128
|
+
"nexus": "cisco_nxos",
|
129
|
+
"iosxr": "cisco_iosxr",
|
130
|
+
"ios-xr": "cisco_iosxr",
|
131
|
+
"ios-xe": "cisco_xe",
|
132
|
+
"eos": "arista_eos",
|
133
|
+
"junos": "juniper_junos",
|
134
|
+
"asa": "cisco_asa",
|
109
135
|
}
|
110
|
-
|
136
|
+
|
111
137
|
return platform_mappings.get(normalized, normalized)
|
112
|
-
|
138
|
+
|
113
139
|
def _get_device_type(self) -> str:
|
114
140
|
"""Get the appropriate Netmiko device_type for the platform."""
|
115
141
|
if not self.config.platform:
|
116
142
|
logger.debug("No platform specified, attempting auto-detection")
|
117
|
-
return
|
118
|
-
|
143
|
+
return "autodetect"
|
144
|
+
|
119
145
|
normalized_platform = self.normalize_platform_name(self.config.platform)
|
120
|
-
device_type = self.DEVICE_TYPE_MAP.get(normalized_platform,
|
121
|
-
|
122
|
-
logger.debug(
|
146
|
+
device_type = self.DEVICE_TYPE_MAP.get(normalized_platform, "autodetect")
|
147
|
+
|
148
|
+
logger.debug(
|
149
|
+
f"Platform '{normalized_platform}' mapped to device_type '{device_type}'"
|
150
|
+
)
|
123
151
|
return device_type
|
124
|
-
|
152
|
+
|
125
153
|
def _auto_detect_device_type(self) -> str:
|
126
154
|
"""Use Netmiko's auto-detection for unknown platforms."""
|
127
155
|
try:
|
128
156
|
logger.debug(f"Attempting auto-detection for {self.config.hostname}")
|
129
|
-
|
157
|
+
|
130
158
|
device_dict = {
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
159
|
+
"device_type": "autodetect",
|
160
|
+
"host": self.config.hostname,
|
161
|
+
"username": self.config.username,
|
162
|
+
"password": self.config.password,
|
163
|
+
"port": self.config.port,
|
164
|
+
"timeout": self.config.timeout_socket,
|
137
165
|
}
|
138
|
-
|
166
|
+
|
139
167
|
guesser = SSHDetect(**device_dict)
|
140
168
|
best_match = guesser.autodetect()
|
141
|
-
|
169
|
+
|
142
170
|
if best_match:
|
143
|
-
logger.info(
|
171
|
+
logger.info(
|
172
|
+
f"Auto-detected device type '{best_match}' for {self.config.hostname}"
|
173
|
+
)
|
144
174
|
return best_match
|
145
175
|
else:
|
146
|
-
logger.warning(
|
147
|
-
|
148
|
-
|
176
|
+
logger.warning(
|
177
|
+
f"Auto-detection failed for {self.config.hostname}, using generic"
|
178
|
+
)
|
179
|
+
return "generic_termserver"
|
180
|
+
|
149
181
|
except Exception as e:
|
150
182
|
logger.warning(f"Auto-detection error for {self.config.hostname}: {str(e)}")
|
151
|
-
return
|
152
|
-
|
183
|
+
return "generic_termserver"
|
184
|
+
|
153
185
|
def _build_connection_params(self) -> Dict[str, Any]:
|
154
186
|
"""Build connection parameters for Netmiko."""
|
155
187
|
device_type = self._get_device_type()
|
156
|
-
|
188
|
+
|
157
189
|
# Handle auto-detection
|
158
|
-
if device_type ==
|
190
|
+
if device_type == "autodetect":
|
159
191
|
device_type = self._auto_detect_device_type()
|
160
|
-
|
192
|
+
|
161
193
|
params = {
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
194
|
+
"device_type": device_type,
|
195
|
+
"host": self.config.hostname,
|
196
|
+
"username": self.config.username,
|
197
|
+
"password": self.config.password,
|
198
|
+
"port": self.config.port,
|
199
|
+
"timeout": self.config.timeout_socket,
|
200
|
+
"banner_timeout": self._netmiko_config.get("banner_timeout", 15),
|
201
|
+
"auth_timeout": self._netmiko_config.get("auth_timeout", 15),
|
202
|
+
"blocking_timeout": self.config.timeout_ops,
|
171
203
|
}
|
172
|
-
|
204
|
+
|
173
205
|
# Add advanced options from Netmiko config
|
174
|
-
if
|
175
|
-
params[
|
176
|
-
|
177
|
-
if
|
178
|
-
|
179
|
-
|
206
|
+
if "global_delay_factor" in self._netmiko_config:
|
207
|
+
params["global_delay_factor"] = self._netmiko_config["global_delay_factor"]
|
208
|
+
|
209
|
+
if (
|
210
|
+
"session_log" in self._netmiko_config
|
211
|
+
and self._netmiko_config["session_log"]
|
212
|
+
):
|
213
|
+
params["session_log"] = self._netmiko_config["session_log"]
|
214
|
+
|
180
215
|
# SSH key options - only set if explicitly enabled
|
181
|
-
if self._netmiko_config.get(
|
182
|
-
params[
|
183
|
-
if
|
184
|
-
params[
|
216
|
+
if self._netmiko_config.get("use_keys", False):
|
217
|
+
params["use_keys"] = True
|
218
|
+
if "key_file" in self._netmiko_config:
|
219
|
+
params["key_file"] = self._netmiko_config["key_file"]
|
185
220
|
else:
|
186
221
|
# Explicitly disable key authentication for faster connections
|
187
|
-
params[
|
188
|
-
|
222
|
+
params["use_keys"] = False
|
223
|
+
|
189
224
|
# SSH agent options
|
190
|
-
if not self._netmiko_config.get(
|
191
|
-
params[
|
192
|
-
|
225
|
+
if not self._netmiko_config.get("allow_agent", True):
|
226
|
+
params["allow_agent"] = False
|
227
|
+
|
193
228
|
# Add any other valid Netmiko options from extra_options
|
194
229
|
if self.config.extra_options:
|
195
230
|
# Filter to only include valid Netmiko parameters
|
196
231
|
valid_params = self._filter_valid_netmiko_params(self.config.extra_options)
|
197
232
|
params.update(valid_params)
|
198
|
-
|
199
|
-
logger.debug(
|
233
|
+
|
234
|
+
logger.debug(
|
235
|
+
f"Netmiko connection params: device_type={device_type}, host={params['host']}"
|
236
|
+
)
|
200
237
|
return params
|
201
|
-
|
238
|
+
|
202
239
|
def connect(self) -> None:
|
203
240
|
"""Establish connection to the device using Netmiko with retry logic."""
|
204
241
|
if self._connection:
|
205
242
|
logger.debug(f"Already connected to {self.config.hostname}")
|
206
243
|
return
|
207
|
-
|
244
|
+
|
208
245
|
# Validate connectivity first
|
209
246
|
validate_device_connectivity(self.config.hostname, self.config.port)
|
210
|
-
|
211
|
-
max_retries = self._retry_config[
|
212
|
-
retry_delay = self._retry_config[
|
247
|
+
|
248
|
+
max_retries = self._retry_config["max_retries"]
|
249
|
+
retry_delay = self._retry_config["retry_delay"]
|
213
250
|
last_error = None
|
214
|
-
|
251
|
+
|
215
252
|
for attempt in range(max_retries + 1):
|
216
253
|
try:
|
217
254
|
if attempt > 0:
|
218
|
-
logger.debug(
|
255
|
+
logger.debug(
|
256
|
+
f"Connection attempt {attempt + 1}/{max_retries + 1} after {retry_delay}s delay"
|
257
|
+
)
|
219
258
|
time.sleep(retry_delay)
|
220
|
-
retry_delay *= self._retry_config[
|
259
|
+
retry_delay *= self._retry_config["backoff_multiplier"]
|
221
260
|
else:
|
222
|
-
logger.debug(
|
223
|
-
|
261
|
+
logger.debug(
|
262
|
+
f"Initial connection attempt to {self.config.hostname}"
|
263
|
+
)
|
264
|
+
|
224
265
|
# Build connection parameters
|
225
266
|
conn_params = self._build_connection_params()
|
226
|
-
|
267
|
+
|
227
268
|
# Create and establish connection
|
228
|
-
logger.debug(
|
269
|
+
logger.debug(
|
270
|
+
f"Creating Netmiko ConnectHandler for {self.config.hostname}"
|
271
|
+
)
|
229
272
|
self._connection = ConnectHandler(**conn_params)
|
230
|
-
|
231
|
-
logger.info(
|
273
|
+
|
274
|
+
logger.info(
|
275
|
+
f"Successfully connected to {self.config.hostname} using Netmiko"
|
276
|
+
)
|
232
277
|
return
|
233
|
-
|
278
|
+
|
234
279
|
except NetmikoAuthenticationException as e:
|
235
|
-
logger.error(
|
280
|
+
logger.error(
|
281
|
+
f"Authentication failed for {self.config.hostname}: {str(e)}"
|
282
|
+
)
|
236
283
|
raise DeviceConnectionError(f"Authentication failed: {str(e)}")
|
237
|
-
|
284
|
+
|
238
285
|
except NetmikoTimeoutException as e:
|
239
286
|
last_error = e
|
240
|
-
logger.warning(
|
241
|
-
|
287
|
+
logger.warning(
|
288
|
+
f"Connection timeout for {self.config.hostname}: {str(e)}"
|
289
|
+
)
|
290
|
+
|
242
291
|
if attempt >= max_retries:
|
243
|
-
raise DeviceConnectionError(
|
244
|
-
|
292
|
+
raise DeviceConnectionError(
|
293
|
+
f"Connection timeout after {max_retries + 1} attempts: {str(e)}"
|
294
|
+
)
|
295
|
+
|
245
296
|
except NetmikoBaseException as e:
|
246
297
|
last_error = e
|
247
298
|
logger.warning(f"Netmiko error for {self.config.hostname}: {str(e)}")
|
248
|
-
|
299
|
+
|
249
300
|
if attempt >= max_retries:
|
250
301
|
raise DeviceConnectionError(f"Netmiko connection failed: {str(e)}")
|
251
|
-
|
302
|
+
|
252
303
|
except Exception as e:
|
253
304
|
last_error = e
|
254
305
|
logger.warning(f"Unexpected error for {self.config.hostname}: {str(e)}")
|
255
|
-
|
306
|
+
|
256
307
|
if attempt >= max_retries:
|
257
308
|
raise DeviceConnectionError(f"Connection failed: {str(e)}")
|
258
|
-
|
309
|
+
|
259
310
|
def disconnect(self) -> None:
|
260
311
|
"""Close connection to the device."""
|
261
312
|
if self._connection:
|
@@ -267,114 +318,124 @@ class NetmikoConnector(BaseDeviceConnector):
|
|
267
318
|
logger.warning(f"Error during disconnect: {str(e)}")
|
268
319
|
finally:
|
269
320
|
self._connection = None
|
270
|
-
|
271
|
-
def execute_command(
|
321
|
+
|
322
|
+
def execute_command(
|
323
|
+
self, command: str, command_type: str = "show"
|
324
|
+
) -> CommandResult:
|
272
325
|
"""Execute a command on the device.
|
273
|
-
|
326
|
+
|
274
327
|
Args:
|
275
328
|
command: The command string to execute
|
276
329
|
command_type: Type of command ('show' or 'config') for proper handling
|
277
|
-
|
330
|
+
|
278
331
|
Returns:
|
279
332
|
CommandResult with execution details
|
280
333
|
"""
|
281
334
|
if not self._connection:
|
282
335
|
raise CommandExecutionError("Not connected to device")
|
283
|
-
|
284
|
-
logger.debug(
|
336
|
+
|
337
|
+
logger.debug(
|
338
|
+
f"Executing {command_type} command on {self.config.hostname}: {command}"
|
339
|
+
)
|
285
340
|
start_time = time.time()
|
286
|
-
|
341
|
+
|
287
342
|
try:
|
288
343
|
# Use command_type parameter to determine execution method
|
289
|
-
if command_type ==
|
344
|
+
if command_type == "config":
|
290
345
|
output = self._execute_config_command(command)
|
291
346
|
parsed_data = None # Config commands don't get parsed
|
292
347
|
else:
|
293
348
|
output, parsed_data = self._execute_show_command(command)
|
294
|
-
|
349
|
+
|
295
350
|
execution_time = time.time() - start_time
|
296
|
-
|
351
|
+
|
297
352
|
# Create initial result
|
298
353
|
result = CommandResult(
|
299
354
|
command=command,
|
300
355
|
output=output,
|
301
356
|
success=True,
|
302
|
-
execution_time=execution_time
|
357
|
+
execution_time=execution_time,
|
303
358
|
)
|
304
|
-
|
359
|
+
|
305
360
|
# Add parsed data if available
|
306
361
|
if parsed_data:
|
307
362
|
result.parsed_output = parsed_data
|
308
363
|
result.parsing_success = True
|
309
|
-
result.parsing_method =
|
310
|
-
|
364
|
+
result.parsing_method = "textfsm"
|
365
|
+
|
311
366
|
# Check for syntax errors in the output even if command executed successfully
|
312
|
-
parsed_error = self._error_parser.parse_command_output(
|
367
|
+
parsed_error = self._error_parser.parse_command_output(
|
368
|
+
output, self.config.platform
|
369
|
+
)
|
313
370
|
if parsed_error:
|
314
|
-
logger.warning(
|
371
|
+
logger.warning(
|
372
|
+
f"Syntax error detected in command output: {parsed_error.error_type.value}"
|
373
|
+
)
|
315
374
|
# Update result with syntax error information
|
316
375
|
result.has_syntax_error = True
|
317
376
|
result.syntax_error_type = parsed_error.error_type.value
|
318
377
|
result.syntax_error_vendor = parsed_error.vendor
|
319
378
|
result.syntax_error_guidance = parsed_error.guidance
|
320
|
-
|
379
|
+
|
321
380
|
# Enhance the output with error information
|
322
|
-
enhanced_output = output + "\n\n" + "="*50 + "\n"
|
323
|
-
enhanced_output += "SYNTAX ERROR DETECTED\n" + "="*50 + "\n"
|
381
|
+
enhanced_output = output + "\n\n" + "=" * 50 + "\n"
|
382
|
+
enhanced_output += "SYNTAX ERROR DETECTED\n" + "=" * 50 + "\n"
|
324
383
|
enhanced_output += f"Error Type: {parsed_error.error_type.value.replace('_', ' ').title()}\n"
|
325
384
|
enhanced_output += f"Vendor: {self._error_parser._get_vendor_display_name(parsed_error.vendor)}\n"
|
326
385
|
enhanced_output += f"Confidence: {parsed_error.confidence:.0%}\n\n"
|
327
386
|
enhanced_output += parsed_error.enhanced_message + "\n\n"
|
328
387
|
enhanced_output += parsed_error.guidance
|
329
|
-
|
388
|
+
|
330
389
|
result.output = enhanced_output
|
331
|
-
|
390
|
+
|
332
391
|
# Log final result summary
|
333
392
|
if parsed_data:
|
334
|
-
logger.debug(
|
393
|
+
logger.debug(
|
394
|
+
f"Command completed in {execution_time:.2f}s with {len(parsed_data)} parsed records"
|
395
|
+
)
|
335
396
|
else:
|
336
397
|
logger.debug(f"Command completed in {execution_time:.2f}s")
|
337
398
|
return result
|
338
|
-
|
399
|
+
|
339
400
|
except NetmikoBaseException as e:
|
340
401
|
execution_time = time.time() - start_time
|
341
402
|
error_msg = str(e)
|
342
403
|
logger.error(f"Command execution failed: {error_msg}")
|
343
|
-
|
404
|
+
|
344
405
|
return CommandResult(
|
345
406
|
command=command,
|
346
407
|
output="",
|
347
408
|
success=False,
|
348
409
|
error_message=error_msg,
|
349
|
-
execution_time=execution_time
|
410
|
+
execution_time=execution_time,
|
350
411
|
)
|
351
|
-
|
412
|
+
|
352
413
|
except Exception as e:
|
353
414
|
execution_time = time.time() - start_time
|
354
415
|
error_msg = f"Unexpected error: {str(e)}"
|
355
416
|
logger.error(f"Command execution failed: {error_msg}")
|
356
|
-
|
417
|
+
|
357
418
|
return CommandResult(
|
358
419
|
command=command,
|
359
420
|
output="",
|
360
421
|
success=False,
|
361
422
|
error_message=error_msg,
|
362
|
-
execution_time=execution_time
|
423
|
+
execution_time=execution_time,
|
363
424
|
)
|
364
|
-
|
425
|
+
|
365
426
|
def _execute_show_command(self, command: str) -> Tuple[str, Optional[list]]:
|
366
427
|
"""Execute a show/display command and return both raw output and parsed data.
|
367
|
-
|
428
|
+
|
368
429
|
Args:
|
369
430
|
command: The command to execute
|
370
|
-
|
431
|
+
|
371
432
|
Returns:
|
372
433
|
tuple: (raw_output, parsed_data) where parsed_data is None if parsing failed
|
373
434
|
"""
|
374
435
|
try:
|
375
436
|
# Execute command once and get raw output
|
376
437
|
raw_output = self._connection.send_command(command)
|
377
|
-
|
438
|
+
|
378
439
|
# Now attempt TextFSM parsing using the textfsm library directly
|
379
440
|
# This avoids re-executing the command on the device
|
380
441
|
parsed_data = None
|
@@ -383,37 +444,39 @@ class NetmikoConnector(BaseDeviceConnector):
|
|
383
444
|
import textfsm
|
384
445
|
import os
|
385
446
|
from ntc_templates.parse import parse_output
|
386
|
-
|
447
|
+
|
387
448
|
# Try to parse using ntc-templates (which is what Netmiko uses)
|
388
449
|
try:
|
389
450
|
parsed_result = parse_output(
|
390
451
|
platform=self._connection.device_type,
|
391
452
|
command=command,
|
392
|
-
data=raw_output
|
453
|
+
data=raw_output,
|
393
454
|
)
|
394
|
-
|
395
|
-
if (
|
396
|
-
|
397
|
-
|
455
|
+
|
456
|
+
if (
|
457
|
+
isinstance(parsed_result, list)
|
458
|
+
and len(parsed_result) > 0
|
459
|
+
and isinstance(parsed_result[0], dict)
|
460
|
+
):
|
398
461
|
parsed_data = parsed_result
|
399
462
|
logger.debug(f"TextFSM parsed {len(parsed_data)} records")
|
400
463
|
else:
|
401
464
|
logger.debug("No TextFSM template found")
|
402
|
-
|
465
|
+
|
403
466
|
except Exception as ntc_error:
|
404
467
|
logger.debug(f"TextFSM parsing failed: {str(ntc_error)}")
|
405
|
-
|
468
|
+
|
406
469
|
except ImportError:
|
407
470
|
logger.debug("TextFSM or ntc-templates not available, skipping parsing")
|
408
471
|
except Exception as parse_error:
|
409
472
|
logger.debug(f"TextFSM parsing failed: {str(parse_error)}")
|
410
|
-
|
473
|
+
|
411
474
|
return raw_output, parsed_data
|
412
|
-
|
475
|
+
|
413
476
|
except Exception as e:
|
414
477
|
logger.error(f"Show command failed: {str(e)}")
|
415
478
|
raise CommandExecutionError(f"Show command failed: {str(e)}")
|
416
|
-
|
479
|
+
|
417
480
|
def _execute_config_command(self, command: str) -> str:
|
418
481
|
"""Execute a configuration command."""
|
419
482
|
try:
|
@@ -421,20 +484,20 @@ class NetmikoConnector(BaseDeviceConnector):
|
|
421
484
|
commands = [command]
|
422
485
|
else:
|
423
486
|
commands = command
|
424
|
-
|
487
|
+
|
425
488
|
return self._connection.send_config_set(commands)
|
426
489
|
except Exception as e:
|
427
490
|
logger.error(f"Config command failed: {str(e)}")
|
428
491
|
raise CommandExecutionError(f"Config command failed: {str(e)}")
|
429
|
-
|
492
|
+
|
430
493
|
# Note: The _attempt_parsing method has been removed as parsing is now handled
|
431
494
|
# directly in _execute_show_command to avoid re-executing commands on the device
|
432
|
-
|
495
|
+
|
433
496
|
def is_connected(self) -> bool:
|
434
497
|
"""Check if device is connected."""
|
435
498
|
if not self._connection:
|
436
499
|
return False
|
437
|
-
|
500
|
+
|
438
501
|
try:
|
439
502
|
# Simple connectivity test
|
440
503
|
self._connection.find_prompt()
|