nornir-collection 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. nornir_collection/__init__.py +0 -0
  2. nornir_collection/batfish/__init__.py +0 -0
  3. nornir_collection/batfish/assert_config.py +358 -0
  4. nornir_collection/batfish/utils.py +129 -0
  5. nornir_collection/cisco/__init__.py +0 -0
  6. nornir_collection/cisco/configuration_management/__init__.py +0 -0
  7. nornir_collection/cisco/configuration_management/cli/__init__.py +0 -0
  8. nornir_collection/cisco/configuration_management/cli/config_tasks.py +569 -0
  9. nornir_collection/cisco/configuration_management/cli/config_workflow.py +107 -0
  10. nornir_collection/cisco/configuration_management/cli/show_tasks.py +677 -0
  11. nornir_collection/cisco/configuration_management/netconf/__init__.py +0 -0
  12. nornir_collection/cisco/configuration_management/netconf/config_tasks.py +564 -0
  13. nornir_collection/cisco/configuration_management/netconf/config_workflow.py +298 -0
  14. nornir_collection/cisco/configuration_management/netconf/nr_cfg_iosxe_netconf.py +186 -0
  15. nornir_collection/cisco/configuration_management/netconf/ops_tasks.py +307 -0
  16. nornir_collection/cisco/configuration_management/processor.py +151 -0
  17. nornir_collection/cisco/configuration_management/pyats.py +236 -0
  18. nornir_collection/cisco/configuration_management/restconf/__init__.py +0 -0
  19. nornir_collection/cisco/configuration_management/restconf/cisco_rpc.py +514 -0
  20. nornir_collection/cisco/configuration_management/restconf/config_workflow.py +95 -0
  21. nornir_collection/cisco/configuration_management/restconf/tasks.py +325 -0
  22. nornir_collection/cisco/configuration_management/utils.py +511 -0
  23. nornir_collection/cisco/software_upgrade/__init__.py +0 -0
  24. nornir_collection/cisco/software_upgrade/cisco_software_upgrade.py +283 -0
  25. nornir_collection/cisco/software_upgrade/utils.py +794 -0
  26. nornir_collection/cisco/support_api/__init__.py +0 -0
  27. nornir_collection/cisco/support_api/api_calls.py +1173 -0
  28. nornir_collection/cisco/support_api/cisco_maintenance_report.py +221 -0
  29. nornir_collection/cisco/support_api/cisco_support.py +727 -0
  30. nornir_collection/cisco/support_api/reports.py +747 -0
  31. nornir_collection/cisco/support_api/utils.py +316 -0
  32. nornir_collection/fortinet/__init__.py +0 -0
  33. nornir_collection/fortinet/utils.py +36 -0
  34. nornir_collection/git.py +224 -0
  35. nornir_collection/netbox/__init__.py +0 -0
  36. nornir_collection/netbox/custom_script.py +107 -0
  37. nornir_collection/netbox/inventory.py +360 -0
  38. nornir_collection/netbox/scan_prefixes_and_update_ip_addresses.py +989 -0
  39. nornir_collection/netbox/set_device_status.py +67 -0
  40. nornir_collection/netbox/sync_datasource.py +111 -0
  41. nornir_collection/netbox/update_cisco_inventory_data.py +158 -0
  42. nornir_collection/netbox/update_cisco_support_plugin_data.py +339 -0
  43. nornir_collection/netbox/update_fortinet_inventory_data.py +161 -0
  44. nornir_collection/netbox/update_purestorage_inventory_data.py +144 -0
  45. nornir_collection/netbox/utils.py +261 -0
  46. nornir_collection/netbox/verify_device_primary_ip.py +202 -0
  47. nornir_collection/nornir_plugins/__init__.py +0 -0
  48. nornir_collection/nornir_plugins/inventory/__init__.py +0 -0
  49. nornir_collection/nornir_plugins/inventory/netbox.py +250 -0
  50. nornir_collection/nornir_plugins/inventory/staggered_yaml.py +143 -0
  51. nornir_collection/nornir_plugins/inventory/utils.py +277 -0
  52. nornir_collection/purestorage/__init__.py +0 -0
  53. nornir_collection/purestorage/utils.py +53 -0
  54. nornir_collection/utils.py +741 -0
  55. nornir_collection-0.0.1.dist-info/LICENSE +21 -0
  56. nornir_collection-0.0.1.dist-info/METADATA +136 -0
  57. nornir_collection-0.0.1.dist-info/RECORD +59 -0
  58. nornir_collection-0.0.1.dist-info/WHEEL +5 -0
  59. nornir_collection-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,677 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ This module contains screen-scraping functions and tasks related to Nornir.
