osism 0.20250628.0__py3-none-any.whl → 0.20250709.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.
- osism/api.py +3 -3
- osism/commands/baremetal.py +37 -6
- osism/commands/manage.py +0 -251
- osism/commands/redfish.py +219 -0
- osism/commands/sonic.py +973 -0
- osism/settings.py +3 -0
- osism/tasks/__init__.py +2 -7
- osism/tasks/ansible.py +1 -3
- osism/tasks/conductor/__init__.py +7 -0
- osism/tasks/conductor/config.py +52 -35
- osism/tasks/conductor/ironic.py +96 -102
- osism/tasks/conductor/redfish.py +300 -0
- osism/tasks/conductor/sonic/config_generator.py +38 -14
- osism/tasks/conductor/utils.py +148 -0
- osism/tasks/netbox.py +3 -7
- osism/tasks/reconciler.py +3 -7
- osism/utils/__init__.py +28 -0
- {osism-0.20250628.0.dist-info → osism-0.20250709.0.dist-info}/METADATA +4 -3
- {osism-0.20250628.0.dist-info → osism-0.20250709.0.dist-info}/RECORD +25 -22
- {osism-0.20250628.0.dist-info → osism-0.20250709.0.dist-info}/entry_points.txt +10 -1
- osism-0.20250709.0.dist-info/pbr.json +1 -0
- osism-0.20250628.0.dist-info/pbr.json +0 -1
- {osism-0.20250628.0.dist-info → osism-0.20250709.0.dist-info}/WHEEL +0 -0
- {osism-0.20250628.0.dist-info → osism-0.20250709.0.dist-info}/licenses/AUTHORS +0 -0
- {osism-0.20250628.0.dist-info → osism-0.20250709.0.dist-info}/licenses/LICENSE +0 -0
- {osism-0.20250628.0.dist-info → osism-0.20250709.0.dist-info}/top_level.txt +0 -0
osism/commands/sonic.py
ADDED
@@ -0,0 +1,973 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
|
3
|
+
import json
|
4
|
+
import logging
|
5
|
+
import os
|
6
|
+
from datetime import datetime
|
7
|
+
|
8
|
+
from cliff.command import Command
|
9
|
+
from loguru import logger
|
10
|
+
import paramiko
|
11
|
+
from prompt_toolkit import prompt
|
12
|
+
|
13
|
+
from osism import utils
|
14
|
+
from osism.tasks import netbox
|
15
|
+
|
16
|
+
# Suppress paramiko logging messages globally
|
17
|
+
logging.getLogger("paramiko").setLevel(logging.ERROR)
|
18
|
+
logging.getLogger("paramiko.transport").setLevel(logging.ERROR)
|
19
|
+
|
20
|
+
|
21
|
+
class SonicCommandBase(Command):
|
22
|
+
"""Base class for SONiC commands with common functionality"""
|
23
|
+
|
24
|
+
def _get_device_from_netbox(self, hostname):
|
25
|
+
"""Get device from NetBox by name or inventory_hostname"""
|
26
|
+
device = utils.nb.dcim.devices.get(name=hostname)
|
27
|
+
if not device:
|
28
|
+
devices = utils.nb.dcim.devices.filter(cf_inventory_hostname=hostname)
|
29
|
+
if devices:
|
30
|
+
device = devices[0]
|
31
|
+
logger.info(f"Device found by inventory_hostname: {device.name}")
|
32
|
+
else:
|
33
|
+
logger.error(
|
34
|
+
f"Device {hostname} not found in NetBox (searched by name and inventory_hostname)"
|
35
|
+
)
|
36
|
+
return None
|
37
|
+
return device
|
38
|
+
|
39
|
+
def _get_config_context(self, device, hostname):
|
40
|
+
"""Get and validate device configuration context"""
|
41
|
+
if not hasattr(device, "local_context_data") or not device.local_context_data:
|
42
|
+
logger.error(f"Device {hostname} has no local_context_data in NetBox")
|
43
|
+
return None
|
44
|
+
return device.local_context_data
|
45
|
+
|
46
|
+
def _save_config_context(self, config_context, hostname, today):
|
47
|
+
"""Save config context to local file"""
|
48
|
+
config_context_file = f"/tmp/config_db_{hostname}_{today}.json"
|
49
|
+
try:
|
50
|
+
with open(config_context_file, "w") as f:
|
51
|
+
json.dump(config_context, f, indent=2)
|
52
|
+
logger.info(f"Config context saved to {config_context_file}")
|
53
|
+
return config_context_file
|
54
|
+
except Exception as e:
|
55
|
+
logger.error(f"Failed to save config context: {e}")
|
56
|
+
return None
|
57
|
+
|
58
|
+
def _get_ssh_connection_details(self, config_context, device, hostname):
|
59
|
+
"""Extract SSH connection details from config context and NetBox"""
|
60
|
+
ssh_host = None
|
61
|
+
ssh_username = None
|
62
|
+
|
63
|
+
# Try to get SSH details from config context
|
64
|
+
if "management" in config_context:
|
65
|
+
mgmt = config_context["management"]
|
66
|
+
if "ip" in mgmt:
|
67
|
+
ssh_host = mgmt["ip"]
|
68
|
+
if "username" in mgmt:
|
69
|
+
ssh_username = mgmt["username"]
|
70
|
+
|
71
|
+
# Fallback: try to get OOB IP from NetBox
|
72
|
+
if not ssh_host:
|
73
|
+
from osism.tasks.conductor.netbox import get_device_oob_ip
|
74
|
+
|
75
|
+
oob_result = get_device_oob_ip(device)
|
76
|
+
if oob_result:
|
77
|
+
ssh_host = oob_result[0]
|
78
|
+
|
79
|
+
if not ssh_host:
|
80
|
+
logger.error(f"No SSH host found for device {hostname}")
|
81
|
+
return None, None
|
82
|
+
|
83
|
+
if not ssh_username:
|
84
|
+
ssh_username = "admin" # Default SONiC username
|
85
|
+
|
86
|
+
return ssh_host, ssh_username
|
87
|
+
|
88
|
+
def _create_ssh_connection(self, ssh_host, ssh_username):
|
89
|
+
"""Create and return SSH connection"""
|
90
|
+
ssh_key_path = "/ansible/secrets/id_rsa.operator"
|
91
|
+
|
92
|
+
if not os.path.exists(ssh_key_path):
|
93
|
+
logger.error(f"SSH private key not found at {ssh_key_path}")
|
94
|
+
return None
|
95
|
+
|
96
|
+
ssh = paramiko.SSHClient()
|
97
|
+
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
98
|
+
|
99
|
+
try:
|
100
|
+
ssh.connect(
|
101
|
+
hostname=ssh_host,
|
102
|
+
username=ssh_username,
|
103
|
+
key_filename=ssh_key_path,
|
104
|
+
timeout=30,
|
105
|
+
)
|
106
|
+
return ssh
|
107
|
+
except Exception as e:
|
108
|
+
logger.error(f"Failed to create SSH connection: {e}")
|
109
|
+
return None
|
110
|
+
|
111
|
+
def _generate_backup_filename(self, ssh, hostname, today):
|
112
|
+
"""Generate unique backup filename on switch"""
|
113
|
+
base_backup_path = f"/home/admin/config_db_{hostname}_{today}"
|
114
|
+
x = 1
|
115
|
+
while True:
|
116
|
+
backup_filename = f"{base_backup_path}_{x}.json"
|
117
|
+
check_cmd = f"ls {backup_filename} 2>/dev/null"
|
118
|
+
stdin, stdout, stderr = ssh.exec_command(check_cmd)
|
119
|
+
if stdout.read().decode().strip() == "":
|
120
|
+
return backup_filename
|
121
|
+
x += 1
|
122
|
+
|
123
|
+
def _backup_current_config(self, ssh, backup_filename):
|
124
|
+
"""Backup current configuration on switch"""
|
125
|
+
logger.info(f"Backing up current configuration on switch to {backup_filename}")
|
126
|
+
backup_cmd = f"sudo cp /etc/sonic/config_db.json {backup_filename}"
|
127
|
+
stdin, stdout, stderr = ssh.exec_command(backup_cmd)
|
128
|
+
exit_status = stdout.channel.recv_exit_status()
|
129
|
+
|
130
|
+
if exit_status != 0:
|
131
|
+
error_msg = stderr.read().decode()
|
132
|
+
logger.error(f"Failed to backup configuration on switch: {error_msg}")
|
133
|
+
return False
|
134
|
+
|
135
|
+
logger.info("Configuration backed up successfully on switch")
|
136
|
+
return True
|
137
|
+
|
138
|
+
def _upload_config_context(self, ssh, config_context_file, hostname):
|
139
|
+
"""Upload config context file to switch"""
|
140
|
+
switch_config_file = f"/tmp/config_db_{hostname}_current.json"
|
141
|
+
logger.info(f"Uploading config context to {switch_config_file} on switch")
|
142
|
+
|
143
|
+
sftp = ssh.open_sftp()
|
144
|
+
try:
|
145
|
+
sftp.put(config_context_file, switch_config_file)
|
146
|
+
logger.info(
|
147
|
+
f"Config context successfully uploaded to {switch_config_file} on switch"
|
148
|
+
)
|
149
|
+
return switch_config_file
|
150
|
+
except Exception as e:
|
151
|
+
logger.error(f"Failed to upload config context to switch: {e}")
|
152
|
+
return None
|
153
|
+
finally:
|
154
|
+
sftp.close()
|
155
|
+
|
156
|
+
def _load_configuration(self, ssh, switch_config_file):
|
157
|
+
"""Load and apply configuration on switch"""
|
158
|
+
logger.info("Loading and applying new configuration on switch")
|
159
|
+
load_cmd = f"sudo config load -y {switch_config_file}"
|
160
|
+
stdin, stdout, stderr = ssh.exec_command(load_cmd)
|
161
|
+
exit_status = stdout.channel.recv_exit_status()
|
162
|
+
|
163
|
+
if exit_status != 0:
|
164
|
+
error_msg = stderr.read().decode()
|
165
|
+
logger.error(f"Failed to load configuration: {error_msg}")
|
166
|
+
return False
|
167
|
+
|
168
|
+
logger.info("Configuration loaded and applied successfully")
|
169
|
+
return True
|
170
|
+
|
171
|
+
def _reload_configuration(self, ssh):
|
172
|
+
"""Reload configuration to restart services"""
|
173
|
+
logger.info("Reloading configuration to restart services")
|
174
|
+
reload_cmd = "sudo config reload -y"
|
175
|
+
stdin, stdout, stderr = ssh.exec_command(reload_cmd)
|
176
|
+
exit_status = stdout.channel.recv_exit_status()
|
177
|
+
|
178
|
+
if exit_status != 0:
|
179
|
+
error_msg = stderr.read().decode()
|
180
|
+
logger.error(f"Failed to reload configuration: {error_msg}")
|
181
|
+
return False
|
182
|
+
|
183
|
+
logger.info("Configuration reloaded successfully")
|
184
|
+
return True
|
185
|
+
|
186
|
+
def _save_configuration(self, ssh):
|
187
|
+
"""Save configuration to persist changes"""
|
188
|
+
logger.info("Saving configuration to persist changes")
|
189
|
+
save_cmd = "sudo config save -y"
|
190
|
+
stdin, stdout, stderr = ssh.exec_command(save_cmd)
|
191
|
+
exit_status = stdout.channel.recv_exit_status()
|
192
|
+
|
193
|
+
if exit_status != 0:
|
194
|
+
error_msg = stderr.read().decode()
|
195
|
+
logger.error(f"Failed to save configuration: {error_msg}")
|
196
|
+
return False
|
197
|
+
|
198
|
+
logger.info("Configuration saved successfully")
|
199
|
+
return True
|
200
|
+
|
201
|
+
def _cleanup_temp_file(self, ssh, switch_config_file):
|
202
|
+
"""Delete temporary configuration file"""
|
203
|
+
logger.info(f"Cleaning up temporary file {switch_config_file}")
|
204
|
+
delete_cmd = f"rm {switch_config_file}"
|
205
|
+
stdin, stdout, stderr = ssh.exec_command(delete_cmd)
|
206
|
+
exit_status = stdout.channel.recv_exit_status()
|
207
|
+
|
208
|
+
if exit_status != 0:
|
209
|
+
error_msg = stderr.read().decode()
|
210
|
+
logger.warning(f"Failed to delete temporary file: {error_msg}")
|
211
|
+
else:
|
212
|
+
logger.info("Temporary file deleted successfully")
|
213
|
+
|
214
|
+
def _get_ztp_status(self, ssh):
|
215
|
+
"""Get ZTP (Zero Touch Provisioning) status"""
|
216
|
+
logger.info("Checking ZTP status")
|
217
|
+
status_cmd = "show ztp status"
|
218
|
+
stdin, stdout, stderr = ssh.exec_command(status_cmd)
|
219
|
+
exit_status = stdout.channel.recv_exit_status()
|
220
|
+
|
221
|
+
if exit_status != 0:
|
222
|
+
error_msg = stderr.read().decode()
|
223
|
+
logger.error(f"Failed to get ZTP status: {error_msg}")
|
224
|
+
return None
|
225
|
+
|
226
|
+
output = stdout.read().decode().strip()
|
227
|
+
return output
|
228
|
+
|
229
|
+
def _enable_ztp(self, ssh):
|
230
|
+
"""Enable ZTP (Zero Touch Provisioning)"""
|
231
|
+
logger.info("Enabling ZTP")
|
232
|
+
enable_cmd = "sudo config ztp enable"
|
233
|
+
stdin, stdout, stderr = ssh.exec_command(enable_cmd)
|
234
|
+
exit_status = stdout.channel.recv_exit_status()
|
235
|
+
|
236
|
+
if exit_status != 0:
|
237
|
+
error_msg = stderr.read().decode()
|
238
|
+
logger.error(f"Failed to enable ZTP: {error_msg}")
|
239
|
+
return False
|
240
|
+
|
241
|
+
logger.info("ZTP enabled successfully")
|
242
|
+
return True
|
243
|
+
|
244
|
+
def _disable_ztp(self, ssh):
|
245
|
+
"""Disable ZTP (Zero Touch Provisioning)"""
|
246
|
+
logger.info("Disabling ZTP")
|
247
|
+
disable_cmd = "sudo config ztp disable"
|
248
|
+
stdin, stdout, stderr = ssh.exec_command(disable_cmd)
|
249
|
+
exit_status = stdout.channel.recv_exit_status()
|
250
|
+
|
251
|
+
if exit_status != 0:
|
252
|
+
error_msg = stderr.read().decode()
|
253
|
+
logger.error(f"Failed to disable ZTP: {error_msg}")
|
254
|
+
return False
|
255
|
+
|
256
|
+
logger.info("ZTP disabled successfully")
|
257
|
+
return True
|
258
|
+
|
259
|
+
|
260
|
+
class Load(SonicCommandBase):
|
261
|
+
"""Load SONiC switch configuration"""
|
262
|
+
|
263
|
+
def get_parser(self, prog_name):
|
264
|
+
parser = super(Load, self).get_parser(prog_name)
|
265
|
+
parser.add_argument(
|
266
|
+
"hostname",
|
267
|
+
type=str,
|
268
|
+
help="Hostname of the SONiC switch to load configuration",
|
269
|
+
)
|
270
|
+
return parser
|
271
|
+
|
272
|
+
def take_action(self, parsed_args):
|
273
|
+
hostname = parsed_args.hostname
|
274
|
+
today = datetime.now().strftime("%Y%m%d")
|
275
|
+
|
276
|
+
try:
|
277
|
+
# Get device from NetBox
|
278
|
+
device = self._get_device_from_netbox(hostname)
|
279
|
+
if not device:
|
280
|
+
return 1
|
281
|
+
|
282
|
+
# Get device configuration context
|
283
|
+
config_context = self._get_config_context(device, hostname)
|
284
|
+
if not config_context:
|
285
|
+
return 1
|
286
|
+
|
287
|
+
# Save config context locally
|
288
|
+
config_context_file = self._save_config_context(
|
289
|
+
config_context, hostname, today
|
290
|
+
)
|
291
|
+
if not config_context_file:
|
292
|
+
return 1
|
293
|
+
|
294
|
+
# Get SSH connection details
|
295
|
+
ssh_host, ssh_username = self._get_ssh_connection_details(
|
296
|
+
config_context, device, hostname
|
297
|
+
)
|
298
|
+
if not ssh_host:
|
299
|
+
return 1
|
300
|
+
|
301
|
+
logger.info(
|
302
|
+
f"Connecting to {hostname} ({ssh_host}) to load SONiC configuration"
|
303
|
+
)
|
304
|
+
|
305
|
+
# Create SSH connection
|
306
|
+
ssh = self._create_ssh_connection(ssh_host, ssh_username)
|
307
|
+
if not ssh:
|
308
|
+
return 1
|
309
|
+
|
310
|
+
try:
|
311
|
+
# Generate backup filename
|
312
|
+
backup_filename = self._generate_backup_filename(ssh, hostname, today)
|
313
|
+
|
314
|
+
# Backup current configuration
|
315
|
+
if not self._backup_current_config(ssh, backup_filename):
|
316
|
+
return 1
|
317
|
+
|
318
|
+
# Upload config context
|
319
|
+
switch_config_file = self._upload_config_context(
|
320
|
+
ssh, config_context_file, hostname
|
321
|
+
)
|
322
|
+
if not switch_config_file:
|
323
|
+
return 1
|
324
|
+
|
325
|
+
# Load configuration
|
326
|
+
if not self._load_configuration(ssh, switch_config_file):
|
327
|
+
return 1
|
328
|
+
|
329
|
+
# Save configuration
|
330
|
+
if not self._save_configuration(ssh):
|
331
|
+
return 1
|
332
|
+
|
333
|
+
# Cleanup
|
334
|
+
self._cleanup_temp_file(ssh, switch_config_file)
|
335
|
+
|
336
|
+
logger.info("SONiC configuration load completed successfully")
|
337
|
+
logger.info(f"- Config context saved locally to: {config_context_file}")
|
338
|
+
logger.info("- Configuration loaded and saved on switch")
|
339
|
+
logger.info(f"- Backup created on switch: {backup_filename}")
|
340
|
+
|
341
|
+
return 0
|
342
|
+
|
343
|
+
except paramiko.AuthenticationException:
|
344
|
+
logger.error(f"Authentication failed for {ssh_host}")
|
345
|
+
return 1
|
346
|
+
except paramiko.SSHException as e:
|
347
|
+
logger.error(f"SSH connection failed: {e}")
|
348
|
+
return 1
|
349
|
+
except Exception as e:
|
350
|
+
logger.error(f"Unexpected error during SSH operations: {e}")
|
351
|
+
return 1
|
352
|
+
finally:
|
353
|
+
ssh.close()
|
354
|
+
|
355
|
+
except Exception as e:
|
356
|
+
logger.error(f"Error loading SONiC device {hostname}: {e}")
|
357
|
+
return 1
|
358
|
+
|
359
|
+
|
360
|
+
class Backup(SonicCommandBase):
|
361
|
+
"""Backup SONiC switch configuration"""
|
362
|
+
|
363
|
+
def get_parser(self, prog_name):
|
364
|
+
parser = super(Backup, self).get_parser(prog_name)
|
365
|
+
parser.add_argument(
|
366
|
+
"hostname", type=str, help="Hostname of the SONiC switch to backup"
|
367
|
+
)
|
368
|
+
return parser
|
369
|
+
|
370
|
+
def take_action(self, parsed_args):
|
371
|
+
hostname = parsed_args.hostname
|
372
|
+
today = datetime.now().strftime("%Y%m%d")
|
373
|
+
|
374
|
+
try:
|
375
|
+
# Get device from NetBox
|
376
|
+
device = self._get_device_from_netbox(hostname)
|
377
|
+
if not device:
|
378
|
+
return 1
|
379
|
+
|
380
|
+
# Get device configuration context for SSH connection details
|
381
|
+
config_context = self._get_config_context(device, hostname)
|
382
|
+
if not config_context:
|
383
|
+
return 1
|
384
|
+
|
385
|
+
# Get SSH connection details
|
386
|
+
ssh_host, ssh_username = self._get_ssh_connection_details(
|
387
|
+
config_context, device, hostname
|
388
|
+
)
|
389
|
+
if not ssh_host:
|
390
|
+
return 1
|
391
|
+
|
392
|
+
logger.info(
|
393
|
+
f"Connecting to {hostname} ({ssh_host}) to backup SONiC configuration"
|
394
|
+
)
|
395
|
+
|
396
|
+
# Create SSH connection
|
397
|
+
ssh = self._create_ssh_connection(ssh_host, ssh_username)
|
398
|
+
if not ssh:
|
399
|
+
return 1
|
400
|
+
|
401
|
+
try:
|
402
|
+
# Generate backup filename
|
403
|
+
backup_filename = self._generate_backup_filename(ssh, hostname, today)
|
404
|
+
|
405
|
+
# Backup current configuration
|
406
|
+
if not self._backup_current_config(ssh, backup_filename):
|
407
|
+
return 1
|
408
|
+
|
409
|
+
logger.info("SONiC configuration backup completed successfully")
|
410
|
+
logger.info(f"- Backup created on switch: {backup_filename}")
|
411
|
+
|
412
|
+
return 0
|
413
|
+
|
414
|
+
except paramiko.AuthenticationException:
|
415
|
+
logger.error(f"Authentication failed for {ssh_host}")
|
416
|
+
return 1
|
417
|
+
except paramiko.SSHException as e:
|
418
|
+
logger.error(f"SSH connection failed: {e}")
|
419
|
+
return 1
|
420
|
+
except Exception as e:
|
421
|
+
logger.error(f"Unexpected error during SSH operations: {e}")
|
422
|
+
return 1
|
423
|
+
finally:
|
424
|
+
ssh.close()
|
425
|
+
|
426
|
+
except Exception as e:
|
427
|
+
logger.error(f"Error backing up SONiC device {hostname}: {e}")
|
428
|
+
return 1
|
429
|
+
|
430
|
+
|
431
|
+
class Ztp(SonicCommandBase):
|
432
|
+
"""Manage SONiC switch ZTP (Zero Touch Provisioning)"""
|
433
|
+
|
434
|
+
def get_parser(self, prog_name):
|
435
|
+
parser = super(Ztp, self).get_parser(prog_name)
|
436
|
+
parser.add_argument(
|
437
|
+
"action",
|
438
|
+
choices=["status", "enable", "disable"],
|
439
|
+
help="Action to perform: status (show ZTP status), enable (enable ZTP), or disable (disable ZTP)",
|
440
|
+
)
|
441
|
+
parser.add_argument(
|
442
|
+
"hostname", type=str, help="Hostname of the SONiC switch to manage ZTP"
|
443
|
+
)
|
444
|
+
return parser
|
445
|
+
|
446
|
+
def take_action(self, parsed_args):
|
447
|
+
hostname = parsed_args.hostname
|
448
|
+
action = parsed_args.action
|
449
|
+
|
450
|
+
try:
|
451
|
+
# Get device from NetBox
|
452
|
+
device = self._get_device_from_netbox(hostname)
|
453
|
+
if not device:
|
454
|
+
return 1
|
455
|
+
|
456
|
+
# Get device configuration context for SSH connection details
|
457
|
+
config_context = self._get_config_context(device, hostname)
|
458
|
+
if not config_context:
|
459
|
+
return 1
|
460
|
+
|
461
|
+
# Get SSH connection details
|
462
|
+
ssh_host, ssh_username = self._get_ssh_connection_details(
|
463
|
+
config_context, device, hostname
|
464
|
+
)
|
465
|
+
if not ssh_host:
|
466
|
+
return 1
|
467
|
+
|
468
|
+
if action == "enable":
|
469
|
+
logger.info(f"Connecting to {hostname} ({ssh_host}) to enable ZTP")
|
470
|
+
elif action == "disable":
|
471
|
+
logger.info(f"Connecting to {hostname} ({ssh_host}) to disable ZTP")
|
472
|
+
else:
|
473
|
+
logger.info(
|
474
|
+
f"Connecting to {hostname} ({ssh_host}) to check ZTP status"
|
475
|
+
)
|
476
|
+
|
477
|
+
# Create SSH connection
|
478
|
+
ssh = self._create_ssh_connection(ssh_host, ssh_username)
|
479
|
+
if not ssh:
|
480
|
+
return 1
|
481
|
+
|
482
|
+
try:
|
483
|
+
if action == "enable":
|
484
|
+
# Enable ZTP
|
485
|
+
if not self._enable_ztp(ssh):
|
486
|
+
return 1
|
487
|
+
logger.info("ZTP management completed successfully")
|
488
|
+
logger.info("- ZTP has been enabled")
|
489
|
+
|
490
|
+
elif action == "disable":
|
491
|
+
# Disable ZTP
|
492
|
+
if not self._disable_ztp(ssh):
|
493
|
+
return 1
|
494
|
+
logger.info("ZTP management completed successfully")
|
495
|
+
logger.info("- ZTP has been disabled")
|
496
|
+
|
497
|
+
else:
|
498
|
+
# Get status only
|
499
|
+
status = self._get_ztp_status(ssh)
|
500
|
+
if status is None:
|
501
|
+
return 1
|
502
|
+
logger.info("ZTP status check completed successfully")
|
503
|
+
logger.info(f"- ZTP Status: {status}")
|
504
|
+
|
505
|
+
return 0
|
506
|
+
|
507
|
+
except paramiko.AuthenticationException:
|
508
|
+
logger.error(f"Authentication failed for {ssh_host}")
|
509
|
+
return 1
|
510
|
+
except paramiko.SSHException as e:
|
511
|
+
logger.error(f"SSH connection failed: {e}")
|
512
|
+
return 1
|
513
|
+
except Exception as e:
|
514
|
+
logger.error(f"Unexpected error during SSH operations: {e}")
|
515
|
+
return 1
|
516
|
+
finally:
|
517
|
+
ssh.close()
|
518
|
+
|
519
|
+
except Exception as e:
|
520
|
+
logger.error(f"Error managing ZTP on SONiC device {hostname}: {e}")
|
521
|
+
return 1
|
522
|
+
|
523
|
+
|
524
|
+
class Reload(SonicCommandBase):
|
525
|
+
"""Reload SONiC switch configuration"""
|
526
|
+
|
527
|
+
def get_parser(self, prog_name):
|
528
|
+
parser = super(Reload, self).get_parser(prog_name)
|
529
|
+
parser.add_argument(
|
530
|
+
"hostname", type=str, help="Hostname of the SONiC switch to reload"
|
531
|
+
)
|
532
|
+
return parser
|
533
|
+
|
534
|
+
def take_action(self, parsed_args):
|
535
|
+
hostname = parsed_args.hostname
|
536
|
+
today = datetime.now().strftime("%Y%m%d")
|
537
|
+
|
538
|
+
try:
|
539
|
+
# Get device from NetBox
|
540
|
+
device = self._get_device_from_netbox(hostname)
|
541
|
+
if not device:
|
542
|
+
return 1
|
543
|
+
|
544
|
+
# Get device configuration context
|
545
|
+
config_context = self._get_config_context(device, hostname)
|
546
|
+
if not config_context:
|
547
|
+
return 1
|
548
|
+
|
549
|
+
# Save config context locally
|
550
|
+
config_context_file = self._save_config_context(
|
551
|
+
config_context, hostname, today
|
552
|
+
)
|
553
|
+
if not config_context_file:
|
554
|
+
return 1
|
555
|
+
|
556
|
+
# Get SSH connection details
|
557
|
+
ssh_host, ssh_username = self._get_ssh_connection_details(
|
558
|
+
config_context, device, hostname
|
559
|
+
)
|
560
|
+
if not ssh_host:
|
561
|
+
return 1
|
562
|
+
|
563
|
+
logger.info(
|
564
|
+
f"Connecting to {hostname} ({ssh_host}) to reload SONiC configuration"
|
565
|
+
)
|
566
|
+
|
567
|
+
# Create SSH connection
|
568
|
+
ssh = self._create_ssh_connection(ssh_host, ssh_username)
|
569
|
+
if not ssh:
|
570
|
+
return 1
|
571
|
+
|
572
|
+
try:
|
573
|
+
# Generate backup filename
|
574
|
+
backup_filename = self._generate_backup_filename(ssh, hostname, today)
|
575
|
+
|
576
|
+
# Backup current configuration
|
577
|
+
if not self._backup_current_config(ssh, backup_filename):
|
578
|
+
return 1
|
579
|
+
|
580
|
+
# Upload config context
|
581
|
+
switch_config_file = self._upload_config_context(
|
582
|
+
ssh, config_context_file, hostname
|
583
|
+
)
|
584
|
+
if not switch_config_file:
|
585
|
+
return 1
|
586
|
+
|
587
|
+
# Load configuration
|
588
|
+
if not self._load_configuration(ssh, switch_config_file):
|
589
|
+
return 1
|
590
|
+
|
591
|
+
# Reload configuration
|
592
|
+
reload_successful = self._reload_configuration(ssh)
|
593
|
+
|
594
|
+
# Save configuration only if reload was successful
|
595
|
+
if reload_successful:
|
596
|
+
if not self._save_configuration(ssh):
|
597
|
+
return 1
|
598
|
+
else:
|
599
|
+
logger.warning("Skipping config save due to reload failure")
|
600
|
+
|
601
|
+
# Cleanup
|
602
|
+
self._cleanup_temp_file(ssh, switch_config_file)
|
603
|
+
|
604
|
+
logger.info("SONiC configuration reload completed successfully")
|
605
|
+
logger.info(f"- Config context saved locally to: {config_context_file}")
|
606
|
+
if reload_successful:
|
607
|
+
logger.info("- Configuration loaded, reloaded, and saved on switch")
|
608
|
+
else:
|
609
|
+
logger.info(
|
610
|
+
"- Configuration loaded on switch (save skipped due to reload failure)"
|
611
|
+
)
|
612
|
+
logger.info(f"- Backup created on switch: {backup_filename}")
|
613
|
+
|
614
|
+
return 0
|
615
|
+
|
616
|
+
except paramiko.AuthenticationException:
|
617
|
+
logger.error(f"Authentication failed for {ssh_host}")
|
618
|
+
return 1
|
619
|
+
except paramiko.SSHException as e:
|
620
|
+
logger.error(f"SSH connection failed: {e}")
|
621
|
+
return 1
|
622
|
+
except Exception as e:
|
623
|
+
logger.error(f"Unexpected error during SSH operations: {e}")
|
624
|
+
return 1
|
625
|
+
finally:
|
626
|
+
ssh.close()
|
627
|
+
|
628
|
+
except Exception as e:
|
629
|
+
logger.error(f"Error reloading SONiC device {hostname}: {e}")
|
630
|
+
return 1
|
631
|
+
|
632
|
+
|
633
|
+
class Reboot(SonicCommandBase):
|
634
|
+
"""Reboot SONiC switch"""
|
635
|
+
|
636
|
+
def get_parser(self, prog_name):
|
637
|
+
parser = super(Reboot, self).get_parser(prog_name)
|
638
|
+
parser.add_argument(
|
639
|
+
"hostname", type=str, help="Hostname of the SONiC switch to reboot"
|
640
|
+
)
|
641
|
+
return parser
|
642
|
+
|
643
|
+
def take_action(self, parsed_args):
|
644
|
+
hostname = parsed_args.hostname
|
645
|
+
|
646
|
+
try:
|
647
|
+
# Get device from NetBox
|
648
|
+
device = self._get_device_from_netbox(hostname)
|
649
|
+
if not device:
|
650
|
+
return 1
|
651
|
+
|
652
|
+
# Get device configuration context for SSH connection details
|
653
|
+
config_context = self._get_config_context(device, hostname)
|
654
|
+
if not config_context:
|
655
|
+
return 1
|
656
|
+
|
657
|
+
# Get SSH connection details
|
658
|
+
ssh_host, ssh_username = self._get_ssh_connection_details(
|
659
|
+
config_context, device, hostname
|
660
|
+
)
|
661
|
+
if not ssh_host:
|
662
|
+
return 1
|
663
|
+
|
664
|
+
logger.info(f"Connecting to {hostname} ({ssh_host}) to reboot SONiC switch")
|
665
|
+
|
666
|
+
# Create SSH connection
|
667
|
+
ssh = self._create_ssh_connection(ssh_host, ssh_username)
|
668
|
+
if not ssh:
|
669
|
+
return 1
|
670
|
+
|
671
|
+
try:
|
672
|
+
# Reboot the switch
|
673
|
+
logger.info("Rebooting SONiC switch")
|
674
|
+
reboot_cmd = "sudo reboot"
|
675
|
+
stdin, stdout, stderr = ssh.exec_command(reboot_cmd)
|
676
|
+
|
677
|
+
# Note: We don't check exit status here because the connection will be terminated
|
678
|
+
# by the reboot command before we can receive the status
|
679
|
+
|
680
|
+
logger.info("SONiC switch reboot command executed successfully")
|
681
|
+
logger.info("- Switch is rebooting now")
|
682
|
+
logger.info("- Connection will be terminated by the reboot")
|
683
|
+
|
684
|
+
return 0
|
685
|
+
|
686
|
+
except paramiko.AuthenticationException:
|
687
|
+
logger.error(f"Authentication failed for {ssh_host}")
|
688
|
+
return 1
|
689
|
+
except paramiko.SSHException as e:
|
690
|
+
logger.error(f"SSH connection failed: {e}")
|
691
|
+
return 1
|
692
|
+
except Exception as e:
|
693
|
+
logger.error(f"Unexpected error during SSH operations: {e}")
|
694
|
+
return 1
|
695
|
+
finally:
|
696
|
+
ssh.close()
|
697
|
+
|
698
|
+
except Exception as e:
|
699
|
+
logger.error(f"Error rebooting SONiC device {hostname}: {e}")
|
700
|
+
return 1
|
701
|
+
|
702
|
+
|
703
|
+
class Reset(SonicCommandBase):
|
704
|
+
"""Factory reset SONiC switch using ONIE"""
|
705
|
+
|
706
|
+
def get_parser(self, prog_name):
|
707
|
+
parser = super(Reset, self).get_parser(prog_name)
|
708
|
+
parser.add_argument(
|
709
|
+
"hostname", type=str, help="Hostname of the SONiC switch to factory reset"
|
710
|
+
)
|
711
|
+
parser.add_argument(
|
712
|
+
"--force",
|
713
|
+
action="store_true",
|
714
|
+
help="Force factory reset without confirmation prompt",
|
715
|
+
)
|
716
|
+
return parser
|
717
|
+
|
718
|
+
def take_action(self, parsed_args):
|
719
|
+
hostname = parsed_args.hostname
|
720
|
+
force = parsed_args.force
|
721
|
+
|
722
|
+
if not force:
|
723
|
+
logger.warning(
|
724
|
+
"Factory reset will completely wipe the switch and require reinstallation!"
|
725
|
+
)
|
726
|
+
response = prompt("Are you sure you want to proceed? [yes/no]: ")
|
727
|
+
if response.lower() not in ["yes", "y"]:
|
728
|
+
logger.info("Factory reset cancelled by user")
|
729
|
+
return 0
|
730
|
+
|
731
|
+
try:
|
732
|
+
# Get device from NetBox
|
733
|
+
device = self._get_device_from_netbox(hostname)
|
734
|
+
if not device:
|
735
|
+
return 1
|
736
|
+
|
737
|
+
# Get device configuration context for SSH connection details
|
738
|
+
config_context = self._get_config_context(device, hostname)
|
739
|
+
if not config_context:
|
740
|
+
return 1
|
741
|
+
|
742
|
+
# Get SSH connection details
|
743
|
+
ssh_host, ssh_username = self._get_ssh_connection_details(
|
744
|
+
config_context, device, hostname
|
745
|
+
)
|
746
|
+
if not ssh_host:
|
747
|
+
return 1
|
748
|
+
|
749
|
+
logger.info(
|
750
|
+
f"Connecting to {hostname} ({ssh_host}) to perform factory reset"
|
751
|
+
)
|
752
|
+
|
753
|
+
# Create SSH connection
|
754
|
+
ssh = self._create_ssh_connection(ssh_host, ssh_username)
|
755
|
+
if not ssh:
|
756
|
+
return 1
|
757
|
+
|
758
|
+
try:
|
759
|
+
# Factory reset using ONIE uninstall
|
760
|
+
logger.info("Initiating factory reset via ONIE uninstall")
|
761
|
+
logger.warning("This will completely wipe the switch!")
|
762
|
+
|
763
|
+
# Set ONIE mode to uninstall and boot into ONIE
|
764
|
+
logger.info("Setting ONIE mode to uninstall")
|
765
|
+
grub_cmd1 = (
|
766
|
+
"sudo grub-editenv /host/grub/grubenv set onie_mode=uninstall"
|
767
|
+
)
|
768
|
+
stdin, stdout, stderr = ssh.exec_command(grub_cmd1)
|
769
|
+
exit_status = stdout.channel.recv_exit_status()
|
770
|
+
|
771
|
+
if exit_status != 0:
|
772
|
+
error_msg = stderr.read().decode()
|
773
|
+
logger.error(f"Failed to set ONIE mode: {error_msg}")
|
774
|
+
return 1
|
775
|
+
|
776
|
+
logger.info("Setting next boot entry to ONIE")
|
777
|
+
grub_cmd2 = "sudo grub-editenv /host/grub/grubenv set next_entry=ONIE"
|
778
|
+
stdin, stdout, stderr = ssh.exec_command(grub_cmd2)
|
779
|
+
exit_status = stdout.channel.recv_exit_status()
|
780
|
+
|
781
|
+
if exit_status != 0:
|
782
|
+
error_msg = stderr.read().decode()
|
783
|
+
logger.error(f"Failed to set next boot entry: {error_msg}")
|
784
|
+
return 1
|
785
|
+
|
786
|
+
logger.info("Rebooting into ONIE uninstall mode")
|
787
|
+
reset_cmd = "sudo reboot"
|
788
|
+
stdin, stdout, stderr = ssh.exec_command(reset_cmd)
|
789
|
+
|
790
|
+
# Note: We don't check exit status here because the connection will be terminated
|
791
|
+
# by the reboot command before we can receive the status
|
792
|
+
|
793
|
+
logger.info("Factory reset command executed successfully")
|
794
|
+
logger.info("- Switch is entering ONIE uninstall mode")
|
795
|
+
logger.info("- All configuration and data will be wiped")
|
796
|
+
logger.info("- Switch will need to be reinstalled after reset")
|
797
|
+
logger.info("- Connection will be terminated by the reboot")
|
798
|
+
|
799
|
+
# Set provision_state to 'ztp' in NetBox
|
800
|
+
logger.info("Setting provision_state to 'ztp' in NetBox")
|
801
|
+
netbox.set_provision_state.delay(hostname, "ztp")
|
802
|
+
|
803
|
+
return 0
|
804
|
+
|
805
|
+
except paramiko.AuthenticationException:
|
806
|
+
logger.error(f"Authentication failed for {ssh_host}")
|
807
|
+
return 1
|
808
|
+
except paramiko.SSHException as e:
|
809
|
+
logger.error(f"SSH connection failed: {e}")
|
810
|
+
return 1
|
811
|
+
except Exception as e:
|
812
|
+
logger.error(f"Unexpected error during SSH operations: {e}")
|
813
|
+
return 1
|
814
|
+
finally:
|
815
|
+
ssh.close()
|
816
|
+
|
817
|
+
except Exception as e:
|
818
|
+
logger.error(
|
819
|
+
f"Error performing factory reset on SONiC device {hostname}: {e}"
|
820
|
+
)
|
821
|
+
return 1
|
822
|
+
|
823
|
+
|
824
|
+
class Show(SonicCommandBase):
|
825
|
+
"""Execute show commands on SONiC switch"""
|
826
|
+
|
827
|
+
def get_parser(self, prog_name):
|
828
|
+
parser = super(Show, self).get_parser(prog_name)
|
829
|
+
parser.add_argument(
|
830
|
+
"hostname", type=str, help="Hostname of the SONiC switch to query"
|
831
|
+
)
|
832
|
+
parser.add_argument(
|
833
|
+
"command",
|
834
|
+
nargs="*",
|
835
|
+
help="Show command and parameters to execute (e.g., 'interfaces', 'version', 'ip route'). If not specified, executes 'show' to display available commands",
|
836
|
+
)
|
837
|
+
return parser
|
838
|
+
|
839
|
+
def take_action(self, parsed_args):
|
840
|
+
hostname = parsed_args.hostname
|
841
|
+
command_parts = parsed_args.command if parsed_args.command else []
|
842
|
+
|
843
|
+
try:
|
844
|
+
# Get device from NetBox
|
845
|
+
device = self._get_device_from_netbox(hostname)
|
846
|
+
if not device:
|
847
|
+
return 1
|
848
|
+
|
849
|
+
# Get device configuration context for SSH connection details
|
850
|
+
config_context = self._get_config_context(device, hostname)
|
851
|
+
if not config_context:
|
852
|
+
return 1
|
853
|
+
|
854
|
+
# Get SSH connection details
|
855
|
+
ssh_host, ssh_username = self._get_ssh_connection_details(
|
856
|
+
config_context, device, hostname
|
857
|
+
)
|
858
|
+
if not ssh_host:
|
859
|
+
return 1
|
860
|
+
|
861
|
+
# Build the show command
|
862
|
+
if command_parts:
|
863
|
+
show_command = "show " + " ".join(command_parts)
|
864
|
+
else:
|
865
|
+
show_command = "show"
|
866
|
+
logger.info(f"Executing command on {hostname} ({ssh_host}): {show_command}")
|
867
|
+
|
868
|
+
# Create SSH connection
|
869
|
+
ssh = self._create_ssh_connection(ssh_host, ssh_username)
|
870
|
+
if not ssh:
|
871
|
+
return 1
|
872
|
+
|
873
|
+
try:
|
874
|
+
# Execute the show command
|
875
|
+
stdin, stdout, stderr = ssh.exec_command(show_command)
|
876
|
+
exit_status = stdout.channel.recv_exit_status()
|
877
|
+
|
878
|
+
# Read output
|
879
|
+
output = stdout.read().decode().strip()
|
880
|
+
error_output = stderr.read().decode().strip()
|
881
|
+
|
882
|
+
if exit_status != 0:
|
883
|
+
logger.error(f"Command failed with exit code {exit_status}")
|
884
|
+
if error_output:
|
885
|
+
logger.error(f"Error output: {error_output}")
|
886
|
+
return 1
|
887
|
+
|
888
|
+
# Print the command output
|
889
|
+
if output:
|
890
|
+
print(output)
|
891
|
+
else:
|
892
|
+
logger.info("Command executed successfully (no output)")
|
893
|
+
|
894
|
+
return 0
|
895
|
+
|
896
|
+
except paramiko.AuthenticationException:
|
897
|
+
logger.error(f"Authentication failed for {ssh_host}")
|
898
|
+
return 1
|
899
|
+
except paramiko.SSHException as e:
|
900
|
+
logger.error(f"SSH connection failed: {e}")
|
901
|
+
return 1
|
902
|
+
except Exception as e:
|
903
|
+
logger.error(f"Unexpected error during SSH operations: {e}")
|
904
|
+
return 1
|
905
|
+
finally:
|
906
|
+
ssh.close()
|
907
|
+
|
908
|
+
except Exception as e:
|
909
|
+
logger.error(
|
910
|
+
f"Error executing show command on SONiC device {hostname}: {e}"
|
911
|
+
)
|
912
|
+
return 1
|
913
|
+
|
914
|
+
|
915
|
+
class Console(SonicCommandBase):
|
916
|
+
"""Open interactive SSH console to SONiC switch"""
|
917
|
+
|
918
|
+
def get_parser(self, prog_name):
|
919
|
+
parser = super(Console, self).get_parser(prog_name)
|
920
|
+
parser.add_argument(
|
921
|
+
"hostname", type=str, help="Hostname of the SONiC switch to connect to"
|
922
|
+
)
|
923
|
+
return parser
|
924
|
+
|
925
|
+
def take_action(self, parsed_args):
|
926
|
+
hostname = parsed_args.hostname
|
927
|
+
|
928
|
+
try:
|
929
|
+
# Get device from NetBox
|
930
|
+
device = self._get_device_from_netbox(hostname)
|
931
|
+
if not device:
|
932
|
+
return 1
|
933
|
+
|
934
|
+
# Get device configuration context for SSH connection details
|
935
|
+
config_context = self._get_config_context(device, hostname)
|
936
|
+
if not config_context:
|
937
|
+
return 1
|
938
|
+
|
939
|
+
# Get SSH connection details
|
940
|
+
ssh_host, ssh_username = self._get_ssh_connection_details(
|
941
|
+
config_context, device, hostname
|
942
|
+
)
|
943
|
+
if not ssh_host:
|
944
|
+
return 1
|
945
|
+
|
946
|
+
# SSH key path
|
947
|
+
ssh_key_path = "/ansible/secrets/id_rsa.operator"
|
948
|
+
|
949
|
+
if not os.path.exists(ssh_key_path):
|
950
|
+
logger.error(f"SSH private key not found at {ssh_key_path}")
|
951
|
+
return 1
|
952
|
+
|
953
|
+
logger.info(f"Connecting to {hostname} ({ssh_host}) via SSH console")
|
954
|
+
|
955
|
+
# Execute SSH command using os.system to provide interactive terminal
|
956
|
+
ssh_command = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no {ssh_username}@{ssh_host}"
|
957
|
+
|
958
|
+
logger.info("Starting SSH session...")
|
959
|
+
logger.info("To exit the console, type 'exit' or press Ctrl+D")
|
960
|
+
|
961
|
+
# Execute the SSH command
|
962
|
+
exit_code = os.system(ssh_command)
|
963
|
+
|
964
|
+
if exit_code == 0:
|
965
|
+
logger.info("SSH session ended successfully")
|
966
|
+
return 0
|
967
|
+
else:
|
968
|
+
logger.error(f"SSH session failed with exit code {exit_code}")
|
969
|
+
return 1
|
970
|
+
|
971
|
+
except Exception as e:
|
972
|
+
logger.error(f"Error connecting to SONiC device {hostname}: {e}")
|
973
|
+
return 1
|