osism 0.20250701.0__py3-none-any.whl → 0.20250804.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,1168 @@
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
+ from tabulate import tabulate
13
+
14
+ from osism import utils
15
+ from osism.tasks import netbox
16
+ from osism.tasks.conductor.netbox import (
17
+ get_nb_device_query_list_sonic,
18
+ get_device_oob_ip,
19
+ )
20
+ from osism.tasks.conductor.sonic.constants import DEFAULT_SONIC_ROLES, SUPPORTED_HWSKUS
21
+ from osism.utils.ssh import cleanup_ssh_known_hosts_for_node
22
+
23
+ # Suppress paramiko logging messages globally
24
+ logging.getLogger("paramiko").setLevel(logging.ERROR)
25
+ logging.getLogger("paramiko.transport").setLevel(logging.ERROR)
26
+
27
+
28
+ class SonicCommandBase(Command):
29
+ """Base class for SONiC commands with common functionality"""
30
+
31
+ def _get_device_from_netbox(self, hostname):
32
+ """Get device from NetBox by name or inventory_hostname"""
33
+ device = utils.nb.dcim.devices.get(name=hostname)
34
+ if not device:
35
+ devices = utils.nb.dcim.devices.filter(cf_inventory_hostname=hostname)
36
+ if devices:
37
+ device = devices[0]
38
+ logger.info(f"Device found by inventory_hostname: {device.name}")
39
+ else:
40
+ logger.error(
41
+ f"Device {hostname} not found in NetBox (searched by name and inventory_hostname)"
42
+ )
43
+ return None
44
+ return device
45
+
46
+ def _get_config_context(self, device, hostname):
47
+ """Get and validate device configuration context"""
48
+ if not hasattr(device, "local_context_data") or not device.local_context_data:
49
+ logger.error(f"Device {hostname} has no local_context_data in NetBox")
50
+ return None
51
+
52
+ # Filter out keys that start with underscore
53
+ filtered_context = {
54
+ key: value
55
+ for key, value in device.local_context_data.items()
56
+ if not key.startswith("_")
57
+ }
58
+
59
+ return filtered_context
60
+
61
+ def _save_config_context(self, config_context, hostname, today):
62
+ """Save config context to local file"""
63
+ config_context_file = f"/tmp/config_db_{hostname}_{today}.json"
64
+ try:
65
+ with open(config_context_file, "w") as f:
66
+ json.dump(config_context, f, indent=2)
67
+ logger.info(f"Config context saved to {config_context_file}")
68
+ return config_context_file
69
+ except Exception as e:
70
+ logger.error(f"Failed to save config context: {e}")
71
+ return None
72
+
73
+ def _get_ssh_connection_details(self, config_context, device, hostname):
74
+ """Extract SSH connection details from config context and NetBox"""
75
+ ssh_host = None
76
+ ssh_username = None
77
+
78
+ # Try to get SSH details from config context
79
+ if "management" in config_context:
80
+ mgmt = config_context["management"]
81
+ if "ip" in mgmt:
82
+ ssh_host = mgmt["ip"]
83
+ if "username" in mgmt:
84
+ ssh_username = mgmt["username"]
85
+
86
+ # Fallback: try to get OOB IP from NetBox
87
+ if not ssh_host:
88
+ from osism.tasks.conductor.netbox import get_device_oob_ip
89
+
90
+ oob_result = get_device_oob_ip(device)
91
+ if oob_result:
92
+ ssh_host = oob_result[0]
93
+
94
+ if not ssh_host:
95
+ logger.error(f"No SSH host found for device {hostname}")
96
+ return None, None
97
+
98
+ if not ssh_username:
99
+ ssh_username = "admin" # Default SONiC username
100
+
101
+ return ssh_host, ssh_username
102
+
103
+ def _create_ssh_connection(self, ssh_host, ssh_username):
104
+ """Create and return SSH connection"""
105
+ ssh_key_path = "/ansible/secrets/id_rsa.operator"
106
+
107
+ if not os.path.exists(ssh_key_path):
108
+ logger.error(f"SSH private key not found at {ssh_key_path}")
109
+ return None
110
+
111
+ ssh = paramiko.SSHClient()
112
+ # Load system host keys from centralized known_hosts file
113
+ try:
114
+ ssh.load_host_keys("/share/known_hosts")
115
+ except FileNotFoundError:
116
+ logger.debug(
117
+ "Centralized known_hosts file not found, creating empty host key store"
118
+ )
119
+ ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
120
+
121
+ try:
122
+ ssh.connect(
123
+ hostname=ssh_host,
124
+ username=ssh_username,
125
+ key_filename=ssh_key_path,
126
+ timeout=30,
127
+ )
128
+ return ssh
129
+ except Exception as e:
130
+ logger.error(f"Failed to create SSH connection: {e}")
131
+ return None
132
+
133
+ def _generate_backup_filename(self, ssh, hostname, today):
134
+ """Generate unique backup filename on switch"""
135
+ base_backup_path = f"/home/admin/config_db_{hostname}_{today}"
136
+ x = 1
137
+ while True:
138
+ backup_filename = f"{base_backup_path}_{x}.json"
139
+ check_cmd = f"ls {backup_filename} 2>/dev/null"
140
+ stdin, stdout, stderr = ssh.exec_command(check_cmd)
141
+ if stdout.read().decode().strip() == "":
142
+ return backup_filename
143
+ x += 1
144
+
145
+ def _backup_current_config(self, ssh, backup_filename):
146
+ """Backup current configuration on switch"""
147
+ logger.info(f"Backing up current configuration on switch to {backup_filename}")
148
+ backup_cmd = f"sudo cp /etc/sonic/config_db.json {backup_filename}"
149
+ stdin, stdout, stderr = ssh.exec_command(backup_cmd)
150
+ exit_status = stdout.channel.recv_exit_status()
151
+
152
+ if exit_status != 0:
153
+ error_msg = stderr.read().decode()
154
+ logger.error(f"Failed to backup configuration on switch: {error_msg}")
155
+ return False
156
+
157
+ logger.info("Configuration backed up successfully on switch")
158
+ return True
159
+
160
+ def _upload_config_context(self, ssh, config_context_file, hostname):
161
+ """Upload config context file to switch"""
162
+ switch_config_file = f"/tmp/config_db_{hostname}_current.json"
163
+ logger.info(f"Uploading config context to {switch_config_file} on switch")
164
+
165
+ sftp = ssh.open_sftp()
166
+ try:
167
+ sftp.put(config_context_file, switch_config_file)
168
+ logger.info(
169
+ f"Config context successfully uploaded to {switch_config_file} on switch"
170
+ )
171
+ return switch_config_file
172
+ except Exception as e:
173
+ logger.error(f"Failed to upload config context to switch: {e}")
174
+ return None
175
+ finally:
176
+ sftp.close()
177
+
178
+ def _load_configuration(self, ssh, switch_config_file):
179
+ """Load and apply configuration on switch"""
180
+ logger.info("Loading and applying new configuration on switch")
181
+ load_cmd = f"sudo config load -y {switch_config_file}"
182
+ stdin, stdout, stderr = ssh.exec_command(load_cmd)
183
+ exit_status = stdout.channel.recv_exit_status()
184
+
185
+ if exit_status != 0:
186
+ error_msg = stderr.read().decode()
187
+ logger.error(f"Failed to load configuration: {error_msg}")
188
+ return False
189
+
190
+ logger.info("Configuration loaded and applied successfully")
191
+ return True
192
+
193
+ def _reload_configuration(self, ssh):
194
+ """Reload configuration to restart services"""
195
+ logger.info("Reloading configuration to restart services")
196
+ reload_cmd = "sudo config reload -y"
197
+ stdin, stdout, stderr = ssh.exec_command(reload_cmd)
198
+ exit_status = stdout.channel.recv_exit_status()
199
+
200
+ if exit_status != 0:
201
+ error_msg = stderr.read().decode()
202
+ logger.error(f"Failed to reload configuration: {error_msg}")
203
+ return False
204
+
205
+ logger.info("Configuration reloaded successfully")
206
+ return True
207
+
208
+ def _save_configuration(self, ssh):
209
+ """Save configuration to persist changes"""
210
+ logger.info("Saving configuration to persist changes")
211
+ save_cmd = "sudo config save -y"
212
+ stdin, stdout, stderr = ssh.exec_command(save_cmd)
213
+ exit_status = stdout.channel.recv_exit_status()
214
+
215
+ if exit_status != 0:
216
+ error_msg = stderr.read().decode()
217
+ logger.error(f"Failed to save configuration: {error_msg}")
218
+ return False
219
+
220
+ logger.info("Configuration saved successfully")
221
+ return True
222
+
223
+ def _cleanup_temp_file(self, ssh, switch_config_file):
224
+ """Delete temporary configuration file"""
225
+ logger.info(f"Cleaning up temporary file {switch_config_file}")
226
+ delete_cmd = f"rm {switch_config_file}"
227
+ stdin, stdout, stderr = ssh.exec_command(delete_cmd)
228
+ exit_status = stdout.channel.recv_exit_status()
229
+
230
+ if exit_status != 0:
231
+ error_msg = stderr.read().decode()
232
+ logger.warning(f"Failed to delete temporary file: {error_msg}")
233
+ else:
234
+ logger.info("Temporary file deleted successfully")
235
+
236
+ def _get_ztp_status(self, ssh):
237
+ """Get ZTP (Zero Touch Provisioning) status"""
238
+ logger.info("Checking ZTP status")
239
+ status_cmd = "show ztp status"
240
+ stdin, stdout, stderr = ssh.exec_command(status_cmd)
241
+ exit_status = stdout.channel.recv_exit_status()
242
+
243
+ if exit_status != 0:
244
+ error_msg = stderr.read().decode()
245
+ logger.error(f"Failed to get ZTP status: {error_msg}")
246
+ return None
247
+
248
+ output = stdout.read().decode().strip()
249
+ return output
250
+
251
+ def _enable_ztp(self, ssh):
252
+ """Enable ZTP (Zero Touch Provisioning)"""
253
+ logger.info("Enabling ZTP")
254
+ enable_cmd = "sudo config ztp enable"
255
+ stdin, stdout, stderr = ssh.exec_command(enable_cmd)
256
+ exit_status = stdout.channel.recv_exit_status()
257
+
258
+ if exit_status != 0:
259
+ error_msg = stderr.read().decode()
260
+ logger.error(f"Failed to enable ZTP: {error_msg}")
261
+ return False
262
+
263
+ logger.info("ZTP enabled successfully")
264
+ return True
265
+
266
+ def _disable_ztp(self, ssh):
267
+ """Disable ZTP (Zero Touch Provisioning)"""
268
+ logger.info("Disabling ZTP")
269
+ disable_cmd = "sudo config ztp disable"
270
+ stdin, stdout, stderr = ssh.exec_command(disable_cmd)
271
+ exit_status = stdout.channel.recv_exit_status()
272
+
273
+ if exit_status != 0:
274
+ error_msg = stderr.read().decode()
275
+ logger.error(f"Failed to disable ZTP: {error_msg}")
276
+ return False
277
+
278
+ logger.info("ZTP disabled successfully")
279
+ return True
280
+
281
+
282
+ class Load(SonicCommandBase):
283
+ """Load SONiC switch configuration"""
284
+
285
+ def get_parser(self, prog_name):
286
+ parser = super(Load, self).get_parser(prog_name)
287
+ parser.add_argument(
288
+ "hostname",
289
+ type=str,
290
+ help="Hostname of the SONiC switch to load configuration",
291
+ )
292
+ return parser
293
+
294
+ def take_action(self, parsed_args):
295
+ hostname = parsed_args.hostname
296
+ today = datetime.now().strftime("%Y%m%d")
297
+
298
+ try:
299
+ # Get device from NetBox
300
+ device = self._get_device_from_netbox(hostname)
301
+ if not device:
302
+ return 1
303
+
304
+ # Get device configuration context
305
+ config_context = self._get_config_context(device, hostname)
306
+ if not config_context:
307
+ return 1
308
+
309
+ # Save config context locally
310
+ config_context_file = self._save_config_context(
311
+ config_context, hostname, today
312
+ )
313
+ if not config_context_file:
314
+ return 1
315
+
316
+ # Get SSH connection details
317
+ ssh_host, ssh_username = self._get_ssh_connection_details(
318
+ config_context, device, hostname
319
+ )
320
+ if not ssh_host:
321
+ return 1
322
+
323
+ logger.info(
324
+ f"Connecting to {hostname} ({ssh_host}) to load SONiC configuration"
325
+ )
326
+
327
+ # Create SSH connection
328
+ ssh = self._create_ssh_connection(ssh_host, ssh_username)
329
+ if not ssh:
330
+ return 1
331
+
332
+ try:
333
+ # Generate backup filename
334
+ backup_filename = self._generate_backup_filename(ssh, hostname, today)
335
+
336
+ # Backup current configuration
337
+ if not self._backup_current_config(ssh, backup_filename):
338
+ return 1
339
+
340
+ # Upload config context
341
+ switch_config_file = self._upload_config_context(
342
+ ssh, config_context_file, hostname
343
+ )
344
+ if not switch_config_file:
345
+ return 1
346
+
347
+ # Load configuration
348
+ if not self._load_configuration(ssh, switch_config_file):
349
+ return 1
350
+
351
+ # Save configuration
352
+ if not self._save_configuration(ssh):
353
+ return 1
354
+
355
+ # Cleanup
356
+ self._cleanup_temp_file(ssh, switch_config_file)
357
+
358
+ logger.info("SONiC configuration load completed successfully")
359
+ logger.info(f"- Config context saved locally to: {config_context_file}")
360
+ logger.info("- Configuration loaded and saved on switch")
361
+ logger.info(f"- Backup created on switch: {backup_filename}")
362
+
363
+ return 0
364
+
365
+ except paramiko.AuthenticationException:
366
+ logger.error(f"Authentication failed for {ssh_host}")
367
+ return 1
368
+ except paramiko.SSHException as e:
369
+ logger.error(f"SSH connection failed: {e}")
370
+ return 1
371
+ except Exception as e:
372
+ logger.error(f"Unexpected error during SSH operations: {e}")
373
+ return 1
374
+ finally:
375
+ ssh.close()
376
+
377
+ except Exception as e:
378
+ logger.error(f"Error loading SONiC device {hostname}: {e}")
379
+ return 1
380
+
381
+
382
+ class Backup(SonicCommandBase):
383
+ """Backup SONiC switch configuration"""
384
+
385
+ def get_parser(self, prog_name):
386
+ parser = super(Backup, self).get_parser(prog_name)
387
+ parser.add_argument(
388
+ "hostname", type=str, help="Hostname of the SONiC switch to backup"
389
+ )
390
+ return parser
391
+
392
+ def take_action(self, parsed_args):
393
+ hostname = parsed_args.hostname
394
+ today = datetime.now().strftime("%Y%m%d")
395
+
396
+ try:
397
+ # Get device from NetBox
398
+ device = self._get_device_from_netbox(hostname)
399
+ if not device:
400
+ return 1
401
+
402
+ # Get device configuration context for SSH connection details
403
+ config_context = self._get_config_context(device, hostname)
404
+ if not config_context:
405
+ return 1
406
+
407
+ # Get SSH connection details
408
+ ssh_host, ssh_username = self._get_ssh_connection_details(
409
+ config_context, device, hostname
410
+ )
411
+ if not ssh_host:
412
+ return 1
413
+
414
+ logger.info(
415
+ f"Connecting to {hostname} ({ssh_host}) to backup SONiC configuration"
416
+ )
417
+
418
+ # Create SSH connection
419
+ ssh = self._create_ssh_connection(ssh_host, ssh_username)
420
+ if not ssh:
421
+ return 1
422
+
423
+ try:
424
+ # Generate backup filename
425
+ backup_filename = self._generate_backup_filename(ssh, hostname, today)
426
+
427
+ # Backup current configuration
428
+ if not self._backup_current_config(ssh, backup_filename):
429
+ return 1
430
+
431
+ logger.info("SONiC configuration backup completed successfully")
432
+ logger.info(f"- Backup created on switch: {backup_filename}")
433
+
434
+ return 0
435
+
436
+ except paramiko.AuthenticationException:
437
+ logger.error(f"Authentication failed for {ssh_host}")
438
+ return 1
439
+ except paramiko.SSHException as e:
440
+ logger.error(f"SSH connection failed: {e}")
441
+ return 1
442
+ except Exception as e:
443
+ logger.error(f"Unexpected error during SSH operations: {e}")
444
+ return 1
445
+ finally:
446
+ ssh.close()
447
+
448
+ except Exception as e:
449
+ logger.error(f"Error backing up SONiC device {hostname}: {e}")
450
+ return 1
451
+
452
+
453
+ class Ztp(SonicCommandBase):
454
+ """Manage SONiC switch ZTP (Zero Touch Provisioning)"""
455
+
456
+ def get_parser(self, prog_name):
457
+ parser = super(Ztp, self).get_parser(prog_name)
458
+ parser.add_argument(
459
+ "action",
460
+ choices=["status", "enable", "disable"],
461
+ help="Action to perform: status (show ZTP status), enable (enable ZTP), or disable (disable ZTP)",
462
+ )
463
+ parser.add_argument(
464
+ "hostname", type=str, help="Hostname of the SONiC switch to manage ZTP"
465
+ )
466
+ return parser
467
+
468
+ def take_action(self, parsed_args):
469
+ hostname = parsed_args.hostname
470
+ action = parsed_args.action
471
+
472
+ try:
473
+ # Get device from NetBox
474
+ device = self._get_device_from_netbox(hostname)
475
+ if not device:
476
+ return 1
477
+
478
+ # Get device configuration context for SSH connection details
479
+ config_context = self._get_config_context(device, hostname)
480
+ if not config_context:
481
+ return 1
482
+
483
+ # Get SSH connection details
484
+ ssh_host, ssh_username = self._get_ssh_connection_details(
485
+ config_context, device, hostname
486
+ )
487
+ if not ssh_host:
488
+ return 1
489
+
490
+ if action == "enable":
491
+ logger.info(f"Connecting to {hostname} ({ssh_host}) to enable ZTP")
492
+ elif action == "disable":
493
+ logger.info(f"Connecting to {hostname} ({ssh_host}) to disable ZTP")
494
+ else:
495
+ logger.info(
496
+ f"Connecting to {hostname} ({ssh_host}) to check ZTP status"
497
+ )
498
+
499
+ # Create SSH connection
500
+ ssh = self._create_ssh_connection(ssh_host, ssh_username)
501
+ if not ssh:
502
+ return 1
503
+
504
+ try:
505
+ if action == "enable":
506
+ # Enable ZTP
507
+ if not self._enable_ztp(ssh):
508
+ return 1
509
+ logger.info("ZTP management completed successfully")
510
+ logger.info("- ZTP has been enabled")
511
+
512
+ elif action == "disable":
513
+ # Disable ZTP
514
+ if not self._disable_ztp(ssh):
515
+ return 1
516
+ logger.info("ZTP management completed successfully")
517
+ logger.info("- ZTP has been disabled")
518
+
519
+ else:
520
+ # Get status only
521
+ status = self._get_ztp_status(ssh)
522
+ if status is None:
523
+ return 1
524
+ logger.info("ZTP status check completed successfully")
525
+ logger.info(f"- ZTP Status: {status}")
526
+
527
+ return 0
528
+
529
+ except paramiko.AuthenticationException:
530
+ logger.error(f"Authentication failed for {ssh_host}")
531
+ return 1
532
+ except paramiko.SSHException as e:
533
+ logger.error(f"SSH connection failed: {e}")
534
+ return 1
535
+ except Exception as e:
536
+ logger.error(f"Unexpected error during SSH operations: {e}")
537
+ return 1
538
+ finally:
539
+ ssh.close()
540
+
541
+ except Exception as e:
542
+ logger.error(f"Error managing ZTP on SONiC device {hostname}: {e}")
543
+ return 1
544
+
545
+
546
+ class Reload(SonicCommandBase):
547
+ """Reload SONiC switch configuration"""
548
+
549
+ def get_parser(self, prog_name):
550
+ parser = super(Reload, self).get_parser(prog_name)
551
+ parser.add_argument(
552
+ "hostname", type=str, help="Hostname of the SONiC switch to reload"
553
+ )
554
+ return parser
555
+
556
+ def take_action(self, parsed_args):
557
+ hostname = parsed_args.hostname
558
+ today = datetime.now().strftime("%Y%m%d")
559
+
560
+ try:
561
+ # Get device from NetBox
562
+ device = self._get_device_from_netbox(hostname)
563
+ if not device:
564
+ return 1
565
+
566
+ # Get device configuration context
567
+ config_context = self._get_config_context(device, hostname)
568
+ if not config_context:
569
+ return 1
570
+
571
+ # Save config context locally
572
+ config_context_file = self._save_config_context(
573
+ config_context, hostname, today
574
+ )
575
+ if not config_context_file:
576
+ return 1
577
+
578
+ # Get SSH connection details
579
+ ssh_host, ssh_username = self._get_ssh_connection_details(
580
+ config_context, device, hostname
581
+ )
582
+ if not ssh_host:
583
+ return 1
584
+
585
+ logger.info(
586
+ f"Connecting to {hostname} ({ssh_host}) to reload SONiC configuration"
587
+ )
588
+
589
+ # Create SSH connection
590
+ ssh = self._create_ssh_connection(ssh_host, ssh_username)
591
+ if not ssh:
592
+ return 1
593
+
594
+ try:
595
+ # Generate backup filename
596
+ backup_filename = self._generate_backup_filename(ssh, hostname, today)
597
+
598
+ # Backup current configuration
599
+ if not self._backup_current_config(ssh, backup_filename):
600
+ return 1
601
+
602
+ # Upload config context
603
+ switch_config_file = self._upload_config_context(
604
+ ssh, config_context_file, hostname
605
+ )
606
+ if not switch_config_file:
607
+ return 1
608
+
609
+ # Load configuration
610
+ if not self._load_configuration(ssh, switch_config_file):
611
+ return 1
612
+
613
+ # Reload configuration
614
+ reload_successful = self._reload_configuration(ssh)
615
+
616
+ # Save configuration only if reload was successful
617
+ if reload_successful:
618
+ if not self._save_configuration(ssh):
619
+ return 1
620
+ else:
621
+ logger.warning("Skipping config save due to reload failure")
622
+
623
+ # Cleanup
624
+ self._cleanup_temp_file(ssh, switch_config_file)
625
+
626
+ logger.info("SONiC configuration reload completed successfully")
627
+ logger.info(f"- Config context saved locally to: {config_context_file}")
628
+ if reload_successful:
629
+ logger.info("- Configuration loaded, reloaded, and saved on switch")
630
+ else:
631
+ logger.info(
632
+ "- Configuration loaded on switch (save skipped due to reload failure)"
633
+ )
634
+ logger.info(f"- Backup created on switch: {backup_filename}")
635
+
636
+ return 0
637
+
638
+ except paramiko.AuthenticationException:
639
+ logger.error(f"Authentication failed for {ssh_host}")
640
+ return 1
641
+ except paramiko.SSHException as e:
642
+ logger.error(f"SSH connection failed: {e}")
643
+ return 1
644
+ except Exception as e:
645
+ logger.error(f"Unexpected error during SSH operations: {e}")
646
+ return 1
647
+ finally:
648
+ ssh.close()
649
+
650
+ except Exception as e:
651
+ logger.error(f"Error reloading SONiC device {hostname}: {e}")
652
+ return 1
653
+
654
+
655
+ class Reboot(SonicCommandBase):
656
+ """Reboot SONiC switch"""
657
+
658
+ def get_parser(self, prog_name):
659
+ parser = super(Reboot, self).get_parser(prog_name)
660
+ parser.add_argument(
661
+ "hostname", type=str, help="Hostname of the SONiC switch to reboot"
662
+ )
663
+ return parser
664
+
665
+ def take_action(self, parsed_args):
666
+ hostname = parsed_args.hostname
667
+
668
+ try:
669
+ # Get device from NetBox
670
+ device = self._get_device_from_netbox(hostname)
671
+ if not device:
672
+ return 1
673
+
674
+ # Get device configuration context for SSH connection details
675
+ config_context = self._get_config_context(device, hostname)
676
+ if not config_context:
677
+ return 1
678
+
679
+ # Get SSH connection details
680
+ ssh_host, ssh_username = self._get_ssh_connection_details(
681
+ config_context, device, hostname
682
+ )
683
+ if not ssh_host:
684
+ return 1
685
+
686
+ logger.info(f"Connecting to {hostname} ({ssh_host}) to reboot SONiC switch")
687
+
688
+ # Create SSH connection
689
+ ssh = self._create_ssh_connection(ssh_host, ssh_username)
690
+ if not ssh:
691
+ return 1
692
+
693
+ try:
694
+ # Reboot the switch
695
+ logger.info("Rebooting SONiC switch")
696
+ reboot_cmd = "sudo reboot"
697
+ stdin, stdout, stderr = ssh.exec_command(reboot_cmd)
698
+
699
+ # Note: We don't check exit status here because the connection will be terminated
700
+ # by the reboot command before we can receive the status
701
+
702
+ logger.info("SONiC switch reboot command executed successfully")
703
+ logger.info("- Switch is rebooting now")
704
+ logger.info("- Connection will be terminated by the reboot")
705
+
706
+ return 0
707
+
708
+ except paramiko.AuthenticationException:
709
+ logger.error(f"Authentication failed for {ssh_host}")
710
+ return 1
711
+ except paramiko.SSHException as e:
712
+ logger.error(f"SSH connection failed: {e}")
713
+ return 1
714
+ except Exception as e:
715
+ logger.error(f"Unexpected error during SSH operations: {e}")
716
+ return 1
717
+ finally:
718
+ ssh.close()
719
+
720
+ except Exception as e:
721
+ logger.error(f"Error rebooting SONiC device {hostname}: {e}")
722
+ return 1
723
+
724
+
725
+ class Reset(SonicCommandBase):
726
+ """Factory reset SONiC switch using ONIE"""
727
+
728
+ def get_parser(self, prog_name):
729
+ parser = super(Reset, self).get_parser(prog_name)
730
+ parser.add_argument(
731
+ "hostname", type=str, help="Hostname of the SONiC switch to factory reset"
732
+ )
733
+ parser.add_argument(
734
+ "--force",
735
+ action="store_true",
736
+ help="Force factory reset without confirmation prompt",
737
+ )
738
+ return parser
739
+
740
+ def take_action(self, parsed_args):
741
+ hostname = parsed_args.hostname
742
+ force = parsed_args.force
743
+
744
+ if not force:
745
+ logger.warning(
746
+ "Factory reset will completely wipe the switch and require reinstallation!"
747
+ )
748
+ response = prompt("Are you sure you want to proceed? [yes/no]: ")
749
+ if response.lower() not in ["yes", "y"]:
750
+ logger.info("Factory reset cancelled by user")
751
+ return 0
752
+
753
+ try:
754
+ # Get device from NetBox
755
+ device = self._get_device_from_netbox(hostname)
756
+ if not device:
757
+ return 1
758
+
759
+ # Get device configuration context for SSH connection details
760
+ config_context = self._get_config_context(device, hostname)
761
+ if not config_context:
762
+ return 1
763
+
764
+ # Get SSH connection details
765
+ ssh_host, ssh_username = self._get_ssh_connection_details(
766
+ config_context, device, hostname
767
+ )
768
+ if not ssh_host:
769
+ return 1
770
+
771
+ logger.info(
772
+ f"Connecting to {hostname} ({ssh_host}) to perform factory reset"
773
+ )
774
+
775
+ # Create SSH connection
776
+ ssh = self._create_ssh_connection(ssh_host, ssh_username)
777
+ if not ssh:
778
+ return 1
779
+
780
+ try:
781
+ # Factory reset using ONIE uninstall
782
+ logger.info("Initiating factory reset via ONIE uninstall")
783
+ logger.warning("This will completely wipe the switch!")
784
+
785
+ # Set ONIE mode to uninstall and boot into ONIE
786
+ logger.info("Setting ONIE mode to uninstall")
787
+ grub_cmd1 = (
788
+ "sudo grub-editenv /host/grub/grubenv set onie_mode=uninstall"
789
+ )
790
+ stdin, stdout, stderr = ssh.exec_command(grub_cmd1)
791
+ exit_status = stdout.channel.recv_exit_status()
792
+
793
+ if exit_status != 0:
794
+ error_msg = stderr.read().decode()
795
+ logger.error(f"Failed to set ONIE mode: {error_msg}")
796
+ return 1
797
+
798
+ logger.info("Setting next boot entry to ONIE")
799
+ grub_cmd2 = "sudo grub-editenv /host/grub/grubenv set next_entry=ONIE"
800
+ stdin, stdout, stderr = ssh.exec_command(grub_cmd2)
801
+ exit_status = stdout.channel.recv_exit_status()
802
+
803
+ if exit_status != 0:
804
+ error_msg = stderr.read().decode()
805
+ logger.error(f"Failed to set next boot entry: {error_msg}")
806
+ return 1
807
+
808
+ logger.info("Rebooting into ONIE uninstall mode")
809
+ reset_cmd = "sudo reboot"
810
+ stdin, stdout, stderr = ssh.exec_command(reset_cmd)
811
+
812
+ # Note: We don't check exit status here because the connection will be terminated
813
+ # by the reboot command before we can receive the status
814
+
815
+ logger.info("Factory reset command executed successfully")
816
+ logger.info("- Switch is entering ONIE uninstall mode")
817
+ logger.info("- All configuration and data will be wiped")
818
+ logger.info("- Switch will need to be reinstalled after reset")
819
+ logger.info("- Connection will be terminated by the reboot")
820
+
821
+ # Clean up SSH known_hosts entries for the reset node
822
+ logger.info(f"Cleaning up SSH known_hosts entries for {hostname}")
823
+ result = cleanup_ssh_known_hosts_for_node(hostname)
824
+ if result:
825
+ logger.info("- SSH known_hosts cleanup completed successfully")
826
+ else:
827
+ logger.warning("- SSH known_hosts cleanup completed with warnings")
828
+
829
+ # Set provision_state to 'ztp' in NetBox
830
+ logger.info("Setting provision_state to 'ztp' in NetBox")
831
+ netbox.set_provision_state.delay(hostname, "ztp")
832
+
833
+ return 0
834
+
835
+ except paramiko.AuthenticationException:
836
+ logger.error(f"Authentication failed for {ssh_host}")
837
+ return 1
838
+ except paramiko.SSHException as e:
839
+ logger.error(f"SSH connection failed: {e}")
840
+ return 1
841
+ except Exception as e:
842
+ logger.error(f"Unexpected error during SSH operations: {e}")
843
+ return 1
844
+ finally:
845
+ ssh.close()
846
+
847
+ except Exception as e:
848
+ logger.error(
849
+ f"Error performing factory reset on SONiC device {hostname}: {e}"
850
+ )
851
+ return 1
852
+
853
+
854
+ class Show(SonicCommandBase):
855
+ """Execute show commands on SONiC switch"""
856
+
857
+ def get_parser(self, prog_name):
858
+ parser = super(Show, self).get_parser(prog_name)
859
+ parser.add_argument(
860
+ "hostname", type=str, help="Hostname of the SONiC switch to query"
861
+ )
862
+ parser.add_argument(
863
+ "command",
864
+ nargs="*",
865
+ help="Show command and parameters to execute (e.g., 'interfaces', 'version', 'ip route'). If not specified, executes 'show' to display available commands",
866
+ )
867
+ return parser
868
+
869
+ def take_action(self, parsed_args):
870
+ hostname = parsed_args.hostname
871
+ command_parts = parsed_args.command if parsed_args.command else []
872
+
873
+ try:
874
+ # Get device from NetBox
875
+ device = self._get_device_from_netbox(hostname)
876
+ if not device:
877
+ return 1
878
+
879
+ # Get device configuration context for SSH connection details
880
+ config_context = self._get_config_context(device, hostname)
881
+ if not config_context:
882
+ return 1
883
+
884
+ # Get SSH connection details
885
+ ssh_host, ssh_username = self._get_ssh_connection_details(
886
+ config_context, device, hostname
887
+ )
888
+ if not ssh_host:
889
+ return 1
890
+
891
+ # Build the show command
892
+ if command_parts:
893
+ show_command = "show " + " ".join(command_parts)
894
+ else:
895
+ show_command = "show"
896
+ logger.info(f"Executing command on {hostname} ({ssh_host}): {show_command}")
897
+
898
+ # Create SSH connection
899
+ ssh = self._create_ssh_connection(ssh_host, ssh_username)
900
+ if not ssh:
901
+ return 1
902
+
903
+ try:
904
+ # Execute the show command
905
+ stdin, stdout, stderr = ssh.exec_command(show_command)
906
+ exit_status = stdout.channel.recv_exit_status()
907
+
908
+ # Read output
909
+ output = stdout.read().decode().strip()
910
+ error_output = stderr.read().decode().strip()
911
+
912
+ if exit_status != 0:
913
+ logger.error(f"Command failed with exit code {exit_status}")
914
+ if error_output:
915
+ logger.error(f"Error output: {error_output}")
916
+ return 1
917
+
918
+ # Print the command output
919
+ if output:
920
+ print(output)
921
+ else:
922
+ logger.info("Command executed successfully (no output)")
923
+
924
+ return 0
925
+
926
+ except paramiko.AuthenticationException:
927
+ logger.error(f"Authentication failed for {ssh_host}")
928
+ return 1
929
+ except paramiko.SSHException as e:
930
+ logger.error(f"SSH connection failed: {e}")
931
+ return 1
932
+ except Exception as e:
933
+ logger.error(f"Unexpected error during SSH operations: {e}")
934
+ return 1
935
+ finally:
936
+ ssh.close()
937
+
938
+ except Exception as e:
939
+ logger.error(
940
+ f"Error executing show command on SONiC device {hostname}: {e}"
941
+ )
942
+ return 1
943
+
944
+
945
+ class Console(SonicCommandBase):
946
+ """Open interactive SSH console to SONiC switch"""
947
+
948
+ def get_parser(self, prog_name):
949
+ parser = super(Console, self).get_parser(prog_name)
950
+ parser.add_argument(
951
+ "hostname", type=str, help="Hostname of the SONiC switch to connect to"
952
+ )
953
+ return parser
954
+
955
+ def take_action(self, parsed_args):
956
+ hostname = parsed_args.hostname
957
+
958
+ try:
959
+ # Get device from NetBox
960
+ device = self._get_device_from_netbox(hostname)
961
+ if not device:
962
+ return 1
963
+
964
+ # Get device configuration context for SSH connection details
965
+ config_context = self._get_config_context(device, hostname)
966
+ if not config_context:
967
+ return 1
968
+
969
+ # Get SSH connection details
970
+ ssh_host, ssh_username = self._get_ssh_connection_details(
971
+ config_context, device, hostname
972
+ )
973
+ if not ssh_host:
974
+ return 1
975
+
976
+ # SSH key path
977
+ ssh_key_path = "/ansible/secrets/id_rsa.operator"
978
+
979
+ if not os.path.exists(ssh_key_path):
980
+ logger.error(f"SSH private key not found at {ssh_key_path}")
981
+ return 1
982
+
983
+ logger.info(f"Connecting to {hostname} ({ssh_host}) via SSH console")
984
+
985
+ # Execute SSH command using os.system to provide interactive terminal
986
+ ssh_command = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/share/known_hosts {ssh_username}@{ssh_host}"
987
+
988
+ logger.info("Starting SSH session...")
989
+ logger.info("To exit the console, type 'exit' or press Ctrl+D")
990
+
991
+ # Execute the SSH command
992
+ exit_code = os.system(ssh_command)
993
+
994
+ if exit_code == 0:
995
+ logger.info("SSH session ended successfully")
996
+ return 0
997
+ else:
998
+ logger.error(f"SSH session failed with exit code {exit_code}")
999
+ return 1
1000
+
1001
+ except Exception as e:
1002
+ logger.error(f"Error connecting to SONiC device {hostname}: {e}")
1003
+ return 1
1004
+
1005
+
1006
+ class List(Command):
1007
+ """List SONiC switches with their details"""
1008
+
1009
+ def get_parser(self, prog_name):
1010
+ parser = super(List, self).get_parser(prog_name)
1011
+ parser.add_argument(
1012
+ "device",
1013
+ nargs="?",
1014
+ type=str,
1015
+ help="Optional device name to filter by (same as sonic sync parameter)",
1016
+ )
1017
+ return parser
1018
+
1019
+ def take_action(self, parsed_args):
1020
+ device_name = parsed_args.device
1021
+
1022
+ try:
1023
+ devices = []
1024
+
1025
+ if device_name:
1026
+ # When specific device is requested, fetch it directly
1027
+ try:
1028
+ device = utils.nb.dcim.devices.get(name=device_name)
1029
+ if device:
1030
+ # Check if device role matches allowed roles
1031
+ if device.role and device.role.slug in DEFAULT_SONIC_ROLES:
1032
+ devices.append(device)
1033
+ logger.debug(
1034
+ f"Found device: {device.name} with role: {device.role.slug}"
1035
+ )
1036
+ else:
1037
+ logger.warning(
1038
+ f"Device {device_name} has role '{device.role.slug if device.role else 'None'}' "
1039
+ f"which is not in allowed SONiC roles: {', '.join(DEFAULT_SONIC_ROLES)}"
1040
+ )
1041
+ return 1
1042
+ else:
1043
+ logger.error(f"Device {device_name} not found in NetBox")
1044
+ return 1
1045
+ except Exception as e:
1046
+ logger.error(f"Error fetching device {device_name}: {e}")
1047
+ return 1
1048
+ else:
1049
+ # Get device query list from NETBOX_FILTER_CONDUCTOR_SONIC
1050
+ nb_device_query_list = get_nb_device_query_list_sonic()
1051
+
1052
+ for nb_device_query in nb_device_query_list:
1053
+ # Query devices with the NETBOX_FILTER_CONDUCTOR_SONIC criteria
1054
+ for device in utils.nb.dcim.devices.filter(**nb_device_query):
1055
+ # Check if device role matches allowed roles
1056
+ if device.role and device.role.slug in DEFAULT_SONIC_ROLES:
1057
+ devices.append(device)
1058
+ logger.debug(
1059
+ f"Found device: {device.name} with role: {device.role.slug}"
1060
+ )
1061
+
1062
+ logger.info(f"Found {len(devices)} devices matching criteria")
1063
+
1064
+ # Prepare table data
1065
+ table_data = []
1066
+ headers = [
1067
+ "Name",
1068
+ "OOB IP",
1069
+ "Primary IP",
1070
+ "HWSKU",
1071
+ "Version",
1072
+ "Provision State",
1073
+ ]
1074
+
1075
+ for device in devices:
1076
+ # Get device name
1077
+ device_name = device.name
1078
+
1079
+ # Get OOB IP address
1080
+ oob_ip = "N/A"
1081
+ try:
1082
+ oob_result = get_device_oob_ip(device)
1083
+ if oob_result:
1084
+ oob_ip = oob_result[0] # Get just the IP address
1085
+ except Exception as e:
1086
+ logger.debug(f"Could not get OOB IP for {device_name}: {e}")
1087
+
1088
+ # Get primary IP address
1089
+ primary_ip = "N/A"
1090
+ try:
1091
+ if device.primary_ip4:
1092
+ # Extract IP address from CIDR notation
1093
+ primary_ip = str(device.primary_ip4).split("/")[0]
1094
+ elif device.primary_ip6:
1095
+ # Extract IP address from CIDR notation
1096
+ primary_ip = str(device.primary_ip6).split("/")[0]
1097
+ except Exception as e:
1098
+ logger.debug(f"Could not get primary IP for {device_name}: {e}")
1099
+
1100
+ # Get HWSKU and Version from sonic_parameters custom field
1101
+ hwsku = "N/A"
1102
+ version = "N/A"
1103
+ try:
1104
+ if (
1105
+ hasattr(device, "custom_fields")
1106
+ and "sonic_parameters" in device.custom_fields
1107
+ and device.custom_fields["sonic_parameters"]
1108
+ and isinstance(device.custom_fields["sonic_parameters"], dict)
1109
+ ):
1110
+ sonic_params = device.custom_fields["sonic_parameters"]
1111
+ if "hwsku" in sonic_params and sonic_params["hwsku"]:
1112
+ hwsku = sonic_params["hwsku"]
1113
+ if "version" in sonic_params and sonic_params["version"]:
1114
+ version = sonic_params["version"]
1115
+ except Exception as e:
1116
+ logger.debug(
1117
+ f"Could not extract sonic_parameters for {device_name}: {e}"
1118
+ )
1119
+
1120
+ # Determine provision state
1121
+ provision_state = "Unknown"
1122
+ try:
1123
+ if hwsku == "N/A":
1124
+ provision_state = "No HWSKU"
1125
+ elif hwsku not in SUPPORTED_HWSKUS:
1126
+ provision_state = "Unsupported HWSKU"
1127
+ else:
1128
+ # Use device status to determine provision state
1129
+ if device.status:
1130
+ status_value = (
1131
+ device.status.value
1132
+ if hasattr(device.status, "value")
1133
+ else str(device.status)
1134
+ )
1135
+ if status_value == "active":
1136
+ provision_state = "Provisioned"
1137
+ elif status_value == "staged":
1138
+ provision_state = "Staged"
1139
+ elif status_value == "planned":
1140
+ provision_state = "Planned"
1141
+ else:
1142
+ provision_state = status_value.title()
1143
+ else:
1144
+ provision_state = "No Status"
1145
+ except Exception as e:
1146
+ logger.debug(
1147
+ f"Could not determine provision state for {device_name}: {e}"
1148
+ )
1149
+
1150
+ table_data.append(
1151
+ [device_name, oob_ip, primary_ip, hwsku, version, provision_state]
1152
+ )
1153
+
1154
+ # Sort by device name
1155
+ table_data.sort(key=lambda x: x[0])
1156
+
1157
+ # Print the table
1158
+ if table_data:
1159
+ print(tabulate(table_data, headers=headers, tablefmt="psql"))
1160
+ print(f"\nTotal: {len(table_data)} devices")
1161
+ else:
1162
+ print("No SONiC devices found matching the criteria")
1163
+
1164
+ return 0
1165
+
1166
+ except Exception as e:
1167
+ logger.error(f"Error listing SONiC devices: {e}")
1168
+ return 1