Skip to content

Commit 15aa17e

Browse files
committed
Command getter refactor
1 parent 9a4d9fa commit 15aa17e

File tree

1 file changed

+195
-143
lines changed

1 file changed

+195
-143
lines changed

nautobot_device_onboarding/nornir_plays/command_getter.py

Lines changed: 195 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -91,154 +91,181 @@ def _get_commands_to_run(yaml_parsed_info, skip_list=None):
9191
return deduplicate_command_list(all_commands)
9292

9393

94-
def netmiko_send_commands(task: Task,
95-
command_getter_yaml_data: Dict,
96-
command_getter_job: str,
97-
logger,
98-
nautobot_job
99-
):
100-
"""Run commands specified in PLATFORM_COMMAND_MAP."""
101-
if not task.host.platform:
102-
return Result(host=task.host, result=f"{task.host.name} has no platform set.", failed=True)
103-
if task.host.platform not in get_all_network_driver_mappings().keys() or not "cisco_wlc_ssh":
104-
return Result(host=task.host, result=f"{task.host.name} has a unsupported platform set.", failed=True)
105-
if not command_getter_yaml_data[task.host.platform].get(command_getter_job):
94+
def netmiko_send_commands(
95+
task: Task,
96+
command_getter_yaml_data: Dict,
97+
command_getter_job: str,
98+
logger,
99+
nautobot_job,
100+
**kwargs,
101+
):
102+
"""Run platform-specific commands with optional parsing and logging."""
103+
104+
command_exclusions = kwargs.get("command_exclusions", [])
105+
connectivity_test = kwargs.get("connectivity_test", False)
106+
# sync_cables = kwargs.get("sync_cables", False)
107+
108+
# ---- 1. Validation -------------------------------------------------------
109+
validation_result = _validate_task(task, command_getter_yaml_data, command_getter_job)
110+
if validation_result:
111+
return validation_result
112+
113+
if connectivity_test and not _check_connectivity(task):
106114
return Result(
107-
host=task.host, result=f"{task.host.name} has missing definitions in command_mapper YAML file.", failed=True
115+
host=task.host,
116+
result=f"{task.host.name} failed connectivity check via tcp_ping.",
117+
failed=True,
108118
)
109-
if nautobot_job.connectivity_test:
110-
if not tcp_ping(task.host.hostname, task.host.port):
111-
return Result(
112-
host=task.host, result=f"{task.host.name} failed connectivity check via tcp_ping.", failed=True
113-
)
114-
task.host.data["platform_parsing_info"] = command_getter_yaml_data[task.host.platform]
115119

116-
skip_conditions = {
117-
"interfaces__tagged_vlans": not sync_vlans,
118-
"vlan_map": not sync_vlans,
119-
"interfaces__untagged_vlan": not sync_vlans,
120-
"interfaces__vrf": not sync_vrfs,
121-
"cables": not sync_cables,
122-
"software_version": not sync_software_version,
123-
}
120+
task.host.data["platform_parsing_info"] = command_getter_yaml_data[task.host.platform]
124121

122+
# ---- 2. Command Preparation ---------------------------------------------
125123
commands = _get_commands_to_run(
126-
command_getter_yaml_data[task.host.platform][command_getter_job],
127-
# getattr(nautobot_job, "sync_vlans", False),
128-
# getattr(nautobot_job, "sync_vrfs", False),
129-
# getattr(nautobot_job, "sync_cables", False),
130-
# getattr(nautobot_job, "sync_software_version", False),
124+
yaml_parsed_info=command_getter_yaml_data[task.host.platform][command_getter_job],
125+
skip_list=command_exclusions,
131126
)
127+
logger.debug(f"Commands to run: {[cmd['command'] for cmd in commands]}")
128+
129+
# ---- 3. Command Execution & Parsing -------------------------------------
130+
for idx, command in enumerate(commands):
131+
try:
132+
current_result = _run_command(task, command)
133+
_handle_command_result(task, idx, command, current_result, nautobot_job, logger)
134+
except NornirSubTaskError as e:
135+
return _handle_subtask_error(task, idx, e)
136+
137+
return Result(host=task.host, result="Commands executed successfully.", failed=False)
138+
139+
140+
# -----------------------------------------------------------------------------
141+
# Helper functions
142+
# -----------------------------------------------------------------------------
143+
144+
def _validate_task(task, yaml_data, job):
145+
"""Check platform and YAML definition validity."""
146+
platform = task.host.platform
147+
if not platform:
148+
return Result(host=task.host, result=f"{task.host.name} has no platform set.", failed=True)
149+
150+
supported_platforms = get_all_network_driver_mappings().keys()
151+
if platform not in supported_platforms and platform != "cisco_wlc_ssh":
152+
return Result(host=task.host, result=f"{task.host.name} has an unsupported platform.", failed=True)
132153