4
+
5
+ The functions are ordered as followed:
6
+ - Helper Functions
7
+ - Single Nornir Screen-Scraping Tasks
8
+ - Nornir Screen-Scraping Tasks in regular Functions
9
+ """
10
+
11
+ import sys
12
+ import json
13
+ import difflib
14
+ from colorama import Fore, Style, init
15
+ from nornir_utils.plugins.tasks.files import write_file
16
+ from nornir.core import Nornir
17
+ from nornir.core.task import Task, Result
18
+ from nornir_collection.utils import (
19
+ print_task_title,
20
+ print_result,
21
+ task_name,
22
+ task_host,
23
+ task_info,
24
+ task_error,
25
+ )
26
+
27
+ init(autoreset=True, strip=False)
28
+
29
+
30
+ #### Helper Functions ########################################################################################
31
+
32
+
33
+ def _cisco_config_normalization(config: str) -> str:
34
+ """
35
+ Normalize a Cisco device configuration by removing specific lines and empty lines.
36
+ """
37
+ # Create a list with strings to skip if they exists in a line of the config
38
+ skip_if_line_contains = ["Building configuration", "Current configuration", "Last configuration change"]
39
+
40
+ # List comprehension to skip empty lines
41
+ config_list = [i for i in config.splitlines() if i]
42
+
43
+ # Create a string from the list of strings with some data normalization
44
+ config_normalized = ""
45
+ for line in config_list:
46
+ for item in skip_if_line_contains:
47
+ if item in line:
48
+ break
49
+ else:
50
+ config_normalized += f"{line}\n"
51
+
52
+ return config_normalized
53
+
54
+
55
+ def _get_software_version(task: Task) -> tuple:
56
+ """
57
+ TBD
58
+ """
59
+ # Manually create Scrapli connection
60
+ conn = task.host.get_connection("scrapli", task.nornir.config)
61
+ # Run Scrapli send_command to get show version
62
+ output = conn.send_command(command="show version")
63
+
64
+ # Parse the show version scrapli response with the genie parter
65
+ output_parsed = output.genie_parse_output()
66
+ verbose_result = json.dumps(output_parsed, indent=4)
67
+
68
+ # Find the correct software version of the device
69
+ version = None
70
+ if "nxos" in conn.genie_platform:
71
+ version = output_parsed["platform"]["software"]["system_version"].lower()
72
+ elif "iosxe" in conn.genie_platform:
73
+ if "version" in output_parsed["version"]:
74
+ # Replace all "(" and ")" in the version to normalizing version format to the Cisco support API
75
+ # -> Make 15.2(7)E6 to 15.2.7E6
76
+ version = output_parsed["version"]["version"].replace("(", ".")
77
+ version = version.replace(")", "")
78
+ elif "xe_version" in output_parsed["version"]:
79
+ # Replace all 0 in the xe_version to normalizing iosxe and non-iosxe version format
80
+ # -> Make 17.03.05 to 17.3.5
81
+ version = output_parsed["version"]["xe_version"].replace("0", "")
82
+ else:
83
+ result = (
84
+ f"'{task.name}' -> CliResponse <Success: False>\n"
85
+ f"-> Unsupport scrapli genie platform: {conn.genie_platform}\n"
86
+ f"-> Or no software version identified for that platform type\n\n"
87
+ f"\n{verbose_result}"
88
+ )
89
+ # Return the version which should still be None, the result string and a boolian for failed
90
+ return version, result, True
91
+
92
+ # Return the successfully found version, the verbose result string and a boolian for failed
93
+ return version, verbose_result, False
94
+
95
+
96
+ def _get_serial_numbers(task: Task) -> tuple:
97
+ """
98
+ TBD
99
+ """
100
+ # Manually create Scrapli connection
101
+ conn = task.host.get_connection("scrapli", task.nornir.config)
102
+ # Run Scrapli send_command to get show version
103
+ output = conn.send_command(command="show version")
104
+
105
+ # Parse the show version scrapli response with the genie parter
106
+ output_parsed = output.genie_parse_output()
107
+ verbose_result = json.dumps(output_parsed, indent=4)
108
+
109
+ # Find the correct serial numbers of the device (multiple serials if its a stack)
110
+ serials = {}
111
+ # Dict for additional serials which are managed by that host, e.g. WLC
112
+ add_serials = {}
113
+
114
+ # If the devices is from the platform family nexus
115
+ if "nxos" in conn.genie_platform:
116
+ # Run Scrapli send_command to get show inventory
117
+ output = conn.send_command(command="show inventory")
118
+ # Parse the show inventory scrapli response with the genie parter
119
+ output_parsed = output.genie_parse_output()
120
+ verbose_result = json.dumps(output_parsed, indent=4)
121
+ # Get the serial number from show licence all
122
+ serials["1"] = output_parsed["name"]["Chassis"]["serial_number"].upper()
123
+
124
+ # If the devices is from the platform family iosxe
125
+ elif "iosxe" in conn.genie_platform:
126
+ # If the device have a catalyst 9800 WLC
127
+ if "C9800_IOSXE-K9" in output_parsed["version"]["image_id"]:
128
+ # Run Scrapli send_command to get show inventory
129
+ output = conn.send_command(command="show license all")
130
+ # Parse the show inventory scrapli response with the genie parter
131
+ output_parsed = output.genie_parse_output()
132
+ verbose_result = json.dumps(output_parsed, indent=4)
133
+ # Get the WLC serial numbers from show licence all
134
+ for num, item in enumerate(output_parsed["product_information"]["ha_udi_list"].values(), start=1):
135
+ serials[str(num)] = item["sn"]
136
+ # Run Scrapli send_command to get show ap config general
137
+ output = conn.send_command(command="show ap config general")
138
+ # Parse the show ap config general scrapli response with the genie parter to get all APs
139
+ output_parsed = output.genie_parse_output()
140
+ verbose_result += "\n" + json.dumps(output_parsed, indent=4)
141
+ # Find the correct serial numbers of all associated APs and create an dict
142
+ for ap_name, ap_data in output_parsed["ap_name"].items():
143
+ add_serials[ap_data["ap_serial_number"]] = ap_name
144
+
145
+ # If the device is a catalyst 9000 router
146
+ elif "c8000be" in output_parsed["version"]["platform"]:
147
+ serials["1"] = output_parsed["version"]["chassis_sn"].upper()
148
+
149
+ # If the device have a catalyst 9000 IOS-XE or a previous IOS image
150
+ elif output_parsed["version"]["os"] == "IOS-XE" or output_parsed["version"]["os"] == "IOS":
151
+ for switch_num, switch_details in output_parsed["version"]["switch_num"].items():
152
+ serials[switch_num] = switch_details["system_sn"].upper()
153
+
154
+ # If nothing matched before
155
+ else:
156
+ result = (
157
+ f"'{task.name}' -> CliResponse <Success: False>\n"
158
+ f"-> Or no serials identified for that platform type {conn.genie_platform}\n\n"
159
+ f"\n{verbose_result}"
160
+ )
161
+ # Return the serials and add_serials dict, the result string and a boolian for failed
162
+ return serials, add_serials, result, True
163
+
164
+ else:
165
+ result = (
166
+ f"'{task.name}' -> CliResponse <Success: False>\n"
167
+ f"-> Unsupport scrapli genie platform: {conn.genie_platform}\n"
168
+ f"-> Or no serials identified for that platform type\n\n"
169
+ f"\n{verbose_result}"
170
+ )
171
+ # Return the serials and add_serials dict, the result string and a boolian for failed
172
+ return serials, add_serials, result, True
173
+
174
+ # Order the serials dict by the switch_num in ascending order
175
+ serials = {key: serials[key] for key in sorted(list(serials.keys()))}
176
+
177
+ # Return the serials and add_serials dict, the verbose result string and a boolian for failed
178
+ return serials, add_serials, verbose_result, False
179
+
180
+
181
+ #### Single Nornir Screen Scraping Tasks #####################################################################
182
+
183
+
184
+ def create_diff_running_config_golden_config_task(task: Task) -> Result:
185
+ """
186
+ TBD
187
+ """
188
+ # Manually create Scrapli connection
189
+ conn = task.host.get_connection("scrapli", task.nornir.config)
190
+
191
+ # Run Scrapli send_command to get show running-config
192
+ running_config = conn.send_command(command="show running-config")
193
+ if running_config.failed:
194
+ result = (
195
+ f"'{task.name}' -> ScrapliResponse <Success: False>\n"
196
+ + "-> Scrapli failed to get 'show running-config'"
197
+ )
198
+ return Result(host=task.host, result=result, failed=True)
199
+
200
+ # Cisco Config normalization
201
+ running_config_str = _cisco_config_normalization(running_config.result)
202
+
203
+ # Run Scrapli send_command to get more flash:golden-config
204
+ golden_config = conn.send_command(command="more flash:golden-config")
205
+ if golden_config.failed:
206
+ result = (
207
+ f"'{task.name}' -> ScrapliResponse <Success: False>\n"
208
+ + "-> Scrapli failed to get 'more flash:golden-config'"
209
+ )
210
+ return Result(host=task.host, result=result, failed=True)
211
+
212
+ # Cisco Config normalization
213
+ golden_config_str = _cisco_config_normalization(golden_config.result)
214
+
215
+ # Create a unified diff with the Python standard library difflib
216
+ diff = difflib.unified_diff(
217
+ running_config_str.splitlines(),
218
+ golden_config_str.splitlines(),
219
+ "running-config",
220
+ "golden-config",
221
+ )
222
+
223
+ # Create a string of the diff generator
224
+ diff = "\n".join([i.rstrip() for i in diff])
225
+
226
+ # Check the diff string and set the result
227
+ if diff:
228
+ result = (
229
+ f"'{task.name}' -> NornirResponse <Success: False>\n"
230
+ + "-> There is a diff between the running-config and the golden-config\n"
231
+ + f"\n{diff}"
232
+ )
233
+ return Result(host=task.host, result=result, failed=True)
234
+
235
+ result = (
236
+ f"'{task.name}' -> NornirResponse <Success: True>\n"
237
+ + "-> The running-config and the golden-config are identical"
238
+ )
239
+ return Result(host=task.host, result=result, failed=False)
240
+
241
+
242
+ def cli_verify_current_software_version_task(task: Task, verbose=False) -> Result:
243
+ """
244
+ TBD
245
+ """
246
+ # Get the desired version from the Nornir inventory
247
+ desired_version = task.host["software"]["version"]
248
+
249
+ # Use the helper function _get_software_version to get the software version
250
+ current_version, verbose_result, failed = _get_software_version(task=task)
251
+
252
+ # If failed is the the verbose result contains the full Nornir result string (result + verbose_result)
253
+ if failed:
254
+ # Return the Nornir result as failed
255
+ result = verbose_result
256
+ return Result(host=task.host, result=result, failed=True, overall_task_failed=True)
257
+
258
+ # Write the current version into the Nornir inventory
259
+ task.host["software"]["current_version"] = current_version
260
+
261
+ # If the desired version and the current version are the same
262
+ if desired_version in current_version:
263
+ result = (
264
+ f"'{task.name}' -> CliResponse <Success: True>\n"
265
+ f"-> Desired version {desired_version} match installed version {current_version}"
266
+ )
267
+ # Define the result variable for print_result
268
+ result = result + "\n\n" + verbose_result if verbose else result
269
+
270
+ # Return the Nornir result as success
271
+ return Result(host=task.host, result=result)
272
+
273
+ # Else the desired version and the current version are not the same
274
+ result = (
275
+ f"'{task.name}' -> CliResponse <Success: False>\n"
276
+ f"-> Desired version {desired_version} don't match installed version {current_version}"
277
+ )
278
+ # Define the result variable for print_result
279
+ result = result + "\n\n" + verbose_result if verbose else result
280
+
281
+ # Return the Nornir result as failed
282
+ return Result(host=task.host, result=result, failed=True, need_upgrade=True)
283
+
284
+
285
+ def cli_install_one_shot_task(task: Task, issu: bool = False, verbose: bool = False) -> Result:
286
+ """
287
+ TBD
288
+ """
289
+ # Get the host destination file from the Nornir inventory
290
+ dest_file = task.host["software"]["dest_file"]
291
+
292
+ # Manually create Netmiko connection
293
+ net_connect = task.host.get_connection("netmiko", task.nornir.config)
294
+
295
+ # Define the command string for the one-shot software install with or without ISSU
296
+ if issu:
297
+ command_string = f"install add file flash:{dest_file} activate issu commit"
298
+ expect_string = "install_add_activate_commit: Activating PACKAGE"
299
+ else:
300
+ command_string = f"install add file flash:{dest_file} activate commit prompt-level none"
301
+ expect_string = "install_add_activate_commit: Activating PACKAGE"
302
+
303
+ # As the command is really long Netmiko stops when the expect_string is seen. When the package activating
304
+ # start the software is added, nothing should go wrong anymore and the fping_track_upgrade_process()
305
+ # can be started to verify the upgrade and reload process.
306
+ output = net_connect.send_command(
307
+ command_string=command_string, expect_string=expect_string, read_timeout=1200
308
+ )
309
+
310
+ if expect_string in output:
311
+ result_summary = f"'{task.name}' -> CliResponse <Success: True>"
312
+ # Define the result variable for print_result
313
+ result = result_summary + "\n\n" + output if verbose else result_summary
314
+
315
+ # Return the Nornir result as success
316
+ return Result(host=task.host, result=result)
317
+
318
+ # Else the cli one-shot software install failed
319
+ result = f"'{task.name}' -> CliResponse <Success: False>\n\n" f"{result}"
320
+
321
+ # Return the custom Nornir result as failed
322
+ return Result(host=task.host, result=result, failed=True)
323
+
324
+
325
+ def cli_install_remove_inactive_task(task: Task, verbose=False) -> Result:
326
+ """
327
+ TBD
328
+ """
329
+ # Set the result_summary for a successful task
330
+ result_summary = f"'{task.name}' -> CliResponse <Success: True>"
331
+
332
+ # Manually create Netmiko connection
333
+ net_connect = task.host.get_connection("netmiko", task.nornir.config)
334
+
335
+ # Execute send_command with an expect_string to finish. Netmiko feeds the expect_string into pythons RE
336
+ # library and therefor the pipe can be used as logical "or" statement.
337
+ output = net_connect.send_command(
338
+ command_string="install remove inactive",
339
+ expect_string="Do you want to remove the above files?|SUCCESS: install_remove",
340
+ read_timeout=600,
341
+ )
342
+
343
+ if "Do you want to remove the above files?" in output:
344
+ output += net_connect.send_command(
345
+ command_string="y",
346
+ expect_string=f"{task.host}#",
347
+ read_timeout=600,
348
+ )
349
+ # Define the result variable for print_result
350
+ result = result_summary + "\n\n" + output if verbose else result_summary
351
+
352
+ # Return the custom Nornir result as success
353
+ return Result(host=task.host, result=result)
354
+
355
+ if "SUCCESS: install_remove" in output:
356
+ # Define the result variable for print_result
357
+ result = result_summary + "\n\n" + output if verbose else result_summary
358
+
359
+ # Return the custom Nornir result as success
360
+ return Result(host=task.host, result=result)
361
+
362
+ # Else the command install remove inactive failed without traceback exception
363
+ result = f"'{task.name}' -> CliResponse <Success: False>\n\n{output}"
364
+ # Return the Nornir result as failed
365
+ return Result(host=task.host, result=result, failed=True)
366
+
367
+
368
+ def cli_get_serial_numbers(task: Task, verbose: bool = False) -> Result:
369
+ """
370
+ TBD
371
+ """
372
+ # Use the helper function _get_serial_numbers to get the software version
373
+ serials, add_serials, verbose_result, failed = _get_serial_numbers(task=task)
374
+
375
+ # If failed is the the verbose result contains the full Nornir result string (result + verbose_result)
376
+ if failed:
377
+ # Return the Nornir result as failed
378
+ result = verbose_result
379
+ return Result(host=task.host, result=result, failed=True)
380
+
381
+ # Order the serials dict by the switch_num in ascending order
382
+ order_list = sorted(list(serials.keys()))
383
+ serials = {key: serials[key] for key in order_list}
384
+
385
+ # The serial number was found, so the serials dict in not empty
386
+ result = f"'{task.name}' -> CliResponse <Success: True>"
387
+ for switch_num, serial in serials.items():
388
+ result += f"\n-> Device {switch_num}: {serial}"
389
+ for serial in add_serials.keys(): # pylint: disable=consider-iterating-dictionary
390
+ result += f"\n-> Sub-Device: {serial}"
391
+ # Set the result print level to summary or verbose
392
+ result = result + "\n\n" + verbose_result if verbose else result
393
+
394
+ # Return the Nornir result as successful
395
+ return Result(host=task.host, result=result, serials=serials, add_serials=add_serials)
396
+
397
+
398
+ def cli_get_software_version(task: Task, verbose: bool = False) -> Result:
399
+ """
400
+ TBD
401
+ """
402
+ # Use the helper function _get_software_version to get the software version
403
+ version, verbose_result, failed = _get_software_version(task=task)
404
+
405
+ # If failed is the the verbose_result contains the full Nornir result string (result + verbose_result)
406
+ if failed:
407
+ # Return the Nornir result as failed
408
+ result = verbose_result
409
+ return Result(host=task.host, result=result, failed=True)
410
+
411
+ # The software version was found and is not None
412
+ result = f"'{task.name}' -> CliResponse <Success: True>\n" f"-> Device software version {version}"
413
+ # Set the result print level to summary or verbose
414
+ result = result + "\n\n" + verbose_result if verbose else result
415
+
416
+ # Return the custom Nornir result as successful
417
+ return Result(host=task.host, result=result, version=version)
418
+
419
+
420
+ def cli_verify_destination_md5_hash_task(task: Task) -> Result:
421
+ """
422
+ TBD
423
+ """
424
+
425
+ # Prepare all needed variables form the Nornir inventory
426
+ dest_file = task.host["software"]["dest_file"]
427
+ source_md5 = task.host["software"]["source_md5"]
428
+
429
+ # Manually create Scrapli connection
430
+ conn = task.host.get_connection("scrapli", task.nornir.config)
431
+ # Run the Scrapli Nornir task send_command to verify the destination file md5 hash
432
+ output = conn.send_command(
433
+ command=f"verify /md5 flash:{dest_file} {source_md5}",
434
+ strip_prompt=True,
435
+ timeout_ops=180,
436
+ )
437
+
438
+ # Extract the md5 hash from the output string
439
+ for line in output.result.splitlines():
440
+ # Source and destination md5 hast are identical
441
+ if line.startswith("Verified"):
442
+ # Split the line string into words
443
+ dest_md5 = line.split()
444
+ # Slicing the list -> -1 means the last element of the list which is the md5 hash
445
+ dest_md5 = dest_md5[-1]
446
+ result = (
447
+ f"'{task.name}' -> CliResponse <Success: True>\n"
448
+ "MD5-Hashes are identical:\n"
449
+ f"-> Source MD5-Hash: {source_md5}\n"
450
+ f"-> Destination MD5-Hash: {dest_md5}"
451
+ )
452
+ # Return the custom Nornir result as successful
453
+ return Result(host=task.host, result=result)
454
+
455
+ # Source and destination md5 hash are different -> Upload failed
456
+ if line.startswith("Computed signature"):
457
+ # Split the string into words
458
+ dest_md5 = line.split()
459
+ # Slicing the list -> -1 means the last element of the list which is the md5 hash
460
+ dest_md5 = dest_md5[-1]
461
+ result = (
462
+ f"'{task.name}' -> CliResponse <Success: False>\n"
463
+ "MD5-Hashes are not identical:\n"
464
+ f"-> Source MD5-Hash: {source_md5}\n"
465
+ f"-> Destination MD5-Hash: {dest_md5}"
466
+ )
467
+ # Return the custom Nornir result as failed
468
+ return Result(host=task.host, result=result, failed=True)
469
+
470
+ # There is an %Error as the file don't exist
471
+ if line.startswith("%Error"):
472
+ result = f"'{task.name}' -> CliResponse <Success: False>\n" + f"-> {line}"
473
+ # Return the custom Nornir result as failed
474
+ return Result(host=task.host, result=result, failed=True)
475
+
476
+ # If no if statement match the whole for loop -> Return the custom Nornir result as failed
477
+ result = f"'{task.name}' -> CliResponse <Success: False>\n" + f"-> {output.result}"
478
+ return Result(host=task.host, result=result, failed=True, overall_task_failed=True)
479
+
480
+
481
+ def custom_write_file(
482
+ task: Task, commands: list, path: str, filename_suffix: str, backup_config: bool = False
483
+ ) -> Result:
484
+ """
485
+ This custom Nornir task takes a list of commands to execute, a path and a filename suffix as arguments and
486
+ writes the output of the commands to that file. The start of the filename is the hostname and as suffix
487
+ can any text be added.
488
+ """
489
+ # Manually create Scrapli connection
490
+ conn = task.host.get_connection("scrapli", task.nornir.config)
491
+
492
+ # The emtpy string will be filled and written to file
493
+ content = ""
494
+
495
+ # Execute each command individual to add custom content for each command
496
+ for command in commands:
497
+ output = conn.send_command(command=command, strip_prompt=True, timeout_ops=180)
498
+
499
+ if backup_config:
500
+ # If the file should be a backup of the configuration, then remove unwanted lines which are not
501
+ # real configurations and to avoid unwanted git commits
502
+ for line in output.result.splitlines():
503
+ if line.startswith("Building configuration..."):
504
+ continue
505
+ if line.startswith("Current configuration :"):
506
+ continue
507
+ if line.startswith("! Last configuration change"):
508
+ continue
509
+ if line.startswith("! NVRAM config last updated"):
510
+ continue
511
+
512
+ # If no line to exclude matched, add the line to the content
513
+ content += f"{line}\n"
514
+
515
+ else:
516
+ # Add the promt with the command and then the command result
517
+ content += f"{task.host}#{command}\n{output.result}\n\n"
518
+
519
+ # Remove any possible starting or ending blank lines
520
+ content = content.rstrip()
521
+ content = content.lstrip()
522
+
523
+ # Write the content variable to the file specified by the arguments
524
+ task.run(task=write_file, filename=f"{path}/{task.host}{filename_suffix}", content=content, append=False)
525
+
526
+
527
+ #### Nornir Task in regular Functions ########################################################################
528
+
529
+
530
+ def nr_pre_config_check(nr: Nornir) -> bool:
531
+ """
532
+ Perform a pre-configuration check by verifying the running configuration against the golden configuration.
533
+ This function runs a custom Nornir Scrapli task to create a diff between the running configuration and
534
+ the golden configuration. It prints the task title and the results of the task. If the task fails, it
535
+ indicates that the running configuration and the golden configuration are not identical.
536
+ """
537
+ # pylint: disable=invalid-name
538
+
539
+ print_task_title("Pre-configuration check")
540
+
541
+ # Run the custom Nornir Scrapli task create_diff_running_config_golden_config
542
+ task_text = "NORNIR verify running-config against golden-config"
543
+ result = nr.run(
544
+ name=task_text,
545
+ task=create_diff_running_config_golden_config_task,
546
+ on_failed=True,
547
+ )
548
+ # Print the results
549
+ print_result(result)
550
+
551
+ # Return False if the overall task failed as the running-config and golden-config are not identical
552
+ return not result.failed
553
+
554
+
555
+ def cli_verify_destination_md5_hash(nr):
556
+ """
557
+ Verifies the MD5 hash of a destination file on network devices using Nornir. This function runs a custom
558
+ Nornir task to verify the MD5 hash of a destination file on network devices. It prints the result of the
559
+ task and checks if the overall task failed. If the task fails, it prints an error message and exits the
560
+ script. It also collects and returns a list of hosts where the destination file MD5 hash does not match
561
+ or the destination file does not exist.
562
+ """
563
+ # pylint: disable=invalid-name
564
+
565
+ # Run the custom Nornir task verify_destination_md5_hash
566
+ task_result = nr.run(
567
+ task=cli_verify_destination_md5_hash_task,
568
+ name="CLI verify destination file",
569
+ on_failed=True,
570
+ )
571
+
572
+ # Print the Nornir task result
573
+ print_result(task_result)
574
+
575
+ # If the task overall task result failed -> Print results and exit the script
576
+ for host in task_result:
577
+ if hasattr(task_result[host], "overall_task_failed"):
578
+ print("\n")
579
+ print(task_error(text="CLI verify destination file", changed=False))
580
+ print("\U0001f4a5 ALERT: CLI DESTINATION FILE MD5 HASH VERIFICATION FAILED! \U0001f4a5")
581
+ print(
582
+ f"\n{Style.BRIGHT}{Fore.RED}-> Analyse the Nornir output for failed task results\n"
583
+ "-> May apply Nornir inventory changes and run the script again\n"
584
+ )
585
+ sys.exit(1)
586
+
587
+ # List to fill with hosts with not matching destination file md5 hash or not existing destination file
588
+ failed_hosts = list(task_result.failed_hosts)
589
+
590
+ return failed_hosts
591
+
592
+
593
+ def write_commands_to_file(
594
+ nr: Nornir,
595
+ name: str,
596
+ commands: list,
597
+ path: str,
598
+ filename_suffix: str,
599
+ backup_config: bool = False,
600
+ verbose: bool = False,
601
+ ) -> bool:
602
+ """
603
+ #### CODE REFACTORING NEEDED -> INTRODUCE print_result ####
604
+
605
+ This function takes a list of commands to execute, a path and a filename suffix as arguments and writes
606
+ the output of the commands to that file. The start of the filename is the hostname and as suffix can any
607
+ text be added.
608
+ """
609
+ # pylint: disable=invalid-name,too-many-arguments,too-many-locals
610
+
611
+ # Set the variable to return at the end of the function to True
612
+ cfg_status = True
613
+
614
+ print(task_name(text=name))
615
+
616
+ task_result = nr.run(
617
+ task=custom_write_file,
618
+ commands=commands,
619
+ path=path,
620
+ filename_suffix=filename_suffix,
621
+ backup_config=backup_config,
622
+ on_failed=True,
623
+ )
624
+
625
+ for host, multi_result in task_result.items():
626
+ print(task_host(host=host, changed=task_result[host].changed))
627
+
628
+ # Print first all Scrapli send_command results
629
+ for result in multi_result:
630
+ # Get the attribute scrapli_response from result into a variable -> Return None if don't exist
631
+ scrapli_result = getattr(result, "scrapli_response", None)
632
+
633
+ if scrapli_result is None:
634
+ continue
635
+
636
+ task_text = "Execute command"
637
+ cmd = str(scrapli_result.channel_input)
638
+
639
+ if scrapli_result.failed:
640
+ print(task_error(text=task_text, changed=scrapli_result.failed))
641
+ print(f"'{cmd}' -> CliResponse <Success: False>\n")
642
+ print(f"{host}#{cmd}")
643
+ print(scrapli_result.result)
644
+ else:
645
+ print(task_info(text=task_text, changed=scrapli_result.failed))
646
+ print(f"'{cmd}' -> CliResponse <Success: True>")
647
+ if verbose:
648
+ print(f"{host}#{cmd}")
649
+ print(scrapli_result.result)
650
+
651
+ # The write_file result is only present if all Scrapli send_command tasks were successful
652
+ filepath = f"{path}/{host}{filename_suffix}"
653
+
654
+ # pylint: disable=undefined-loop-variable
655
+ if [True for result in multi_result if result.name == "write_file"]:
656
+ # The write_file result is present and no exception exists
657
+ if result.exception is None:
658
+ print(task_info(text="Save command(s) to file", changed=False))
659
+ print(f"'{filepath}' -> NornirResponse <Success: True>")
660
+
661
+ # An exception is raised when the folder don't exist
662
+ else:
663
+ print(task_error(text="Save command(s) to file", changed=False))
664
+ print(f"'{filepath}' -> NornirResponse <Success: False>\n")
665
+ print(result.exception)
666
+ cfg_status = False
667
+
668
+ # The write_file result is not present as one or more commands have failed
669
+ else:
670
+ print(task_error(text="Save command(s) to file", changed=False))
671
+ print(f"'{filepath}' -> NornirResponse <Success: False>\n")
672
+ print("Command(s) failed -> Command(s) have not been written to file\n")
673
+ print(result.exception)
674
+ print(result.result)
675
+ cfg_status = False
676
+
677
+ return cfg_status