diff --git a/LICENSE b/LICENSE index 91f9667..2ce0438 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Carl Montanari +Copyright (c) 2020 Carl Montanari Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 957d567..f26750e 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,36 @@ [![Python 3.8](https://img.shields.io/badge/python-3.8-blue.svg)](https://www.python.org/downloads/release/python-380/) [![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) -nornsible +Nornsible ======= -Wrap Nornir with Ansible-like host/group limits and tagging! +Bring some of the nice things about Ansible to Nornir! -The idea behind nornsible is to allow for Nornir scripts to be written to operate on an entire environment, and then limited to a subset of host(s) based on simple command line arguments. Of course you can simply do this yourself, but why not let nornsible handle it for you! +The idea behind Nornsible is to allow for Nornir scripts to be written to operate on an entire environment, and then limited to a subset of host(s) based on simple command line arguments. Of course you can simply do this yourself, but why not let nornsible handle it for you! Nornsible provides the ability to -- via command line arguments -- filter Nornir inventories by hostname or group name, or both. There is also a handy flag to set the number of workers; quite useful for setting workers equal to 1 for troubleshooting purposes. -Lastly, nornsible supports the concept of tags. Tags correspond to the name of *custom* tasks and operate very much like tags in Ansible. Provide a list of tags to execute, and Nornsible will ensure that Nornir only runs those tasks. Provide a list of tags to skip, and Nornsible will ensure that Nornir only runs those not in that list. Easy peasy. +Nornsible all supports the concept of tags. Tags correspond to the name of *custom* tasks and operate very much like tags in Ansible. Provide a list of tags to execute, and Nornsible will ensure that Nornir only runs those tasks. Provide a list of tags to skip, and Nornsible will ensure that Nornir only runs those not in that list. Easy peasy. +Finally, Nornsible supports multiple inventory files as well as dynamic inventory files -- just like Ansible. -# How does nornsible work? + +# How does Nornsible work? Nornsible accepts an instantiated Nornir object as an argument and returns a slightly modified Nornir object. Nornsible sets the desired number of workers if applicable, and adds an attribute for "run_tags" and "skip_tags" based on your command line input. To take advantage of the tags feature Nornsible provides a decorator that you can use to wrap your custom tasks. This decorator inspects the task being ran and checks the task name against the lists of run and skip tags. If the task is allowed, Nornsible simply allows the task to run as per normal, if it is *not* allowed, Nornsible will print a pretty message and move on. +Nornsible inventory can be used by simply installing Nornsible and setting the inventory plugin in your config file as follows: + +```yaml +inventory: + plugin: nornsible.inventory.AnsibleInventory + options: + inventory: inventory.yaml +``` + +Note that instead of `hostsfile` Nornsible inventory uses `inventory` -- this is intentional to make sure to differentiate between the standard Nornir Ansible support and nornsible. # Caveats @@ -137,3 +149,9 @@ I broke testing into two main categories -- unit and integration. Unit is what y # To Do - Add handling for "not" in host/group limit; i.e.: "-t !localhost" to run against all hosts *not* localhost. +- Add more examples for using nornsible inventory in different ways -- i.e. multiple inventory, dynamic inventory, hash_behavior settings, etc. +- Add more detailed readme info on inventory stuff. +- Add support for host/group vars in directories in the host/group vars parent directory... + - i.e. `host_vars/myhost/somevar1.yaml` and `host_vars/myhost/somevar2.yaml` +- Add integration testing for inventory bits. +- Fix/add logging -- ensure inventory logs to nornir log as per usual, but also create a nornsible log for all nornsible "stuff". \ No newline at end of file diff --git a/examples/basic_inventory/config.yaml b/examples/basic_inventory/config.yaml new file mode 100644 index 0000000..c0c9406 --- /dev/null +++ b/examples/basic_inventory/config.yaml @@ -0,0 +1,4 @@ +inventory: + plugin: nornsible.inventory.AnsibleInventory + options: + inventory: inventory.yaml diff --git a/examples/basic_inventory/group_vars/all.yaml b/examples/basic_inventory/group_vars/all.yaml new file mode 100644 index 0000000..4c41d42 --- /dev/null +++ b/examples/basic_inventory/group_vars/all.yaml @@ -0,0 +1,3 @@ +--- +username: username +password: password diff --git a/examples/basic_inventory/group_vars/ios.yaml b/examples/basic_inventory/group_vars/ios.yaml new file mode 100644 index 0000000..3f05124 --- /dev/null +++ b/examples/basic_inventory/group_vars/ios.yaml @@ -0,0 +1,7 @@ +--- +platform: ios +connection_options: + netmiko: + platform: cisco_ios + napalm: + platform: ios diff --git a/examples/basic_inventory/inventory.yaml b/examples/basic_inventory/inventory.yaml new file mode 100644 index 0000000..9d8b4b4 --- /dev/null +++ b/examples/basic_inventory/inventory.yaml @@ -0,0 +1,7 @@ +--- +all: + children: + ios: + hosts: + c3560: + ansible_host: c3560x diff --git a/examples/basic_inventory/try_me.py b/examples/basic_inventory/try_me.py new file mode 100644 index 0000000..982cb5a --- /dev/null +++ b/examples/basic_inventory/try_me.py @@ -0,0 +1,15 @@ +from nornir import InitNornir +from nornir.plugins.tasks import networking + + +def main(): + nr = InitNornir(config_file="config.yaml") + nr = nr.filter(name="c3560") + agg_result = nr.run( + task=networking.netmiko_send_command, command_string="show run | i hostname" + ) + print(agg_result["c3560"].result) + + +if __name__ == "__main__": + main() diff --git a/examples/config.yaml b/examples/basics/config.yaml similarity index 100% rename from examples/config.yaml rename to examples/basics/config.yaml diff --git a/examples/groups.yaml b/examples/basics/groups.yaml similarity index 100% rename from examples/groups.yaml rename to examples/basics/groups.yaml diff --git a/examples/hosts.yaml b/examples/basics/hosts.yaml similarity index 100% rename from examples/hosts.yaml rename to examples/basics/hosts.yaml diff --git a/examples/try_me.md b/examples/basics/try_me.md similarity index 100% rename from examples/try_me.md rename to examples/basics/try_me.md diff --git a/examples/try_me.py b/examples/basics/try_me.py similarity index 100% rename from examples/try_me.py rename to examples/basics/try_me.py diff --git a/nornsible/__init__.py b/nornsible/__init__.py index de3a3b8..82bd103 100644 --- a/nornsible/__init__.py +++ b/nornsible/__init__.py @@ -1,28 +1,20 @@ -"""nornir tag and host/group limit wrapper""" +"""ansible-like inventory utility for nornir""" import logging from logging import NullHandler -from nornsible.nornsible import ( - InitNornsible, - nornsible_delegate, - nornsible_task, - nornsible_task_message, - patch_inventory, - parse_cli_args, - patch_config, - print_result, -) +from nornsible.decorators import nornsible_delegate, nornsible_task + +from nornsible.nornsible import InitNornsible +from nornsible.functions import print_result +from nornsible.inventory import AnsibleInventory -__version__ = "2019.11.05" +__version__ = "2020.01.11" __all__ = ( + "AnsibleInventory", "InitNornsible", "nornsible_delegate", "nornsible_task", - "nornsible_task_message", - "patch_inventory", - "parse_cli_args", - "patch_config", "print_result", ) diff --git a/nornsible/cli.py b/nornsible/cli.py new file mode 100644 index 0000000..da54a55 --- /dev/null +++ b/nornsible/cli.py @@ -0,0 +1,57 @@ +import argparse +from typing import List + + +def parse_cli_args(raw_args: List[str]) -> dict: + """ + Parse CLI provided arguments; ignore unrecognized. + + Arguments: + raw_args: List of CLI provided arguments + + Returns: + cli_args: Processed CLI arguments + + Raises: + N/A # noqa + + """ + parser = argparse.ArgumentParser(description="Nornir Script Wrapper") + parser.add_argument( + "-w", + "--workers", + help="number of workers to set for global configuration", + type=int, + default=0, + ) + parser.add_argument( + "-l", + "--limit", + help="limit to host or comma separated list of hosts", + type=str.lower, + default="", + ) + parser.add_argument( + "-g", + "--groups", + help="limit to group or comma separated list of groups", + type=str.lower, + default="", + ) + parser.add_argument( + "-t", "--tags", help="names of tasks to explicitly run", type=str.lower, default="" + ) + parser.add_argument("-s", "--skip", help="names of tasks to skip", type=str.lower, default="") + parser.add_argument( + "-d", "--disable-delegate", help="disable adding delegate host", action="store_true" + ) + args, _ = parser.parse_known_args(raw_args) + cli_args = { + "workers": args.workers if args.workers else False, + "limit": set(args.limit.split(",")) if args.limit else False, + "groups": set(args.groups.split(",")) if args.groups else False, + "run_tags": set(args.tags.split(",")) if args.tags else [], + "skip_tags": set(args.skip.split(",")) if args.skip else [], + "disable_delegate": args.disable_delegate, + } + return cli_args diff --git a/nornsible/decorators.py b/nornsible/decorators.py new file mode 100644 index 0000000..71c33fb --- /dev/null +++ b/nornsible/decorators.py @@ -0,0 +1,113 @@ +import threading +from typing import Dict, List, Any, Union, Callable, Optional + +from colorama import Back, Fore, init, Style +from nornir.core.task import Result, Task + + +init(autoreset=True, strip=False) +LOCK = threading.Lock() + + +def nornsible_task_message(msg: str, critical: Optional[bool] = False) -> None: + """ + Handle printing pretty messages for nornsible_task decorator + + Args: + msg: message to beautifully print to stdout + critical: (optional) message is critical + + Returns: + N/A + + Raises: + N/A # noqa + + """ + if critical: + back = Back.RED + fore = Fore.WHITE + else: + back = Back.CYAN + fore = Fore.WHITE + + LOCK.acquire() + try: + print(f"{Style.BRIGHT}{back}{fore}{msg}{'-' * (80 - len(msg))}") + finally: + LOCK.release() + + +def nornsible_task(wrapped_func: Callable) -> Callable: + """ + Decorate an "operation" -- execute or skip the operation based on tags + + Args: + wrapped_func: function to wrap in tag processor + + Returns: + tag_wrapper: wrapped function + + Raises: + N/A # noqa + + """ + + def tag_wrapper( + task: Task, *args: List[Any], **kwargs: Dict[str, Any] + ) -> Union[Callable, Result]: + if task.host.name == "delegate": + return Result( + host=task.host, result="Task skipped, delegate host!", failed=False, changed=False + ) + if {wrapped_func.__name__}.intersection(task.nornir.skip_tags): + msg = f"---- {task.host} skipping task {wrapped_func.__name__} " + nornsible_task_message(msg) + return Result(host=task.host, result="Task skipped!", failed=False, changed=False) + if not task.nornir.run_tags: + return wrapped_func(task, *args, **kwargs) + if {wrapped_func.__name__}.intersection(task.nornir.run_tags): + return wrapped_func(task, *args, **kwargs) + msg = f"---- {task.host} skipping task {wrapped_func.__name__} " + nornsible_task_message(msg) + return Result(host=task.host, result="Task skipped!", failed=False, changed=False) + + tag_wrapper.__name__ = wrapped_func.__name__ + return tag_wrapper + + +def nornsible_delegate(wrapped_func: Callable) -> Callable: + """ + Decorate an "operation" -- execute only on "delegate" (localhost) + + Args: + wrapped_func: function to wrap in delegate_wrapper + + Returns: + tag_wrapper: wrapped function + + Raises: + N/A # noqa + + """ + + def delegate_wrapper( + task: Task, *args: List[Any], **kwargs: Dict[str, Any] + ) -> Union[Callable, Result]: + if "delegate" not in task.nornir.inventory.hosts.keys(): + msg = f"---- WARNING no delegate available for task {wrapped_func.__name__} " + nornsible_task_message(msg, critical=True) + return Result( + host=task.host, result="Task skipped, delegate host!", failed=False, changed=False + ) + if task.host.name != "delegate": + return Result( + host=task.host, + result="Task skipped, non-delegate host!", + failed=False, + changed=False, + ) + return wrapped_func(task, *args, **kwargs) + + delegate_wrapper.__name__ = wrapped_func.__name__ + return delegate_wrapper diff --git a/nornsible/functions.py b/nornsible/functions.py new file mode 100644 index 0000000..c6d3e9a --- /dev/null +++ b/nornsible/functions.py @@ -0,0 +1,36 @@ +import logging +import threading +from typing import List, Optional + +from nornir.core.task import AggregatedResult, MultiResult, Result +from nornir.plugins.functions.text import _print_result + + +LOCK = threading.Lock() + + +def print_result( + result: Result, + host: Optional[str] = None, + nr_vars: List[str] = None, + failed: bool = False, + severity_level: int = logging.INFO, +) -> None: + updated_agg_result = AggregatedResult(result.name) + for hostname, multi_result in result.items(): + updated_multi_result = MultiResult(result.name) + for r in multi_result: + if isinstance(r.result, str) and r.result.startswith("Task skipped"): + continue + updated_multi_result.append(r) + if updated_multi_result: + updated_agg_result[hostname] = updated_multi_result # noqa + + if not updated_agg_result: + return + + LOCK.acquire() + try: + _print_result(updated_agg_result, host, nr_vars, failed, severity_level) + finally: + LOCK.release() diff --git a/nornsible/inventory.py b/nornsible/inventory.py new file mode 100644 index 0000000..4d89220 --- /dev/null +++ b/nornsible/inventory.py @@ -0,0 +1,301 @@ +import configparser as cp +from collections import defaultdict +from copy import deepcopy +import json +from json.decoder import JSONDecodeError +import logging +import os +from pathlib import Path +import subprocess +from typing import ( + Any, + DefaultDict, + Dict, + List, + MutableMapping, + Tuple, +) + +from nornir.core.deserializer.inventory import Inventory + +# Support Nornir 2.2.0 -> 2.3.0 +try: + from nornir.core.exceptions import NornirNoValidInventoryError +except ImportError: + + class NornirNoValidInventoryError(Exception): # type: ignore + pass + + +from nornir.plugins.inventory.ansible import ( + AnsibleParser, + INIParser, + YAMLParser, + VARS_FILENAME_EXTENSIONS, +) +from ruamel.yaml.composer import ComposerError +from ruamel.yaml.scanner import ScannerError + +NORNIR_LOGGER = logging.getLogger("nornir") +VARS_FILENAME_EXTENSIONS.append(".py") + + +class ScriptParser(AnsibleParser): + def verify_file(self) -> bool: + with open(self.hostsfile, "rb") as inv_file: + initial_chars = inv_file.read(2) + if initial_chars.startswith(b"#!") and os.access(self.hostsfile, os.X_OK): + return True + return False + + def load_hosts_file(self) -> None: + if not self.verify_file(): + raise TypeError(f"AnsibleInventory: invalid script file {self.hostsfile}") + + proc = subprocess.Popen( + [self.hostsfile, "--list"], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + std_out, std_err = proc.communicate() + + if proc.returncode != 0: + raise OSError(f"AnsibleInventory: {self.hostsfile} exited with non-zero return code") + + processed = json.loads(std_out.decode()) + self.original_data = self.normalize(processed) + + @staticmethod + def normalize(data: Dict[str, Any]) -> Dict[str, Any]: + groups: DefaultDict[str, Dict[str, Any]] = defaultdict(dict) + result: Dict[str, Dict[str, Dict[str, Dict[str, Any]]]] = {"all": {"children": groups}} + + # hostvars are stored in ["_meta"]["hostvars"] if present + hostvars = data.get("_meta", {}).get("hostvars", None) + + if "all" in data.keys(): + data = data["all"] + if "vars" in data.keys(): + groups["defaults"]["vars"] = data.pop("vars") + + for group, gdata in data.items(): + if "vars" in gdata.keys(): + groups[group]["vars"] = gdata["vars"] + if "children" in gdata.keys(): + groups[group]["children"] = {} + for child in gdata["children"]: + groups[group]["children"][child] = {} + if "hosts" in gdata.keys(): + groups[group]["hosts"] = {} + for host in gdata["hosts"]: + groups[group]["hosts"][host] = hostvars.get(host, None) + return result + + +class AnsibleInventory(Inventory): + def __init__(self, inventory: str = "", hash_behavior: str = "replace", **kwargs: Any,) -> None: + """ + Ansible Inventory plugin supporting ini, yaml, and dynamic inventory sources. + + Arguments: + inventory: Comma separated list of valid Ansible inventory sources + hash_behavior: Determines behavior of how duplicate dicts vars in inventory are handled. + With 'replace' (default), highest priority (first file parsed) dicts are retained + and subsequent dicts ignored. With 'merge', subsequent dicts are merged into any + higher priority dicts in inventory. This is intended to duplicate Ansible + "hash_behaviour" setting. + **kwargs: keyword arguments to pas to super + + Returns: + N/A # noqa + + Raises: + N/A # noqa + + """ + if hash_behavior.lower() not in ("replace", "merge"): + raise ValueError( + f"'hash_behavior' value {hash_behavior} is invalid, must be replace|merge" + ) + hosts, groups, defaults = self.parse(inventory, hash_behavior) + super().__init__(hosts=hosts, groups=groups, defaults=defaults, **kwargs) + + def combine_inventory( + self, inventory_one: Dict[str, Any], inventory_two: Dict[str, Any], hash_behavior: str + ) -> Dict[str, Any]: + """ + Parent method for combining inventory based on hash_behavior + + Arguments: + inventory_one: TODO + inventory_two: TODO + hash_behavior: see init method + + Returns: + inventory_update: updated inventory based on hash_behavior + + Raises: + N/A # noqa + + """ + if hash_behavior == "merge": + return self._merge_inventory(inventory_one, inventory_two) + return self._replace_inventory(inventory_one, inventory_two) + + def _merge_inventory( + self, inventory_one: Dict[str, Any], inventory_two: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Merge inventory data + + Arguments: + inventory_one: TODO + inventory_two: TODO + + Returns: + inventory_update: updated inventory based on hash_behavior + + Raises: + N/A # noqa + + """ + # if inventory_one is empty or inventories are equal, return copy of inventory_two + if not inventory_one or inventory_one == inventory_two: + return deepcopy(inventory_two) + + # deepcopy original (inventory_one) to new dict (inventory_update) + inventory_update = deepcopy(inventory_one) + + # iterate through inventory_two k,v + for k, v in inventory_two.items(): + # if inventory_two k is already in new dict (inventory_update) and the value in both + # original dicts (inventory_one copy as inventory_update, and inventory_two that we are + # currently iterating over) is a mapping (dict), create new value as dict of + # inventory_two value. pass back to this merge method to merge the child dict + if ( + k in inventory_update.keys() + and isinstance(inventory_update[k], MutableMapping) + and isinstance(v, MutableMapping) + ): + updated_value: Dict[str, Any] = dict(v) + inventory_update[k] = self._merge_inventory(inventory_update[k], updated_value) + # otherwise, simply assign value to key in new dict (inventory_update) + else: + inventory_update[k] = v + return inventory_update + + @staticmethod + def _replace_inventory( + inventory_one: Dict[str, Any], inventory_two: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Replace inventory data + + Arguments: + inventory_one: TODO + inventory_two: TODO + + Returns: + inventory_update: updated inventory based on hash_behavior + + Raises: + N/A # noqa + + """ + # deepcopy original (inventory_one) to new dict (inventory_update) + inventory_update = deepcopy(inventory_one) + # update new dict (inventory_update) w/ inventory_two + inventory_update.update(inventory_two) + return inventory_update + + @staticmethod + def _gather_possible_inventory_sources(inventory: str) -> List[str]: + possible_sources: List[str] = [] + inventory_locations = inventory.split(",") + + for location in inventory_locations: + inv: Path = Path(location).expanduser() + if inv.is_dir(): + files = Path(location).glob("*") + possible_sources.extend( + [ + str(f.resolve()) + for f in files + if f.suffix in VARS_FILENAME_EXTENSIONS and not f.is_dir() + ] + ) + elif inv.is_file(): + possible_sources.append(str(inv.resolve())) + return possible_sources + + @staticmethod + def _gather_valid_inventory_sources(possible_sources: List[str]) -> List[AnsibleParser]: + valid_sources: List[AnsibleParser] = [] + for possible_source in possible_sources: + try: + parser: AnsibleParser = INIParser(possible_source) + valid_sources.append(parser) + continue + except cp.Error: + NORNIR_LOGGER.info( + "AnsibleInventory: file %r is not INI file, moving to next parser...", + possible_source, + ) + try: + parser = YAMLParser(possible_source) + valid_sources.append(parser) + continue + except (ScannerError, ComposerError): + NORNIR_LOGGER.info( + "AnsibleInventory: file %r is not YAML file, moving to next parser...", + possible_source, + ) + try: + parser = ScriptParser(possible_source) + valid_sources.append(parser) + continue + except (TypeError, OSError, JSONDecodeError) as e: + NORNIR_LOGGER.info( + "AnsibleInventory: file %r is not executable Python file. " + "Error: %r no more parsers to try...", + possible_source, + e, + ) + return valid_sources + + def parse( + self, inventory: str, hash_behavior: str + ) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]: + """ + Merge inventory data + + Arguments: + inventory: Comma separated list of valid Ansible inventory sources + hash_behavior: see init method + + Returns: + hosts: dict of all hosts parsed in inventory source(s) + groups: dict of all groups parsed in inventory source(s) + defaults: dict of all defaults parsed in inventory source(s) + + Raises: + NornirNoValidInventoryError: if no valid inventory sources to parse + + """ + possible_sources: List = self._gather_possible_inventory_sources(inventory) + valid_sources: List[AnsibleParser] = self._gather_valid_inventory_sources(possible_sources) + + if not valid_sources: + raise NornirNoValidInventoryError( + f"AnsibleInventory: no valid inventory source(s). Tried: {possible_sources}" + ) + + hosts: Dict[str, Any] = {} + groups: Dict[str, Any] = {} + defaults: Dict[str, Any] = {} + + for source in valid_sources: + source.parse() + hosts = self.combine_inventory(hosts, source.hosts, hash_behavior) + groups = self.combine_inventory(groups, source.groups, hash_behavior) + defaults = self.combine_inventory(defaults, source.defaults, hash_behavior) + + return hosts, groups, defaults diff --git a/nornsible/nornsible.py b/nornsible/nornsible.py index 60353fb..97cf430 100644 --- a/nornsible/nornsible.py +++ b/nornsible/nornsible.py @@ -1,72 +1,9 @@ -import argparse -import logging import sys -import threading -from typing import Dict, List, Any, Union, Callable, Optional -from colorama import Back, Fore, init, Style from nornir.core import Nornir, Config, Inventory from nornir.core.inventory import Host -from nornir.core.task import AggregatedResult, MultiResult, Result, Task -from nornir.plugins.functions.text import _print_result -init(autoreset=True, strip=False) -LOCK = threading.Lock() - - -def parse_cli_args(raw_args: List[str]) -> dict: - """ - Parse CLI provided arguments; ignore unrecognized. - - Arguments: - raw_args: List of CLI provided arguments - - Returns: - cli_args: Processed CLI arguments - - Raises: - N/A # noqa - - """ - parser = argparse.ArgumentParser(description="Nornir Script Wrapper") - parser.add_argument( - "-w", - "--workers", - help="number of workers to set for global configuration", - type=int, - default=0, - ) - parser.add_argument( - "-l", - "--limit", - help="limit to host or comma separated list of hosts", - type=str.lower, - default="", - ) - parser.add_argument( - "-g", - "--groups", - help="limit to group or comma separated list of groups", - type=str.lower, - default="", - ) - parser.add_argument( - "-t", "--tags", help="names of tasks to explicitly run", type=str.lower, default="" - ) - parser.add_argument("-s", "--skip", help="names of tasks to skip", type=str.lower, default="") - parser.add_argument( - "-d", "--disable-delegate", help="disable adding delegate host", action="store_true" - ) - args, _ = parser.parse_known_args(raw_args) - cli_args = { - "workers": args.workers if args.workers else False, - "limit": set(args.limit.split(",")) if args.limit else False, - "groups": set(args.groups.split(",")) if args.groups else False, - "run_tags": set(args.tags.split(",")) if args.tags else [], - "skip_tags": set(args.skip.split(",")) if args.skip else [], - "disable_delegate": args.disable_delegate, - } - return cli_args +from nornsible.cli import parse_cli_args def patch_inventory(cli_args: dict, inv: Inventory) -> Inventory: @@ -152,137 +89,6 @@ def patch_config(cli_args: dict, conf: Config) -> Config: return conf -def nornsible_task_message(msg: str, critical: Optional[bool] = False) -> None: - """ - Handle printing pretty messages for nornsible_task decorator - - Args: - msg: message to beautifully print to stdout - critical: (optional) message is critical - - Returns: - N/A - - Raises: - N/A # noqa - - """ - if critical: - back = Back.RED - fore = Fore.WHITE - else: - back = Back.CYAN - fore = Fore.WHITE - - LOCK.acquire() - try: - print(f"{Style.BRIGHT}{back}{fore}{msg}{'-' * (80 - len(msg))}") - finally: - LOCK.release() - - -def nornsible_task(wrapped_func: Callable) -> Callable: - """ - Decorate an "operation" -- execute or skip the operation based on tags - - Args: - wrapped_func: function to wrap in tag processor - - Returns: - tag_wrapper: wrapped function - - Raises: - N/A # noqa - - """ - - def tag_wrapper( - task: Task, *args: List[Any], **kwargs: Dict[str, Any] - ) -> Union[Callable, Result]: - if task.host.name == "delegate": - return Result( - host=task.host, result="Task skipped, delegate host!", failed=False, changed=False - ) - if {wrapped_func.__name__}.intersection(task.nornir.skip_tags): - msg = f"---- {task.host} skipping task {wrapped_func.__name__} " - nornsible_task_message(msg) - return Result(host=task.host, result="Task skipped!", failed=False, changed=False) - if not task.nornir.run_tags: - return wrapped_func(task, *args, **kwargs) - if {wrapped_func.__name__}.intersection(task.nornir.run_tags): - return wrapped_func(task, *args, **kwargs) - msg = f"---- {task.host} skipping task {wrapped_func.__name__} " - nornsible_task_message(msg) - return Result(host=task.host, result="Task skipped!", failed=False, changed=False) - - tag_wrapper.__name__ = wrapped_func.__name__ - return tag_wrapper - - -def nornsible_delegate(wrapped_func: Callable) -> Callable: - """ - Decorate an "operation" -- execute only on "delegate" (localhost) - - Args: - wrapped_func: function to wrap in delegate_wrapper - - Returns: - tag_wrapper: wrapped function - - Raises: - N/A # noqa - - """ - - def delegate_wrapper( - task: Task, *args: List[Any], **kwargs: Dict[str, Any] - ) -> Union[Callable, Result]: - if "delegate" not in task.nornir.inventory.hosts.keys(): - msg = f"---- WARNING no delegate available for task {wrapped_func.__name__} " - nornsible_task_message(msg, critical=True) - return Result( - host=task.host, result="Task skipped, delegate host!", failed=False, changed=False - ) - if task.host.name != "delegate": - return Result( - host=task.host, - result="Task skipped, non-delegate host!", - failed=False, - changed=False, - ) - return wrapped_func(task, *args, **kwargs) - - delegate_wrapper.__name__ = wrapped_func.__name__ - return delegate_wrapper - - -def print_result( - result: Result, - host: Optional[str] = None, - nr_vars: List[str] = None, - failed: bool = False, - severity_level: int = logging.INFO, -) -> None: - updated_agg_result = AggregatedResult(result.name) - for hostname, multi_result in result.items(): - updated_multi_result = MultiResult(result.name) - for r in multi_result: - if isinstance(r.result, str) and r.result.startswith("Task skipped"): - continue - updated_multi_result.append(r) - if updated_multi_result: - updated_agg_result[hostname] = updated_multi_result # noqa - - if not updated_agg_result: - return - - LOCK.acquire() - try: - _print_result(updated_agg_result, host, nr_vars, failed, severity_level) - finally: - LOCK.release() - - def InitNornsible(nr: Nornir) -> Nornir: """ Patch nornir object based on cli arguments diff --git a/setup.py b/setup.py index 7550f55..272c70b 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""nornir tag and host/group limit wrapper""" +"""ansible-like inventory utility for nornir""" import setuptools __author__ = "Carl Montanari" @@ -9,10 +9,10 @@ setuptools.setup( name="nornsible", - version="2019.11.05", + version="2020.01.11", author=__author__, author_email="carl.r.montanari@gmail.com", - description="Wrapper for tags and host/group limiting for nornir scripts.", + description="Ansible-like inventory utility for Nornir.", long_description=README, long_description_content_type="text/markdown", packages=setuptools.find_packages(), diff --git a/tests/_test_nornir_inventory/groups.yaml b/tests/_test_nornir_inventory/basic/groups.yaml similarity index 100% rename from tests/_test_nornir_inventory/groups.yaml rename to tests/_test_nornir_inventory/basic/groups.yaml diff --git a/tests/_test_nornir_inventory/hosts.yaml b/tests/_test_nornir_inventory/basic/hosts.yaml similarity index 100% rename from tests/_test_nornir_inventory/hosts.yaml rename to tests/_test_nornir_inventory/basic/hosts.yaml diff --git a/tests/_test_nornir_inventory/basic_script/expected/defaults.yaml b/tests/_test_nornir_inventory/basic_script/expected/defaults.yaml new file mode 100644 index 0000000..c4c3930 --- /dev/null +++ b/tests/_test_nornir_inventory/basic_script/expected/defaults.yaml @@ -0,0 +1,12 @@ +connection_options: {} +data: + ansible_connection: network_cli + ansible_network_os: ios + ansible_python_interpreter: python + ansible_ssh_common_args: -o ProxyCommand="ssh -W %h:%p -p 10000 guest@10.105.152.50" + env: staging +hostname: null +password: null +platform: null +port: null +username: null diff --git a/tests/_test_nornir_inventory/basic_script/expected/groups.yaml b/tests/_test_nornir_inventory/basic_script/expected/groups.yaml new file mode 100644 index 0000000..49e0ae7 --- /dev/null +++ b/tests/_test_nornir_inventory/basic_script/expected/groups.yaml @@ -0,0 +1,67 @@ +access: + connection_options: {} + data: {} + groups: + - ios + hostname: null + password: null + platform: null + port: null + username: null +asa: + connection_options: {} + data: + ansible_become: yes + ansible_become_method: enable + ansible_network_os: asa + ansible_persistent_command_timeout: 30 + cisco_asa: true + network_os: asa + groups: + - virl + hostname: null + password: null + platform: null + port: null + username: null +dist: + connection_options: {} + data: {} + groups: + - ios + hostname: null + password: null + platform: null + port: null + username: null +ios: + connection_options: {} + data: + network_os: ios + nornir_nos: ios + groups: + - virl + hostname: null + password: null + platform: ios + port: null + username: null +routers: + connection_options: {} + data: {} + groups: + - ios + hostname: null + password: null + platform: null + port: null + username: null +virl: + connection_options: {} + data: {} + groups: [] + hostname: null + password: null + platform: null + port: null + username: null diff --git a/tests/_test_nornir_inventory/basic_script/expected/hosts.yaml b/tests/_test_nornir_inventory/basic_script/expected/hosts.yaml new file mode 100644 index 0000000..8a874e5 --- /dev/null +++ b/tests/_test_nornir_inventory/basic_script/expected/hosts.yaml @@ -0,0 +1,61 @@ +access1: + connection_options: {} + data: {} + groups: + - access + hostname: 10.255.0.6 + password: null + platform: null + port: null + username: null +asa1: + connection_options: {} + data: + dot1x: true + groups: + - asa + hostname: 10.255.0.2 + password: null + platform: null + port: null + username: null +dist1: + connection_options: {} + data: {} + groups: + - dist + hostname: 10.255.0.5 + password: null + platform: null + port: null + username: null +dist2: + connection_options: {} + data: {} + groups: + - dist + hostname: 10.255.0.11 + password: null + platform: null + port: null + username: null +iosv-1: + connection_options: {} + data: {} + groups: + - routers + hostname: 10.255.0.12 + password: null + platform: null + port: null + username: null +iosv-2: + connection_options: {} + data: {} + groups: + - routers + hostname: 10.255.0.13 + password: null + platform: null + port: null + username: null diff --git a/tests/_test_nornir_inventory/basic_script/source/no_shebang.py b/tests/_test_nornir_inventory/basic_script/source/no_shebang.py new file mode 100755 index 0000000..ba2b25c --- /dev/null +++ b/tests/_test_nornir_inventory/basic_script/source/no_shebang.py @@ -0,0 +1,95 @@ +inv = """ +{ + "virl": { + "children": [ + "asa", + "ios" + ], + "hosts": [], + "vars": {} + }, + "asa": { + "children": [], + "hosts": [ + "asa1" + ], + "vars": { + "ansible_become": "yes", + "ansible_become_method": "enable", + "ansible_network_os": "asa", + "ansible_persistent_command_timeout": 30, + "cisco_asa": true, + "network_os": "asa" + } + }, + "ios": { + "children": [ + "access", + "dist", + "routers" + ], + "hosts": [], + "vars": { + "network_os": "ios", + "nornir_nos": "ios", + "platform": "ios" + } + }, + "access": { + "children": [], + "hosts": [ + "access1" + ], + "vars": {} + }, + "dist": { + "children": [], + "hosts": [ + "dist1", + "dist2" + ], + "vars": {} + }, + "routers": { + "children": [], + "hosts": [ + "iosv-1", + "iosv-2" + ], + "vars": {} + }, + "_meta": { + "hostvars": { + "asa1": { + "ansible_host": "10.255.0.2", + "dot1x": true + }, + "access1": { + "ansible_host": "10.255.0.6" + }, + "dist1": { + "ansible_host": "10.255.0.5" + }, + "dist2": { + "ansible_host": "10.255.0.11" + }, + "iosv-1": { + "ansible_host": "10.255.0.12" + }, + "iosv-2": { + "ansible_host": "10.255.0.13" + } + } + }, + "vars": { + "ansible_connection": "network_cli", + "ansible_network_os": "ios", + "ansible_python_interpreter": "python", + "ansible_ssh_common_args": "-o ProxyCommand=\\"ssh -W %h:%p -p 10000 guest@10.105.152.50\\"", + "env": "staging" + } +} +""" + + +print(inv) diff --git a/tests/_test_nornir_inventory/basic_script/source/non_executable.py b/tests/_test_nornir_inventory/basic_script/source/non_executable.py new file mode 100644 index 0000000..1def474 --- /dev/null +++ b/tests/_test_nornir_inventory/basic_script/source/non_executable.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +inv = """ +{ + "virl": { + "children": [ + "asa", + "ios" + ], + "hosts": [], + "vars": {} + }, + "asa": { + "children": [], + "hosts": [ + "asa1" + ], + "vars": { + "ansible_become": "yes", + "ansible_become_method": "enable", + "ansible_network_os": "asa", + "ansible_persistent_command_timeout": 30, + "cisco_asa": true, + "network_os": "asa" + } + }, + "ios": { + "children": [ + "access", + "dist", + "routers" + ], + "hosts": [], + "vars": { + "network_os": "ios", + "nornir_nos": "ios", + "platform": "ios" + } + }, + "access": { + "children": [], + "hosts": [ + "access1" + ], + "vars": {} + }, + "dist": { + "children": [], + "hosts": [ + "dist1", + "dist2" + ], + "vars": {} + }, + "routers": { + "children": [], + "hosts": [ + "iosv-1", + "iosv-2" + ], + "vars": {} + }, + "_meta": { + "hostvars": { + "asa1": { + "ansible_host": "10.255.0.2", + "dot1x": true + }, + "access1": { + "ansible_host": "10.255.0.6" + }, + "dist1": { + "ansible_host": "10.255.0.5" + }, + "dist2": { + "ansible_host": "10.255.0.11" + }, + "iosv-1": { + "ansible_host": "10.255.0.12" + }, + "iosv-2": { + "ansible_host": "10.255.0.13" + } + } + }, + "vars": { + "ansible_connection": "network_cli", + "ansible_network_os": "ios", + "ansible_python_interpreter": "python", + "ansible_ssh_common_args": "-o ProxyCommand=\\"ssh -W %h:%p -p 10000 guest@10.105.152.50\\"", + "env": "staging" + } +} +""" + + +print(inv) diff --git a/tests/_test_nornir_inventory/basic_script/source/non_zero_exit.py b/tests/_test_nornir_inventory/basic_script/source/non_zero_exit.py new file mode 100755 index 0000000..9350e91 --- /dev/null +++ b/tests/_test_nornir_inventory/basic_script/source/non_zero_exit.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +import sys + +sys.exit(1) diff --git a/tests/_test_nornir_inventory/basic_script/source/success.py b/tests/_test_nornir_inventory/basic_script/source/success.py new file mode 100755 index 0000000..1def474 --- /dev/null +++ b/tests/_test_nornir_inventory/basic_script/source/success.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +inv = """ +{ + "virl": { + "children": [ + "asa", + "ios" + ], + "hosts": [], + "vars": {} + }, + "asa": { + "children": [], + "hosts": [ + "asa1" + ], + "vars": { + "ansible_become": "yes", + "ansible_become_method": "enable", + "ansible_network_os": "asa", + "ansible_persistent_command_timeout": 30, + "cisco_asa": true, + "network_os": "asa" + } + }, + "ios": { + "children": [ + "access", + "dist", + "routers" + ], + "hosts": [], + "vars": { + "network_os": "ios", + "nornir_nos": "ios", + "platform": "ios" + } + }, + "access": { + "children": [], + "hosts": [ + "access1" + ], + "vars": {} + }, + "dist": { + "children": [], + "hosts": [ + "dist1", + "dist2" + ], + "vars": {} + }, + "routers": { + "children": [], + "hosts": [ + "iosv-1", + "iosv-2" + ], + "vars": {} + }, + "_meta": { + "hostvars": { + "asa1": { + "ansible_host": "10.255.0.2", + "dot1x": true + }, + "access1": { + "ansible_host": "10.255.0.6" + }, + "dist1": { + "ansible_host": "10.255.0.5" + }, + "dist2": { + "ansible_host": "10.255.0.11" + }, + "iosv-1": { + "ansible_host": "10.255.0.12" + }, + "iosv-2": { + "ansible_host": "10.255.0.13" + } + } + }, + "vars": { + "ansible_connection": "network_cli", + "ansible_network_os": "ios", + "ansible_python_interpreter": "python", + "ansible_ssh_common_args": "-o ProxyCommand=\\"ssh -W %h:%p -p 10000 guest@10.105.152.50\\"", + "env": "staging" + } +} +""" + + +print(inv) diff --git a/tests/_test_nornir_inventory/basic_script/source/success_all_in_keys.py b/tests/_test_nornir_inventory/basic_script/source/success_all_in_keys.py new file mode 100755 index 0000000..8fd6347 --- /dev/null +++ b/tests/_test_nornir_inventory/basic_script/source/success_all_in_keys.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +inv = """ +{ + "all": { + "virl": { + "children": [ + "asa", + "ios" + ], + "hosts": [], + "vars": {} + }, + "asa": { + "children": [], + "hosts": [ + "asa1" + ], + "vars": { + "ansible_become": "yes", + "ansible_become_method": "enable", + "ansible_network_os": "asa", + "ansible_persistent_command_timeout": 30, + "cisco_asa": true, + "network_os": "asa" + } + }, + "ios": { + "children": [ + "access", + "dist", + "routers" + ], + "hosts": [], + "vars": { + "network_os": "ios", + "nornir_nos": "ios", + "platform": "ios" + } + }, + "access": { + "children": [], + "hosts": [ + "access1" + ], + "vars": {} + }, + "dist": { + "children": [], + "hosts": [ + "dist1", + "dist2" + ], + "vars": {} + }, + "routers": { + "children": [], + "hosts": [ + "iosv-1", + "iosv-2" + ], + "vars": {} + } + }, + "_meta": { + "hostvars": { + "asa1": { + "ansible_host": "10.255.0.2", + "dot1x": true + }, + "access1": { + "ansible_host": "10.255.0.6" + }, + "dist1": { + "ansible_host": "10.255.0.5" + }, + "dist2": { + "ansible_host": "10.255.0.11" + }, + "iosv-1": { + "ansible_host": "10.255.0.12" + }, + "iosv-2": { + "ansible_host": "10.255.0.13" + } + } + }, + "vars": { + "ansible_connection": "network_cli", + "ansible_network_os": "ios", + "ansible_python_interpreter": "python", + "ansible_ssh_common_args": "-o ProxyCommand=\\"ssh -W %h:%p -p 10000 guest@10.105.152.50\\"", + "env": "staging" + } +} +""" + + +print(inv) diff --git a/tests/_test_nornir_inventory/multiple_sources/expected/merge/defaults.yaml b/tests/_test_nornir_inventory/multiple_sources/expected/merge/defaults.yaml new file mode 100644 index 0000000..debee65 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/expected/merge/defaults.yaml @@ -0,0 +1,14 @@ +connection_options: {} +data: + ansible_connection: network_cli + ansible_network_os: ios + ansible_python_interpreter: python + ansible_ssh_common_args: -o ProxyCommand="ssh -W %h:%p -p 10000 guest@10.105.152.50" + env: staging + my_other_var: from_all + my_var: from_all +hostname: null +password: null +platform: null +port: null +username: null diff --git a/tests/_test_nornir_inventory/multiple_sources/expected/merge/groups.yaml b/tests/_test_nornir_inventory/multiple_sources/expected/merge/groups.yaml new file mode 100644 index 0000000..ccd7fe8 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/expected/merge/groups.yaml @@ -0,0 +1,111 @@ +access: + connection_options: {} + data: {} + groups: + - ios + hostname: null + password: null + platform: null + port: null + username: null +asa: + connection_options: {} + data: + ansible_become: yes + ansible_become_method: enable + ansible_network_os: asa + ansible_persistent_command_timeout: 30 + cisco_asa: true + network_os: asa + groups: + - virl + hostname: null + password: null + platform: null + port: null + username: null +dist: + connection_options: {} + data: {} + groups: + - ios + hostname: null + password: null + platform: null + port: null + username: null +ios: + connection_options: {} + data: + network_os: ios + nornir_nos: ios + groups: + - virl + hostname: null + password: null + platform: ios + port: null + username: null +routers: + connection_options: {} + data: {} + groups: + - ios + hostname: null + password: null + platform: null + port: null + username: null +virl: + connection_options: {} + data: {} + groups: [] + hostname: null + password: null + platform: null + port: null + username: null +dbservers: + connection_options: {} + data: + my_var: from_dbservers + my_yet_another_var: from_dbservers + groups: + - servers + hostname: null + password: null + platform: null + port: null + username: null +frontend: + connection_options: {} + data: {} + groups: [] + hostname: null + password: null + platform: null + port: null + username: null +servers: + connection_options: {} + data: + escape_pods: 2 + halon_system_timeout: 30 + self_destruct_countdown: 60 + some_server: foo.southeast.example.com + groups: [] + hostname: null + password: null + platform: null + port: null + username: null +webservers: + connection_options: {} + data: {} + groups: + - servers + hostname: null + password: null + platform: null + port: null + username: null diff --git a/tests/_test_nornir_inventory/multiple_sources/expected/merge/hosts.yaml b/tests/_test_nornir_inventory/multiple_sources/expected/merge/hosts.yaml new file mode 100644 index 0000000..3d716b0 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/expected/merge/hosts.yaml @@ -0,0 +1,115 @@ +access1: + connection_options: {} + data: {} + groups: + - access + hostname: 10.255.0.6 + password: null + platform: null + port: null + username: null +asa1: + connection_options: {} + data: + dot1x: true + groups: + - asa + hostname: 10.255.0.2 + password: null + platform: null + port: null + username: null +dist1: + connection_options: {} + data: {} + groups: + - dist + hostname: 10.255.0.5 + password: null + platform: null + port: null + username: null +dist2: + connection_options: {} + data: {} + groups: + - dist + hostname: 10.255.0.11 + password: null + platform: null + port: null + username: null +iosv-1: + connection_options: {} + data: {} + groups: + - routers + hostname: 10.255.0.12 + password: null + platform: null + port: null + username: null +iosv-2: + connection_options: {} + data: {} + groups: + - routers + hostname: 10.255.0.13 + password: null + platform: null + port: null + username: null +bar.example.com: + connection_options: {} + data: {} + groups: + - webservers + hostname: null + password: null + platform: null + port: null + username: null +foo.example.com: + connection_options: {} + data: {} + groups: + - frontend + - webservers + hostname: null + password: null + platform: null + port: null + username: null +one.example.com: + connection_options: {} + data: + my_var: from_one.example.com + groups: + - dbservers + hostname: null + password: null + platform: null + port: null + username: null +three.example.com: + connection_options: {} + data: {} + groups: + - dbservers + hostname: 192.0.2.50 + password: null + platform: null + port: 5555 + username: null +two.example.com: + connection_options: {} + data: + my_var: from_hostfile + whatever: asdasd + groups: + - dbservers + hostname: null + password: null + platform: null + port: null + username: null \ No newline at end of file diff --git a/tests/_test_nornir_inventory/multiple_sources/expected/replace/defaults.yaml b/tests/_test_nornir_inventory/multiple_sources/expected/replace/defaults.yaml new file mode 100644 index 0000000..c4c3930 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/expected/replace/defaults.yaml @@ -0,0 +1,12 @@ +connection_options: {} +data: + ansible_connection: network_cli + ansible_network_os: ios + ansible_python_interpreter: python + ansible_ssh_common_args: -o ProxyCommand="ssh -W %h:%p -p 10000 guest@10.105.152.50" + env: staging +hostname: null +password: null +platform: null +port: null +username: null diff --git a/tests/_test_nornir_inventory/multiple_sources/expected/replace/groups.yaml b/tests/_test_nornir_inventory/multiple_sources/expected/replace/groups.yaml new file mode 100644 index 0000000..ccd7fe8 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/expected/replace/groups.yaml @@ -0,0 +1,111 @@ +access: + connection_options: {} + data: {} + groups: + - ios + hostname: null + password: null + platform: null + port: null + username: null +asa: + connection_options: {} + data: + ansible_become: yes + ansible_become_method: enable + ansible_network_os: asa + ansible_persistent_command_timeout: 30 + cisco_asa: true + network_os: asa + groups: + - virl + hostname: null + password: null + platform: null + port: null + username: null +dist: + connection_options: {} + data: {} + groups: + - ios + hostname: null + password: null + platform: null + port: null + username: null +ios: + connection_options: {} + data: + network_os: ios + nornir_nos: ios + groups: + - virl + hostname: null + password: null + platform: ios + port: null + username: null +routers: + connection_options: {} + data: {} + groups: + - ios + hostname: null + password: null + platform: null + port: null + username: null +virl: + connection_options: {} + data: {} + groups: [] + hostname: null + password: null + platform: null + port: null + username: null +dbservers: + connection_options: {} + data: + my_var: from_dbservers + my_yet_another_var: from_dbservers + groups: + - servers + hostname: null + password: null + platform: null + port: null + username: null +frontend: + connection_options: {} + data: {} + groups: [] + hostname: null + password: null + platform: null + port: null + username: null +servers: + connection_options: {} + data: + escape_pods: 2 + halon_system_timeout: 30 + self_destruct_countdown: 60 + some_server: foo.southeast.example.com + groups: [] + hostname: null + password: null + platform: null + port: null + username: null +webservers: + connection_options: {} + data: {} + groups: + - servers + hostname: null + password: null + platform: null + port: null + username: null diff --git a/tests/_test_nornir_inventory/multiple_sources/expected/replace/hosts.yaml b/tests/_test_nornir_inventory/multiple_sources/expected/replace/hosts.yaml new file mode 100644 index 0000000..3d716b0 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/expected/replace/hosts.yaml @@ -0,0 +1,115 @@ +access1: + connection_options: {} + data: {} + groups: + - access + hostname: 10.255.0.6 + password: null + platform: null + port: null + username: null +asa1: + connection_options: {} + data: + dot1x: true + groups: + - asa + hostname: 10.255.0.2 + password: null + platform: null + port: null + username: null +dist1: + connection_options: {} + data: {} + groups: + - dist + hostname: 10.255.0.5 + password: null + platform: null + port: null + username: null +dist2: + connection_options: {} + data: {} + groups: + - dist + hostname: 10.255.0.11 + password: null + platform: null + port: null + username: null +iosv-1: + connection_options: {} + data: {} + groups: + - routers + hostname: 10.255.0.12 + password: null + platform: null + port: null + username: null +iosv-2: + connection_options: {} + data: {} + groups: + - routers + hostname: 10.255.0.13 + password: null + platform: null + port: null + username: null +bar.example.com: + connection_options: {} + data: {} + groups: + - webservers + hostname: null + password: null + platform: null + port: null + username: null +foo.example.com: + connection_options: {} + data: {} + groups: + - frontend + - webservers + hostname: null + password: null + platform: null + port: null + username: null +one.example.com: + connection_options: {} + data: + my_var: from_one.example.com + groups: + - dbservers + hostname: null + password: null + platform: null + port: null + username: null +three.example.com: + connection_options: {} + data: {} + groups: + - dbservers + hostname: 192.0.2.50 + password: null + platform: null + port: 5555 + username: null +two.example.com: + connection_options: {} + data: + my_var: from_hostfile + whatever: asdasd + groups: + - dbservers + hostname: null + password: null + platform: null + port: null + username: null \ No newline at end of file diff --git a/tests/_test_nornir_inventory/multiple_sources/source/source1/group_vars/all b/tests/_test_nornir_inventory/multiple_sources/source/source1/group_vars/all new file mode 100644 index 0000000..13cb84e --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/source/source1/group_vars/all @@ -0,0 +1,3 @@ +--- +my_var: from_all +my_other_var: from_all diff --git a/tests/_test_nornir_inventory/multiple_sources/source/source1/group_vars/dbservers b/tests/_test_nornir_inventory/multiple_sources/source/source1/group_vars/dbservers new file mode 100644 index 0000000..443b50c --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/source/source1/group_vars/dbservers @@ -0,0 +1,3 @@ +--- +my_var: from_dbservers +my_yet_another_var: from_dbservers diff --git a/tests/_test_nornir_inventory/multiple_sources/source/source1/host_vars/one.example.com b/tests/_test_nornir_inventory/multiple_sources/source/source1/host_vars/one.example.com new file mode 100644 index 0000000..a011cd0 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/source/source1/host_vars/one.example.com @@ -0,0 +1,2 @@ +--- +my_var: from_one.example.com diff --git a/tests/_test_nornir_inventory/multiple_sources/source/source1/host_vars/two.example.com b/tests/_test_nornir_inventory/multiple_sources/source/source1/host_vars/two.example.com new file mode 100644 index 0000000..9d27cf1 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/source/source1/host_vars/two.example.com @@ -0,0 +1,2 @@ +--- +whatever: asdasd diff --git a/tests/_test_nornir_inventory/multiple_sources/source/source1/hosts b/tests/_test_nornir_inventory/multiple_sources/source/source1/hosts new file mode 100644 index 0000000..4eeea25 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/source/source1/hosts @@ -0,0 +1,21 @@ +[webservers] +foo.example.com +bar.example.com + +[dbservers] +one.example.com +two.example.com my_var=from_hostfile +three.example.com ansible_port=5555 ansible_host=192.0.2.50 + +[frontend] +foo.example.com + +[servers:children] +webservers +dbservers + +[servers:vars] +some_server=foo.southeast.example.com +halon_system_timeout=30 +self_destruct_countdown=60 +escape_pods=2 diff --git a/tests/_test_nornir_inventory/multiple_sources/source/source2/hosts b/tests/_test_nornir_inventory/multiple_sources/source/source2/hosts new file mode 100755 index 0000000..f2f5e31 --- /dev/null +++ b/tests/_test_nornir_inventory/multiple_sources/source/source2/hosts @@ -0,0 +1,96 @@ +#!/usr/bin/env python +inv = """ +{ + "virl": { + "children": [ + "asa", + "ios" + ], + "hosts": [], + "vars": {} + }, + "asa": { + "children": [], + "hosts": [ + "asa1" + ], + "vars": { + "ansible_become": "yes", + "ansible_become_method": "enable", + "ansible_network_os": "asa", + "ansible_persistent_command_timeout": 30, + "cisco_asa": true, + "network_os": "asa" + } + }, + "ios": { + "children": [ + "access", + "dist", + "routers" + ], + "hosts": [], + "vars": { + "network_os": "ios", + "nornir_nos": "ios", + "platform": "ios" + } + }, + "access": { + "children": [], + "hosts": [ + "access1" + ], + "vars": {} + }, + "dist": { + "children": [], + "hosts": [ + "dist1", + "dist2" + ], + "vars": {} + }, + "routers": { + "children": [], + "hosts": [ + "iosv-1", + "iosv-2" + ], + "vars": {} + }, + "_meta": { + "hostvars": { + "asa1": { + "ansible_host": "10.255.0.2", + "dot1x": true + }, + "access1": { + "ansible_host": "10.255.0.6" + }, + "dist1": { + "ansible_host": "10.255.0.5" + }, + "dist2": { + "ansible_host": "10.255.0.11" + }, + "iosv-1": { + "ansible_host": "10.255.0.12" + }, + "iosv-2": { + "ansible_host": "10.255.0.13" + } + } + }, + "vars": { + "ansible_connection": "network_cli", + "ansible_network_os": "ios", + "ansible_python_interpreter": "python", + "ansible_ssh_common_args": "-o ProxyCommand=\\"ssh -W %h:%p -p 10000 guest@10.105.152.50\\"", + "env": "staging" + } +} +""" + + +print(inv) \ No newline at end of file diff --git a/tests/_test_nornir_inventory/parse_error/parse_error b/tests/_test_nornir_inventory/parse_error/parse_error new file mode 100644 index 0000000..d2e91f2 --- /dev/null +++ b/tests/_test_nornir_inventory/parse_error/parse_error @@ -0,0 +1,6 @@ +asdas +das +das +das +dasdasdasdasd=asd adadsad +asdasdasd: qqwewqe diff --git a/tests/integration/test_nornsible_integration.py b/tests/integration/test_nornsible_cli_integration.py similarity index 95% rename from tests/integration/test_nornsible_integration.py rename to tests/integration/test_nornsible_cli_integration.py index ebc7732..ba48771 100644 --- a/tests/integration/test_nornsible_integration.py +++ b/tests/integration/test_nornsible_cli_integration.py @@ -34,8 +34,8 @@ def test_nornsible_task_skip_task(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -54,8 +54,8 @@ def test_nornsible_task_skip_task_disable_delegate(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -73,8 +73,8 @@ def test_nornsible_task_explicit_task(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -99,8 +99,8 @@ def test_nornsible_task_no_tags(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -123,8 +123,8 @@ def test_nornsible_delegate(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -146,8 +146,8 @@ def test_nornsible_delegate_disable_delegate(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, diff --git a/tests/unit/test_nornsible_unit.py b/tests/unit/test_nornsible_cli_unit.py similarity index 92% rename from tests/unit/test_nornsible_unit.py rename to tests/unit/test_nornsible_cli_unit.py index 8f214fa..97b516f 100644 --- a/tests/unit/test_nornsible_unit.py +++ b/tests/unit/test_nornsible_cli_unit.py @@ -6,14 +6,11 @@ from nornir.core.task import AggregatedResult, MultiResult, Result import nornsible -from nornsible import ( - InitNornsible, - nornsible_task_message, - parse_cli_args, - patch_config, - patch_inventory, - print_result, -) +from nornsible import InitNornsible, print_result +from nornsible.nornsible import patch_config, patch_inventory +from nornsible.cli import parse_cli_args +from nornsible.decorators import nornsible_task_message + NORNSIBLE_DIR = nornsible.__file__ TEST_DIR = f"{Path(NORNSIBLE_DIR).parents[1]}/tests/" @@ -79,8 +76,8 @@ def test_patch_inventory_basic_limit_host(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -97,8 +94,8 @@ def test_patch_inventory_limit_host_ignore_case(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -114,8 +111,8 @@ def test_patch_inventory_basic_limit_group(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -131,8 +128,8 @@ def test_patch_inventory_limit_group_ignore_case(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -148,8 +145,8 @@ def test_patch_config_basic_limit_workers(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -165,8 +162,8 @@ def test_patch_inventory_basic_limit_host_invalid(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -182,8 +179,8 @@ def test_patch_inventory_basic_limit_group_invalid(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -199,8 +196,8 @@ def test_set_nornsible_limit_host(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -216,8 +213,8 @@ def test_set_nornsible_limit_host_disable_delegate(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -233,8 +230,8 @@ def test_set_nornsible_limit_group(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -250,8 +247,8 @@ def test_set_nornsible_limit_group_disable_delegate(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -267,8 +264,8 @@ def test_set_nornsible_workers(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -284,8 +281,8 @@ def test_set_nornsible_limithost_invalid(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -301,8 +298,8 @@ def test_set_nornsible_limithost_invalid_disable_delegate(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -318,8 +315,8 @@ def test_set_nornsible_limit_group_invalid(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -335,8 +332,8 @@ def test_set_nornsible_limit_group_invalid_disable_delegate(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -352,8 +349,8 @@ def test_set_nornsible_do_nothing(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, @@ -369,8 +366,8 @@ def test_set_nornsible_do_nothing_disable_delegate(): inventory={ "plugin": "nornir.plugins.inventory.simple.SimpleInventory", "options": { - "host_file": f"{TEST_DIR}_test_nornir_inventory/hosts.yaml", - "group_file": f"{TEST_DIR}_test_nornir_inventory/groups.yaml", + "host_file": f"{TEST_DIR}_test_nornir_inventory/basic/hosts.yaml", + "group_file": f"{TEST_DIR}_test_nornir_inventory/basic/groups.yaml", }, }, logging={"enabled": False}, diff --git a/tests/unit/test_nornsible_inventory_unit.py b/tests/unit/test_nornsible_inventory_unit.py new file mode 100644 index 0000000..70f3862 --- /dev/null +++ b/tests/unit/test_nornsible_inventory_unit.py @@ -0,0 +1,181 @@ +from pathlib import Path + +import pytest +import ruamel.yaml + +import nornsible +from nornsible.inventory import AnsibleInventory, NornirNoValidInventoryError, ScriptParser + + +NORNSIBLE_DIR = nornsible.__file__ +TEST_DIR = f"{Path(NORNSIBLE_DIR).parents[1]}/tests/" + + +def read(hosts_file, groups_file, defaults_file): + yml = ruamel.yaml.YAML(typ="safe") + with open(hosts_file, "r") as f: + hosts = yml.load(f) + with open(groups_file, "r") as f: + groups = yml.load(f) + with open(defaults_file, "r") as f: + defaults = yml.load(f) + return hosts, groups, defaults + + +def test_no_nornir_valid_inventory_exception(): + # not sure how best to test the actual inventory.py file (for coverage and completeness) + pass + + +def test_script_parser_verify_file_valid(): + parser = ScriptParser(f"{TEST_DIR}_test_nornir_inventory/basic_script/source/success.py") + assert parser.hostsfile == f"{TEST_DIR}_test_nornir_inventory/basic_script/source/success.py" + + +def test_script_parser_verify_file_invalid_not_executable(): + with pytest.raises(TypeError): + ScriptParser(f"{TEST_DIR}_test_nornir_inventory/basic_script/source/non_executable.py") + + +def test_script_parser_verify_file_invalid_no_shebang(): + with pytest.raises(TypeError): + ScriptParser(f"{TEST_DIR}_test_nornir_inventory/basic_script/source/no_shebang.py") + + +def test_script_parser_verify_file_invalid_non_zero_exit(): + with pytest.raises(OSError): + ScriptParser(f"{TEST_DIR}_test_nornir_inventory/basic_script/source/non_zero_exit.py") + + +def test_script_parser_load_hosts_original_data(): + parser = ScriptParser(f"{TEST_DIR}_test_nornir_inventory/basic_script/source/success.py") + assert parser.original_data == { + "all": { + "children": { + "defaults": { + "vars": { + "ansible_connection": "network_cli", + "ansible_network_os": "ios", + "ansible_python_interpreter": "python", + "ansible_ssh_common_args": '-o ProxyCommand="ssh -W %h:%p -p 10000 guest@10.105.152.50"', + "env": "staging", + } + }, + "virl": {"vars": {}, "children": {"asa": {}, "ios": {}}, "hosts": {}}, + "asa": { + "vars": { + "ansible_become": "yes", + "ansible_become_method": "enable", + "ansible_network_os": "asa", + "ansible_persistent_command_timeout": 30, + "cisco_asa": True, + "network_os": "asa", + }, + "children": {}, + "hosts": {"asa1": {"ansible_host": "10.255.0.2", "dot1x": True}}, + }, + "ios": { + "vars": {"network_os": "ios", "nornir_nos": "ios", "platform": "ios"}, + "children": {"access": {}, "dist": {}, "routers": {}}, + "hosts": {}, + }, + "access": { + "vars": {}, + "children": {}, + "hosts": {"access1": {"ansible_host": "10.255.0.6"}}, + }, + "dist": { + "vars": {}, + "children": {}, + "hosts": { + "dist1": {"ansible_host": "10.255.0.5"}, + "dist2": {"ansible_host": "10.255.0.11"}, + }, + }, + "routers": { + "vars": {}, + "children": {}, + "hosts": { + "iosv-1": {"ansible_host": "10.255.0.12"}, + "iosv-2": {"ansible_host": "10.255.0.13"}, + }, + }, + } + } + } + + +def test_script_parser_normalize(): + parser = ScriptParser(f"{TEST_DIR}_test_nornir_inventory/basic_script/source/success.py") + assert isinstance(parser.original_data["all"], dict) + assert isinstance(parser.original_data["all"]["children"], dict) + assert "_meta" not in parser.original_data["all"]["children"].keys() + assert all( + i in parser.original_data["all"]["children"].keys() + for i in ["virl", "asa", "ios", "access", "dist", "routers"] + ) + + +def test_script_parser_normalize_all_in_keys(): + parser = ScriptParser( + f"{TEST_DIR}_test_nornir_inventory/basic_script/source/success_all_in_keys.py" + ) + assert isinstance(parser.original_data["all"], dict) + assert isinstance(parser.original_data["all"]["children"], dict) + assert "_meta" not in parser.original_data["all"]["children"].keys() + assert all( + i in parser.original_data["all"]["children"].keys() + for i in ["virl", "asa", "ios", "access", "dist", "routers"] + ) + + +def test_ansible_inventory_invalid_hash(): + with pytest.raises(ValueError): + AnsibleInventory(f"{TEST_DIR}_test_nornir_inventory/basic_script/source/success.py", "blah") + + +def test_ansible_inventory_basic_script(): + hosts_file = f"{TEST_DIR}_test_nornir_inventory/basic_script/expected/hosts.yaml" + groups_file = f"{TEST_DIR}_test_nornir_inventory/basic_script/expected/groups.yaml" + defaults_file = f"{TEST_DIR}_test_nornir_inventory/basic_script/expected/defaults.yaml" + expected_hosts, expected_groups, expected_defaults = read( + hosts_file, groups_file, defaults_file + ) + inv = AnsibleInventory.deserialize( + inventory=f"{TEST_DIR}_test_nornir_inventory/basic_script/source/success.py", + hash_behavior="merge", + ) + inv_serialized = AnsibleInventory.serialize(inv).dict() + assert inv_serialized["hosts"] == expected_hosts + assert inv_serialized["groups"] == expected_groups + assert inv_serialized["defaults"] == expected_defaults + + +def test_ansible_inventory_no_valid_inventory(): + with pytest.raises(NornirNoValidInventoryError): + AnsibleInventory(f"{TEST_DIR}_test_nornir_inventory/invalid/invalid_ini.ini") + + +@pytest.mark.parametrize("case", ["multiple_sources"]) +@pytest.mark.parametrize("hash_behavior", ["replace", "merge"]) +def test_inventory_multiple_source(case, hash_behavior): + hosts_file = f"{TEST_DIR}_test_nornir_inventory/{case}/expected/{hash_behavior}/hosts.yaml" + groups_file = f"{TEST_DIR}_test_nornir_inventory/{case}/expected/{hash_behavior}/groups.yaml" + defaults_file = ( + f"{TEST_DIR}_test_nornir_inventory/{case}/expected/{hash_behavior}/defaults.yaml" + ) + expected_hosts, expected_groups, expected_defaults = read( + hosts_file, groups_file, defaults_file + ) + + inventory_sources = ( + f"{TEST_DIR}_test_nornir_inventory/{case}/source/source1," + f"{TEST_DIR}_test_nornir_inventory/{case}/source/source2" + ) + + inv = AnsibleInventory.deserialize(inventory=inventory_sources, hash_behavior=hash_behavior) + inv_serialized = AnsibleInventory.serialize(inv).dict() + + assert inv_serialized["hosts"] == expected_hosts + assert inv_serialized["groups"] == expected_groups + assert inv_serialized["defaults"] == expected_defaults diff --git a/tox.ini b/tox.ini index 88e2965..443e9e6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py36,py37 + py36,py37,py38 [testenv] @@ -10,7 +10,7 @@ commands = python -m pytest tests/. -[testenv:py37] +[testenv:py38] deps = -rrequirements-dev.txt commands =