netbox-toolkit-plugin 0.1.0__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/__init__.py +30 -0
- netbox_toolkit/admin.py +16 -0
- netbox_toolkit/api/__init__.py +0 -0
- netbox_toolkit/api/mixins.py +54 -0
- netbox_toolkit/api/schemas.py +234 -0
- netbox_toolkit/api/serializers.py +158 -0
- netbox_toolkit/api/urls.py +10 -0
- netbox_toolkit/api/views/__init__.py +10 -0
- netbox_toolkit/api/views/command_logs.py +170 -0
- netbox_toolkit/api/views/commands.py +267 -0
- netbox_toolkit/config.py +159 -0
- netbox_toolkit/connectors/__init__.py +15 -0
- netbox_toolkit/connectors/base.py +97 -0
- netbox_toolkit/connectors/factory.py +301 -0
- netbox_toolkit/connectors/netmiko_connector.py +443 -0
- netbox_toolkit/connectors/scrapli_connector.py +486 -0
- netbox_toolkit/exceptions.py +36 -0
- netbox_toolkit/filtersets.py +85 -0
- netbox_toolkit/forms.py +31 -0
- netbox_toolkit/migrations/0001_initial.py +54 -0
- netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +66 -0
- netbox_toolkit/migrations/0003_permission_system_update.py +48 -0
- netbox_toolkit/migrations/0004_remove_django_permissions.py +77 -0
- netbox_toolkit/migrations/0005_alter_command_options_and_more.py +25 -0
- netbox_toolkit/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +28 -0
- netbox_toolkit/migrations/0007_alter_commandlog_parsing_template.py +18 -0
- netbox_toolkit/migrations/__init__.py +0 -0
- netbox_toolkit/models.py +89 -0
- netbox_toolkit/navigation.py +30 -0
- netbox_toolkit/search.py +21 -0
- netbox_toolkit/services/__init__.py +7 -0
- netbox_toolkit/services/command_service.py +357 -0
- netbox_toolkit/services/device_service.py +87 -0
- netbox_toolkit/services/rate_limiting_service.py +228 -0
- netbox_toolkit/static/netbox_toolkit/css/toolkit.css +143 -0
- netbox_toolkit/static/netbox_toolkit/js/toolkit.js +657 -0
- netbox_toolkit/tables.py +37 -0
- netbox_toolkit/templates/netbox_toolkit/command.html +108 -0
- netbox_toolkit/templates/netbox_toolkit/command_edit.html +10 -0
- netbox_toolkit/templates/netbox_toolkit/command_list.html +12 -0
- netbox_toolkit/templates/netbox_toolkit/commandlog.html +170 -0
- netbox_toolkit/templates/netbox_toolkit/commandlog_list.html +4 -0
- netbox_toolkit/templates/netbox_toolkit/device_toolkit.html +536 -0
- netbox_toolkit/urls.py +22 -0
- netbox_toolkit/utils/__init__.py +1 -0
- netbox_toolkit/utils/connection.py +125 -0
- netbox_toolkit/utils/error_parser.py +428 -0
- netbox_toolkit/utils/logging.py +58 -0
- netbox_toolkit/utils/network.py +157 -0
- netbox_toolkit/views.py +385 -0
- netbox_toolkit_plugin-0.1.0.dist-info/METADATA +76 -0
- netbox_toolkit_plugin-0.1.0.dist-info/RECORD +56 -0
- netbox_toolkit_plugin-0.1.0.dist-info/WHEEL +5 -0
- netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- netbox_toolkit_plugin-0.1.0.dist-info/licenses/LICENSE +200 -0
- netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,267 @@
|
|
1
|
+
"""
|
2
|
+
API ViewSet for Command resources
|
3
|
+
"""
|
4
|
+
from rest_framework import status
|
5
|
+
from rest_framework.decorators import action
|
6
|
+
from rest_framework.response import Response
|
7
|
+
from django.db import transaction
|
8
|
+
from drf_spectacular.utils import extend_schema_view
|
9
|
+
from dcim.models import Device
|
10
|
+
from netbox.api.viewsets import NetBoxModelViewSet
|
11
|
+
|
12
|
+
from ... import models, filtersets
|
13
|
+
from ...services.command_service import CommandExecutionService
|
14
|
+
from ...services.rate_limiting_service import RateLimitingService
|
15
|
+
from ..serializers import CommandSerializer, CommandExecutionSerializer
|
16
|
+
from ..mixins import APIResponseMixin, PermissionCheckMixin
|
17
|
+
from ..schemas import (
|
18
|
+
COMMAND_LIST_SCHEMA,
|
19
|
+
COMMAND_RETRIEVE_SCHEMA,
|
20
|
+
COMMAND_CREATE_SCHEMA,
|
21
|
+
COMMAND_UPDATE_SCHEMA,
|
22
|
+
COMMAND_PARTIAL_UPDATE_SCHEMA,
|
23
|
+
COMMAND_DESTROY_SCHEMA,
|
24
|
+
COMMAND_EXECUTE_SCHEMA,
|
25
|
+
COMMAND_BULK_EXECUTE_SCHEMA,
|
26
|
+
)
|
27
|
+
|
28
|
+
|
29
|
+
@extend_schema_view(
|
30
|
+
list=COMMAND_LIST_SCHEMA,
|
31
|
+
retrieve=COMMAND_RETRIEVE_SCHEMA,
|
32
|
+
create=COMMAND_CREATE_SCHEMA,
|
33
|
+
update=COMMAND_UPDATE_SCHEMA,
|
34
|
+
partial_update=COMMAND_PARTIAL_UPDATE_SCHEMA,
|
35
|
+
destroy=COMMAND_DESTROY_SCHEMA,
|
36
|
+
)
|
37
|
+
class CommandViewSet(NetBoxModelViewSet, APIResponseMixin, PermissionCheckMixin):
|
38
|
+
queryset = models.Command.objects.all()
|
39
|
+
serializer_class = CommandSerializer
|
40
|
+
filterset_class = filtersets.CommandFilterSet
|
41
|
+
# Using custom RateLimitingService instead of generic API throttling
|
42
|
+
# NetBox automatically handles object-based permissions - no need for explicit permission_classes
|
43
|
+
|
44
|
+
def get_queryset(self):
|
45
|
+
"""NetBox will automatically filter based on user's ObjectPermissions"""
|
46
|
+
# NetBox's object-based permission system will automatically filter this queryset
|
47
|
+
# based on the user's ObjectPermissions for 'view' action on Command objects
|
48
|
+
return super().get_queryset()
|
49
|
+
|
50
|
+
@COMMAND_EXECUTE_SCHEMA
|
51
|
+
@action(detail=True, methods=['post'], url_path='execute')
|
52
|
+
def execute_command(self, request, pk=None):
|
53
|
+
"""Execute a command on a device via API"""
|
54
|
+
command = self.get_object()
|
55
|
+
|
56
|
+
# Validate input using serializer
|
57
|
+
execution_serializer = CommandExecutionSerializer(data=request.data)
|
58
|
+
if not execution_serializer.is_valid():
|
59
|
+
return Response(
|
60
|
+
{
|
61
|
+
'error': 'Invalid input data',
|
62
|
+
'details': execution_serializer.errors
|
63
|
+
},
|
64
|
+
status=status.HTTP_400_BAD_REQUEST
|
65
|
+
)
|
66
|
+
|
67
|
+
validated_data = execution_serializer.validated_data
|
68
|
+
device_id = validated_data['device_id']
|
69
|
+
username = validated_data['username']
|
70
|
+
password = validated_data['password']
|
71
|
+
|
72
|
+
# Get device object
|
73
|
+
try:
|
74
|
+
device = Device.objects.get(id=device_id)
|
75
|
+
except Device.DoesNotExist:
|
76
|
+
return Response(
|
77
|
+
{'error': f'Device with ID {device_id} not found'},
|
78
|
+
status=status.HTTP_404_NOT_FOUND
|
79
|
+
)
|
80
|
+
|
81
|
+
# Check permissions based on command type using NetBox's object-based permissions
|
82
|
+
if command.command_type == 'config':
|
83
|
+
if not self._user_has_action_permission(request.user, command, 'execute_config'):
|
84
|
+
return Response(
|
85
|
+
{'error': 'You do not have permission to execute configuration commands'},
|
86
|
+
status=status.HTTP_403_FORBIDDEN
|
87
|
+
)
|
88
|
+
elif command.command_type == 'show':
|
89
|
+
if not self._user_has_action_permission(request.user, command, 'execute_show'):
|
90
|
+
return Response(
|
91
|
+
{'error': 'You do not have permission to execute show commands'},
|
92
|
+
status=status.HTTP_403_FORBIDDEN
|
93
|
+
)
|
94
|
+
|
95
|
+
# Check custom rate limiting (device-specific with bypass rules)
|
96
|
+
rate_limiting_service = RateLimitingService()
|
97
|
+
rate_limit_check = rate_limiting_service.check_rate_limit(device, request.user)
|
98
|
+
|
99
|
+
if not rate_limit_check['allowed']:
|
100
|
+
return Response(
|
101
|
+
{
|
102
|
+
'error': 'Rate limit exceeded',
|
103
|
+
'details': {
|
104
|
+
'reason': rate_limit_check['reason'],
|
105
|
+
'current_count': rate_limit_check['current_count'],
|
106
|
+
'limit': rate_limit_check['limit'],
|
107
|
+
'time_window_minutes': rate_limit_check['time_window_minutes']
|
108
|
+
}
|
109
|
+
},
|
110
|
+
status=status.HTTP_429_TOO_MANY_REQUESTS
|
111
|
+
)
|
112
|
+
|
113
|
+
# Execute command using the service
|
114
|
+
command_service = CommandExecutionService()
|
115
|
+
result = command_service.execute_command_with_retry(
|
116
|
+
command, device, username, password, max_retries=1
|
117
|
+
)
|
118
|
+
|
119
|
+
# Determine overall success - failed if either execution failed or syntax error detected
|
120
|
+
overall_success = result.success and not result.has_syntax_error
|
121
|
+
|
122
|
+
# Prepare response data
|
123
|
+
response_data = {
|
124
|
+
'success': overall_success,
|
125
|
+
'output': result.output,
|
126
|
+
'error_message': result.error_message,
|
127
|
+
'execution_time': result.execution_time,
|
128
|
+
'command': {
|
129
|
+
'id': command.id,
|
130
|
+
'name': command.name,
|
131
|
+
'command_type': command.command_type
|
132
|
+
},
|
133
|
+
'device': {
|
134
|
+
'id': device.id,
|
135
|
+
'name': device.name
|
136
|
+
}
|
137
|
+
}
|
138
|
+
|
139
|
+
# Add syntax error information if detected
|
140
|
+
if result.has_syntax_error:
|
141
|
+
response_data['syntax_error'] = {
|
142
|
+
'detected': True,
|
143
|
+
'type': result.syntax_error_type,
|
144
|
+
'vendor': result.syntax_error_vendor,
|
145
|
+
'guidance_provided': True
|
146
|
+
}
|
147
|
+
else:
|
148
|
+
response_data['syntax_error'] = {
|
149
|
+
'detected': False
|
150
|
+
}
|
151
|
+
|
152
|
+
# Add parsing information if available
|
153
|
+
if result.parsing_success and result.parsed_output:
|
154
|
+
response_data['parsed_output'] = {
|
155
|
+
'success': True,
|
156
|
+
'method': result.parsing_method,
|
157
|
+
'data': result.parsed_output
|
158
|
+
}
|
159
|
+
else:
|
160
|
+
response_data['parsed_output'] = {
|
161
|
+
'success': False,
|
162
|
+
'method': None,
|
163
|
+
'error': result.parsing_error
|
164
|
+
}
|
165
|
+
|
166
|
+
# Return appropriate status code
|
167
|
+
status_code = status.HTTP_200_OK if overall_success else status.HTTP_400_BAD_REQUEST
|
168
|
+
|
169
|
+
return Response(response_data, status=status_code)
|
170
|
+
|
171
|
+
@COMMAND_BULK_EXECUTE_SCHEMA
|
172
|
+
@action(detail=False, methods=['post'], url_path='bulk-execute')
|
173
|
+
def bulk_execute(self, request):
|
174
|
+
"""Execute multiple commands on multiple devices"""
|
175
|
+
executions = request.data.get('executions', [])
|
176
|
+
|
177
|
+
if not executions:
|
178
|
+
return Response(
|
179
|
+
{'error': 'No executions provided'},
|
180
|
+
status=status.HTTP_400_BAD_REQUEST
|
181
|
+
)
|
182
|
+
|
183
|
+
results = []
|
184
|
+
|
185
|
+
with transaction.atomic():
|
186
|
+
for i, execution_data in enumerate(executions):
|
187
|
+
try:
|
188
|
+
# Validate each execution
|
189
|
+
command_id = execution_data.get('command_id')
|
190
|
+
device_id = execution_data.get('device_id')
|
191
|
+
username = execution_data.get('username')
|
192
|
+
password = execution_data.get('password')
|
193
|
+
|
194
|
+
if not all([command_id, device_id, username, password]):
|
195
|
+
results.append({
|
196
|
+
'execution_id': i + 1,
|
197
|
+
'success': False,
|
198
|
+
'error': 'Missing required fields'
|
199
|
+
})
|
200
|
+
continue
|
201
|
+
|
202
|
+
# Get command and device objects
|
203
|
+
try:
|
204
|
+
command = models.Command.objects.get(id=command_id)
|
205
|
+
device = Device.objects.get(id=device_id)
|
206
|
+
except (models.Command.DoesNotExist, Device.DoesNotExist) as e:
|
207
|
+
results.append({
|
208
|
+
'execution_id': i + 1,
|
209
|
+
'success': False,
|
210
|
+
'error': f'Object not found: {str(e)}'
|
211
|
+
})
|
212
|
+
continue
|
213
|
+
|
214
|
+
# Check permissions
|
215
|
+
action = 'execute_config' if command.command_type == 'config' else 'execute_show'
|
216
|
+
if not self._user_has_action_permission(request.user, command, action):
|
217
|
+
results.append({
|
218
|
+
'execution_id': i + 1,
|
219
|
+
'success': False,
|
220
|
+
'error': 'Insufficient permissions'
|
221
|
+
})
|
222
|
+
continue
|
223
|
+
|
224
|
+
# Execute command
|
225
|
+
command_service = CommandExecutionService()
|
226
|
+
result = command_service.execute_command_with_retry(
|
227
|
+
command, device, username, password, max_retries=1
|
228
|
+
)
|
229
|
+
|
230
|
+
# Create command log entry (this would typically be done by the service)
|
231
|
+
log_entry = models.CommandLog.objects.create(
|
232
|
+
command=command,
|
233
|
+
device=device,
|
234
|
+
user=request.user,
|
235
|
+
output=result.output,
|
236
|
+
error_message=result.error_message,
|
237
|
+
execution_time=result.execution_time,
|
238
|
+
success=result.success and not result.has_syntax_error
|
239
|
+
)
|
240
|
+
|
241
|
+
results.append({
|
242
|
+
'execution_id': i + 1,
|
243
|
+
'success': result.success and not result.has_syntax_error,
|
244
|
+
'command_log_id': log_entry.id,
|
245
|
+
'execution_time': result.execution_time
|
246
|
+
})
|
247
|
+
|
248
|
+
except Exception as e:
|
249
|
+
results.append({
|
250
|
+
'execution_id': i + 1,
|
251
|
+
'success': False,
|
252
|
+
'error': f'Unexpected error: {str(e)}'
|
253
|
+
})
|
254
|
+
|
255
|
+
# Generate summary
|
256
|
+
total = len(results)
|
257
|
+
successful = sum(1 for r in results if r.get('success', False))
|
258
|
+
failed = total - successful
|
259
|
+
|
260
|
+
return Response({
|
261
|
+
'results': results,
|
262
|
+
'summary': {
|
263
|
+
'total': total,
|
264
|
+
'successful': successful,
|
265
|
+
'failed': failed
|
266
|
+
}
|
267
|
+
}, status=status.HTTP_200_OK)
|
netbox_toolkit/config.py
ADDED
@@ -0,0 +1,159 @@
|
|
1
|
+
"""Configuration settings for the NetBox Toolkit plugin."""
|
2
|
+
from typing import Dict, Any
|
3
|
+
from django.conf import settings
|
4
|
+
|
5
|
+
|
6
|
+
class ToolkitConfig:
|
7
|
+
"""Configuration class for toolkit settings."""
|
8
|
+
|
9
|
+
# Default connection timeouts
|
10
|
+
DEFAULT_TIMEOUTS = {
|
11
|
+
'socket': 15,
|
12
|
+
'transport': 15,
|
13
|
+
'ops': 30,
|
14
|
+
'banner': 15,
|
15
|
+
'auth': 15,
|
16
|
+
}
|
17
|
+
|
18
|
+
# Device-specific timeout overrides
|
19
|
+
DEVICE_TIMEOUTS = {
|
20
|
+
'catalyst': {
|
21
|
+
'socket': 20,
|
22
|
+
'transport': 20,
|
23
|
+
'ops': 45,
|
24
|
+
},
|
25
|
+
'nexus': {
|
26
|
+
'socket': 25,
|
27
|
+
'transport': 25,
|
28
|
+
'ops': 60,
|
29
|
+
},
|
30
|
+
}
|
31
|
+
|
32
|
+
# SSH transport options
|
33
|
+
SSH_TRANSPORT_OPTIONS = {
|
34
|
+
'disabled_algorithms': {
|
35
|
+
'kex': [], # Don't disable any key exchange methods
|
36
|
+
},
|
37
|
+
'allowed_kex': [
|
38
|
+
# Modern algorithms
|
39
|
+
'diffie-hellman-group-exchange-sha256',
|
40
|
+
'diffie-hellman-group16-sha512',
|
41
|
+
'diffie-hellman-group18-sha512',
|
42
|
+
'diffie-hellman-group14-sha256',
|
43
|
+
# Legacy algorithms for older devices
|
44
|
+
'diffie-hellman-group-exchange-sha1',
|
45
|
+
'diffie-hellman-group14-sha1',
|
46
|
+
'diffie-hellman-group1-sha1',
|
47
|
+
],
|
48
|
+
}
|
49
|
+
|
50
|
+
# Netmiko configuration for fallback connections
|
51
|
+
NETMIKO_CONFIG = {
|
52
|
+
'banner_timeout': 20,
|
53
|
+
'auth_timeout': 20,
|
54
|
+
'global_delay_factor': 1,
|
55
|
+
'use_keys': False, # Disable SSH key authentication
|
56
|
+
'allow_agent': False, # Disable SSH agent
|
57
|
+
# Session logging (disabled by default)
|
58
|
+
'session_log': None,
|
59
|
+
# Connection options for legacy devices
|
60
|
+
'fast_cli': False, # Disable for older devices
|
61
|
+
'session_log_record_writes': False,
|
62
|
+
'session_log_file_mode': 'write',
|
63
|
+
}
|
64
|
+
|
65
|
+
# Retry configuration
|
66
|
+
RETRY_CONFIG = {
|
67
|
+
'max_retries': 2,
|
68
|
+
'retry_delay': 1, # Reduced from 3s to 1s for faster fallback
|
69
|
+
'backoff_multiplier': 1.5, # Reduced from 2 to 1.5 for faster progression
|
70
|
+
}
|
71
|
+
|
72
|
+
# Fast connection test timeouts (for initial Scrapli viability testing)
|
73
|
+
FAST_TEST_TIMEOUTS = {
|
74
|
+
'socket': 8, # Reduced from 15s to 8s for faster detection
|
75
|
+
'transport': 8, # Reduced from 15s to 8s for faster detection
|
76
|
+
'ops': 15, # Keep ops timeout reasonable for actual commands
|
77
|
+
}
|
78
|
+
|
79
|
+
# Error patterns that should trigger immediate fallback to Netmiko
|
80
|
+
SCRAPLI_FAST_FAIL_PATTERNS = [
|
81
|
+
"No matching key exchange",
|
82
|
+
"No matching cipher",
|
83
|
+
"No matching MAC",
|
84
|
+
"connection not opened",
|
85
|
+
"Error reading SSH protocol banner",
|
86
|
+
"Connection refused",
|
87
|
+
"Operation timed out",
|
88
|
+
"SSH handshake failed",
|
89
|
+
"Protocol version not supported",
|
90
|
+
"Unable to connect to port 22",
|
91
|
+
"Name or service not known",
|
92
|
+
"Network is unreachable"
|
93
|
+
]
|
94
|
+
|
95
|
+
# Platform mappings for better recognition
|
96
|
+
PLATFORM_ALIASES = {
|
97
|
+
'ios': 'cisco_ios',
|
98
|
+
'iosxe': 'cisco_ios',
|
99
|
+
'nxos': 'cisco_nxos',
|
100
|
+
'iosxr': 'cisco_iosxr',
|
101
|
+
'junos': 'juniper_junos',
|
102
|
+
'eos': 'arista_eos',
|
103
|
+
}
|
104
|
+
|
105
|
+
@classmethod
|
106
|
+
def get_fast_test_timeouts(cls) -> Dict[str, int]:
|
107
|
+
"""Get fast connection test timeouts for initial viability testing."""
|
108
|
+
return cls.FAST_TEST_TIMEOUTS.copy()
|
109
|
+
|
110
|
+
@classmethod
|
111
|
+
def should_fast_fail_to_netmiko(cls, error_message: str) -> bool:
|
112
|
+
"""Check if error message indicates immediate fallback to Netmiko is needed."""
|
113
|
+
error_lower = error_message.lower()
|
114
|
+
return any(pattern.lower() in error_lower for pattern in cls.SCRAPLI_FAST_FAIL_PATTERNS)
|
115
|
+
|
116
|
+
@classmethod
|
117
|
+
def get_timeouts_for_device(cls, device_type_model: str = None) -> Dict[str, int]:
|
118
|
+
"""Get timeout configuration for a specific device type."""
|
119
|
+
timeouts = cls.DEFAULT_TIMEOUTS.copy()
|
120
|
+
|
121
|
+
if device_type_model:
|
122
|
+
model_lower = device_type_model.lower()
|
123
|
+
for device_keyword, custom_timeouts in cls.DEVICE_TIMEOUTS.items():
|
124
|
+
if device_keyword in model_lower:
|
125
|
+
timeouts.update(custom_timeouts)
|
126
|
+
break
|
127
|
+
|
128
|
+
return timeouts
|
129
|
+
|
130
|
+
@classmethod
|
131
|
+
def normalize_platform(cls, platform: str) -> str:
|
132
|
+
"""Normalize platform name using aliases."""
|
133
|
+
if not platform:
|
134
|
+
return None
|
135
|
+
|
136
|
+
platform_lower = platform.lower()
|
137
|
+
return cls.PLATFORM_ALIASES.get(platform_lower, platform_lower)
|
138
|
+
|
139
|
+
@classmethod
|
140
|
+
def get_ssh_options(cls) -> Dict[str, Any]:
|
141
|
+
"""Get SSH transport options."""
|
142
|
+
return cls.SSH_TRANSPORT_OPTIONS.copy()
|
143
|
+
|
144
|
+
@classmethod
|
145
|
+
def get_retry_config(cls) -> Dict[str, int]:
|
146
|
+
"""Get retry configuration."""
|
147
|
+
return cls.RETRY_CONFIG.copy()
|
148
|
+
|
149
|
+
@classmethod
|
150
|
+
def get_ssh_transport_options(cls) -> Dict[str, Any]:
|
151
|
+
"""Get SSH transport options for Scrapli."""
|
152
|
+
user_config = getattr(settings, 'NETBOX_TOOLKIT', {})
|
153
|
+
return {**cls.SSH_TRANSPORT_OPTIONS, **user_config.get('ssh_options', {})}
|
154
|
+
|
155
|
+
@classmethod
|
156
|
+
def get_netmiko_config(cls) -> Dict[str, Any]:
|
157
|
+
"""Get Netmiko configuration for fallback connections."""
|
158
|
+
user_config = getattr(settings, 'NETBOX_TOOLKIT', {})
|
159
|
+
return {**cls.NETMIKO_CONFIG, **user_config.get('netmiko', {})}
|
@@ -0,0 +1,15 @@
|
|
1
|
+
"""Connectors package for device connection logic."""
|
2
|
+
|
3
|
+
from .base import BaseDeviceConnector, ConnectionConfig, CommandResult
|
4
|
+
from .scrapli_connector import ScrapliConnector
|
5
|
+
from .netmiko_connector import NetmikoConnector
|
6
|
+
from .factory import ConnectorFactory
|
7
|
+
|
8
|
+
__all__ = [
|
9
|
+
'BaseDeviceConnector',
|
10
|
+
'ConnectionConfig',
|
11
|
+
'CommandResult',
|
12
|
+
'ScrapliConnector',
|
13
|
+
'NetmikoConnector',
|
14
|
+
'ConnectorFactory',
|
15
|
+
]
|
@@ -0,0 +1,97 @@
|
|
1
|
+
"""Base connector interface for device connections."""
|
2
|
+
from abc import ABC, abstractmethod
|
3
|
+
from typing import Dict, Any, Optional
|
4
|
+
from dataclasses import dataclass
|
5
|
+
|
6
|
+
from ..exceptions import DeviceConnectionError, CommandExecutionError
|
7
|
+
|
8
|
+
|
9
|
+
@dataclass
|
10
|
+
class ConnectionConfig:
|
11
|
+
"""Configuration for device connections."""
|
12
|
+
hostname: str
|
13
|
+
username: str
|
14
|
+
password: str
|
15
|
+
port: int = 22
|
16
|
+
timeout_socket: int = 15
|
17
|
+
timeout_transport: int = 15
|
18
|
+
timeout_ops: int = 30
|
19
|
+
auth_strict_key: bool = False
|
20
|
+
transport: str = 'system' # Default to system transport for Scrapli
|
21
|
+
platform: Optional[str] = None
|
22
|
+
extra_options: Optional[Dict[str, Any]] = None
|
23
|
+
|
24
|
+
|
25
|
+
@dataclass
|
26
|
+
class CommandResult:
|
27
|
+
"""Result of command execution."""
|
28
|
+
command: str
|
29
|
+
output: str
|
30
|
+
success: bool
|
31
|
+
error_message: Optional[str] = None
|
32
|
+
execution_time: Optional[float] = None
|
33
|
+
# New fields for syntax error detection
|
34
|
+
has_syntax_error: bool = False
|
35
|
+
syntax_error_type: Optional[str] = None
|
36
|
+
syntax_error_vendor: Optional[str] = None
|
37
|
+
syntax_error_guidance: Optional[str] = None
|
38
|
+
# New fields for command output parsing
|
39
|
+
parsed_output: Optional[Dict[str, Any]] = None
|
40
|
+
parsing_success: bool = False
|
41
|
+
parsing_method: Optional[str] = None # 'textfsm', 'genie', 'ttp'
|
42
|
+
parsing_error: Optional[str] = None
|
43
|
+
|
44
|
+
|
45
|
+
class BaseDeviceConnector(ABC):
|
46
|
+
"""Abstract base class for device connectors."""
|
47
|
+
|
48
|
+
def __init__(self, config: ConnectionConfig):
|
49
|
+
self.config = config
|
50
|
+
self._connection = None
|
51
|
+
|
52
|
+
@abstractmethod
|
53
|
+
def connect(self) -> None:
|
54
|
+
"""Establish connection to the device."""
|
55
|
+
pass
|
56
|
+
|
57
|
+
@abstractmethod
|
58
|
+
def disconnect(self) -> None:
|
59
|
+
"""Close connection to the device."""
|
60
|
+
pass
|
61
|
+
|
62
|
+
@abstractmethod
|
63
|
+
def execute_command(self, command: str, command_type: str = 'show') -> CommandResult:
|
64
|
+
"""Execute a command on the device.
|
65
|
+
|
66
|
+
Args:
|
67
|
+
command: The command string to execute
|
68
|
+
command_type: Type of command ('show' or 'config') for proper handling
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
CommandResult with execution details
|
72
|
+
"""
|
73
|
+
pass
|
74
|
+
|
75
|
+
@abstractmethod
|
76
|
+
def is_connected(self) -> bool:
|
77
|
+
"""Check if connection is active."""
|
78
|
+
pass
|
79
|
+
|
80
|
+
def __enter__(self):
|
81
|
+
"""Context manager entry."""
|
82
|
+
self.connect()
|
83
|
+
return self
|
84
|
+
|
85
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
86
|
+
"""Context manager exit."""
|
87
|
+
self.disconnect()
|
88
|
+
|
89
|
+
@property
|
90
|
+
def hostname(self) -> str:
|
91
|
+
"""Get the hostname for this connection."""
|
92
|
+
return self.config.hostname
|
93
|
+
|
94
|
+
@property
|
95
|
+
def platform(self) -> Optional[str]:
|
96
|
+
"""Get the platform for this connection."""
|
97
|
+
return self.config.platform
|