133-
if (
134-
getattr(nautobot_job, "sync_cables", False)
135-
and "cables" not in command_getter_yaml_data[task.host.platform][command_getter_job].keys()
136-
):
137-
logger.error(
138-
f"{task.host.platform} has missing definitions for cables in command_mapper YAML file. Cables will not be loaded."
154+
if not yaml_data.get(platform, {}).get(job):
155+
return Result(
156+
host=task.host,
157+
result=f"{task.host.name} missing definitions in command_mapper YAML.",
158+
failed=True,
139159
)
140160

141-
logger.debug(f"Commands to run: {[cmd['command'] for cmd in commands]}")
142-
# All commands in this for loop are running within 1 device connection.
143-
for result_idx, command in enumerate(commands):
144-
send_command_kwargs = {}
161+
return None
162+
163+
164+
def _check_connectivity(task):
165+
"""Perform TCP ping check."""
166+
return tcp_ping(task.host.hostname, task.host.port)
167+
168+
169+
def _run_command(task, command):
170+
"""Execute a single Netmiko command."""
171+
return task.run(
172+
task=netmiko_send_command,
173+
name=command["command"],
174+
command_string=command["command"],
175+
read_timeout=60,
176+
)
177+
178+
179+
def _handle_command_result(task, idx, command, current_result, nautobot_job, logger):
180+
"""Parse and store results based on parser type."""
181+
raw_output = current_result.result
182+
parser_type = command.get("parser")
183+
184+
# Debug output
185+
if nautobot_job.debug:
186+
log_message = format_log_message(pprint.pformat(raw_output))
187+
logger.debug(f"Result of '{command['command']}' command:<br><br>{log_message}")
188+
189+
# Handle invalid input gracefully
190+
if isinstance(raw_output, str) and "Invalid input detected" in raw_output:
191+
task.results[idx].result = []
192+
task.results[idx].failed = False
193+
return
194+
195+
if parser_type in SUPPORTED_COMMAND_PARSERS:
196+
parsed = _parse_command_output(task, command, raw_output, parser_type, nautobot_job, logger)
197+
task.results[idx].result = parsed
198+
task.results[idx].failed = False
199+
else:
200+
task.results[idx].result = _handle_raw_or_none(raw_output, parser_type)
201+
task.results[idx].failed = False
202+
203+
204+
def _parse_command_output(task, command, raw_output, parser_type, nautobot_job, logger):
205+
"""Dispatch to the appropriate parser."""
206+
try:
207+
if parser_type == "textfsm":
208+
return _parse_textfsm(task, command, raw_output, nautobot_job, logger)
209+
elif parser_type == "ttp":
210+
return _parse_ttp(task, command, raw_output)
211+
except Exception as e:
212+
logger.warning(f"Parsing failed for {command['command']}: {e}")
213+
return []
214+
return []
215+
216+
217+
def _parse_textfsm(task, command, data, nautobot_job, logger):
218+
git_template_dir = get_git_repo_parser_path("textfsm")
219+
if git_template_dir and not check_for_required_file(git_template_dir, "index"):
220+
logger.debug(f"Missing index file in {git_template_dir}, falling back to defaults.")
221+
git_template_dir = None
222+
223+
parsed_output = parse_output(
224+
platform=get_all_network_driver_mappings()[task.host.platform]["ntc_templates"],
225+
template_dir=git_template_dir,
226+
command=command["command"],
227+
data=data,
228+
try_fallback=bool(git_template_dir),
229+
)
230+
231+
if nautobot_job.debug:
232+
logger.debug(format_log_message(pprint.pformat(parsed_output)))
233+
234+
return parsed_output
235+
236+
237+
def _parse_ttp(task, command, data):
238+
ttp_template_files = load_files_with_precedence(f"{PARSER_DIR}/ttp", "ttp")
239+
template_name = f"{task.host.platform}_{command['command'].replace(' ', '_')}.ttp"
240+
parser = ttp(data=data, template=ttp_template_files[template_name])
241+
parser.parse()
242+
return json.loads(parser.result(format="json")[0])
243+
244+
245+
def _handle_raw_or_none(raw_output, parser_type):
246+
if parser_type == "raw":
247+
return {"raw": raw_output}
248+
if parser_type == "none":
145249
try:
146-
current_result = task.run(
147-
task=netmiko_send_command,
148-
name=command["command"],
149-
command_string=command["command"],
150-
read_timeout=60,
151-
**send_command_kwargs,
152-
)
153-
if nautobot_job.debug:
154-
log_message = format_log_message(pprint.pformat(current_result.result))
155-
logger.debug(f"Result of '{command['command']}' command:<br><br>{log_message}")
156-
if command.get("parser") in SUPPORTED_COMMAND_PARSERS:
157-
if isinstance(current_result.result, str):
158-
if "Invalid input detected at" in current_result.result:
159-
task.results[result_idx].result = []
160-
task.results[result_idx].failed = False
161-
else:
162-
if command["parser"] == "textfsm":
163-
try:
164-
# Look for custom textfsm templates in the git repo
165-
git_template_dir = get_git_repo_parser_path(parser_type="textfsm")
166-
if git_template_dir:
167-
if not check_for_required_file(git_template_dir, "index"):
168-
logger.debug(
169-
f"Unable to find required index file in {git_template_dir} for textfsm parsing. Falling back to default templates."
170-
)
171-
git_template_dir = None
172-
# Parsing textfsm ourselves instead of using netmikos use_<parser> function to be able to handle exceptions
173-
# ourselves. Default for netmiko is if it can't parse to return raw text which is tougher to handle.
174-
parsed_output = parse_output(
175-
platform=get_all_network_driver_mappings()[task.host.platform]["ntc_templates"],
176-
template_dir=git_template_dir if git_template_dir else None,
177-
command=command["command"],
178-
data=current_result.result,
179-
try_fallback=bool(git_template_dir),
180-
)
181-
if nautobot_job.debug:
182-
log_message = format_log_message(pprint.pformat(parsed_output))
183-
logger.debug(
184-
f"Parsed output of '{command['command']}' command:<br><br>{log_message}"
185-
)
186-
task.results[result_idx].result = parsed_output
187-
task.results[result_idx].failed = False
188-
except Exception: # https://github.com/networktocode/ntc-templates/issues/369
189-
try:
190-
if nautobot_job.debug:
191-
traceback_str = traceback.format_exc().replace("\n", "<br>")
192-
logger.warning(
193-
f"Parsing failed for '{command['command']}' command:<br><br>{traceback_str}"
194-
)
195-
except: # noqa: E722, S110
196-
pass
197-
task.results[result_idx].result = []
198-
task.results[result_idx].failed = False
199-
if command["parser"] == "ttp":
200-
try:
201-
# Parsing ttp ourselves instead of using netmikos use_<parser> function to be able to handle exceptions
202-
# ourselves.
203-
ttp_template_files = load_files_with_precedence(
204-
filesystem_dir=f"{PARSER_DIR}/ttp", parser_type="ttp"
205-
)
206-
template_name = f"{task.host.platform}_{command['command'].replace(' ', '_')}.ttp"
207-
parser = ttp(
208-
data=current_result.result,
209-
template=ttp_template_files[template_name],
210-
)
211-
parser.parse()
212-
parsed_result = parser.result(format="json")[0]
213-
# task.results[result_idx].result = json.loads(json.dumps(parsed_result))
214-
task.results[result_idx].result = json.loads(parsed_result)
215-
task.results[result_idx].failed = False
216-
except Exception:
217-
task.results[result_idx].result = []
218-
task.results[result_idx].failed = False
219-
else:
220-
if command["parser"] == "raw":
221-
raw = {"raw": current_result.result}
222-
task.results[result_idx].result = json.loads(json.dumps(raw))
223-
task.results[result_idx].failed = False
224-
if command["parser"] == "none":
225-
try:
226-
jsonified = json.loads(current_result.result)
227-
task.results[result_idx].result = jsonified
228-
task.results[result_idx].failed = False
229-
except Exception:
230-
task.result.failed = False
231-
except NornirSubTaskError:
232-
# These exceptions indicate that the device is unreachable or the credentials are incorrect.
233-
# We should fail the task early to avoid trying all commands on a device that is unreachable.
234-
if type(task.results[result_idx].exception).__name__ == "NetmikoAuthenticationException":
235-
return Result(host=task.host, result=f"{task.host.name} failed authentication.", failed=True)
236-
if type(task.results[result_idx].exception).__name__ == "NetmikoTimeoutException":
237-
return Result(host=task.host, result=f"{task.host.name} SSH Timeout Occured.", failed=True)
238-
# We don't want to fail the entire subtask if SubTaskError is hit, set result to empty list and failed to False
239-
# Handle this type or result latter in the ETL process.
240-
task.results[result_idx].result = []
241-
task.results[result_idx].failed = False
250+
return json.loads(raw_output)
251+
except Exception:
252+
return []
253+
return []
254+
255+
256+
def _handle_subtask_error(task, idx, exception):
257+
"""Handle connection/authentication errors gracefully."""
258+
exc_type = type(task.results[idx].exception).__name__
259+
260+
if exc_type == "NetmikoAuthenticationException":
261+
return Result(host=task.host, result=f"{task.host.name} failed authentication.", failed=True)
262+
if exc_type == "NetmikoTimeoutException":
263+
return Result(host=task.host, result=f"{task.host.name} SSH timeout occurred.", failed=True)
264+
265+
task.results[idx].result = []
266+
task.results[idx].failed = False
267+
268+
return None
242269

243270

244271
@lru_cache(maxsize=None)
@@ -309,12 +336,14 @@ def sync_devices_command_getter(job, log_level):
309336
)
310337
continue
311338
nr_with_processors.inventory.hosts.update(single_host_inventory_constructed)
339+
312340
nr_with_processors.run(
313341
task=netmiko_send_commands,
314342
command_getter_yaml_data=nr_with_processors.inventory.defaults.data["platform_parsing_info"],
315343
command_getter_job="sync_devices",
316344
logger=logger,
317345
nautobot_job=job,
346+
# command_getter_yaml_exclusions=command_exclusions,
318347
)
319348
except Exception as err: # pylint: disable=broad-exception-caught
320349
try:
@@ -348,21 +377,44 @@ def sync_network_data_command_getter(job, log_level):
348377
"defaults": {
349378
"platform_parsing_info": add_platform_parsing_info(),
350379
"network_driver_mappings": list(get_all_network_driver_mappings().keys()),
351-
"sync_vlans": job.sync_vlans,
352-
"sync_vrfs": job.sync_vrfs,
353-
"sync_cables": job.sync_cables,
354-
"sync_software_version": job.sync_software_version,
380+
# "sync_vlans": job.sync_vlans,
381+
# "sync_vrfs": job.sync_vrfs,
382+
# "sync_cables": job.sync_cables,
383+
# "sync_software_version": job.sync_software_version,
355384
},
356385
},
357386
},
358387
) as nornir_obj:
388+
389+
command_exclusions = {
390+
"interfaces__tagged_vlans": not job.sync_vlans,
391+
"vlan_map": not job.sync_vlans,
392+
"interfaces__untagged_vlan": not job.sync_vlans,
393+
"interfaces__vrf": not job.sync_vrfs,
394+
"cables": not job.sync_cables,
395+
"software_version": not job.sync_software_version,
396+
}
397+
exclusions = [k for k, v in command_exclusions.items() if v]
398+
399+
# commands = _get_commands_to_run(
400+
# command_getter_yaml_data[task.host.platform][command_getter_job],
401+
# # getattr(nautobot_job, "sync_vlans", False),
402+
# # getattr(nautobot_job, "sync_vrfs", False),
403+
# # getattr(nautobot_job, "sync_cables", False),
404+
# # getattr(nautobot_job, "sync_software_version", False),
405+
# )
406+
407+
359408
nr_with_processors = nornir_obj.with_processors([CommandGetterProcessor(logger, compiled_results, job)])
360409
nr_with_processors.run(
361410
task=netmiko_send_commands,
362411
command_getter_yaml_data=nr_with_processors.inventory.defaults.data["platform_parsing_info"],
363412
command_getter_job="sync_network_data",
364413
logger=logger,
365-
nautobot_job=job,
414+
command_exclusions=exclusions,
415+
connectivity_test=job.connectivity_test,
416+
sync_cables=job.sync_cables,
417+
# nautobot_job=job,
366418
)
367419
except Exception: # pylint: disable=broad-exception-caught
368420
logger.info(f"Error During Sync Network Data Command Getter: {traceback.format_exc()}")

0 commit comments

Comments
 (0)