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.
@@ -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