diff --git a/.gitignore b/.gitignore index 9ada0655..36cb2695 100644 --- a/.gitignore +++ b/.gitignore @@ -101,5 +101,6 @@ output/ .DS_Store .pytest_cache/ +.mypy_cache/ .vscode diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..155a1624 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,11 @@ +--- +build: + image: latest + +python: + version: 3.6 + pip_install: true + +formats: [] + +requirements_file: docs/requirements.txt diff --git a/.travis.yml b/.travis.yml index 748ef28d..79e093d6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,18 +6,21 @@ addons: - pandoc language: python python: -- 2.7 -- 3.4 -- 3.5 - 3.6 matrix: include: + - python: 3.6 + env: TOXENV=py36 - python: 3.6 env: TOXENV=sphinx - python: 3.6 env: TOXENV=black - python: 3.6 env: TOXENV=pylama + - python: 3.6 + env: TOXENV=mypy + - python: 3.6 + env: TOXENV=nbval install: - pip install tox tox-travis coveralls script: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c6178733..f54da7a5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,19 @@ +2.0.0 - December 17 2018 +======================== + +For details about upgrading to 2.0.0 see the [https://nornir.readthedocs.io/en/2.0.0-beta/upgrading/1_to_2.html](notes). + ++ [CORE_ENHANCEMENTS] Lots of core enhancements, too many to document ++ [CORE_ENHANCEMENTS] Changes on how the inventory ++ [CORE_ENHANCEMENTS] New ``F`` object for advanced filtering of hosts [docs](file:///Users/dbarroso/workspace/nornir/docs/_build/html/howto/advanced_filtering.html) ++ [CORE_ENHANCEMENTS] Improvements on how to serialize/deserialize user facing data like the configuration and the inventory ++ [CORE_ENHANCEMENTS] Connections are now their own type of plugin ++ [CORE_ENHANCEMENTS] Ability to handle connections manually [docs](file:///Users/dbarroso/workspace/nornir/docs/_build/html/howto/handling_connections.html) ++ [CORE_BUGFIX] Lots ++ [PLUGIN_BUGFIX] Lots ++ [PLUGIN_NEW] netmiko_save_config ++ [PLUGIN_NEW] echo_data + 1.1.0 - July 12 2018 ==================== @@ -6,7 +22,6 @@ + [TESTS] replace nsot container with requests-mock #172 + [PLUGIN_IMPROVEMENT] Support SSH Agent forwarding for paramiko SSH connections #159 + [PLUGIN_IMPROVEMENT] allow passing options to napalm getters #156 -+ [CORE_NEW_FEATURE] Filter object #155 + [PLUGIN_BUGFIX] Fix for SSH and API port mapping issues #154 + [CORE_NEW_FEATURE] add to_dict function so the inventory is serializable #146 + [CORE_BUGFIX] Fix issues with using built-in and overwriting variable with loop variable #144 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index b5454511..e5b7e213 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -70,7 +70,7 @@ In order to run tests locally you need to have `Docker ` class. +{% macro document_section(section, schema) %} +{{ section }} +{{ "-" * section|length }} -{% for k, v in params|dictsort %} ----------- +{% for k, v in schema["properties"].items() -%} -{{ k }} ----------------------------------- +``{{ k }}`` +{{ "_" * (k|length + 4) }} +.. list-table:: + :widths: 15 85 -.. raw:: html + * - **Description** + - {{ v["description"] }} + * - **Type** + - ``{{ v["type"] }}`` + * - **Default** + - {{ "``{}``".format(v["default"]) if v["default"] else "" }} + * - **Required** + - ``{{ v["required"] or false }}`` + * - **Environment Variable** + - ``{{ "NORNIR_{}_{}".format(section, k).upper() }}`` - - - - - - {% if v['type'] in ('str', 'int', 'bool') %} - - {% else %} - - {% endif %} - - - -
Environment variableTypeDefault
{{ v['env'] or 'BRIGADE_' + k|upper }}N/A{{ v['type'] }}{{ v['default_doc'] or v['default'] }}
- -{{ v['description'] }} +{% endfor %} +{%- endmacro %} +{% for k, v in schema["properties"].items() if k not in ["user_defined"] %} +{{ document_section(k, v) }} {% endfor %} diff --git a/docs/conf.py b/docs/conf.py index baf2ddff..8f2c0502 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,16 +20,15 @@ import os import sys -from jinja2 import Environment -from jinja2 import FileSystemLoader +from jinja2 import Environment, FileSystemLoader sys.path.insert(0, os.path.abspath("../")) +from nornir.core.deserializer.configuration import Config # noqa -from nornir.core.configuration import CONF # noqa # -- General configuration ------------------------------------------------ -BASEPATH = os.path.dirname(__file__) +BASEPATH = os.path.abspath(os.path.dirname(__file__)) # If your documentation needs a minimal Sphinx version, state it here. # @@ -54,7 +53,7 @@ # General information about the project. project = "nornir" -copyright = "2017, David Barroso" +copyright = "2018, David Barroso" author = "David Barroso" # The version info for the project you're documenting, acts as replacement for @@ -173,13 +172,18 @@ ] +def skip_slots(app, what, name, obj, skip, options): + if obj.__class__.__name__ == "member_descriptor": + return True + return None + + def build_configuration_parameters(app): """Create documentation for configuration parameters.""" - env = Environment(loader=FileSystemLoader("{0}/_data_templates".format(BASEPATH))) template_file = env.get_template("configuration-parameters.j2") data = {} - data["params"] = CONF + data["schema"] = Config.schema() rendered_template = template_file.render(**data) output_dir = "{0}/configuration/generated".format(BASEPATH) with open("{}/parameters.rst".format(output_dir), "w") as f: @@ -189,7 +193,5 @@ def build_configuration_parameters(app): def setup(app): """Map methods to states of the documentation build.""" app.connect("builder-inited", build_configuration_parameters) + app.connect("autodoc-skip-member", skip_slots) app.add_stylesheet("css/custom.css") - - -build_configuration_parameters(None) diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst index 7f5f84ec..df1cffff 100644 --- a/docs/configuration/index.rst +++ b/docs/configuration/index.rst @@ -1,10 +1,25 @@ Configuration ============= -Each configuration parameter are applied in the following order: +The configuration is comprised of a set of sections and parameters for those sections. You can set the configuration programmatically using nornir by passing a dictionary of options for each section, by using a YAML file, by setting the corresponding environment variables or by a combination of the three. The order of preference from less to more preferred is "configuration file" -> "env variable" -> "code". -1. Environment variable -2. Parameter in configuration file / object -3. Default value +An example using ``InitNornir`` would be:: + + nr = InitNornir( + core={"num_workers": 20}, + logging={"file": "mylogs", "level": "debug"} + ) + +A similar example using a ``yaml`` file: + +.. include:: ../howto/advanced_filtering/config.yaml + :code: yaml + +Next, you can find each section and their corresponding options. .. include:: generated/parameters.rst + +user_defined +------------ + +You can set any ```` pair you want here and you will have it available under your configuration object, i.e. ``nr.config.user_defined.my_app_option``. diff --git a/docs/howto/advanced_filtering.ipynb b/docs/howto/advanced_filtering.ipynb new file mode 100644 index 00000000..9ee6e1a1 --- /dev/null +++ b/docs/howto/advanced_filtering.ipynb @@ -0,0 +1,360 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Advanced filtering\n", + "\n", + "In this tutorial we are going to see how to use the ``F`` object to do advanced filtering of hosts. Let's start by initiating nornir and looking at the inventory:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from nornir import InitNornir\n", + "from nornir.core.filter import F\n", + "\n", + "nr = InitNornir(config_file=\"advanced_filtering/config.yaml\")" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---\r\n", + "cat:\r\n", + " groups:\r\n", + " - terrestrial\r\n", + " - mammal\r\n", + " data:\r\n", + " domestic: true\r\n", + " diet: omnivore\r\n", + " additional_data:\r\n", + " lifespan: 17\r\n", + " famous_members:\r\n", + " - garfield\r\n", + " - felix\r\n", + " - grumpy\r\n", + "\r\n", + "bat:\r\n", + " groups:\r\n", + " - terrestrial\r\n", + " - mammal\r\n", + " data:\r\n", + " domestic: false\r\n", + " fly: true\r\n", + " diet: carnivore\r\n", + " additional_data:\r\n", + " lifespan: 15\r\n", + " famous_members:\r\n", + " - batman\r\n", + " - count chocula\r\n", + " - nosferatu\r\n", + "\r\n", + "eagle:\r\n", + " groups:\r\n", + " - terrestrial\r\n", + " - bird\r\n", + " data:\r\n", + " domestic: false\r\n", + " diet: carnivore\r\n", + " additional_data:\r\n", + " lifespan: 50\r\n", + " famous_members:\r\n", + " - thorondor\r\n", + " - sam\r\n", + "\r\n", + "canary:\r\n", + " groups:\r\n", + " - terrestrial\r\n", + " - bird\r\n", + " data:\r\n", + " domestic: true\r\n", + " diet: herbivore\r\n", + " additional_data:\r\n", + " lifespan: 15\r\n", + " famous_members:\r\n", + " - tweetie\r\n", + "\r\n", + "caterpillaer:\r\n", + " groups:\r\n", + " - terrestrial\r\n", + " - invertebrate\r\n", + " data:\r\n", + " domestic: false\r\n", + " diet: herbivore\r\n", + " additional_data:\r\n", + " lifespan: 1\r\n", + " famous_members:\r\n", + " - Hookah-Smoking\r\n", + "\r\n", + "octopus:\r\n", + " groups:\r\n", + " - marine\r\n", + " - invertebrate\r\n", + " data:\r\n", + " domestic: false\r\n", + " diet: carnivore\r\n", + " additional_data:\r\n", + " lifespan: 1\r\n", + " famous_members:\r\n", + " - sharktopus\r\n" + ] + } + ], + "source": [ + "%cat advanced_filtering/inventory/hosts.yaml" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---\r\n", + "mammal:\r\n", + " data:\r\n", + " reproduction: birth\r\n", + " fly: false\r\n", + "\r\n", + "bird:\r\n", + " data:\r\n", + " reproduction: eggs\r\n", + " fly: true\r\n", + "\r\n", + "invertebrate:\r\n", + " data:\r\n", + " reproduction: mitosis\r\n", + " fly: false\r\n", + "\r\n", + "terrestrial: {}\r\n", + "marine: {}\r\n" + ] + } + ], + "source": [ + "%cat advanced_filtering/inventory/groups.yaml" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As you can see we have built ourselves a collection of animals with different properties. The ``F`` object let's you access the magic methods of each typesby just prepeding two underscores and the the name of the magic method. For instance, if you want to check if a list contains a particular element you can just prepend ``__contains``. Let's use this feature to retrieve all the animals that belong to the group ``bird``:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['eagle', 'canary'])\n" + ] + } + ], + "source": [ + "birds = nr.filter(F(groups__contains=\"bird\"))\n", + "print(birds.inventory.hosts.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also invert the ``F`` object by prepending ``~``:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['cat', 'bat', 'caterpillaer', 'octopus'])\n" + ] + } + ], + "source": [ + "not_birds = nr.filter(~F(groups__contains=\"bird\"))\n", + "print(not_birds.inventory.hosts.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also combine ``F`` objects and perform AND and OR operations with the symbols ``&`` and ``|`` (pipe) respectively:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['cat', 'eagle', 'canary'])\n" + ] + } + ], + "source": [ + "domestic_or_bird = nr.filter(F(groups__contains=\"bird\") | F(domestic=True))\n", + "print(domestic_or_bird.inventory.hosts.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['cat'])\n" + ] + } + ], + "source": [ + "domestic_mammals = nr.filter(F(groups__contains=\"mammal\") & F(domestic=True))\n", + "print(domestic_mammals.inventory.hosts.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As expected, you can combine all of the symbols:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['canary'])\n" + ] + } + ], + "source": [ + "flying_not_carnivore = nr.filter(F(fly=True) & ~F(diet=\"carnivore\"))\n", + "print(flying_not_carnivore.inventory.hosts.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can also access nested data the same way you access magic methods, by appending two underscores and the data you want to access. You can keep building on this as much as needed and even access the magic methods of the nested data. For instance, let's get the animals that have a lifespan greater or equal than 15:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['cat', 'bat', 'eagle', 'canary'])\n" + ] + } + ], + "source": [ + "long_lived = nr.filter(F(additional_data__lifespan__ge=15))\n", + "print(long_lived.inventory.hosts.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are two extra facilities to help you working with lists; ``any`` and ``all``. Those facilities let's you send a list of elements and get the objects that has either any of the members or all of them. For instance:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['octopus'])\n" + ] + } + ], + "source": [ + "marine_and_invertebrates = nr.filter(F(groups__all=[\"marine\", \"invertebrate\"]))\n", + "print(marine_and_invertebrates.inventory.hosts.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['eagle', 'canary', 'caterpillaer', 'octopus'])\n" + ] + } + ], + "source": [ + "bird_or_invertebrates = nr.filter(F(groups__any=[\"bird\", \"invertebrate\"]))\n", + "print(bird_or_invertebrates.inventory.hosts.keys())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.5" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/howto/advanced_filtering/config.yaml b/docs/howto/advanced_filtering/config.yaml new file mode 100644 index 00000000..a084ae01 --- /dev/null +++ b/docs/howto/advanced_filtering/config.yaml @@ -0,0 +1,9 @@ +--- +core: + num_workers: 20 +inventory: + plugin: nornir.plugins.inventory.simple.SimpleInventory + options: + host_file: "advanced_filtering/inventory/hosts.yaml" + group_file: "advanced_filtering/inventory/groups.yaml" + diff --git a/docs/howto/advanced_filtering/inventory/groups.yaml b/docs/howto/advanced_filtering/inventory/groups.yaml new file mode 100644 index 00000000..175bae52 --- /dev/null +++ b/docs/howto/advanced_filtering/inventory/groups.yaml @@ -0,0 +1,18 @@ +--- +mammal: + data: + reproduction: birth + fly: false + +bird: + data: + reproduction: eggs + fly: true + +invertebrate: + data: + reproduction: mitosis + fly: false + +terrestrial: {} +marine: {} diff --git a/docs/howto/advanced_filtering/inventory/hosts.yaml b/docs/howto/advanced_filtering/inventory/hosts.yaml new file mode 100644 index 00000000..e5d3acbb --- /dev/null +++ b/docs/howto/advanced_filtering/inventory/hosts.yaml @@ -0,0 +1,78 @@ +--- +cat: + groups: + - terrestrial + - mammal + data: + domestic: true + diet: omnivore + additional_data: + lifespan: 17 + famous_members: + - garfield + - felix + - grumpy + +bat: + groups: + - terrestrial + - mammal + data: + domestic: false + fly: true + diet: carnivore + additional_data: + lifespan: 15 + famous_members: + - batman + - count chocula + - nosferatu + +eagle: + groups: + - terrestrial + - bird + data: + domestic: false + diet: carnivore + additional_data: + lifespan: 50 + famous_members: + - thorondor + - sam + +canary: + groups: + - terrestrial + - bird + data: + domestic: true + diet: herbivore + additional_data: + lifespan: 15 + famous_members: + - tweetie + +caterpillaer: + groups: + - terrestrial + - invertebrate + data: + domestic: false + diet: herbivore + additional_data: + lifespan: 1 + famous_members: + - Hookah-Smoking + +octopus: + groups: + - marine + - invertebrate + data: + domestic: false + diet: carnivore + additional_data: + lifespan: 1 + famous_members: + - sharktopus diff --git a/docs/howto/handling_connections.ipynb b/docs/howto/handling_connections.ipynb new file mode 100644 index 00000000..a04f1da9 --- /dev/null +++ b/docs/howto/handling_connections.ipynb @@ -0,0 +1,203 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to handle connections to devices\n", + "\n", + "## Automatically\n", + "\n", + "By default, connections are handled automatically:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from nornir import InitNornir\n", + "from nornir.plugins.functions.text import print_result\n", + "from nornir.plugins.tasks.networking import napalm_get" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[1m\u001b[36mnapalm_get**********************************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* rtr00 ** changed : False *****************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv napalm_get ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m{\u001b[0m \u001b[0m'facts'\u001b[0m: \u001b[0m{\u001b[0m \u001b[0m'fqdn'\u001b[0m: \u001b[0m'localhost'\u001b[0m,\n", + " \u001b[0m'hostname'\u001b[0m: \u001b[0m'localhost'\u001b[0m,\n", + " \u001b[0m'interface_list'\u001b[0m: \u001b[0m[\u001b[0m \u001b[0m\u001b[0m'Ethernet1'\u001b[0m,\n", + " \u001b[0m'Ethernet2'\u001b[0m,\n", + " \u001b[0m'Ethernet3'\u001b[0m,\n", + " \u001b[0m'Ethernet4'\u001b[0m,\n", + " \u001b[0m'Management1'\u001b[0m]\u001b[0m,\n", + " \u001b[0m'model'\u001b[0m: \u001b[0m'vEOS'\u001b[0m,\n", + " \u001b[0m'os_version'\u001b[0m: \u001b[0m'4.15.5M-3054042.4155M'\u001b[0m,\n", + " \u001b[0m'serial_number'\u001b[0m: \u001b[0m''\u001b[0m,\n", + " \u001b[0m'uptime'\u001b[0m: \u001b[0m'...'\u001b[0m,\n", + " \u001b[0m'vendor'\u001b[0m: \u001b[0m'Arista'\u001b[0m}\u001b[0m}\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END napalm_get ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", + "\u001b[0m" + ] + } + ], + "source": [ + "nr = InitNornir(config_file=\"handling_connections/config.yaml\")\n", + "rtr = nr.filter(name=\"rtr00\")\n", + "r = rtr.run(\n", + " task=napalm_get,\n", + " getters=[\"facts\"]\n", + ")\n", + "print_result(r)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Manually\n", + "\n", + "In some circumstances, you may want to manage connections manually. To do so you can use \n", + "[open_connection](../ref/api/inventory.rst#nornir.core.inventory.Host.open_connection), [close_connection](../ref/api/inventory.rst#nornir.core.inventory.Host.close_connection), [close_connections](../ref/api/inventory.rst#nornir.core.inventory.Host.close_connections) and [Nornir.close_connections](../ref/api/nornir.rst#nornir.core.Nornir.close_connections). For instance:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[1m\u001b[36mtask_manages_connection_manually************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* rtr00 ** changed : False *****************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv task_manages_connection_manually ** changed : False vvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- napalm_get ** changed : False --------------------------------------------- INFO\u001b[0m\n", + "\u001b[0m{\u001b[0m \u001b[0m'facts'\u001b[0m: \u001b[0m{\u001b[0m \u001b[0m'fqdn'\u001b[0m: \u001b[0m'localhost'\u001b[0m,\n", + " \u001b[0m'hostname'\u001b[0m: \u001b[0m'localhost'\u001b[0m,\n", + " \u001b[0m'interface_list'\u001b[0m: \u001b[0m[\u001b[0m \u001b[0m\u001b[0m'Ethernet1'\u001b[0m,\n", + " \u001b[0m'Ethernet2'\u001b[0m,\n", + " \u001b[0m'Ethernet3'\u001b[0m,\n", + " \u001b[0m'Ethernet4'\u001b[0m,\n", + " \u001b[0m'Management1'\u001b[0m]\u001b[0m,\n", + " \u001b[0m'model'\u001b[0m: \u001b[0m'vEOS'\u001b[0m,\n", + " \u001b[0m'os_version'\u001b[0m: \u001b[0m'4.15.5M-3054042.4155M'\u001b[0m,\n", + " \u001b[0m'serial_number'\u001b[0m: \u001b[0m''\u001b[0m,\n", + " \u001b[0m'uptime'\u001b[0m: \u001b[0m'...'\u001b[0m,\n", + " \u001b[0m'vendor'\u001b[0m: \u001b[0m'Arista'\u001b[0m}\u001b[0m}\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END task_manages_connection_manually ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", + "\u001b[0m" + ] + } + ], + "source": [ + "def task_manages_connection_manually(task):\n", + " task.host.open_connection(\"napalm\", configuration=task.nornir.config)\n", + " r = task.run(\n", + " task=napalm_get,\n", + " getters=[\"facts\"]\n", + " )\n", + " task.host.close_connection(\"napalm\")\n", + " \n", + "nr = InitNornir(config_file=\"handling_connections/config.yaml\")\n", + "rtr = nr.filter(name=\"rtr00\")\n", + "r = rtr.run(\n", + " task=task_manages_connection_manually,\n", + ")\n", + "print_result(r)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Specifying connection parameters\n", + "\n", + "When using the [open_connection](../ref/api/inventory.rst#nornir.core.inventory.Host.open_connection) you can specify any parameters you want. If you don't, or if you let nornir open the connection automatically, nornir will read those parameters from the inventory. You can specify standard attributes at the object level if you want to reuse them across different connections or you can override them for each connection. For example:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dev1.group_1:\r\n", + " port: 65001\r\n", + " hostname:\r\n", + " username:\r\n", + " password: a_password\r\n", + " platform: eos\r\n", + " data:\r\n", + " my_var: comes_from_dev1.group_1\r\n", + " www_server: nginx\r\n", + " role: www\r\n", + " nested_data:\r\n", + " a_dict:\r\n", + " a: 1\r\n", + " b: 2\r\n", + " a_list: [1, 2]\r\n", + " a_string: asdasd\r\n", + " groups:\r\n", + " - group_1\r\n", + " connection_options:\r\n", + " paramiko:\r\n", + " port: 65001\r\n", + " hostname: 127.0.0.1\r\n", + " username: root\r\n", + " password: docker\r\n", + " platform: linux\r\n", + " extras: {}\r\n", + " dummy:\r\n", + " hostname: dummy_from_host\r\n", + " port:\r\n", + " username:\r\n", + " password:\r\n", + " platform:\r\n", + " extras:\r\n", + " blah: from_host\r\n", + "\u001b[0m\u001b[0m" + ] + } + ], + "source": [ + "!sed '2,35!d' ../../tests/inventory_data/hosts.yaml" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.5" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/howto/handling_connections/config.yaml b/docs/howto/handling_connections/config.yaml new file mode 100644 index 00000000..e50d6422 --- /dev/null +++ b/docs/howto/handling_connections/config.yaml @@ -0,0 +1,7 @@ +--- +inventory: + plugin: nornir.plugins.inventory.simple.SimpleInventory + options: + host_file: "handling_connections/inventory/hosts.yaml" + group_file: "handling_connections/inventory/groups.yaml" + diff --git a/docs/howto/handling_connections/inventory/hosts.yaml b/docs/howto/handling_connections/inventory/hosts.yaml new file mode 100644 index 00000000..433a179e --- /dev/null +++ b/docs/howto/handling_connections/inventory/hosts.yaml @@ -0,0 +1,8 @@ +--- +rtr00: + connection_options: + napalm: + platform: mock + extras: + optional_args: + path: handling_connections/mocked_data diff --git a/docs/howto/handling_connections/mocked_data/get_facts.1 b/docs/howto/handling_connections/mocked_data/get_facts.1 new file mode 100644 index 00000000..cde51ba6 --- /dev/null +++ b/docs/howto/handling_connections/mocked_data/get_facts.1 @@ -0,0 +1,16 @@ +{ + "fqdn": "localhost", + "hostname": "localhost", + "interface_list": [ + "Ethernet1", + "Ethernet2", + "Ethernet3", + "Ethernet4", + "Management1" + ], + "model": "vEOS", + "os_version": "4.15.5M-3054042.4155M", + "serial_number": "", + "uptime": "...", + "vendor": "Arista" +} diff --git a/docs/howto/transforming_inventory_data.ipynb b/docs/howto/transforming_inventory_data.ipynb new file mode 100644 index 00000000..172a5bc6 --- /dev/null +++ b/docs/howto/transforming_inventory_data.ipynb @@ -0,0 +1,284 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Transforming Inventory Data\n", + "\n", + "Imagine your data looks like:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---\r\n", + "rtr00:\r\n", + " data:\r\n", + " user: automation_user\r\n", + "rtr01:\r\n", + " data:\r\n", + " user: automation_user\r\n" + ] + } + ], + "source": [ + "%cat transforming_inventory_data/inventory/hosts.yaml" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And we want to do two things:\n", + "\n", + "1. Map ``user`` to ``username``\n", + "2. Prompt the user for the password and add it\n", + "\n", + "You can easily do that using a transform_function. For instance:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Modifying hosts' data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can modify inventory data (regardless of the plugin you are using) on the fly easily by password a ``transform_function`` like this:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rtr00.username: automation_user\n", + "rtr01.username: automation_user\n" + ] + } + ], + "source": [ + "from nornir import InitNornir\n", + "import pprint\n", + "\n", + "def adapt_host_data(host):\n", + " # This function receives a Host object for manipulation\n", + " host.username = host[\"user\"]\n", + "\n", + "nr = InitNornir(\n", + " inventory={\n", + " \"plugin\": \"nornir.plugins.inventory.simple.SimpleInventory\",\n", + " \"options\": {\n", + " \"host_file\": \"transforming_inventory_data/inventory/hosts.yaml\",\n", + " \"group_file\": \"transforming_inventory_data/inventory/groups.yaml\",\n", + " },\n", + " \"transform_function\": adapt_host_data,\n", + " },\n", + ")\n", + "for name, host in nr.inventory.hosts.items():\n", + " print(f\"{name}.username: {host.username}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Specifying the transform function in the configuration\n", + "\n", + "You can also specify the transform function in the configuration. In order for that to work the function must be importable. For instance:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "def adapt_host_data(host):\r\n", + " # This function receives a Host object for manipulation\r\n", + " host.username = host[\"user\"]\r\n" + ] + } + ], + "source": [ + "%cat transforming_inventory_data/helpers.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now let's verify we can, indeed, import the function:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from transforming_inventory_data.helpers import adapt_host_data\n", + "adapt_host_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now everything we have to do is put the import path as the ``transform_function`` configuration option:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---\r\n", + "inventory:\r\n", + " plugin: nornir.plugins.inventory.simple.SimpleInventory\r\n", + " options:\r\n", + " host_file: \"transforming_inventory_data/inventory/hosts.yaml\"\r\n", + " group_file: \"transforming_inventory_data/inventory/groups.yaml\"\r\n", + " transform_function: \"transforming_inventory_data.helpers.adapt_host_data\"\r\n" + ] + } + ], + "source": [ + "%cat transforming_inventory_data/config.yaml" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And then we won't need to specify it when calling ``InitNornir``:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rtr00.username: automation_user\n", + "rtr01.username: automation_user\n" + ] + } + ], + "source": [ + "from nornir import InitNornir\n", + "import pprint\n", + "\n", + "nr = InitNornir(\n", + " config_file=\"transforming_inventory_data/config.yaml\",\n", + ")\n", + "for name, host in nr.inventory.hosts.items():\n", + " print(f\"{name}.username: {host.username}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setting a default password\n", + "\n", + "You might be in a situation where you may want to prompt the user for some information, for instance, a password. You can leverage the ``defaults`` for something like this (although you could just put in under the hosts themselves or the groups). Let's see an example:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Before setting password: None\n", + "After setting password: a_secret_password\n", + "After setting password: a_secret_password\n" + ] + } + ], + "source": [ + "from nornir import InitNornir\n", + "\n", + "# let's pretend we used raw_input or something like that\n", + "# password = raw_input(\"Please, enter password: \")\n", + "password = \"a_secret_password\"\n", + "\n", + "nr = InitNornir(\n", + " config_file=\"transforming_inventory_data/config.yaml\",\n", + ")\n", + "print(\"Before setting password: \", nr.inventory.hosts[\"rtr00\"].password)\n", + "nr.inventory.defaults.password = password\n", + "print(\"After setting password: \", nr.inventory.hosts[\"rtr00\"].password)\n", + "print(\"After setting password: \", nr.inventory.defaults.password)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For more information on how inheritance works check the tutorial section [inheritance model](../tutorials/intro/inventory.rst)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.5" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/howto/transforming_inventory_data.rst b/docs/howto/transforming_inventory_data.rst deleted file mode 100644 index fe5cf69e..00000000 --- a/docs/howto/transforming_inventory_data.rst +++ /dev/null @@ -1,25 +0,0 @@ -Transforming Inventory Data -=========================== - -Imagine your data looks like:: - - host1: - username: my_user - password: my_password - host2: - username: my_user - password: my_password - -It turns out nornir is going to look for ``nornir_username`` and ``nornir_password`` to use as credentials. You may not want to change the data in your backend and you may not want to write a custom inventory plugin just to accommodate this difference. Fortunately, ``nornir`` has you covered. You can write a function to do all the data manipulations you want and pass it to any inventory plugin. For instance:: - - def adapt_host_data(host): - host.data["nornir_username"] = host.data["username"] - host.data["nornir_password"] = host.data["password"] - - - inv = NSOTInventory(transform_function=adapt_host_data) - nornir = Nornir(inventory=inv) - -What's going to happen is that the inventory is going to create the :obj:`nornir.core.inventory.Host` and :obj:`nornir.core.inventory.Group` objects as usual and then finally the ``transform_function`` is going to be called for each individual host one by one. - -.. note:: This was a very simple example but the ``transform_function`` can basically do anything you want/need. diff --git a/docs/howto/transforming_inventory_data/config.yaml b/docs/howto/transforming_inventory_data/config.yaml new file mode 100644 index 00000000..8471aa58 --- /dev/null +++ b/docs/howto/transforming_inventory_data/config.yaml @@ -0,0 +1,7 @@ +--- +inventory: + plugin: nornir.plugins.inventory.simple.SimpleInventory + options: + host_file: "transforming_inventory_data/inventory/hosts.yaml" + group_file: "transforming_inventory_data/inventory/groups.yaml" + transform_function: "transforming_inventory_data.helpers.adapt_host_data" diff --git a/docs/howto/transforming_inventory_data/helpers.py b/docs/howto/transforming_inventory_data/helpers.py new file mode 100644 index 00000000..65471ae7 --- /dev/null +++ b/docs/howto/transforming_inventory_data/helpers.py @@ -0,0 +1,3 @@ +def adapt_host_data(host): + # This function receives a Host object for manipulation + host.username = host["user"] diff --git a/docs/howto/transforming_inventory_data/inventory/groups.yaml b/docs/howto/transforming_inventory_data/inventory/groups.yaml new file mode 100644 index 00000000..21583061 --- /dev/null +++ b/docs/howto/transforming_inventory_data/inventory/groups.yaml @@ -0,0 +1,4 @@ +--- +defaults: + data: + a_default_attribute: my_default diff --git a/docs/howto/transforming_inventory_data/inventory/hosts.yaml b/docs/howto/transforming_inventory_data/inventory/hosts.yaml new file mode 100644 index 00000000..cc252a56 --- /dev/null +++ b/docs/howto/transforming_inventory_data/inventory/hosts.yaml @@ -0,0 +1,7 @@ +--- +rtr00: + data: + user: automation_user +rtr01: + data: + user: automation_user diff --git a/docs/howto/writing_a_connection_task.rst b/docs/howto/writing_a_connection_task.rst deleted file mode 100644 index e5601886..00000000 --- a/docs/howto/writing_a_connection_task.rst +++ /dev/null @@ -1,24 +0,0 @@ -Writing a connection task -######################### - -Connection tasks are tasks that establish a connection with a device to provide some sort of reusable mechanism to interact with it. You can find some examples of connections tasks in the :doc:`../plugins/tasks/connections` section. - -Writing a connection task is no different from writing a regular task. The only difference is that the task will have to establish the connection and assign it to the device. - -A continuation you can see a simplified version of the ``paramiko_connection`` connection task as an example:: - - def paramiko_connection(task=None): - host = task.host - - client = paramiko.SSHClient() - - parameters = { - "hostname": host.host, - "username": host.username, - "password": host.password, - "port": host.ssh_port, - } - client.connect(**parameters) - host.connections["paramiko"] = client - -Note the last line where the connection is assigned to the host. Subsequent tasks will be able to retrieve this connection by host calling ``host.get_connection("paramiko")`` diff --git a/docs/howto/writing_a_custom_inventory.rst b/docs/howto/writing_a_custom_inventory.rst deleted file mode 100644 index 6ff73c4c..00000000 --- a/docs/howto/writing_a_custom_inventory.rst +++ /dev/null @@ -1,58 +0,0 @@ -Writing a custom inventory -========================== - -If you have your own backend with host information or you don't like the provided ones you can write your own custom inventory. Doing so is quite easy. A continuation you can find a very simple one with static data. - -.. code-block:: python - - from builtins import super - - from nornir.core.inventory import Inventory - - - class MyInventory(Inventory): - - def __init__(self, **kwargs): - # code to get the data - hosts = { - "host1": { - "data1": "value1", - "data2": "value2", - "groups": ["my_group1"], - }, - "host2": { - "data1": "value1", - "data2": "value2", - "groups": ["my_group1"], - } - } - groups = { - "my_group1": { - "more_data1": "more_value1", - "more_data2": "more_value2", - } - } - defaults = { - "location": "internet", - "language": "Python", - } - - # passing the data to the parent class so the data is - # transformed into actual Host/Group objects - # and set default data for all hosts - super().__init__(hosts, groups, defaults, **kwargs) - - -So if you want to make it dynamic everything you have to do is get the data yourself and organize it in a similar format to the one described in the example above. - -.. note:: it is not mandatory to use groups or defaults. Feel free to skip the attribute ``groups`` and just pass and empty dict or ``None`` to ``super()``. - -Finally, to have nornir use it, you can: - -.. code-block:: python - - from nornir.core import InitNornir - nr = InitNornir(inventory=MyInventory) - - -And that's it, you now have your own inventory plugin :) diff --git a/docs/howto/writing_a_custom_inventory_plugin.ipynb b/docs/howto/writing_a_custom_inventory_plugin.ipynb new file mode 100644 index 00000000..abd89c48 --- /dev/null +++ b/docs/howto/writing_a_custom_inventory_plugin.ipynb @@ -0,0 +1,150 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Writing a custom inventory plugin\n", + "\n", + "If you have your own backend with host information or you don't like the provided ones you can write your own custom inventory. Doing so is quite easy. A continuation you can find a very simple one with static data." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "from nornir.core.deserializer.inventory import Inventory\r\n", + "\r\n", + "\r\n", + "class MyInventory(Inventory):\r\n", + " def __init__(self, **kwargs):\r\n", + " # code to get the data\r\n", + " hosts = {\r\n", + " \"host1\": {\r\n", + " \"data\": {\"data1\": \"value1\", \"data2\": \"value2\", \"data3\": \"value3\"},\r\n", + " \"groups\": [\"my_group1\"],\r\n", + " },\r\n", + " \"host2\": {\r\n", + " \"data\": {\"data1\": \"value1\", \"data2\": \"value2\", \"data3\": \"value3\"},\r\n", + " \"groups\": [\"my_group1\"],\r\n", + " },\r\n", + " }\r\n", + " groups = {\r\n", + " \"my_group1\": {\r\n", + " \"data\": {\r\n", + " \"more_data1\": \"more_value1\",\r\n", + " \"more_data2\": \"more_value2\",\r\n", + " \"more_data3\": \"more_value3\",\r\n", + " }\r\n", + " }\r\n", + " }\r\n", + " defaults = {\"data\": {\"location\": \"internet\", \"language\": \"Python\"}}\r\n", + "\r\n", + " # passing the data to the parent class so the data is\r\n", + " # transformed into actual Host/Group objects\r\n", + " # and set default data for all hosts\r\n", + " super().__init__(hosts=hosts, groups=groups, defaults=defaults, **kwargs)\r\n" + ] + } + ], + "source": [ + "%cat writing_a_custom_inventory_plugin/my_inventory.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A dynamic inventory basically would have to retrieve the data, rearrange in a similar way as in the example above and cal ``super``. Now, let's see how to use it:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'defaults': {'connection_options': {},\n", + " 'data': {'language': 'Python', 'location': 'internet'},\n", + " 'hostname': None,\n", + " 'password': None,\n", + " 'platform': None,\n", + " 'port': None,\n", + " 'username': None},\n", + " 'groups': {'my_group1': {'connection_options': {},\n", + " 'data': {'more_data1': 'more_value1',\n", + " 'more_data2': 'more_value2',\n", + " 'more_data3': 'more_value3'},\n", + " 'groups': [],\n", + " 'hostname': None,\n", + " 'password': None,\n", + " 'platform': None,\n", + " 'port': None,\n", + " 'username': None}},\n", + " 'hosts': {'host1': {'connection_options': {},\n", + " 'data': {'data1': 'value1',\n", + " 'data2': 'value2',\n", + " 'data3': 'value3'},\n", + " 'groups': ['my_group1'],\n", + " 'hostname': None,\n", + " 'password': None,\n", + " 'platform': None,\n", + " 'port': None,\n", + " 'username': None},\n", + " 'host2': {'connection_options': {},\n", + " 'data': {'data1': 'value1',\n", + " 'data2': 'value2',\n", + " 'data3': 'value3'},\n", + " 'groups': ['my_group1'],\n", + " 'hostname': None,\n", + " 'password': None,\n", + " 'platform': None,\n", + " 'port': None,\n", + " 'username': None}}}\n" + ] + } + ], + "source": [ + "from nornir import InitNornir\n", + "from nornir.core.deserializer.inventory import Inventory\n", + "import pprint\n", + "\n", + "nr = InitNornir(\n", + " inventory={\n", + " \"plugin\": \"writing_a_custom_inventory_plugin.my_inventory.MyInventory\"\n", + " }\n", + ")\n", + "pprint.pprint(Inventory.serialize(nr.inventory).dict())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.5" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/howto/writing_a_custom_inventory_plugin/my_inventory.py b/docs/howto/writing_a_custom_inventory_plugin/my_inventory.py new file mode 100644 index 00000000..3cdff8e6 --- /dev/null +++ b/docs/howto/writing_a_custom_inventory_plugin/my_inventory.py @@ -0,0 +1,31 @@ +from nornir.core.deserializer.inventory import Inventory + + +class MyInventory(Inventory): + def __init__(self, **kwargs): + # code to get the data + hosts = { + "host1": { + "data": {"data1": "value1", "data2": "value2", "data3": "value3"}, + "groups": ["my_group1"], + }, + "host2": { + "data": {"data1": "value1", "data2": "value2", "data3": "value3"}, + "groups": ["my_group1"], + }, + } + groups = { + "my_group1": { + "data": { + "more_data1": "more_value1", + "more_data2": "more_value2", + "more_data3": "more_value3", + } + } + } + defaults = {"data": {"location": "internet", "language": "Python"}} + + # passing the data to the parent class so the data is + # transformed into actual Host/Group objects + # and set default data for all hosts + super().__init__(hosts=hosts, groups=groups, defaults=defaults, **kwargs) diff --git a/docs/index.rst b/docs/index.rst index a5f980d6..83486ef9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -34,7 +34,7 @@ A first glance Here is an example on how to quickly build a runbook leveraging Nornir to retrieve information from the network:: - from nornir.core import InitNornir + from nornir import InitNornir from nornir.plugins.functions.text import print_result from nornir.plugins.tasks.networking import napalm_get @@ -61,6 +61,7 @@ Contents configuration/index plugins/index ref/index + upgrading/index Contribute diff --git a/docs/plugins/connections/index.rst b/docs/plugins/connections/index.rst new file mode 100644 index 00000000..1d8f5330 --- /dev/null +++ b/docs/plugins/connections/index.rst @@ -0,0 +1,20 @@ +Connections +=========== + +NAPALM +------ + +.. automodule:: nornir.plugins.connections.napalm + :members: + +Netmiko +------- + +.. automodule:: nornir.plugins.connections.netmiko + :members: + +Paramiko +-------- + +.. automodule:: nornir.plugins.connections.paramiko + :members: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 02f8beb5..7b7686b6 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -3,9 +3,8 @@ Plugins .. toctree:: :maxdepth: 2 - :caption: Plugins - tasks/index + connections/index functions/index inventory/index - + tasks/index diff --git a/docs/plugins/tasks/data.rst b/docs/plugins/tasks/data.rst index 0c93380a..6a142405 100644 --- a/docs/plugins/tasks/data.rst +++ b/docs/plugins/tasks/data.rst @@ -1,6 +1,14 @@ Data ==== +.. toctree:: + :maxdepth: 1 + :glob: + + data/* + +Old-style docs: + .. automodule:: nornir.plugins.tasks.data :members: :undoc-members: diff --git a/docs/plugins/tasks/data/echo_data.ipynb b/docs/plugins/tasks/data/echo_data.ipynb new file mode 100644 index 00000000..3987976c --- /dev/null +++ b/docs/plugins/tasks/data/echo_data.ipynb @@ -0,0 +1,156 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# echo_data" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " Dummy task that echoes the data passed to it. Useful in grouped_tasks\n", + " to debug data passed to tasks.\n", + "\n", + " Arguments:\n", + " ``**kwargs``: Any pair you want\n", + "\n", + " Returns:\n", + " Result object with the following attributes set:\n", + " * result (``dict``): ``**kwargs`` passed to the task\n", + " \n" + ] + } + ], + "source": [ + "from nornir.plugins.tasks.data import echo_data\n", + "\n", + "print(echo_data.__doc__)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[1m\u001b[36mgrouped_task********************************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* bat ** changed : False *******************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv grouped_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- bat ** changed : False ---------------------------------------------------- INFO\u001b[0m\n", + "\u001b[0m{\u001b[0m \u001b[0m'diet'\u001b[0m: \u001b[0m'carnivore'\u001b[0m,\n", + " \u001b[0m'even_complex'\u001b[0m: \u001b[0m{'a': 1, 'b': 2, 'c': 'asd'}\u001b[0m,\n", + " \u001b[0m'famous_members'\u001b[0m: \u001b[0m['batman', 'count chocula', 'nosferatu']\u001b[0m,\n", + " \u001b[0m'more_data'\u001b[0m: \u001b[0m'whatever you want'\u001b[0m}\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END grouped_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* canary ** changed : False ****************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv grouped_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- canary ** changed : False ------------------------------------------------- INFO\u001b[0m\n", + "\u001b[0m{\u001b[0m \u001b[0m'diet'\u001b[0m: \u001b[0m'herbivore'\u001b[0m,\n", + " \u001b[0m'even_complex'\u001b[0m: \u001b[0m{'a': 1, 'b': 2, 'c': 'asd'}\u001b[0m,\n", + " \u001b[0m'famous_members'\u001b[0m: \u001b[0m['tweetie']\u001b[0m,\n", + " \u001b[0m'more_data'\u001b[0m: \u001b[0m'whatever you want'\u001b[0m}\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END grouped_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* cat ** changed : False *******************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv grouped_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- cat ** changed : False ---------------------------------------------------- INFO\u001b[0m\n", + "\u001b[0m{\u001b[0m \u001b[0m'diet'\u001b[0m: \u001b[0m'omnivore'\u001b[0m,\n", + " \u001b[0m'even_complex'\u001b[0m: \u001b[0m{'a': 1, 'b': 2, 'c': 'asd'}\u001b[0m,\n", + " \u001b[0m'famous_members'\u001b[0m: \u001b[0m['garfield', 'felix', 'grumpy']\u001b[0m,\n", + " \u001b[0m'more_data'\u001b[0m: \u001b[0m'whatever you want'\u001b[0m}\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END grouped_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* caterpillaer ** changed : False **********************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv grouped_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- caterpillaer ** changed : False ------------------------------------------- INFO\u001b[0m\n", + "\u001b[0m{\u001b[0m \u001b[0m'diet'\u001b[0m: \u001b[0m'herbivore'\u001b[0m,\n", + " \u001b[0m'even_complex'\u001b[0m: \u001b[0m{'a': 1, 'b': 2, 'c': 'asd'}\u001b[0m,\n", + " \u001b[0m'famous_members'\u001b[0m: \u001b[0m['Hookah-Smoking']\u001b[0m,\n", + " \u001b[0m'more_data'\u001b[0m: \u001b[0m'whatever you want'\u001b[0m}\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END grouped_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* eagle ** changed : False *****************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv grouped_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- eagle ** changed : False -------------------------------------------------- INFO\u001b[0m\n", + "\u001b[0m{\u001b[0m \u001b[0m'diet'\u001b[0m: \u001b[0m'carnivore'\u001b[0m,\n", + " \u001b[0m'even_complex'\u001b[0m: \u001b[0m{'a': 1, 'b': 2, 'c': 'asd'}\u001b[0m,\n", + " \u001b[0m'famous_members'\u001b[0m: \u001b[0m['thorondor', 'sam']\u001b[0m,\n", + " \u001b[0m'more_data'\u001b[0m: \u001b[0m'whatever you want'\u001b[0m}\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END grouped_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* octopus ** changed : False ***************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv grouped_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- octopus ** changed : False ------------------------------------------------ INFO\u001b[0m\n", + "\u001b[0m{\u001b[0m \u001b[0m'diet'\u001b[0m: \u001b[0m'carnivore'\u001b[0m,\n", + " \u001b[0m'even_complex'\u001b[0m: \u001b[0m{'a': 1, 'b': 2, 'c': 'asd'}\u001b[0m,\n", + " \u001b[0m'famous_members'\u001b[0m: \u001b[0m['sharktopus']\u001b[0m,\n", + " \u001b[0m'more_data'\u001b[0m: \u001b[0m'whatever you want'\u001b[0m}\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END grouped_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", + "\u001b[0m" + ] + } + ], + "source": [ + "from nornir import InitNornir\n", + "from nornir.plugins.tasks.data import echo_data\n", + "from nornir.plugins.functions.text import print_result\n", + "\n", + "nr = InitNornir(\n", + " inventory={\n", + " \"options\": {\n", + " \"host_file\": \"echo_data/inventory/hosts.yaml\",\n", + " \"group_file\": \"echo_data/inventory/groups.yaml\"\n", + " }\n", + " }\n", + ")\n", + "\n", + "def grouped_task(task):\n", + " task.run(task=echo_data,\n", + " name=task.host.name,\n", + " diet=task.host[\"diet\"],\n", + " famous_members=task.host[\"additional_data\"][\"famous_members\"],\n", + " more_data=\"whatever you want\",\n", + " even_complex={\"a\": 1, \"b\": 2, \"c\": \"asd\"})\n", + " \n", + "r = nr.run(task=grouped_task)\n", + "print_result(r)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.0" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/plugins/tasks/data/echo_data/inventory/groups.yaml b/docs/plugins/tasks/data/echo_data/inventory/groups.yaml new file mode 100644 index 00000000..175bae52 --- /dev/null +++ b/docs/plugins/tasks/data/echo_data/inventory/groups.yaml @@ -0,0 +1,18 @@ +--- +mammal: + data: + reproduction: birth + fly: false + +bird: + data: + reproduction: eggs + fly: true + +invertebrate: + data: + reproduction: mitosis + fly: false + +terrestrial: {} +marine: {} diff --git a/docs/plugins/tasks/data/echo_data/inventory/hosts.yaml b/docs/plugins/tasks/data/echo_data/inventory/hosts.yaml new file mode 100644 index 00000000..e5d3acbb --- /dev/null +++ b/docs/plugins/tasks/data/echo_data/inventory/hosts.yaml @@ -0,0 +1,78 @@ +--- +cat: + groups: + - terrestrial + - mammal + data: + domestic: true + diet: omnivore + additional_data: + lifespan: 17 + famous_members: + - garfield + - felix + - grumpy + +bat: + groups: + - terrestrial + - mammal + data: + domestic: false + fly: true + diet: carnivore + additional_data: + lifespan: 15 + famous_members: + - batman + - count chocula + - nosferatu + +eagle: + groups: + - terrestrial + - bird + data: + domestic: false + diet: carnivore + additional_data: + lifespan: 50 + famous_members: + - thorondor + - sam + +canary: + groups: + - terrestrial + - bird + data: + domestic: true + diet: herbivore + additional_data: + lifespan: 15 + famous_members: + - tweetie + +caterpillaer: + groups: + - terrestrial + - invertebrate + data: + domestic: false + diet: herbivore + additional_data: + lifespan: 1 + famous_members: + - Hookah-Smoking + +octopus: + groups: + - marine + - invertebrate + data: + domestic: false + diet: carnivore + additional_data: + lifespan: 1 + famous_members: + - sharktopus diff --git a/docs/plugins/tasks/index.rst b/docs/plugins/tasks/index.rst index 1c4704ce..45db3ea4 100644 --- a/docs/plugins/tasks/index.rst +++ b/docs/plugins/tasks/index.rst @@ -7,7 +7,6 @@ Tasks apis commands - connections data files networking diff --git a/docs/ref/api/configuration.rst b/docs/ref/api/configuration.rst index 5bbd3784..d729c447 100644 --- a/docs/ref/api/configuration.rst +++ b/docs/ref/api/configuration.rst @@ -1,6 +1,8 @@ Configuration -############# +============= +Configuration +------------- .. autoclass:: nornir.core.configuration.Config :members: diff --git a/docs/plugins/tasks/connections.rst b/docs/ref/api/connections.rst similarity index 53% rename from docs/plugins/tasks/connections.rst rename to docs/ref/api/connections.rst index 77619b07..17ede1eb 100644 --- a/docs/plugins/tasks/connections.rst +++ b/docs/ref/api/connections.rst @@ -1,6 +1,6 @@ Connections =========== -.. automodule:: nornir.plugins.tasks.connections +.. automodule:: nornir.core.connections :members: :undoc-members: diff --git a/docs/ref/api/exceptions.rst b/docs/ref/api/exceptions.rst index d3f92a9c..282225e3 100644 --- a/docs/ref/api/exceptions.rst +++ b/docs/ref/api/exceptions.rst @@ -4,4 +4,3 @@ Exceptions .. automodule:: nornir.core.exceptions :members: :undoc-members: - diff --git a/docs/ref/api/index.rst b/docs/ref/api/index.rst index d2f5214b..34e65ad6 100644 --- a/docs/ref/api/index.rst +++ b/docs/ref/api/index.rst @@ -2,11 +2,8 @@ Nornir API Reference ===================== .. toctree:: - :maxdepth: 1 + :maxdepth: 2 :caption: Nornir API + :glob: - nornir - configuration - inventory - task - exceptions + * diff --git a/docs/ref/api/inventory.rst b/docs/ref/api/inventory.rst index df93a6a9..a55646ce 100644 --- a/docs/ref/api/inventory.rst +++ b/docs/ref/api/inventory.rst @@ -19,3 +19,10 @@ Group ===== .. autoclass:: nornir.core.inventory.Group + +Defaults +======== + +.. autoclass:: nornir.core.inventory.Defaults + :members: + :undoc-members: diff --git a/docs/ref/api/nornir.rst b/docs/ref/api/nornir.rst index 7af32e14..e8d64724 100644 --- a/docs/ref/api/nornir.rst +++ b/docs/ref/api/nornir.rst @@ -1,18 +1,21 @@ -Data -#### +Core +==== -.. autoclass:: nornir.core.Data - :members: - :undoc-members: +InitNornir +---------- + +.. automethod:: nornir.init_nornir.InitNornir Nornir -####### +------ .. autoclass:: nornir.core.Nornir :members: :undoc-members: -InitNornir -########### +Data +---- -.. automethod:: nornir.core.InitNornir +.. autoclass:: nornir.core.state.GlobalState + :members: + :undoc-members: diff --git a/docs/ref/api/task.rst b/docs/ref/api/task.rst index 5a876de7..51fc034b 100644 --- a/docs/ref/api/task.rst +++ b/docs/ref/api/task.rst @@ -1,26 +1,29 @@ +Task and Results +================ + Task -#### +---- .. autoclass:: nornir.core.task.Task :members: :undoc-members: Result -###### +------ .. autoclass:: nornir.core.task.Result :members: :undoc-members: AggregatedResult -################ +---------------- .. autoclass:: nornir.core.task.AggregatedResult :members: :undoc-members: MultiResult -################ +----------- .. autoclass:: nornir.core.task.MultiResult :members: diff --git a/docs/requirements.txt b/docs/requirements.txt index 97828106..e28da7c4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ -sphinx<1.6 +sphinx sphinx_rtd_theme sphinxcontrib-napoleon jupyter diff --git a/docs/tutorials/intro/config.yaml b/docs/tutorials/intro/config.yaml index 0b6eab1a..ab9e0684 100644 --- a/docs/tutorials/intro/config.yaml +++ b/docs/tutorials/intro/config.yaml @@ -1,6 +1,10 @@ --- -num_workers: 100 -inventory: nornir.plugins.inventory.simple.SimpleInventory -SimpleInventory: - host_file: "inventory/hosts.yaml" - group_file: "inventory/groups.yaml" +core: + num_workers: 100 + +inventory: + plugin: nornir.plugins.inventory.simple.SimpleInventory + options: + host_file: "inventory/hosts.yaml" + group_file: "inventory/groups.yaml" + defaults_file: "inventory/defaults.yaml" diff --git a/docs/tutorials/intro/executing_tasks.ipynb b/docs/tutorials/intro/executing_tasks.ipynb index fb9dd0ec..4a073524 100644 --- a/docs/tutorials/intro/executing_tasks.ipynb +++ b/docs/tutorials/intro/executing_tasks.ipynb @@ -6,7 +6,7 @@ "metadata": {}, "outputs": [], "source": [ - "from nornir.core import InitNornir\n", + "from nornir import InitNornir\n", "nr = InitNornir(config_file=\"config.yaml\")" ] }, @@ -34,19 +34,19 @@ "text": [ "\u001b[1m\u001b[36mremote_command******************************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[34m* host1.cmh ** changed : False *************************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- remote_command ** changed : False ----------------------------------------\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv remote_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0mtotal 8\n", - "drwxrwxrwt 2 root root 4096 Mar 25 17:26 .\n", - "drwxr-xr-x 24 root root 4096 Mar 25 17:26 ..\n", - "\u001b[0m\n", + "drwxrwxrwt 2 root root 4096 Oct 27 14:53 .\n", + "drwxr-xr-x 24 root root 4096 Oct 27 14:53 ..\n", "\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END remote_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[34m* host2.cmh ** changed : False *************************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- remote_command ** changed : False ----------------------------------------\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv remote_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0mtotal 8\n", - "drwxrwxrwt 2 root root 4096 Mar 25 17:27 .\n", - "drwxr-xr-x 24 root root 4096 Mar 25 17:27 ..\n", - "\u001b[0m\n", + "drwxrwxrwt 2 root root 4096 Oct 27 14:54 .\n", + "drwxr-xr-x 24 root root 4096 Oct 27 14:54 ..\n", "\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END remote_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m" ] } @@ -86,18 +86,18 @@ "text": [ "\u001b[1m\u001b[36mnapalm_get**********************************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[34m* spine00.bma ** changed : False ***********************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- napalm_get ** changed : False --------------------------------------------\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv napalm_get ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m{\u001b[0m \u001b[0m'facts'\u001b[0m: \u001b[0m{\u001b[0m \u001b[0m'fqdn'\u001b[0m: \u001b[0m'localhost'\u001b[0m,\n", " \u001b[0m'hostname'\u001b[0m: \u001b[0m'localhost'\u001b[0m,\n", " \u001b[0m'interface_list'\u001b[0m: \u001b[0m['Ethernet1', 'Ethernet2', 'Management1']\u001b[0m,\n", " \u001b[0m'model'\u001b[0m: \u001b[0m'vEOS'\u001b[0m,\n", - " \u001b[0m'os_version'\u001b[0m: \u001b[0m'4.17.5M-4414219.4175M'\u001b[0m,\n", + " \u001b[0m'os_version'\u001b[0m: \u001b[0m'4.20.1F-6820520.4201F'\u001b[0m,\n", " \u001b[0m'serial_number'\u001b[0m: \u001b[0m''\u001b[0m,\n", - " \u001b[0m'uptime'\u001b[0m: \u001b[0m441\u001b[0m,\n", + " \u001b[0m'uptime'\u001b[0m: \u001b[0m499\u001b[0m,\n", " \u001b[0m'vendor'\u001b[0m: \u001b[0m'Arista'\u001b[0m}\u001b[0m}\u001b[0m\n", - "\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END napalm_get ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[34m* spine01.bma ** changed : False ***********************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- napalm_get ** changed : False --------------------------------------------\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv napalm_get ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m{\u001b[0m \u001b[0m'facts'\u001b[0m: \u001b[0m{\u001b[0m \u001b[0m'fqdn'\u001b[0m: \u001b[0m'vsrx'\u001b[0m,\n", " \u001b[0m'hostname'\u001b[0m: \u001b[0m'vsrx'\u001b[0m,\n", " \u001b[0m'interface_list'\u001b[0m: \u001b[0m[\u001b[0m \u001b[0m\u001b[0m'ge-0/0/0'\u001b[0m,\n", @@ -127,10 +127,10 @@ " \u001b[0m'vlan'\u001b[0m]\u001b[0m,\n", " \u001b[0m'model'\u001b[0m: \u001b[0m'FIREFLY-PERIMETER'\u001b[0m,\n", " \u001b[0m'os_version'\u001b[0m: \u001b[0m'12.1X47-D20.7'\u001b[0m,\n", - " \u001b[0m'serial_number'\u001b[0m: \u001b[0m'b4321e51218e'\u001b[0m,\n", - " \u001b[0m'uptime'\u001b[0m: \u001b[0m334\u001b[0m,\n", + " \u001b[0m'serial_number'\u001b[0m: \u001b[0m'66c3cbe24e7b'\u001b[0m,\n", + " \u001b[0m'uptime'\u001b[0m: \u001b[0m385\u001b[0m,\n", " \u001b[0m'vendor'\u001b[0m: \u001b[0m'Juniper'\u001b[0m}\u001b[0m}\u001b[0m\n", - "\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END napalm_get ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m" ] } @@ -218,8 +218,8 @@ "text": [ "\u001b[1m\u001b[36mavailable_resources*************************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[34m* host1.cmh ** changed : False *************************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- available_resources ** changed : False -----------------------------------\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- Available disk ** changed : False ----------------------------------------\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv available_resources ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- Available disk ** changed : False ----------------------------------------- INFO\u001b[0m\n", "\u001b[0mFilesystem Size Used Avail Use% Mounted on\n", "/dev/mapper/precise64-root 79G 2.2G 73G 3% /\n", "udev 174M 4.0K 174M 1% /dev\n", @@ -227,18 +227,18 @@ "none 5.0M 0 5.0M 0% /run/lock\n", "none 183M 0 183M 0% /run/shm\n", "/dev/sda1 228M 25M 192M 12% /boot\n", - "vagrant 373G 215G 159G 58% /vagrant\n", + "vagrant 373G 251G 122G 68% /vagrant\n", "\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- Available memory ** changed : False --------------------------------------\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- Available memory ** changed : False --------------------------------------- INFO\u001b[0m\n", "\u001b[0m total used free shared buffers cached\n", - "Mem: 365 88 277 0 8 36\n", - "-/+ buffers/cache: 43 322\n", + "Mem: 365 87 277 0 8 36\n", + "-/+ buffers/cache: 42 322\n", "Swap: 767 0 767\n", "\u001b[0m\n", - "\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END available_resources ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[34m* host2.cmh ** changed : False *************************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- available_resources ** changed : False -----------------------------------\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- Available disk ** changed : False ----------------------------------------\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32mvvvv available_resources ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- Available disk ** changed : False ----------------------------------------- INFO\u001b[0m\n", "\u001b[0mFilesystem Size Used Avail Use% Mounted on\n", "/dev/mapper/precise64-root 79G 2.2G 73G 3% /\n", "udev 174M 4.0K 174M 1% /dev\n", @@ -246,15 +246,15 @@ "none 5.0M 0 5.0M 0% /run/lock\n", "none 183M 0 183M 0% /run/shm\n", "/dev/sda1 228M 25M 192M 12% /boot\n", - "vagrant 373G 215G 159G 58% /vagrant\n", + "vagrant 373G 251G 122G 68% /vagrant\n", "\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[32m---- Available memory ** changed : False --------------------------------------\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- Available memory ** changed : False --------------------------------------- INFO\u001b[0m\n", "\u001b[0m total used free shared buffers cached\n", - "Mem: 365 93 271 0 9 36\n", - "-/+ buffers/cache: 48 317\n", + "Mem: 365 87 277 0 8 36\n", + "-/+ buffers/cache: 42 322\n", "Swap: 767 0 767\n", "\u001b[0m\n", - "\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m^^^^ END available_resources ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m" ] } @@ -348,7 +348,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.5" + "version": "3.7.0" } }, "nbformat": 4, diff --git a/docs/tutorials/intro/failed_tasks.ipynb b/docs/tutorials/intro/failed_tasks.ipynb index ed7d31c8..2cf57cc0 100644 --- a/docs/tutorials/intro/failed_tasks.ipynb +++ b/docs/tutorials/intro/failed_tasks.ipynb @@ -17,7 +17,7 @@ "metadata": {}, "outputs": [], "source": [ - "from nornir.core import InitNornir\n", + "from nornir import InitNornir\n", "from nornir.plugins.tasks import networking, text\n", "from nornir.plugins.functions.text import print_result\n", "\n", @@ -106,8 +106,8 @@ { "data": { "text/plain": [ - "{'leaf00.cmh': MultiResult: [Result: \"basic_configuration\", Result: \"Base Configuration\", Result: \"Loading Configuration on the device\"],\n", - " 'spine00.cmh': MultiResult: [Result: \"basic_configuration\", Result: \"Base Configuration\", Result: \"Loading Configuration on the device\"]}" + "{'spine00.cmh': MultiResult: [Result: \"basic_configuration\", Result: \"Base Configuration\", Result: \"Loading Configuration on the device\"],\n", + " 'leaf00.cmh': MultiResult: [Result: \"basic_configuration\", Result: \"Base Configuration\", Result: \"Loading Configuration on the device\"]}" ] }, "execution_count": 5, @@ -158,13 +158,13 @@ "}\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[31m---- Loading Configuration on the device ** changed : False -------------------- ERROR\u001b[0m\n", "\u001b[0mTraceback (most recent call last):\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/napalm/eos/eos.py\", line 231, in _load_config\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/napalm/eos/eos.py\", line 231, in _load_config\n", " self.device.run_commands(commands)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/pyeapi/client.py\", line 730, in run_commands\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/pyeapi/client.py\", line 730, in run_commands\n", " response = self._connection.execute(commands, encoding, **kwargs)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/pyeapi/eapilib.py\", line 499, in execute\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/pyeapi/eapilib.py\", line 499, in execute\n", " response = self.send(request)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/pyeapi/eapilib.py\", line 418, in send\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/pyeapi/eapilib.py\", line 418, in send\n", " raise CommandError(code, msg, command_error=err, output=out)\n", "pyeapi.eapilib.CommandError: Error [1002]: CLI command 3 of 6 'system {' failed: invalid command [Invalid input (at token 1: '{')]\n", "\n", @@ -173,12 +173,12 @@ "Traceback (most recent call last):\n", " File \"/Users/dbarroso/workspace/nornir/nornir/core/task.py\", line 62, in start\n", " r = self.task(self, **self.params)\n", - " File \"/Users/dbarroso/workspace/nornir/nornir/plugins/tasks/networking/napalm_configure.py\", line 26, in napalm_configure\n", + " File \"/Users/dbarroso/workspace/nornir/nornir/plugins/tasks/networking/napalm_configure.py\", line 32, in napalm_configure\n", " device.load_merge_candidate(filename=filename, config=configuration)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/napalm/eos/eos.py\", line 245, in load_merge_candidate\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/napalm/eos/eos.py\", line 246, in load_merge_candidate\n", " self._load_config(filename, config, False)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/napalm/eos/eos.py\", line 237, in _load_config\n", - " raise MergeConfigException(e.message)\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/napalm/eos/eos.py\", line 238, in _load_config\n", + " raise MergeConfigException(msg)\n", "napalm.base.exceptions.MergeConfigException: Error [1002]: CLI command 3 of 6 'system {' failed: invalid command [Invalid input (at token 1: '{')]\n", "\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[31m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", @@ -206,13 +206,13 @@ "}\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[31m---- Loading Configuration on the device ** changed : False -------------------- ERROR\u001b[0m\n", "\u001b[0mTraceback (most recent call last):\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/napalm/eos/eos.py\", line 231, in _load_config\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/napalm/eos/eos.py\", line 231, in _load_config\n", " self.device.run_commands(commands)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/pyeapi/client.py\", line 730, in run_commands\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/pyeapi/client.py\", line 730, in run_commands\n", " response = self._connection.execute(commands, encoding, **kwargs)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/pyeapi/eapilib.py\", line 499, in execute\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/pyeapi/eapilib.py\", line 499, in execute\n", " response = self.send(request)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/pyeapi/eapilib.py\", line 418, in send\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/pyeapi/eapilib.py\", line 418, in send\n", " raise CommandError(code, msg, command_error=err, output=out)\n", "pyeapi.eapilib.CommandError: Error [1002]: CLI command 3 of 6 'system {' failed: invalid command [Invalid input (at token 1: '{')]\n", "\n", @@ -221,12 +221,12 @@ "Traceback (most recent call last):\n", " File \"/Users/dbarroso/workspace/nornir/nornir/core/task.py\", line 62, in start\n", " r = self.task(self, **self.params)\n", - " File \"/Users/dbarroso/workspace/nornir/nornir/plugins/tasks/networking/napalm_configure.py\", line 26, in napalm_configure\n", + " File \"/Users/dbarroso/workspace/nornir/nornir/plugins/tasks/networking/napalm_configure.py\", line 32, in napalm_configure\n", " device.load_merge_candidate(filename=filename, config=configuration)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/napalm/eos/eos.py\", line 245, in load_merge_candidate\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/napalm/eos/eos.py\", line 246, in load_merge_candidate\n", " self._load_config(filename, config, False)\n", - " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.6/site-packages/napalm/eos/eos.py\", line 237, in _load_config\n", - " raise MergeConfigException(e.message)\n", + " File \"/Users/dbarroso/.virtualenvs/nornir/lib/python3.7/site-packages/napalm/eos/eos.py\", line 238, in _load_config\n", + " raise MergeConfigException(msg)\n", "napalm.base.exceptions.MergeConfigException: Error [1002]: CLI command 3 of 6 'system {' failed: invalid command [Invalid input (at token 1: '{')]\n", "\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[31m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", @@ -409,7 +409,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "To achieve this `nornir` keeps a list failed hosts in it's shared [data](../../ref/api/nornir.rst#nornir.core.Data) object:" + "To achieve this `nornir` keeps a list failed hosts in it's shared [data](../../ref/api/nornir.rst#nornir.core.state.GlobalState) object:" ] }, { @@ -436,7 +436,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "If you want to mark some hosts as succeeded and make them back eligible for future tasks you can do it individually per host with the function [recover_host](../../ref/api/nornir.rst#nornir.core.Data.recover_host) or reset the list completely with [reset_failed_hosts](../../ref/api/nornir.rst#nornir.core.Data.reset_failed_hosts):" + "If you want to mark some hosts as succeeded and make them back eligible for future tasks you can do it individually per host with the function [recover_host](../../ref/api/nornir.rst#nornir.core.state.GlobalState.recover_host) or reset the list completely with [reset_failed_hosts](../../ref/api/nornir.rst#nornir.core.state.GlobalState.reset_failed_hosts):" ] }, { @@ -471,7 +471,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -484,7 +484,7 @@ } ], "source": [ - "nr = InitNornir(config_file=\"config.yaml\", raise_on_error=True)\n", + "nr = InitNornir(config_file=\"config.yaml\", core={\"raise_on_error\": True})\n", "cmh = nr.filter(site=\"cmh\", type=\"network_device\")\n", "try:\n", " cmh.run(task=basic_configuration)\n", @@ -523,7 +523,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.5" + "version": "3.7.0" } }, "nbformat": 4, diff --git a/docs/tutorials/intro/grouping_tasks.ipynb b/docs/tutorials/intro/grouping_tasks.ipynb index c2afe0a2..d2e1bd9c 100644 --- a/docs/tutorials/intro/grouping_tasks.ipynb +++ b/docs/tutorials/intro/grouping_tasks.ipynb @@ -21,7 +21,7 @@ "metadata": {}, "outputs": [], "source": [ - "from nornir.core import InitNornir\n", + "from nornir import InitNornir\n", "from nornir.plugins.tasks import networking, text\n", "from nornir.plugins.functions.text import print_title, print_result\n", "\n", @@ -49,7 +49,7 @@ " r = task.run(task=text.template_file,\n", " name=\"Base Configuration\",\n", " template=\"base.j2\",\n", - " path=f\"templates/{task.host.nos}\")\n", + " path=f\"templates/{task.host.platform}\")\n", " \n", " # Save the compiled configuration into a host variable\n", " task.host[\"config\"] = r.result\n", @@ -83,7 +83,7 @@ "output_type": "stream", "text": [ "hostname {{ host }}\n", - "ip domain-name {{ site }}.{{ domain }}\n", + "ip domain-name {{ host.site }}.{{ host.domain }}\n", "\u001b[0m\u001b[0m" ] } @@ -103,7 +103,7 @@ "text": [ "system {\n", " host-name {{ host }};\n", - " domain-name {{ site }}.{{ domain }};\n", + " domain-name {{ host.site }}.{{ host.domain }};\n", "}\n", "\u001b[0m\u001b[0m" ] @@ -131,10 +131,10 @@ "text": [ "\u001b[1m\u001b[32m**** Playbook to configure the network *****************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[36mbasic_configuration*************************************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : True *************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", - "\u001b[0mhostname spine00.cmh\n", + "\u001b[0mhostname leaf00.cmh\n", "ip domain-name cmh.acme.local\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m@@ -7,6 +7,9 @@\n", @@ -142,29 +142,29 @@ " !\n", " transceiver qsfp default-mode 4x10G\n", "+!\n", - "+hostname spine00.cmh\n", + "+hostname leaf00.cmh\n", "+ip domain-name cmh.acme.local\n", " !\n", " spanning-tree mode mstp\n", " !\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : True *************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", "\u001b[0msystem {\n", - " host-name spine01.cmh;\n", + " host-name leaf01.cmh;\n", " domain-name cmh.acme.local;\n", "}\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m[edit system]\n", "- host-name vsrx;\n", - "+ host-name spine01.cmh;\n", + "+ host-name leaf01.cmh;\n", "+ domain-name cmh.acme.local;\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : True ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", - "\u001b[0mhostname leaf00.cmh\n", + "\u001b[0mhostname spine00.cmh\n", "ip domain-name cmh.acme.local\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m@@ -7,6 +7,9 @@\n", @@ -172,23 +172,23 @@ " !\n", " transceiver qsfp default-mode 4x10G\n", "+!\n", - "+hostname leaf00.cmh\n", + "+hostname spine00.cmh\n", "+ip domain-name cmh.acme.local\n", " !\n", " spanning-tree mode mstp\n", " !\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : True ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", "\u001b[0msystem {\n", - " host-name leaf01.cmh;\n", + " host-name spine01.cmh;\n", " domain-name cmh.acme.local;\n", "}\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m[edit system]\n", "- host-name vsrx;\n", - "+ host-name leaf01.cmh;\n", + "+ host-name spine01.cmh;\n", "+ domain-name cmh.acme.local;\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m" @@ -210,7 +210,7 @@ "However, this was a `dry_run`. Let's set the `dry_run` variable to `False` so changes are commited and then run the code again:\n", "\n", "
\n", - "**Note:** The `dry_run` value is shared between the main nornir objects and its childs so in the snippet below `nr.dry_run = False` and `cmh.dry_run = False` are equivalent.\n", + "**Note:** The `dry_run` value is shared between the main nornir objects and its childs so in the snippet below `nr.data.dry_run = False` and `cmh.data.dry_run = False` are equivalent.\n", "
" ] }, @@ -225,10 +225,10 @@ "text": [ "\u001b[1m\u001b[32m**** Playbook to configure the network *****************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[36mbasic_configuration*************************************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : True *************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", - "\u001b[0mhostname spine00.cmh\n", + "\u001b[0mhostname leaf00.cmh\n", "ip domain-name cmh.acme.local\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m@@ -7,6 +7,9 @@\n", @@ -236,29 +236,29 @@ " !\n", " transceiver qsfp default-mode 4x10G\n", "+!\n", - "+hostname spine00.cmh\n", + "+hostname leaf00.cmh\n", "+ip domain-name cmh.acme.local\n", " !\n", " spanning-tree mode mstp\n", " !\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : True *************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", "\u001b[0msystem {\n", - " host-name spine01.cmh;\n", + " host-name leaf01.cmh;\n", " domain-name cmh.acme.local;\n", "}\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m[edit system]\n", "- host-name vsrx;\n", - "+ host-name spine01.cmh;\n", + "+ host-name leaf01.cmh;\n", "+ domain-name cmh.acme.local;\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : True ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", - "\u001b[0mhostname leaf00.cmh\n", + "\u001b[0mhostname spine00.cmh\n", "ip domain-name cmh.acme.local\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m@@ -7,6 +7,9 @@\n", @@ -266,23 +266,23 @@ " !\n", " transceiver qsfp default-mode 4x10G\n", "+!\n", - "+hostname leaf00.cmh\n", + "+hostname spine00.cmh\n", "+ip domain-name cmh.acme.local\n", " !\n", " spanning-tree mode mstp\n", " !\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : True ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", "\u001b[0msystem {\n", - " host-name leaf01.cmh;\n", + " host-name spine01.cmh;\n", " domain-name cmh.acme.local;\n", "}\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m[edit system]\n", "- host-name vsrx;\n", - "+ host-name leaf01.cmh;\n", + "+ host-name spine01.cmh;\n", "+ domain-name cmh.acme.local;\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m" @@ -290,7 +290,7 @@ } ], "source": [ - "nr.dry_run = False\n", + "nr.data.dry_run = False\n", "print_title(\"Playbook to configure the network\")\n", "result = cmh.run(task=basic_configuration)\n", "print_result(result)" @@ -314,65 +314,37 @@ "text": [ "\u001b[1m\u001b[32m**** Playbook to configure the network *****************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[36mbasic_configuration*************************************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : False ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", - "\u001b[0mhostname spine00.cmh\n", + "\u001b[0mhostname leaf00.cmh\n", "ip domain-name cmh.acme.local\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", - "\u001b[0m@@ -7,6 +7,9 @@\n", - " action bash sudo /mnt/flash/initialize_ma1.sh\n", - " !\n", - " transceiver qsfp default-mode 4x10G\n", - "+!\n", - "+hostname spine00.cmh\n", - "+ip domain-name cmh.acme.local\n", - " !\n", - " spanning-tree mode mstp\n", - " !\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- Loading Configuration on the device ** changed : False -------------------- INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : False ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", "\u001b[0msystem {\n", - " host-name spine01.cmh;\n", + " host-name leaf01.cmh;\n", " domain-name cmh.acme.local;\n", "}\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", - "\u001b[0m[edit system]\n", - "- host-name vsrx;\n", - "+ host-name spine01.cmh;\n", - "+ domain-name cmh.acme.local;\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- Loading Configuration on the device ** changed : False -------------------- INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : False ***********************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", - "\u001b[0mhostname leaf00.cmh\n", + "\u001b[0mhostname spine00.cmh\n", "ip domain-name cmh.acme.local\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", - "\u001b[0m@@ -7,6 +7,9 @@\n", - " action bash sudo /mnt/flash/initialize_ma1.sh\n", - " !\n", - " transceiver qsfp default-mode 4x10G\n", - "+!\n", - "+hostname leaf00.cmh\n", - "+ip domain-name cmh.acme.local\n", - " !\n", - " spanning-tree mode mstp\n", - " !\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- Loading Configuration on the device ** changed : False -------------------- INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : False ***********************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- INFO\u001b[0m\n", "\u001b[0msystem {\n", - " host-name leaf01.cmh;\n", + " host-name spine01.cmh;\n", " domain-name cmh.acme.local;\n", "}\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", - "\u001b[0m[edit system]\n", - "- host-name vsrx;\n", - "+ host-name leaf01.cmh;\n", - "+ domain-name cmh.acme.local;\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[32m---- Loading Configuration on the device ** changed : False -------------------- INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m" ] @@ -402,7 +374,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.5" + "version": "3.7.0" } }, "nbformat": 4, diff --git a/docs/tutorials/intro/initializing_nornir.ipynb b/docs/tutorials/intro/initializing_nornir.ipynb index ae0d5c0c..5f077eab 100644 --- a/docs/tutorials/intro/initializing_nornir.ipynb +++ b/docs/tutorials/intro/initializing_nornir.ipynb @@ -107,12 +107,16 @@ "}\n", "\n", "\n", - "
1 ---\n",
-       "2 num_workers: 100\n",
-       "3 inventory: nornir.plugins.inventory.simple.SimpleInventory\n",
-       "4 SimpleInventory:\n",
-       "5     host_file: "inventory/hosts.yaml"\n",
-       "6     group_file: "inventory/groups.yaml"\n",
+       "
 1 ---\n",
+       " 2 core:\n",
+       " 3     num_workers: 100\n",
+       " 4 \n",
+       " 5 inventory:\n",
+       " 6     plugin: nornir.plugins.inventory.simple.SimpleInventory\n",
+       " 7     options:\n",
+       " 8         host_file: "inventory/hosts.yaml"\n",
+       " 9         group_file: "inventory/groups.yaml"\n",
+       "10         defaults_file: "inventory/defaults.yaml"\n",
        "
\n", "\n" ], @@ -133,10 +137,6 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "
\n", - "**Note:** To pass options to the inventory plugin add a top-level dictionary named after the inventory class name; `SimpleInventory` in this example.\n", - "
\n", - "\n", "Now to create the [nornir](../../ref/api/nornir.rst#nornir) object:" ] }, @@ -146,7 +146,7 @@ "metadata": {}, "outputs": [], "source": [ - "from nornir.core import InitNornir\n", + "from nornir import InitNornir\n", "nr = InitNornir(config_file=\"config.yaml\")" ] }, @@ -163,11 +163,17 @@ "metadata": {}, "outputs": [], "source": [ - "from nornir.core import InitNornir\n", - "nr = InitNornir(num_workers=100,\n", - " inventory=\"nornir.plugins.inventory.simple.SimpleInventory\",\n", - " SimpleInventory={\"host_file\": \"inventory/hosts.yaml\",\n", - " \"group_file\": \"inventory/groups.yaml\"})" + "from nornir import InitNornir\n", + "nr = InitNornir(\n", + " core={\"num_workers\": 100},\n", + " inventory={\n", + " \"plugin\": \"nornir.plugins.inventory.simple.SimpleInventory\",\n", + " \"options\": {\n", + " \"host_file\": \"inventory/hosts.yaml\",\n", + " \"group_file\": \"inventory/groups.yaml\"\n", + " }\n", + " }\n", + ")" ] }, { @@ -183,8 +189,8 @@ "metadata": {}, "outputs": [], "source": [ - "from nornir.core import InitNornir\n", - "nr = InitNornir(num_workers=50, config_file=\"config.yaml\")" + "from nornir import InitNornir\n", + "nr = InitNornir(core={\"num_workers\": 50}, config_file=\"config.yaml\")" ] }, { @@ -204,7 +210,7 @@ } ], "source": [ - "nr.config.num_workers" + "nr.config.core.num_workers" ] } ], @@ -224,7 +230,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.5" + "version": "3.7.1" } }, "nbformat": 4, diff --git a/docs/tutorials/intro/inventory.ipynb b/docs/tutorials/intro/inventory.ipynb index 24611c5f..8cd900fd 100644 --- a/docs/tutorials/intro/inventory.ipynb +++ b/docs/tutorials/intro/inventory.ipynb @@ -18,9 +18,9 @@ "source": [ "## Inventory\n", "\n", - "The Inventory is arguably the most important piece of nornir. Let's see how it works. To begin with the [inventory](../../ref/api/inventory.rst#nornir.core.inventory.Inventory) is comprised of [hosts](../../ref/api/inventory.rst#nornir.core.inventory.Host) and [groups](../../ref/api/inventory.rst#nornir.core.inventory.Group).\n", + "The Inventory is arguably the most important piece of nornir. Let's see how it works. To begin with the [inventory](../../ref/api/inventory.rst#nornir.core.inventory.Inventory) is comprised of [hosts](../../ref/api/inventory.rst#nornir.core.inventory.Host), [groups](../../ref/api/inventory.rst#nornir.core.inventory.Group) and [defaults](../../ref/api/inventory.rst#nornir.core.inventory.Defaults).\n", "\n", - "In this tutorial we are using the [SimpleInventory](../../plugins/inventory/simple.rst#nornir.plugins.inventory.simple.SimpleInventory) plugin. This inventory plugin stores all the relevant data in two files. Let's start by checking them:" + "In this tutorial we are using the [SimpleInventory](../../plugins/inventory/simple.rst#nornir.plugins.inventory.simple.SimpleInventory) plugin. This inventory plugin stores all the relevant data in three files. Let’s start by checking them:" ] }, { @@ -106,143 +106,167 @@ "\n", "\n", "
  1 ---\n",
-       "  2 host1.cmh:\n",
-       "  3     nornir_host: 127.0.0.1\n",
-       "  4     nornir_ssh_port: 2201\n",
-       "  5     nornir_username: vagrant\n",
-       "  6     nornir_password: vagrant\n",
-       "  7     site: cmh\n",
-       "  8     role: host\n",
-       "  9     groups:\n",
-       " 10         - cmh\n",
-       " 11     nornir_nos: linux\n",
-       " 12     type: host\n",
-       " 13 \n",
-       " 14 host2.cmh:\n",
-       " 15     nornir_host: 127.0.0.1\n",
-       " 16     nornir_ssh_port: 2202\n",
-       " 17     nornir_username: vagrant\n",
-       " 18     nornir_password: vagrant\n",
-       " 19     site: cmh\n",
-       " 20     role: host\n",
-       " 21     groups:\n",
-       " 22         - cmh\n",
-       " 23     nornir_nos: linux\n",
-       " 24     type: host\n",
-       " 25 \n",
-       " 26 spine00.cmh:\n",
-       " 27     nornir_host: 127.0.0.1\n",
-       " 28     nornir_username: vagrant\n",
-       " 29     nornir_password: vagrant\n",
-       " 30     nornir_network_api_port: 12444\n",
-       " 31     site: cmh\n",
-       " 32     role: spine\n",
-       " 33     groups:\n",
-       " 34         - cmh\n",
-       " 35     nornir_nos: eos\n",
-       " 36     type: network_device\n",
-       " 37 \n",
-       " 38 spine01.cmh:\n",
-       " 39     nornir_host: 127.0.0.1\n",
-       " 40     nornir_username: vagrant\n",
-       " 41     nornir_password: ""\n",
-       " 42     nornir_network_api_port: 12204\n",
-       " 43     site: cmh\n",
-       " 44     role: spine\n",
-       " 45     groups:\n",
-       " 46         - cmh\n",
-       " 47     nornir_nos: junos\n",
-       " 48     type: network_device\n",
-       " 49 \n",
-       " 50 leaf00.cmh:\n",
-       " 51     nornir_host: 127.0.0.1\n",
-       " 52     nornir_username: vagrant\n",
-       " 53     nornir_password: vagrant\n",
-       " 54     nornir_network_api_port: 12443\n",
-       " 55     site: cmh\n",
-       " 56     role: leaf\n",
-       " 57     groups:\n",
-       " 58         - cmh\n",
-       " 59     nornir_nos: eos\n",
-       " 60     type: network_device\n",
-       " 61     asn: 65100\n",
-       " 62 \n",
-       " 63 leaf01.cmh:\n",
-       " 64     nornir_host: 127.0.0.1\n",
-       " 65     nornir_username: vagrant\n",
-       " 66     nornir_password: ""\n",
-       " 67     nornir_network_api_port: 12203\n",
-       " 68     site: cmh\n",
-       " 69     role: leaf\n",
-       " 70     groups:\n",
-       " 71         - cmh\n",
-       " 72     nornir_nos: junos\n",
-       " 73     type: network_device\n",
-       " 74     asn: 65101\n",
-       " 75 \n",
-       " 76 host1.bma:\n",
-       " 77     site: bma\n",
-       " 78     role: host\n",
-       " 79     groups:\n",
-       " 80         - bma\n",
-       " 81     nornir_nos: linux\n",
-       " 82     type: host\n",
-       " 83 \n",
-       " 84 host2.bma:\n",
-       " 85     site: bma\n",
-       " 86     role: host\n",
-       " 87     groups:\n",
-       " 88         - bma\n",
-       " 89     nornir_nos: linux\n",
-       " 90     type: host\n",
-       " 91 \n",
-       " 92 spine00.bma:\n",
-       " 93     nornir_host: 127.0.0.1\n",
-       " 94     nornir_username: vagrant\n",
-       " 95     nornir_password: vagrant\n",
-       " 96     nornir_network_api_port: 12444\n",
-       " 97     site: bma\n",
-       " 98     role: spine\n",
-       " 99     groups:\n",
-       "100         - bma\n",
-       "101     nornir_nos: eos\n",
-       "102     type: network_device\n",
-       "103 \n",
-       "104 spine01.bma:\n",
-       "105     nornir_host: 127.0.0.1\n",
-       "106     nornir_username: vagrant\n",
-       "107     nornir_password: ""\n",
-       "108     nornir_network_api_port: 12204\n",
-       "109     site: bma\n",
-       "110     role: spine\n",
-       "111     groups:\n",
-       "112         - bma\n",
-       "113     nornir_nos: junos\n",
-       "114     type: network_device\n",
-       "115 \n",
-       "116 leaf00.bma:\n",
-       "117     nornir_host: 127.0.0.1\n",
-       "118     nornir_username: vagrant\n",
-       "119     nornir_password: vagrant\n",
-       "120     nornir_network_api_port: 12443\n",
-       "121     site: bma\n",
-       "122     role: leaf\n",
-       "123     groups:\n",
-       "124         - bma\n",
-       "125     nornir_nos: eos\n",
-       "126     type: network_device\n",
-       "127 \n",
-       "128 leaf01.bma:\n",
-       "129     nornir_host: 127.0.0.1\n",
-       "130     nornir_username: vagrant\n",
-       "131     nornir_password: wrong_password\n",
-       "132     nornir_network_api_port: 12203\n",
-       "133     site: bma\n",
-       "134     role: leaf\n",
-       "135     groups:\n",
-       "136         - bma\n",
-       "137     nornir_nos: junos\n",
-       "138     type: network_device\n",
+       "  2 host1.cmh:\n",
+       "  3     hostname: 127.0.0.1\n",
+       "  4     port: 2201\n",
+       "  5     username: vagrant\n",
+       "  6     password: vagrant\n",
+       "  7     platform: linux\n",
+       "  8     groups:\n",
+       "  9         - cmh\n",
+       " 10     data:\n",
+       " 11         site: cmh\n",
+       " 12         role: host\n",
+       " 13         type: host\n",
+       " 14         nested_data:\n",
+       " 15             a_dict:\n",
+       " 16                 a: 1\n",
+       " 17                 b: 2\n",
+       " 18             a_list: [1, 2]\n",
+       " 19             a_string: "asdasd"\n",
+       " 20 \n",
+       " 21 host2.cmh:\n",
+       " 22     hostname: 127.0.0.1\n",
+       " 23     port: 2202\n",
+       " 24     username: vagrant\n",
+       " 25     password: vagrant\n",
+       " 26     platform: linux\n",
+       " 27     groups:\n",
+       " 28         - cmh\n",
+       " 29     data:\n",
+       " 30         site: cmh\n",
+       " 31         role: host\n",
+       " 32         type: host\n",
+       " 33         nested_data:\n",
+       " 34             a_dict:\n",
+       " 35                 b: 2\n",
+       " 36                 c: 3\n",
+       " 37             a_list: [1, 2]\n",
+       " 38             a_string: "qwe"\n",
+       " 39 \n",
+       " 40 spine00.cmh:\n",
+       " 41     hostname: 127.0.0.1\n",
+       " 42     username: vagrant\n",
+       " 43     password: vagrant\n",
+       " 44     port: 12444\n",
+       " 45     platform: eos\n",
+       " 46     groups:\n",
+       " 47         - cmh\n",
+       " 48     data:\n",
+       " 49         site: cmh\n",
+       " 50         role: spine\n",
+       " 51         type: network_device\n",
+       " 52 \n",
+       " 53 spine01.cmh:\n",
+       " 54     hostname: 127.0.0.1\n",
+       " 55     username: vagrant\n",
+       " 56     password: ""\n",
+       " 57     platform: junos\n",
+       " 58     port: 12204\n",
+       " 59     groups:\n",
+       " 60         - cmh\n",
+       " 61     data:\n",
+       " 62         site: cmh\n",
+       " 63         role: spine\n",
+       " 64         type: network_device\n",
+       " 65 \n",
+       " 66 leaf00.cmh:\n",
+       " 67     hostname: 127.0.0.1\n",
+       " 68     username: vagrant\n",
+       " 69     password: vagrant\n",
+       " 70     port: 12443\n",
+       " 71     platform: eos\n",
+       " 72     groups:\n",
+       " 73         - cmh\n",
+       " 74     data:\n",
+       " 75         site: cmh\n",
+       " 76         role: leaf\n",
+       " 77         type: network_device\n",
+       " 78         asn: 65100\n",
+       " 79 \n",
+       " 80 leaf01.cmh:\n",
+       " 81     hostname: 127.0.0.1\n",
+       " 82     username: vagrant\n",
+       " 83     password: ""\n",
+       " 84     port: 12203\n",
+       " 85     platform: junos\n",
+       " 86     groups:\n",
+       " 87         - cmh\n",
+       " 88     data:\n",
+       " 89         site: cmh\n",
+       " 90         role: leaf\n",
+       " 91         type: network_device\n",
+       " 92         asn: 65101\n",
+       " 93 \n",
+       " 94 host1.bma:\n",
+       " 95     groups:\n",
+       " 96         - bma\n",
+       " 97     platform: linux\n",
+       " 98     data:\n",
+       " 99         site: bma\n",
+       "100         role: host\n",
+       "101         type: host\n",
+       "102 \n",
+       "103 host2.bma:\n",
+       "104     groups:\n",
+       "105         - bma\n",
+       "106     platform: linux\n",
+       "107     data:\n",
+       "108         site: bma\n",
+       "109         role: host\n",
+       "110         type: host\n",
+       "111 \n",
+       "112 spine00.bma:\n",
+       "113     hostname: 127.0.0.1\n",
+       "114     username: vagrant\n",
+       "115     password: vagrant\n",
+       "116     port: 12444\n",
+       "117     platform: eos\n",
+       "118     groups:\n",
+       "119         - bma\n",
+       "120     data:\n",
+       "121         site: bma\n",
+       "122         role: spine\n",
+       "123         type: network_device\n",
+       "124 \n",
+       "125 spine01.bma:\n",
+       "126     hostname: 127.0.0.1\n",
+       "127     username: vagrant\n",
+       "128     password: ""\n",
+       "129     port: 12204\n",
+       "130     platform: junos\n",
+       "131     groups:\n",
+       "132         - bma\n",
+       "133     data:\n",
+       "134         site: bma\n",
+       "135         role: spine\n",
+       "136         type: network_device\n",
+       "137 \n",
+       "138 leaf00.bma:\n",
+       "139     hostname: 127.0.0.1\n",
+       "140     username: vagrant\n",
+       "141     password: vagrant\n",
+       "142     port: 12443\n",
+       "143     platform: eos\n",
+       "144     groups:\n",
+       "145         - bma\n",
+       "146     data:\n",
+       "147         site: bma\n",
+       "148         role: leaf\n",
+       "149         type: network_device\n",
+       "150 \n",
+       "151 leaf01.bma:\n",
+       "152     hostname: 127.0.0.1\n",
+       "153     username: vagrant\n",
+       "154     password: wrong_password\n",
+       "155     port: 12203\n",
+       "156     platform: junos\n",
+       "157     groups:\n",
+       "158         - bma\n",
+       "159     data:\n",
+       "160         site: bma\n",
+       "161         role: leaf\n",
+       "162         type: network_device\n",
        "
\n", "\n" ], @@ -264,15 +288,117 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The hosts file is basically a map where the outermost key is the hostname and then any arbitrary `` pair you want inside. Usually `nornir_*` keys have special meaning, you can investigate the [hosts](../../ref/api/inventory.rst#nornir.core.inventory.Host) class for details on those. In addition, the `groups` key is a list of groups you can inherite data from. We will inspect soon how the inheritance model works.\n", - "\n", - "Now, let's look at the groups file:" + "The hosts file is basically a map where the outermost key is the name of the host and then an `InventoryElement` object. You can see the schema of the object by executing:" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"title\": \"InventoryElement\",\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"hostname\": {\n", + " \"title\": \"Hostname\",\n", + " \"type\": \"string\"\n", + " },\n", + " \"port\": {\n", + " \"title\": \"Port\",\n", + " \"type\": \"integer\"\n", + " },\n", + " \"username\": {\n", + " \"title\": \"Username\",\n", + " \"type\": \"string\"\n", + " },\n", + " \"password\": {\n", + " \"title\": \"Password\",\n", + " \"type\": \"string\"\n", + " },\n", + " \"platform\": {\n", + " \"title\": \"Platform\",\n", + " \"type\": \"string\"\n", + " },\n", + " \"groups\": {\n", + " \"title\": \"Groups\",\n", + " \"default\": [],\n", + " \"type\": \"array\",\n", + " \"items\": {\n", + " \"type\": \"string\"\n", + " }\n", + " },\n", + " \"data\": {\n", + " \"title\": \"Data\",\n", + " \"default\": {},\n", + " \"type\": \"object\"\n", + " },\n", + " \"connection_options\": {\n", + " \"title\": \"Connection_Options\",\n", + " \"default\": {},\n", + " \"type\": \"object\",\n", + " \"additionalProperties\": {\n", + " \"$ref\": \"#/definitions/ConnectionOptions\"\n", + " }\n", + " }\n", + " },\n", + " \"definitions\": {\n", + " \"ConnectionOptions\": {\n", + " \"title\": \"ConnectionOptions\",\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"hostname\": {\n", + " \"title\": \"Hostname\",\n", + " \"type\": \"string\"\n", + " },\n", + " \"port\": {\n", + " \"title\": \"Port\",\n", + " \"type\": \"integer\"\n", + " },\n", + " \"username\": {\n", + " \"title\": \"Username\",\n", + " \"type\": \"string\"\n", + " },\n", + " \"password\": {\n", + " \"title\": \"Password\",\n", + " \"type\": \"string\"\n", + " },\n", + " \"platform\": {\n", + " \"title\": \"Platform\",\n", + " \"type\": \"string\"\n", + " },\n", + " \"extras\": {\n", + " \"title\": \"Extras\",\n", + " \"type\": \"object\"\n", + " }\n", + " }\n", + " }\n", + " }\n", + "}\n" + ] + } + ], + "source": [ + "from nornir.core.deserializer.inventory import InventoryElement\n", + "import json\n", + "print(json.dumps(InventoryElement.schema(), indent=4))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `groups_file` follows the same rules as the `hosts_file`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, "outputs": [ { "data": { @@ -352,26 +478,26 @@ "\n", "\n", "
 1 ---\n",
-       " 2 defaults:\n",
-       " 3     domain: acme.local\n",
-       " 4 \n",
-       " 5 global:\n",
-       " 6     domain: global.local\n",
-       " 7     asn: 1\n",
-       " 8 \n",
-       " 9 eu:\n",
-       "10     asn: 65100\n",
-       "11 \n",
-       "12 bma:\n",
-       "13     groups:\n",
-       "14         - eu\n",
-       "15         - global\n",
-       "16 \n",
-       "17 cmh:\n",
-       "18     asn: 65000\n",
-       "19     vlans:\n",
-       "20       100: frontend\n",
-       "21       200: backend\n",
+       " 2 global:\n",
+       " 3     data:\n",
+       " 4         domain: global.local\n",
+       " 5         asn: 1\n",
+       " 6 \n",
+       " 7 eu:\n",
+       " 8     data:\n",
+       " 9         asn: 65100\n",
+       "10 \n",
+       "11 bma:\n",
+       "12     groups:\n",
+       "13         - eu\n",
+       "14         - global\n",
+       "15 \n",
+       "16 cmh:\n",
+       "17     data:\n",
+       "18         asn: 65000\n",
+       "19         vlans:\n",
+       "20           100: frontend\n",
+       "21           200: backend\n",
        "
\n", "\n" ], @@ -379,7 +505,7 @@ "" ] }, - "execution_count": 3, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -393,34 +519,138 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Pretty similar to the hosts file.\n", - "\n", - "### Accessing the inventory\n", - "\n", - "You can access the [inventory](../../ref/api/inventory.rst#nornir.core.inventory.Inventory) with the `inventory` attribute:" + "Finally, the defaults file has the same schema as the `InventoryElement` we described before but without outer keys to denote individual elements. We will see how the data in the groups and defaults file is used later on in this tutorial." ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "metadata": {}, "outputs": [ { "data": { + "text/html": [ + "\n", + "
1 ---\n",
+       "2 data:\n",
+       "3     domain: acme.local\n",
+       "
\n", + "\n" + ], "text/plain": [ - "" + "" ] }, - "execution_count": 4, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "from nornir.core import InitNornir\n", + "# defaults file\n", + "%highlight_file inventory/defaults.yaml" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Accessing the inventory\n", + "\n", + "You can access the [inventory](../../ref/api/inventory.rst#nornir.core.inventory.Inventory) with the `inventory` attribute:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'host1.cmh': Host: host1.cmh, 'host2.cmh': Host: host2.cmh, 'spine00.cmh': Host: spine00.cmh, 'spine01.cmh': Host: spine01.cmh, 'leaf00.cmh': Host: leaf00.cmh, 'leaf01.cmh': Host: leaf01.cmh, 'host1.bma': Host: host1.bma, 'host2.bma': Host: host2.bma, 'spine00.bma': Host: spine00.bma, 'spine01.bma': Host: spine01.bma, 'leaf00.bma': Host: leaf00.bma, 'leaf01.bma': Host: leaf01.bma}\n" + ] + } + ], + "source": [ + "from nornir import InitNornir\n", "nr = InitNornir(config_file=\"config.yaml\")\n", "\n", - "nr.inventory" + "print(nr.inventory.hosts)" ] }, { @@ -432,27 +662,27 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'host1.bma': Host: host1.bma,\n", - " 'host1.cmh': Host: host1.cmh,\n", - " 'host2.bma': Host: host2.bma,\n", + "{'host1.cmh': Host: host1.cmh,\n", " 'host2.cmh': Host: host2.cmh,\n", - " 'leaf00.bma': Host: leaf00.bma,\n", + " 'spine00.cmh': Host: spine00.cmh,\n", + " 'spine01.cmh': Host: spine01.cmh,\n", " 'leaf00.cmh': Host: leaf00.cmh,\n", - " 'leaf01.bma': Host: leaf01.bma,\n", " 'leaf01.cmh': Host: leaf01.cmh,\n", + " 'host1.bma': Host: host1.bma,\n", + " 'host2.bma': Host: host2.bma,\n", " 'spine00.bma': Host: spine00.bma,\n", - " 'spine00.cmh': Host: spine00.cmh,\n", " 'spine01.bma': Host: spine01.bma,\n", - " 'spine01.cmh': Host: spine01.cmh}" + " 'leaf00.bma': Host: leaf00.bma,\n", + " 'leaf01.bma': Host: leaf01.bma}" ] }, - "execution_count": 5, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -463,19 +693,19 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'bma': Group: bma,\n", - " 'cmh': Group: cmh,\n", + "{'global': Group: global,\n", " 'eu': Group: eu,\n", - " 'global': Group: global}" + " 'bma': Group: bma,\n", + " 'cmh': Group: cmh}" ] }, - "execution_count": 6, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -486,7 +716,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 9, "metadata": {}, "outputs": [ { @@ -495,7 +725,7 @@ "Host: leaf01.bma" ] }, - "execution_count": 7, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -513,16 +743,16 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 10, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "dict_keys(['name', 'groups', 'nornir_host', 'nornir_username', 'nornir_password', 'nornir_network_api_port', 'site', 'role', 'nornir_nos', 'type', 'asn', 'domain'])" + "dict_keys(['site', 'role', 'type', 'asn', 'domain'])" ] }, - "execution_count": 8, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -534,7 +764,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 11, "metadata": { "scrolled": true }, @@ -545,7 +775,7 @@ "'bma'" ] }, - "execution_count": 9, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -565,7 +795,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 12, "metadata": {}, "outputs": [ { @@ -646,26 +876,26 @@ "\n", "\n", "
 1 ---\n",
-       " 2 defaults:\n",
-       " 3     domain: acme.local\n",
-       " 4 \n",
-       " 5 global:\n",
-       " 6     domain: global.local\n",
-       " 7     asn: 1\n",
-       " 8 \n",
-       " 9 eu:\n",
-       "10     asn: 65100\n",
-       "11 \n",
-       "12 bma:\n",
-       "13     groups:\n",
-       "14         - eu\n",
-       "15         - global\n",
-       "16 \n",
-       "17 cmh:\n",
-       "18     asn: 65000\n",
-       "19     vlans:\n",
-       "20       100: frontend\n",
-       "21       200: backend\n",
+       " 2 global:\n",
+       " 3     data:\n",
+       " 4         domain: global.local\n",
+       " 5         asn: 1\n",
+       " 6 \n",
+       " 7 eu:\n",
+       " 8     data:\n",
+       " 9         asn: 65100\n",
+       "10 \n",
+       "11 bma:\n",
+       "12     groups:\n",
+       "13         - eu\n",
+       "14         - global\n",
+       "15 \n",
+       "16 cmh:\n",
+       "17     data:\n",
+       "18         asn: 65000\n",
+       "19         vlans:\n",
+       "20           100: frontend\n",
+       "21           200: backend\n",
        "
\n", "\n" ], @@ -673,7 +903,7 @@ "" ] }, - "execution_count": 10, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -694,7 +924,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 13, "metadata": {}, "outputs": [ { @@ -703,7 +933,7 @@ "'global.local'" ] }, - "execution_count": 11, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -715,7 +945,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 14, "metadata": {}, "outputs": [ { @@ -724,7 +954,7 @@ "65100" ] }, - "execution_count": 12, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } @@ -737,12 +967,12 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The group `defaults` is special. This group contains data that will be returned if neither the host nor the parents have a specific value for it." + "Values in `defaults` will be returned if neither the host nor the parents have a specific value for it." ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 15, "metadata": {}, "outputs": [ { @@ -751,7 +981,7 @@ "'acme.local'" ] }, - "execution_count": 13, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -770,7 +1000,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -797,7 +1027,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 17, "metadata": {}, "outputs": [ { @@ -828,7 +1058,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 18, "metadata": {}, "outputs": [ { @@ -837,7 +1067,7 @@ "dict_keys(['host1.cmh', 'host2.cmh', 'spine00.cmh', 'spine01.cmh', 'leaf00.cmh', 'leaf01.cmh'])" ] }, - "execution_count": 16, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -855,7 +1085,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 19, "metadata": {}, "outputs": [ { @@ -864,7 +1094,7 @@ "dict_keys(['spine00.cmh', 'spine01.cmh'])" ] }, - "execution_count": 17, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } @@ -882,7 +1112,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 20, "metadata": {}, "outputs": [ { @@ -891,7 +1121,7 @@ "dict_keys(['spine00.cmh', 'spine01.cmh'])" ] }, - "execution_count": 18, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -909,7 +1139,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 21, "metadata": {}, "outputs": [ { @@ -918,7 +1148,7 @@ "dict_keys(['spine00.cmh', 'spine01.cmh'])" ] }, - "execution_count": 19, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } @@ -930,7 +1160,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 22, "metadata": {}, "outputs": [ { @@ -939,7 +1169,7 @@ "dict_keys(['leaf00.cmh', 'leaf01.cmh'])" ] }, - "execution_count": 20, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" } @@ -957,27 +1187,27 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 23, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'host1.bma': Host: host1.bma,\n", - " 'host2.bma': Host: host2.bma,\n", - " 'leaf00.bma': Host: leaf00.bma,\n", - " 'leaf01.bma': Host: leaf01.bma,\n", - " 'spine00.bma': Host: spine00.bma,\n", - " 'spine01.bma': Host: spine01.bma}" + "{Host: host1.bma,\n", + " Host: host2.bma,\n", + " Host: leaf00.bma,\n", + " Host: leaf01.bma,\n", + " Host: spine00.bma,\n", + " Host: spine01.bma}" ] }, - "execution_count": 21, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "nr.inventory.groups[\"eu\"].children()" + "nr.inventory.children_of_group(\"eu\")" ] }, { @@ -986,12 +1216,19 @@ "source": [ "#### Advanced filtering\n", "\n", - "Sometimes you need more fancy filtering. For those cases you can use a filter function:" + "Sometimes you need more fancy filtering. For those cases you have two options:\n", + "\n", + "1. Use a filter function.\n", + "2. Use a filter object.\n", + "\n", + "##### Filter functions\n", + "\n", + "The ``filter_func`` parameter let's you run your own code to filter the hosts. The function signature is as simple as ``my_func(host)`` where host is an object of type [Host](../../ref/api/inventory.rst#nornir.core.inventory.Host) and it has to return either ``True`` or ``False`` to indicate if you want to host or not." ] }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 24, "metadata": {}, "outputs": [ { @@ -1000,7 +1237,7 @@ "dict_keys(['spine00.cmh', 'spine01.cmh', 'spine00.bma', 'spine01.bma'])" ] }, - "execution_count": 22, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } @@ -1014,7 +1251,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 25, "metadata": {}, "outputs": [ { @@ -1023,7 +1260,7 @@ "dict_keys(['host1.cmh', 'host2.cmh', 'host1.bma', 'host2.bma'])" ] }, - "execution_count": 23, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -1032,6 +1269,169 @@ "# Or a lambda function\n", "nr.filter(filter_func=lambda h: len(h.name) == 9).inventory.hosts.keys()" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Filter Object\n", + "\n", + "You can also use a filter object to create incrementally a complext query object. Let's see how it works by example:" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "# first you need to import the F object\n", + "from nornir.core.filter import F" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['host1.cmh', 'host2.cmh', 'spine00.cmh', 'spine01.cmh', 'leaf00.cmh', 'leaf01.cmh'])\n" + ] + } + ], + "source": [ + "# hosts in group cmh\n", + "cmh = nr.filter(F(groups__contains=\"cmh\"))\n", + "print(cmh.inventory.hosts.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys([])\n" + ] + } + ], + "source": [ + "# devices running either linux or eos\n", + "linux_or_eos = nr.filter(F(nornir_nos=\"linux\") | F(nornir_nos=\"eos\"))\n", + "print(linux_or_eos.inventory.hosts.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['spine00.cmh', 'spine01.cmh'])\n" + ] + } + ], + "source": [ + "# spines in cmh\n", + "cmh_and_spine = nr.filter(F(groups__contains=\"cmh\") & F(role=\"spine\"))\n", + "print(cmh_and_spine.inventory.hosts.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['host1.cmh', 'host2.cmh', 'leaf00.cmh', 'leaf01.cmh'])\n" + ] + } + ], + "source": [ + "# cmh devices that are not spines\n", + "cmh_and_not_spine = nr.filter(F(groups__contains=\"cmh\") & ~F(role=\"spine\"))\n", + "print(cmh_and_not_spine.inventory.hosts.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can also access nested data and even check if dicts/lists/strings contains elements. Again, let's see by example:" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['host1.cmh'])\n" + ] + } + ], + "source": [ + "nested_string_asd = nr.filter(F(nested_data__a_string__contains=\"asd\"))\n", + "print(nested_string_asd.inventory.hosts.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['host2.cmh'])\n" + ] + } + ], + "source": [ + "a_dict_element_equals = nr.filter(F(nested_data__a_dict__c=3))\n", + "print(a_dict_element_equals.inventory.hosts.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['host1.cmh', 'host2.cmh'])\n" + ] + } + ], + "source": [ + "a_list_contains = nr.filter(F(nested_data__a_list__contains=2))\n", + "print(a_list_contains.inventory.hosts.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can basically access any nested data by separating the elements in the path with two underscores `__`. Then you can use `__contains` to check if an element exists or if a string has a particular substring." + ] } ], "metadata": { @@ -1051,7 +1451,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.5" + "version": "3.7.1" } }, "nbformat": 4, diff --git a/docs/tutorials/intro/inventory/Vagrantfile b/docs/tutorials/intro/inventory/Vagrantfile index b0c2f21a..a38af3ea 100644 --- a/docs/tutorials/intro/inventory/Vagrantfile +++ b/docs/tutorials/intro/inventory/Vagrantfile @@ -2,7 +2,7 @@ # vi: set ft=ruby : """ You will need the boxes: - * vEOS-4.17.5M + * vEOS-lab-4.20.1F * JunOS - juniper/ffp-12.1X47-D20.7-packetmode * To provision and test JunOS first you have to add the ssh vagrant ssh key into the ssh-agent. I.e.: ssh-add /opt/vagrant/embedded/gems/gems/vagrant-`vagrant --version | awk '{ print $2 }'`/keys/vagrant @@ -12,7 +12,7 @@ Vagrant.configure(2) do |config| config.vbguest.auto_update = false config.vm.define "spine00" do |spine00| - spine00.vm.box = "vEOS-lab-4.17.5M" + spine00.vm.box = "vEOS-lab-4.20.1F" spine00.vm.network :forwarded_port, guest: 443, host: 12444, id: 'https' @@ -31,7 +31,7 @@ Vagrant.configure(2) do |config| config.vm.define "leaf00" do |leaf00| - leaf00.vm.box = "vEOS-lab-4.17.5M" + leaf00.vm.box = "vEOS-lab-4.20.1F" leaf00.vm.network :forwarded_port, guest: 443, host: 12443, id: 'https' diff --git a/docs/tutorials/intro/inventory/defaults.yaml b/docs/tutorials/intro/inventory/defaults.yaml new file mode 100644 index 00000000..38d3b12e --- /dev/null +++ b/docs/tutorials/intro/inventory/defaults.yaml @@ -0,0 +1,3 @@ +--- +data: + domain: acme.local diff --git a/docs/tutorials/intro/inventory/groups.yaml b/docs/tutorials/intro/inventory/groups.yaml index 451d6bae..0a9fc619 100644 --- a/docs/tutorials/intro/inventory/groups.yaml +++ b/docs/tutorials/intro/inventory/groups.yaml @@ -1,13 +1,12 @@ --- -defaults: - domain: acme.local - global: - domain: global.local - asn: 1 + data: + domain: global.local + asn: 1 eu: - asn: 65100 + data: + asn: 65100 bma: groups: @@ -15,7 +14,8 @@ bma: - global cmh: - asn: 65000 - vlans: - 100: frontend - 200: backend + data: + asn: 65000 + vlans: + 100: frontend + 200: backend diff --git a/docs/tutorials/intro/inventory/hosts.yaml b/docs/tutorials/intro/inventory/hosts.yaml index cc253298..c8081951 100644 --- a/docs/tutorials/intro/inventory/hosts.yaml +++ b/docs/tutorials/intro/inventory/hosts.yaml @@ -1,138 +1,162 @@ --- host1.cmh: - nornir_host: 127.0.0.1 - nornir_ssh_port: 2201 - nornir_username: vagrant - nornir_password: vagrant - site: cmh - role: host + hostname: 127.0.0.1 + port: 2201 + username: vagrant + password: vagrant + platform: linux groups: - cmh - nornir_nos: linux - type: host + data: + site: cmh + role: host + type: host + nested_data: + a_dict: + a: 1 + b: 2 + a_list: [1, 2] + a_string: "asdasd" host2.cmh: - nornir_host: 127.0.0.1 - nornir_ssh_port: 2202 - nornir_username: vagrant - nornir_password: vagrant - site: cmh - role: host + hostname: 127.0.0.1 + port: 2202 + username: vagrant + password: vagrant + platform: linux groups: - cmh - nornir_nos: linux - type: host + data: + site: cmh + role: host + type: host + nested_data: + a_dict: + b: 2 + c: 3 + a_list: [1, 2] + a_string: "qwe" spine00.cmh: - nornir_host: 127.0.0.1 - nornir_username: vagrant - nornir_password: vagrant - nornir_network_api_port: 12444 - site: cmh - role: spine + hostname: 127.0.0.1 + username: vagrant + password: vagrant + port: 12444 + platform: eos groups: - cmh - nornir_nos: eos - type: network_device + data: + site: cmh + role: spine + type: network_device spine01.cmh: - nornir_host: 127.0.0.1 - nornir_username: vagrant - nornir_password: "" - nornir_network_api_port: 12204 - site: cmh - role: spine + hostname: 127.0.0.1 + username: vagrant + password: "" + platform: junos + port: 12204 groups: - cmh - nornir_nos: junos - type: network_device + data: + site: cmh + role: spine + type: network_device leaf00.cmh: - nornir_host: 127.0.0.1 - nornir_username: vagrant - nornir_password: vagrant - nornir_network_api_port: 12443 - site: cmh - role: leaf + hostname: 127.0.0.1 + username: vagrant + password: vagrant + port: 12443 + platform: eos groups: - cmh - nornir_nos: eos - type: network_device - asn: 65100 + data: + site: cmh + role: leaf + type: network_device + asn: 65100 leaf01.cmh: - nornir_host: 127.0.0.1 - nornir_username: vagrant - nornir_password: "" - nornir_network_api_port: 12203 - site: cmh - role: leaf + hostname: 127.0.0.1 + username: vagrant + password: "" + port: 12203 + platform: junos groups: - cmh - nornir_nos: junos - type: network_device - asn: 65101 + data: + site: cmh + role: leaf + type: network_device + asn: 65101 host1.bma: - site: bma - role: host groups: - bma - nornir_nos: linux - type: host + platform: linux + data: + site: bma + role: host + type: host host2.bma: - site: bma - role: host groups: - bma - nornir_nos: linux - type: host + platform: linux + data: + site: bma + role: host + type: host spine00.bma: - nornir_host: 127.0.0.1 - nornir_username: vagrant - nornir_password: vagrant - nornir_network_api_port: 12444 - site: bma - role: spine + hostname: 127.0.0.1 + username: vagrant + password: vagrant + port: 12444 + platform: eos groups: - bma - nornir_nos: eos - type: network_device + data: + site: bma + role: spine + type: network_device spine01.bma: - nornir_host: 127.0.0.1 - nornir_username: vagrant - nornir_password: "" - nornir_network_api_port: 12204 - site: bma - role: spine + hostname: 127.0.0.1 + username: vagrant + password: "" + port: 12204 + platform: junos groups: - bma - nornir_nos: junos - type: network_device + data: + site: bma + role: spine + type: network_device leaf00.bma: - nornir_host: 127.0.0.1 - nornir_username: vagrant - nornir_password: vagrant - nornir_network_api_port: 12443 - site: bma - role: leaf + hostname: 127.0.0.1 + username: vagrant + password: vagrant + port: 12443 + platform: eos groups: - bma - nornir_nos: eos - type: network_device + data: + site: bma + role: leaf + type: network_device leaf01.bma: - nornir_host: 127.0.0.1 - nornir_username: vagrant - nornir_password: wrong_password - nornir_network_api_port: 12203 - site: bma - role: leaf + hostname: 127.0.0.1 + username: vagrant + password: wrong_password + port: 12203 + platform: junos groups: - bma - nornir_nos: junos - type: network_device + data: + site: bma + role: leaf + type: network_device diff --git a/docs/tutorials/intro/task_results.ipynb b/docs/tutorials/intro/task_results.ipynb index 978c6a11..e9dc529f 100644 --- a/docs/tutorials/intro/task_results.ipynb +++ b/docs/tutorials/intro/task_results.ipynb @@ -17,7 +17,7 @@ "metadata": {}, "outputs": [], "source": [ - "from nornir.core import InitNornir\n", + "from nornir import InitNornir\n", "from nornir.plugins.tasks import networking, text\n", "from nornir.plugins.functions.text import print_title\n", "import logging\n", @@ -30,7 +30,7 @@ " r = task.run(task=text.template_file,\n", " name=\"Base Configuration\",\n", " template=\"base.j2\",\n", - " path=f\"templates/{task.host.nos}\",\n", + " path=f\"templates/{task.host.platform}\",\n", " severity_level=logging.DEBUG)\n", "\n", " # Save the compiled configuration into a host variable\n", @@ -79,7 +79,7 @@ "output_type": "stream", "text": [ "\u001b[1m\u001b[36mbasic_configuration*************************************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : True *************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m@@ -7,6 +7,9 @@\n", @@ -87,21 +87,21 @@ " !\n", " transceiver qsfp default-mode 4x10G\n", "+!\n", - "+hostname spine00.cmh\n", + "+hostname leaf00.cmh\n", "+ip domain-name cmh.acme.local\n", " !\n", " spanning-tree mode mstp\n", " !\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : True *************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m[edit system]\n", "- host-name vsrx;\n", - "+ host-name spine01.cmh;\n", + "+ host-name leaf01.cmh;\n", "+ domain-name cmh.acme.local;\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : True ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m@@ -7,6 +7,9 @@\n", @@ -109,18 +109,18 @@ " !\n", " transceiver qsfp default-mode 4x10G\n", "+!\n", - "+hostname leaf00.cmh\n", + "+hostname spine00.cmh\n", "+ip domain-name cmh.acme.local\n", " !\n", " spanning-tree mode mstp\n", " !\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : True ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m[edit system]\n", "- host-name vsrx;\n", - "+ host-name leaf01.cmh;\n", + "+ host-name spine01.cmh;\n", "+ domain-name cmh.acme.local;\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m" @@ -224,10 +224,10 @@ "output_type": "stream", "text": [ "\u001b[1m\u001b[36mbasic_configuration*************************************************************\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : True *************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- DEBUG\u001b[0m\n", - "\u001b[0mhostname spine00.cmh\n", + "\u001b[0mhostname leaf00.cmh\n", "ip domain-name cmh.acme.local\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m@@ -7,6 +7,9 @@\n", @@ -235,29 +235,29 @@ " !\n", " transceiver qsfp default-mode 4x10G\n", "+!\n", - "+hostname spine00.cmh\n", + "+hostname leaf00.cmh\n", "+ip domain-name cmh.acme.local\n", " !\n", " spanning-tree mode mstp\n", " !\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : True ************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : True *************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- DEBUG\u001b[0m\n", "\u001b[0msystem {\n", - " host-name spine01.cmh;\n", + " host-name leaf01.cmh;\n", " domain-name cmh.acme.local;\n", "}\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m[edit system]\n", "- host-name vsrx;\n", - "+ host-name spine01.cmh;\n", + "+ host-name leaf01.cmh;\n", "+ domain-name cmh.acme.local;\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf00.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine00.cmh ** changed : True ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- DEBUG\u001b[0m\n", - "\u001b[0mhostname leaf00.cmh\n", + "\u001b[0mhostname spine00.cmh\n", "ip domain-name cmh.acme.local\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m@@ -7,6 +7,9 @@\n", @@ -265,23 +265,23 @@ " !\n", " transceiver qsfp default-mode 4x10G\n", "+!\n", - "+hostname leaf00.cmh\n", + "+hostname spine00.cmh\n", "+ip domain-name cmh.acme.local\n", " !\n", " spanning-tree mode mstp\n", " !\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", - "\u001b[0m\u001b[1m\u001b[34m* leaf01.cmh ** changed : True *************************************************\u001b[0m\n", + "\u001b[0m\u001b[1m\u001b[34m* spine01.cmh ** changed : True ************************************************\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32mvvvv basic_configuration ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m---- Base Configuration ** changed : False ------------------------------------- DEBUG\u001b[0m\n", "\u001b[0msystem {\n", - " host-name leaf01.cmh;\n", + " host-name spine01.cmh;\n", " domain-name cmh.acme.local;\n", "}\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[33m---- Loading Configuration on the device ** changed : True --------------------- INFO\u001b[0m\n", "\u001b[0m[edit system]\n", "- host-name vsrx;\n", - "+ host-name leaf01.cmh;\n", + "+ host-name spine01.cmh;\n", "+ domain-name cmh.acme.local;\u001b[0m\n", "\u001b[0m\u001b[1m\u001b[32m^^^^ END basic_configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n", "\u001b[0m" @@ -493,7 +493,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.5" + "version": "3.7.0" } }, "nbformat": 4, diff --git a/docs/tutorials/intro/templates/eos/base.j2 b/docs/tutorials/intro/templates/eos/base.j2 index 692a00cd..9675effe 100644 --- a/docs/tutorials/intro/templates/eos/base.j2 +++ b/docs/tutorials/intro/templates/eos/base.j2 @@ -1,2 +1,2 @@ hostname {{ host }} -ip domain-name {{ site }}.{{ domain }} +ip domain-name {{ host.site }}.{{ host.domain }} diff --git a/docs/tutorials/intro/templates/junos/base.j2 b/docs/tutorials/intro/templates/junos/base.j2 index 775270c8..290fe320 100644 --- a/docs/tutorials/intro/templates/junos/base.j2 +++ b/docs/tutorials/intro/templates/junos/base.j2 @@ -1,4 +1,4 @@ system { host-name {{ host }}; - domain-name {{ site }}.{{ domain }}; + domain-name {{ host.site }}.{{ host.domain }}; } diff --git a/docs/upgrading/1_to_2.rst b/docs/upgrading/1_to_2.rst new file mode 100644 index 00000000..d11fcfe8 --- /dev/null +++ b/docs/upgrading/1_to_2.rst @@ -0,0 +1,31 @@ +Upgrading to nornir 2.x from 1.x +================================ + +Changes in the inventory +------------------------ + +When specifying connection parameters, in nornir 1.x those parameters where specified with attributes like ``nornir_username``, ``nornir_password``, etc. All of those have been removed and now the only supported parameters are: + +* ``hostname`` +* ``username`` +* ``password`` +* ``port`` (which replaces both ``nornir_ssh_port`` and ``nornir_network_api_port``) +* ``platform`` (which replaces both ``os`` and ``network_operating_system``) + +You can check the following how to for more details on `how to <../howto/handling_connections.rst>`_ use these parameters. + +Changed to path importing ``InitNornir`` +---------------------------------------- + +In order to import ``InitNornir`` correctly you have to change the old path:: + + from nornir.core import InitNornir + +to:: + + from nornir import InitNornir + +Changes to the configuration +---------------------------- + +The format of the configuration has slightly changed. Some of the options that used to be under the root object, for instance ``num_workers``, ``jinja_filters`` and ``raise_on_error`` are now under ``core`` and ``jinja2`` sections. For details, go to the `configuration section <../configuration/index.rst>`_ diff --git a/docs/upgrading/index.rst b/docs/upgrading/index.rst new file mode 100644 index 00000000..9fd800f8 --- /dev/null +++ b/docs/upgrading/index.rst @@ -0,0 +1,8 @@ +Notes when upgrading nornir +=========================== + +.. toctree:: + :maxdepth: 1 + :glob: + + * diff --git a/nornir/__init__.py b/nornir/__init__.py index 593894db..7306fbdc 100644 --- a/nornir/__init__.py +++ b/nornir/__init__.py @@ -1,6 +1,11 @@ +from nornir.init_nornir import InitNornir import pkg_resources + try: __version__ = pkg_resources.get_distribution("nornir").version except pkg_resources.DistributionNotFound: __version__ = "Not installed" + + +__all__ = ("InitNornir", "__version__") diff --git a/nornir/core/__init__.py b/nornir/core/__init__.py index 668cffb1..8b8a4ccd 100644 --- a/nornir/core/__init__.py +++ b/nornir/core/__init__.py @@ -1,66 +1,10 @@ import logging import logging.config -import sys from multiprocessing.dummy import Pool from nornir.core.configuration import Config +from nornir.core.state import GlobalState from nornir.core.task import AggregatedResult, Task -from nornir.plugins.tasks import connections - - -if sys.version_info.major == 2: - import copy_reg - import types - - # multithreading requires objects passed around to be pickable - # following methods allow py2 to know how to pickle methods - - def _pickle_method(method): - func_name = method.im_func.__name__ - obj = method.im_self - cls = method.im_class - return _unpickle_method, (func_name, obj, cls) - - def _unpickle_method(func_name, obj, cls): - for cls_tmp in cls.mro(): - try: - func = cls_tmp.__dict__[func_name] - except KeyError: - pass - else: - break - - else: - raise ValueError("Method ({}) not found for obj: {}".format(func_name, obj)) - - return func.__get__(obj, cls_tmp) - - copy_reg.pickle(types.MethodType, _pickle_method, _unpickle_method) - - -class Data(object): - """ - This class is just a placeholder to share data amongst different - versions of Nornir after running ``filter`` multiple times. - - Attributes: - failed_hosts (list): Hosts that have failed to run a task properly - """ - - def __init__(self): - self.failed_hosts = set() - - def recover_host(self, host): - """Remove ``host`` from list of failed hosts.""" - self.failed_hosts.discard(host) - - def reset_failed_hosts(self): - """Reset failed hosts and make all hosts available for future tasks.""" - self.failed_hosts = set() - - def to_dict(self): - """ Return a dictionary representing the object. """ - return self.__dict__ class Nornir(object): @@ -70,107 +14,40 @@ class Nornir(object): Arguments: inventory (:obj:`nornir.core.inventory.Inventory`): Inventory to work with - data(:obj:`nornir.core.Data`): shared data amongst different iterations of nornir + data(GlobalState): shared data amongst different iterations of nornir dry_run(``bool``): Whether if we are testing the changes or not config (:obj:`nornir.core.configuration.Config`): Configuration object - config_file (``str``): Path to Yaml configuration file - available_connections (``dict``): dict of connection types that will be made available. - Defaults to :obj:`nornir.plugins.tasks.connections.available_connections` Attributes: inventory (:obj:`nornir.core.inventory.Inventory`): Inventory to work with - data(:obj:`nornir.core.Data`): shared data amongst different iterations of nornir + data(:obj:`nornir.core.GlobalState`): shared data amongst different iterations of nornir dry_run(``bool``): Whether if we are testing the changes or not config (:obj:`nornir.core.configuration.Config`): Configuration parameters - available_connections (``dict``): dict of connection types are available """ - def __init__( - self, - inventory, - dry_run, - config=None, - config_file=None, - available_connections=None, - logger=None, - data=None, - ): - self.logger = logger or logging.getLogger("nornir") + def __init__(self, inventory, config=None, logger=None, data=None): + self.data = data if data is not None else GlobalState() + self.logger = logger or logging.getLogger(__name__) - self.data = data or Data() self.inventory = inventory - self.inventory.nornir = self - self.data.dry_run = dry_run - - if config_file: - self.config = Config(config_file=config_file) - else: - self.config = config or Config() - - self.configure_logging() - if available_connections is not None: - self.available_connections = available_connections - else: - self.available_connections = connections.available_connections - - @property - def dry_run(self): - return self.data.dry_run - - def configure_logging(self): - dictConfig = self.config.logging_dictConfig or { - "version": 1, - "disable_existing_loggers": False, - "formatters": {"simple": {"format": self.config.logging_format}}, - "handlers": {}, - "loggers": {}, - "root": { - "level": "CRITICAL" if self.config.logging_loggers else self.config.logging_level.upper(), # noqa - "handlers": [], - "formatter": "simple", - }, - } - handlers_list = [] - if self.config.logging_file: - dictConfig["root"]["handlers"].append("info_file_handler") - handlers_list.append("info_file_handler") - dictConfig["handlers"]["info_file_handler"] = { - "class": "logging.handlers.RotatingFileHandler", - "level": "NOTSET", - "formatter": "simple", - "filename": self.config.logging_file, - "maxBytes": 10485760, - "backupCount": 20, - "encoding": "utf8", - } - if self.config.logging_to_console: - dictConfig["root"]["handlers"].append("info_console") - handlers_list.append("info_console") - dictConfig["handlers"]["info_console"] = { - "class": "logging.StreamHandler", - "level": "NOTSET", - "formatter": "simple", - "stream": "ext://sys.stdout", - } + self.config = config or Config() - for logger in self.config.logging_loggers: - dictConfig["loggers"][logger] = { - "level": self.config.logging_level.upper(), "handlers": handlers_list - } + def __enter__(self): + return self - if dictConfig["root"]["handlers"]: - logging.config.dictConfig(dictConfig) + def __exit__(self, exc_type, exc_val, exc_tb): + self.close_connections(on_good=True, on_failed=True) - def filter(self, **kwargs): + def filter(self, *args, **kwargs): """ See :py:meth:`nornir.core.inventory.Inventory.filter` Returns: :obj:`Nornir`: A new object with same configuration as ``self`` but filtered inventory. """ - b = Nornir(dry_run=self.dry_run, **self.__dict__) - b.inventory = self.inventory.filter(**kwargs) + b = Nornir(**self.__dict__) + b.inventory = self.inventory.filter(*args, **kwargs) return b def _run_serial(self, task, hosts, **kwargs): @@ -201,7 +78,7 @@ def run( raise_on_error=None, on_good=True, on_failed=False, - **kwargs + **kwargs, ): """ Run task over all the hosts in the inventory. @@ -217,12 +94,12 @@ def run( Raises: :obj:`nornir.core.exceptions.NornirExecutionError`: if at least a task fails - and self.config.raise_on_error is set to ``True`` + and self.config.core.raise_on_error is set to ``True`` Returns: :obj:`nornir.core.task.AggregatedResult`: results of each execution """ - num_workers = num_workers or self.config.num_workers + num_workers = num_workers or self.config.core.num_workers run_on = [] if on_good: @@ -246,34 +123,37 @@ def run( else: result = self._run_parallel(task, run_on, num_workers, **kwargs) - raise_on_error = raise_on_error if raise_on_error is not None else self.config.raise_on_error # noqa + raise_on_error = ( + raise_on_error + if raise_on_error is not None + else self.config.core.raise_on_error + ) # noqa if raise_on_error: result.raise_on_error() else: self.data.failed_hosts.update(result.failed_hosts.keys()) return result - def to_dict(self): + def dict(self): """ Return a dictionary representing the object. """ - return {"data": self.data.to_dict(), "inventory": self.inventory.to_dict()} + return {"data": self.data.dict(), "inventory": self.inventory.dict()} + def close_connections(self, on_good=True, on_failed=False): + def close_connections_task(task): + task.host.close_connections() -def InitNornir(config_file="", dry_run=False, **kwargs): - """ - Arguments: - config_file(str): Path to the configuration file (optional) - dry_run(bool): Whether to simulate changes or not - **kwargs: Extra information to pass to the - :obj:`nornir.core.configuration.Config` object + self.run(task=close_connections_task, on_good=on_good, on_failed=on_failed) - Returns: - :obj:`nornir.core.Nornir`: fully instantiated and configured - """ - conf = Config(config_file=config_file, **kwargs) + @classmethod + def get_validators(cls): + yield cls.validate - inv_class = conf.inventory - inv_args = getattr(conf, inv_class.__name__, {}) - transform_function = conf.transform_function - inv = inv_class(transform_function=transform_function, **inv_args) + @classmethod + def validate(cls, v): + if not isinstance(v, cls): + raise ValueError(f"Nornir: Nornir expected not {type(v)}") + return v - return Nornir(inventory=inv, dry_run=dry_run, config=conf) + @property + def state(self): + return GlobalState diff --git a/nornir/core/configuration.py b/nornir/core/configuration.py index a691343c..d863a485 100644 --- a/nornir/core/configuration.py +++ b/nornir/core/configuration.py @@ -1,180 +1,122 @@ -import importlib -import os - - -import yaml - - -CONF = { - "inventory": { - "description": "Path to inventory modules.", - "type": "str", - "default": "nornir.plugins.inventory.simple.SimpleInventory", - }, - "transform_function": { - "description": "Path to transform function. The transform_function you provide " - "will run against each host in the inventory. This is useful to manipulate host " - "data and make it more consumable. For instance, if your inventory has a 'user' " - "attribute you could use this function to map it to 'nornir_user'", - "type": "str", - "default": {}, - }, - "jinja_filters": { - "description": "Path to callable returning jinja filters to be used.", - "type": "str", - "default": {}, - }, - "num_workers": { - "description": "Number of Nornir worker processes that are run at the same time, " - "configuration can be overridden on individual tasks by using the " - "`num_workers` argument to (:obj:`nornir.core.Nornir.run`)", - "type": "int", - "default": 20, - }, - "raise_on_error": { - "description": "If set to ``True``, (:obj:`nornir.core.Nornir.run`) method of will raise " - "an exception if at least a host failed.", - "type": "bool", - "default": False, - }, - "ssh_config_file": { - "description": "User ssh_config_file", - "type": "str", - "default": os.path.join(os.path.expanduser("~"), ".ssh", "config"), - "default_doc": "~/.ssh/config", - }, - "logging_dictConfig": { - "description": "Configuration dictionary schema supported by the logging subsystem. " - "Overrides rest of logging_* parameters.", - "type": "dict", - "default": {}, - }, - "logging_level": { - "description": "Logging level. Can be any supported level by the logging subsystem", - "type": "str", - "default": "debug", - }, - "logging_file": { - "description": "Logging file. Empty string disables logging to file.", - "type": "str", - "default": "nornir.log", - }, - "logging_format": { - "description": "Logging format", - "type": "str", - "default": "%(asctime)s - %(name)12s - %(levelname)8s - %(funcName)10s() - %(message)s", - }, - "logging_to_console": { - "description": "Whether to log to stdout or not.", - "type": "bool", - "default": False, - }, - "logging_loggers": { - "description": "List of loggers to configure. This allows you to enable logging for " - "multiple loggers. For instance, you could enable logging for both nornir " - "and paramiko or just for paramiko. An empty list will enable logging for " - "all loggers.", - "type": "list", - "default": ["nornir"], - }, -} - -types = {"int": int, "str": str} +import logging +import logging.config +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Type + + +if TYPE_CHECKING: + from nornir.core.deserializer.inventory import Inventory # noqa + + +class SSHConfig(object): + __slots__ = "config_file" + + def __init__(self, config_file: str) -> None: + self.config_file = config_file + + +class InventoryConfig(object): + __slots__ = "plugin", "options", "transform_function" + + def __init__( + self, + plugin: Type["Inventory"], + options: Dict[str, Any], + transform_function: Optional[Callable[..., Any]], + ) -> None: + self.plugin = plugin + self.options = options + self.transform_function = transform_function + + +class LoggingConfig(object): + __slots__ = "level", "file", "format", "to_console", "loggers" + + def __init__( + self, level: int, file_: str, format_: str, to_console: bool, loggers: List[str] + ) -> None: + self.level = level + self.file = file_ + self.format = format_ + self.to_console = to_console + self.loggers = loggers + + def configure(self): + rootHandlers: List[Any] = [] + root = { + "level": "CRITICAL" if self.loggers else self.level.upper(), + "handlers": rootHandlers, + "formatter": "simple", + } + handlers: Dict[str, Any] = {} + loggers: Dict[str, Any] = {} + dictConfig = { + "version": 1, + "disable_existing_loggers": False, + "formatters": {"simple": {"format": self.format}}, + "handlers": handlers, + "loggers": loggers, + "root": root, + } + handlers_list = [] + if self.file: + root["handlers"].append("info_file_handler") + handlers_list.append("info_file_handler") + handlers["info_file_handler"] = { + "class": "logging.handlers.RotatingFileHandler", + "level": "NOTSET", + "formatter": "simple", + "filename": self.file, + "maxBytes": 10485760, + "backupCount": 20, + "encoding": "utf8", + } + if self.to_console: + root["handlers"].append("info_console") + handlers_list.append("info_console") + handlers["info_console"] = { + "class": "logging.StreamHandler", + "level": "NOTSET", + "formatter": "simple", + "stream": "ext://sys.stdout", + } + + for logger in self.loggers: + loggers[logger] = {"level": self.level, "handlers": handlers_list} + + if rootHandlers: + logging.config.dictConfig(dictConfig) + + +class Jinja2Config(object): + __slots__ = "filters" + + def __init__(self, filters: Optional[Dict[str, Callable[..., Any]]]) -> None: + self.filters = filters or {} + + +class CoreConfig(object): + __slots__ = ("num_workers", "raise_on_error") + + def __init__(self, num_workers: int, raise_on_error: bool) -> None: + self.num_workers = num_workers + self.raise_on_error = raise_on_error class Config(object): - """ - This object handles the configuration of Nornir. - - Arguments: - config_file(``str``): Yaml configuration file. - """ - - def __init__(self, config_file=None, **kwargs): - if config_file: - with open(config_file, "r") as f: - data = yaml.load(f.read()) or {} - else: - data = {} - - for parameter, param_conf in CONF.items(): - self._assign_property(parameter, param_conf, data) - - for k, v in data.items(): - if k not in CONF: - setattr(self, k, v) - - for k, v in kwargs.items(): - setattr(self, k, v) - - resolve_imports = ["inventory", "transform_function", "jinja_filters"] - for r in resolve_imports: - obj = self._resolve_import_from_string(kwargs.get(r, getattr(self, r))) - setattr(self, r, obj) - - callable_func = ["jinja_filters"] - for c in callable_func: - func = getattr(self, c) - if func: - setattr(self, c, func()) - - def string_to_bool(self, v): - if v.lower() in ["false", "no", "n", "off", "0"]: - return False - - else: - return True - - def _assign_property(self, parameter, param_conf, data): - v = None - if param_conf["type"] in ("bool", "int", "str"): - env = param_conf.get("env") or "BRIGADE_" + parameter.upper() - v = os.environ.get(env) - if v is None: - v = data.get(parameter, param_conf["default"]) - else: - if param_conf["type"] == "bool": - v = self.string_to_bool(v) - else: - v = types[param_conf["type"]](v) - setattr(self, parameter, v) - - def get(self, parameter, env=None, default=None, parameter_type="str", root=""): - """ - Retrieve a custom parameter from the configuration. - - Arguments: - parameter(str): Name of the parameter to retrieve - env(str): Environment variable name to retrieve the object from - default: default value in case no parameter is found - parameter_type(str): if a value is found cast the variable to this type - root(str): parent key in the configuration file where to look for the parameter - """ - value = os.environ.get(env) if env else None - if value is None: - if root: - d = getattr(self, root, {}) - value = d.get(parameter, default) - else: - value = getattr(self, parameter, default) - if parameter_type in [bool, "bool"]: - if not isinstance(value, bool): - value = self.string_to_bool(value) - else: - value = types[str(parameter_type)](value) - return value - - def _resolve_import_from_string(self, import_path): - """ - Resolves import from a string. Checks if callable or path is given. - - Arguments: - import_path(str): path of the import - """ - if not import_path or callable(import_path): - return import_path - - module_name = ".".join(import_path.split(".")[:-1]) - obj_name = import_path.split(".")[-1] - module = importlib.import_module(module_name) - return getattr(module, obj_name) + __slots__ = ("core", "ssh", "inventory", "jinja2", "logging", "user_defined") + + def __init__( + self, + inventory: InventoryConfig, + ssh: SSHConfig, + logging: LoggingConfig, + jinja2: Jinja2Config, + core: CoreConfig, + user_defined: Dict[str, Any], + ) -> None: + self.inventory = inventory + self.ssh = ssh + self.logging = logging + self.jinja2 = jinja2 + self.core = core + self.user_defined = user_defined diff --git a/nornir/core/connections.py b/nornir/core/connections.py new file mode 100644 index 00000000..fa5b19c6 --- /dev/null +++ b/nornir/core/connections.py @@ -0,0 +1,119 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, NoReturn, Optional, Type + + +from nornir.core.configuration import Config +from nornir.core.exceptions import ( + ConnectionPluginAlreadyRegistered, + ConnectionPluginNotRegistered, +) + + +class ConnectionPlugin(ABC): + """ + Connection plugins have to inherit from this class and provide implementations + for both the :meth:`open` and :meth:`close` methods. + + Attributes: + connection: Underlying connection. Populated by :meth:`open`. + state: Dictionary to hold any data that needs to be shared between + the connection plugin and the plugin tasks using this connection. + """ + + __slots__ = ("connection", "state") + + def __init__(self) -> None: + self.connection: Any = UnestablishedConnection() + self.state: Dict[str, Any] = {} + + @abstractmethod + def open( + self, + hostname: Optional[str], + username: Optional[str], + password: Optional[str], + port: Optional[int], + platform: Optional[str], + extras: Optional[Dict[str, Any]] = None, + configuration: Optional[Config] = None, + ) -> None: + """ + Connect to the device and populate the attribute :attr:`connection` with + the underlying connection + """ + pass + + @abstractmethod + def close(self) -> None: + """Close the connection with the device""" + pass + + +class UnestablishedConnection(object): + def close(self) -> NoReturn: + raise ValueError("Connection not established") + + disconnect = close + + +class Connections(Dict[str, ConnectionPlugin]): + available: Dict[str, Type[ConnectionPlugin]] = {} + + @classmethod + def register(cls, name: str, plugin: Type[ConnectionPlugin]) -> None: + """Registers a connection plugin with a specified name + + Args: + name: name of the connection plugin to register + plugin: defined connection plugin class + + Raises: + :obj:`nornir.core.exceptions.ConnectionPluginAlreadyRegistered` if + another plugin with the specified name was already registered + """ + existing_plugin = cls.available.get(name) + if existing_plugin is None: + cls.available[name] = plugin + elif existing_plugin != plugin: + raise ConnectionPluginAlreadyRegistered( + f"Connection plugin {plugin.__name__} can't be registered as " + f"{name!r} because plugin {existing_plugin.__name__} " + f"was already registered under this name" + ) + + @classmethod + def deregister(cls, name: str) -> None: + """Deregisters a registered connection plugin by its name + + Args: + name: name of the connection plugin to deregister + + Raises: + :obj:`nornir.core.exceptions.ConnectionPluginNotRegistered` + """ + if name not in cls.available: + raise ConnectionPluginNotRegistered( + f"Connection {name!r} is not registered" + ) + cls.available.pop(name) + + @classmethod + def deregister_all(cls) -> None: + """Deregisters all registered connection plugins""" + cls.available = {} + + @classmethod + def get_plugin(cls, name: str) -> Type[ConnectionPlugin]: + """Fetches the connection plugin by name if already registered + + Args: + name: name of the connection plugin + + Raises: + :obj:`nornir.core.exceptions.ConnectionPluginNotRegistered` + """ + if name not in cls.available: + raise ConnectionPluginNotRegistered( + f"Connection {name!r} is not registered" + ) + return cls.available[name] diff --git a/tests/core/test_configuration/empty.yaml b/nornir/core/deserializer/__init__.py similarity index 100% rename from tests/core/test_configuration/empty.yaml rename to nornir/core/deserializer/__init__.py diff --git a/nornir/core/deserializer/configuration.py b/nornir/core/deserializer/configuration.py new file mode 100644 index 00000000..39b998db --- /dev/null +++ b/nornir/core/deserializer/configuration.py @@ -0,0 +1,184 @@ +import importlib +import logging +from typing import Any, Callable, Dict, List, Optional, Type, cast + +from nornir.core import configuration +from nornir.core.deserializer.inventory import Inventory + +from pydantic import BaseSettings, Schema + +import ruamel.yaml + + +logger = logging.getLogger(__name__) + + +class SSHConfig(BaseSettings): + config_file: str = Schema( + default="~/.ssh/config", description="Path to ssh configuration file" + ) + + class Config: + env_prefix = "NORNIR_SSH_" + ignore_extra = False + + @classmethod + def deserialize(cls, **kwargs) -> configuration.SSHConfig: + s = SSHConfig(**kwargs) + return configuration.SSHConfig(**s.dict()) + + +class InventoryConfig(BaseSettings): + plugin: str = Schema( + default="nornir.plugins.inventory.simple.SimpleInventory", + description="Import path to inventory plugin", + ) + options: Dict[str, Any] = Schema( + default={}, description="kwargs to pass to the inventory plugin" + ) + transform_function: str = Schema( + default="", + description=( + "Path to transform function. The transform_function " + "you provide will run against each host in the inventory" + ), + ) + + class Config: + env_prefix = "NORNIR_INVENTORY_" + ignore_extra = False + + @classmethod + def deserialize(cls, **kwargs) -> configuration.InventoryConfig: + inv = InventoryConfig(**kwargs) + return configuration.InventoryConfig( + plugin=cast(Type[Inventory], _resolve_import_from_string(inv.plugin)), + options=inv.options, + transform_function=_resolve_import_from_string(inv.transform_function), + ) + + +class LoggingConfig(BaseSettings): + level: str = Schema(default="debug", description="Logging level") + file: str = Schema(default="nornir.log", descritpion="Logging file") + format: str = Schema( + default="%(asctime)s - %(name)12s - %(levelname)8s - %(funcName)10s() - %(message)s", + description="Logging format", + ) + to_console: bool = Schema( + default=False, description="Whether to log to console or not" + ) + loggers: List[str] = Schema(default=["nornir"], description="Loggers to configure") + + class Config: + env_prefix = "NORNIR_LOGGING_" + ignore_extra = False + + @classmethod + def deserialize(cls, **kwargs) -> configuration.LoggingConfig: + logging_config = LoggingConfig(**kwargs) + return configuration.LoggingConfig( + level=getattr(logging, logging_config.level.upper()), + file_=logging_config.file, + format_=logging_config.format, + to_console=logging_config.to_console, + loggers=logging_config.loggers, + ) + + +class Jinja2Config(BaseSettings): + filters: str = Schema( + default="", description="Path to callable returning jinja filters to be used" + ) + + class Config: + env_prefix = "NORNIR_JINJA2_" + ignore_extra = False + + @classmethod + def deserialize(cls, **kwargs) -> configuration.Jinja2Config: + c = Jinja2Config(**kwargs) + jinja_filter_func = _resolve_import_from_string(c.filters) + jinja_filters = jinja_filter_func() if jinja_filter_func else {} + return configuration.Jinja2Config(filters=jinja_filters) + + +class CoreConfig(BaseSettings): + num_workers: int = Schema( + default=20, + description="Number of Nornir worker threads that are run at the same time by default", + ) + raise_on_error: bool = Schema( + default=False, + description=( + "If set to ``True``, (:obj:`nornir.core.Nornir.run`) method of " + "will raise exception :obj:`nornir.core.exceptions.NornirExecutionError` " + "if at least a host failed" + ), + ) + + class Config: + env_prefix = "NORNIR_CORE_" + ignore_extra = False + + @classmethod + def deserialize(cls, **kwargs) -> configuration.CoreConfig: + c = CoreConfig(**kwargs) + return configuration.CoreConfig(**c.dict()) + + +class Config(BaseSettings): + core: CoreConfig = CoreConfig() + inventory: InventoryConfig = InventoryConfig() + ssh: SSHConfig = SSHConfig() + logging: LoggingConfig = LoggingConfig() + jinja2: Jinja2Config = Jinja2Config() + user_defined: Dict[str, Any] = Schema( + default={}, description="User-defined pairs" + ) + + class Config: + env_prefix = "NORNIR_" + ignore_extra = False + + @classmethod + def deserialize(cls, **kwargs) -> configuration.Config: + c = Config( + core=CoreConfig(**kwargs.pop("core", {})), + ssh=SSHConfig(**kwargs.pop("ssh", {})), + inventory=InventoryConfig(**kwargs.pop("inventory", {})), + logging=LoggingConfig(**kwargs.pop("logging", {})), + jinja2=Jinja2Config(**kwargs.pop("jinja2", {})), + **kwargs, + ) + return configuration.Config( + core=CoreConfig.deserialize(**c.core.dict()), + inventory=InventoryConfig.deserialize(**c.inventory.dict()), + ssh=SSHConfig.deserialize(**c.ssh.dict()), + logging=LoggingConfig.deserialize(**c.logging.dict()), + jinja2=Jinja2Config.deserialize(**c.jinja2.dict()), + user_defined=c.user_defined, + ) + + @classmethod + def load_from_file(cls, config_file: str, **kwargs) -> configuration.Config: + config_dict: Dict[str, Any] = {} + if config_file: + yml = ruamel.yaml.YAML(typ="safe") + with open(config_file, "r") as f: + config_dict = yml.load(f) or {} + return Config.deserialize(**{**config_dict, **kwargs}) + + +def _resolve_import_from_string(import_path: Any) -> Optional[Callable[..., Any]]: + try: + if not import_path: + return None + elif callable(import_path): + return import_path + module_name, obj_name = import_path.rsplit(".", 1) + module = importlib.import_module(module_name) + return getattr(module, obj_name) + except Exception as e: + logger.error(f"failed to load import_path '{import_path}'\n{e}") + raise diff --git a/nornir/core/deserializer/inventory.py b/nornir/core/deserializer/inventory.py new file mode 100644 index 00000000..3eec7cde --- /dev/null +++ b/nornir/core/deserializer/inventory.py @@ -0,0 +1,145 @@ +from typing import Any, Dict, List, Optional, Union + +from nornir.core import inventory + +from pydantic import BaseModel + + +VarsDict = Dict[str, Any] +HostsDict = Dict[str, VarsDict] +GroupsDict = Dict[str, VarsDict] +DefaultsDict = VarsDict + + +class BaseAttributes(BaseModel): + hostname: Optional[str] = None + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + platform: Optional[str] = None + + class Config: + ignore_extra = False + + +class ConnectionOptions(BaseAttributes): + extras: Optional[Dict[str, Any]] + + +class InventoryElement(BaseAttributes): + groups: List[str] = [] + data: Dict[str, Any] = {} + connection_options: Dict[str, ConnectionOptions] = {} + + @classmethod + def deserialize( + cls, + name: str, + hostname: Optional[str] = None, + port: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + platform: Optional[str] = None, + groups: Optional[List[str]] = None, + data: Optional[Dict[str, Any]] = None, + connection_options: Optional[Dict[str, ConnectionOptions]] = None, + defaults: inventory.Defaults = None, + ) -> Dict[str, Any]: + parent_groups = inventory.ParentGroups(groups) + connection_options = connection_options or {} + conn_opts = { + k: inventory.ConnectionOptions(**v) for k, v in connection_options.items() + } + return { + "name": name, + "hostname": hostname, + "port": port, + "username": username, + "password": password, + "platform": platform, + "groups": parent_groups, + "data": data, + "connection_options": conn_opts, + "defaults": defaults, + } + + @classmethod + def deserialize_host(cls, **kwargs) -> inventory.Host: + return inventory.Host(**cls.deserialize(**kwargs)) + + @classmethod + def deserialize_group(cls, **kwargs) -> inventory.Group: + return inventory.Group(**cls.deserialize(**kwargs)) + + @classmethod + def serialize(cls, e: Union[inventory.Host, inventory.Group]) -> "InventoryElement": + d = {} + for f in cls.__fields__: + d[f] = object.__getattribute__(e, f) + d["groups"] = list(d["groups"]) + + d["connection_options"] = { + k: {f: getattr(v, f) for f in v.__recursive_slots__()} + for k, v in d["connection_options"].items() + } + return InventoryElement(**d) + + +class Defaults(BaseAttributes): + data: Dict[str, Any] = {} + connection_options: Dict[str, ConnectionOptions] = {} + + @classmethod + def serialize(cls, defaults: inventory.Defaults) -> "InventoryElement": + d = {} + for f in cls.__fields__: + d[f] = getattr(defaults, f) + + d["connection_options"] = { + k: {f: getattr(v, f) for f in v.__recursive_slots__()} + for k, v in d["connection_options"].items() + } + return Defaults(**d) + + +class Inventory(BaseModel): + hosts: Dict[str, InventoryElement] + groups: Dict[str, InventoryElement] + defaults: Defaults + + @classmethod + def deserialize(cls, transform_function=None, *args, **kwargs): + deserialized = cls(*args, **kwargs) + + defaults_dict = deserialized.defaults.dict() + for k, v in defaults_dict["connection_options"].items(): + defaults_dict["connection_options"][k] = inventory.ConnectionOptions(**v) + defaults = inventory.Defaults(**defaults_dict) + + hosts = inventory.Hosts() + for n, h in deserialized.hosts.items(): + hosts[n] = InventoryElement.deserialize_host( + defaults=defaults, name=n, **h.dict() + ) + + groups = inventory.Groups() + for n, g in deserialized.groups.items(): + groups[n] = InventoryElement.deserialize_group(name=n, **g.dict()) + + return inventory.Inventory( + hosts=hosts, + groups=groups, + defaults=defaults, + transform_function=transform_function, + ) + + @classmethod + def serialize(cls, inv: inventory.Inventory): + hosts = {} + for n, h in inv.hosts.items(): + hosts[n] = InventoryElement.serialize(h) + groups = {} + for n, g in inv.groups.items(): + groups[n] = InventoryElement.serialize(g) + defaults = Defaults.serialize(inv.defaults) + return Inventory(hosts=hosts, groups=groups, defaults=defaults) diff --git a/nornir/core/exceptions.py b/nornir/core/exceptions.py index e3a05f5d..94a72b00 100644 --- a/nornir/core/exceptions.py +++ b/nornir/core/exceptions.py @@ -1,4 +1,42 @@ -from builtins import super +class ConnectionException(Exception): + """ + Superclass for all the Connection* Exceptions + """ + + def __init__(self, connection): + self.connection = connection + + +class ConnectionAlreadyOpen(ConnectionException): + """ + Raised when opening an already opened connection + """ + + pass + + +class ConnectionNotOpen(ConnectionException): + """ + Raised when trying to close a connection that isn't open + """ + + pass + + +class ConnectionPluginAlreadyRegistered(ConnectionException): + """ + Raised when trying to register an already registered plugin + """ + + pass + + +class ConnectionPluginNotRegistered(ConnectionException): + """ + Raised when trying to access a plugin that is not registered + """ + + pass class CommandError(Exception): @@ -36,6 +74,9 @@ def __init__(self, result): @property def failed_hosts(self): + """ + Hosts that failed to complete the task + """ return {k: v for k, v in self.result.items() if v.failed} def __str__(self): diff --git a/nornir/core/filter.py b/nornir/core/filter.py new file mode 100644 index 00000000..43fc39c2 --- /dev/null +++ b/nornir/core/filter.py @@ -0,0 +1,92 @@ +class F_OP_BASE(object): + def __init__(self, op1, op2): + self.op1 = op1 + self.op2 = op2 + + def __and__(self, other): + return AND(self, other) + + def __or__(self, other): + return OR(self, other) + + def __repr__(self): + return "( {} {} {} )".format(self.op1, self.__class__.__name__, self.op2) + + +class AND(F_OP_BASE): + def __call__(self, host): + return self.op1(host) and self.op2(host) + + +class OR(F_OP_BASE): + def __call__(self, host): + return self.op1(host) or self.op2(host) + + +class F(object): + def __init__(self, **kwargs): + self.filters = kwargs + + def __call__(self, host): + return all( + F._verify_rules(host, k.split("__"), v) for k, v in self.filters.items() + ) + + def __and__(self, other): + return AND(self, other) + + def __or__(self, other): + return OR(self, other) + + def __invert__(self): + return NOT_F(**self.filters) + + def __repr__(self): + return "".format(self.filters) + + @staticmethod + def _verify_rules(data, rule, value): + if len(rule) > 1: + try: + return F._verify_rules(data.get(rule[0], {}), rule[1:], value) + except AttributeError: + return False + + elif len(rule) == 1: + operator = "__{}__".format(rule[0]) + if hasattr(data, operator): + return getattr(data, operator)(value) + + elif hasattr(data, rule[0]): + if callable(getattr(data, rule[0])): + return getattr(data, rule[0])(value) + + else: + return getattr(data, rule[0]) == value + + elif rule == ["in"]: + return data in value + elif rule == ["any"]: + return any([x in data for x in value]) + elif rule == ["all"]: + return all([x in data for x in value]) + else: + return data.get(rule[0]) == value + + else: + raise Exception( + "I don't know how I got here:\n{}\n{}\n{}".format(data, rule, value) + ) + + +class NOT_F(F): + def __call__(self, host): + return not any( + F._verify_rules(host, k.split("__"), v) for k, v in self.filters.items() + ) + + def __invert__(self): + return F(**self.filters) + + def __repr__(self): + return "".format(self.filters) diff --git a/nornir/core/inventory.py b/nornir/core/inventory.py index 7563188b..62bdf021 100644 --- a/nornir/core/inventory.py +++ b/nornir/core/inventory.py @@ -1,79 +1,100 @@ -import getpass - - -class Host(object): - """ - Represents a host. - - Arguments: - name (str): Name of the host - group (:obj:`Group`, optional): Group the host belongs to - nornir (:obj:`nornir.core.Nornir`): Reference to the parent nornir object - **kwargs: Host data - - Attributes: - name (str): Name of the host - groups (list of :obj:`Group`): Groups the host belongs to - defaults (``dict``): Default values for host/group data - data (dict): data about the device - connections (``dict``): Already established connections - - Note: - - You can access the host data in two ways: - - 1. Via the ``data`` attribute - In this case you will get access - **only** to the data that belongs to the host. - 2. Via the host itself as a dict - :obj:`Host` behaves like a - dict. The difference between accessing data via the ``data`` attribute - and directly via the host itself is that the latter will also - return the data if it's available via a parent :obj:`Group`. - - For instance:: - - --- - # hosts - my_host: - ip: 1.2.3.4 - groups: [bma] - - --- - # groups - bma: - site: bma - - defaults: - domain: acme.com - - * ``my_host.data["ip"]`` will return ``1.2.3.4`` - * ``my_host["ip"]`` will return ``1.2.3.4`` - * ``my_host.data["site"]`` will ``fail`` - * ``my_host["site"]`` will return ``bma`` - * ``my_host.data["domain"]`` will ``fail`` - * ``my_host.group.data["domain"]`` will ``fail`` - * ``my_host["domain"]`` will return ``acme.com`` - * ``my_host.group["domain"]`` will return ``acme.com`` - * ``my_host.group.group.data["domain"]`` will return ``acme.com`` - """ - - def __init__(self, name, groups=None, nornir=None, defaults=None, **kwargs): - self.nornir = nornir - self.name = name - self.groups = groups or [] - self.data = {} - self.data["name"] = name - self.connections = {} - self.defaults = defaults or {} - self._ssh_forward_agent = False - - if len(self.groups): - if isinstance(groups[0], str): - self.data["groups"] = groups - else: - self.data["groups"] = [g.name for g in groups] +from collections import UserList +from typing import Any, Dict, List, Optional, Set, Union + +from nornir.core.configuration import Config +from nornir.core.connections import ConnectionPlugin, Connections +from nornir.core.exceptions import ConnectionAlreadyOpen, ConnectionNotOpen + + +class BaseAttributes(object): + __slots__ = ("hostname", "port", "username", "password", "platform") + + def __init__( + self, + hostname: Optional[str] = None, + port: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + platform: Optional[str] = None, + ) -> None: + self.hostname = hostname + self.port = port + self.username = username + self.password = password + self.platform = platform + + def __recursive_slots__(self): + s = self.__slots__ + for b in self.__class__.__bases__: + if hasattr(b, "__recursive_slots__"): + s += b().__recursive_slots__() + elif hasattr(b, "__slots__"): + s += b.__slots__ + return s + + def dict(self): + return {k: object.__getattribute__(self, k) for k in self.__recursive_slots__()} + + +class ConnectionOptions(BaseAttributes): + __slots__ = ("extras",) + + def __init__(self, extras: Optional[Dict[str, Any]] = None, **kwargs) -> None: + self.extras = extras + super().__init__(**kwargs) + + +class ParentGroups(UserList): + __slots__ = "refs" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.refs: List["Group"] = kwargs.get("refs", []) + + def __contains__(self, value) -> bool: + return value in self.data or value in self.refs + + +class InventoryElement(BaseAttributes): + __slots__ = ("groups", "data", "connection_options") + + def __init__( + self, + groups: Optional[ParentGroups] = None, + data: Optional[Dict[str, Any]] = None, + connection_options: Optional[Dict[str, ConnectionOptions]] = None, + **kwargs, + ) -> None: + self.groups = groups or ParentGroups() + self.data = data or {} + self.connection_options = connection_options or {} + super().__init__(**kwargs) - for k, v in kwargs.items(): - self.data[k] = v + +class Defaults(BaseAttributes): + __slots__ = ("data", "connection_options") + + def __init__( + self, + data: Optional[Dict[str, Any]] = None, + connection_options: Optional[Dict[str, ConnectionOptions]] = None, + **kwargs, + ) -> None: + self.data = data or {} + self.connection_options = connection_options or {} + super().__init__(**kwargs) + + +class Host(InventoryElement): + __slots__ = ("name", "connections", "defaults") + + def __init__( + self, name: str, defaults: Optional[Defaults] = None, **kwargs + ) -> None: + self.name = name + self.defaults = defaults or Defaults() + self.connections: Connections = Connections() + super().__init__(**kwargs) def _resolve_data(self): processed = [] @@ -81,12 +102,12 @@ def _resolve_data(self): for k, v in self.data.items(): processed.append(k) result[k] = v - for g in self.groups: + for g in self.groups.refs: for k, v in g.items(): if k not in processed: processed.append(k) result[k] = v - for k, v in self.defaults.items(): + for k, v in self.defaults.data.items(): if k not in processed: processed.append(k) result[k] = v @@ -107,39 +128,64 @@ def items(self): """ return self._resolve_data().items() - def to_dict(self): - """ Return a dictionary representing the object. """ - return self.data - def has_parent_group(self, group): """Retuns whether the object is a child of the :obj:`Group` ``group``""" - for g in self.groups: - if g is group or g.has_parent_group(group): + if isinstance(group, str): + return self._has_parent_group_by_name(group) + + else: + return self._has_parent_group_by_object(group) + + def _has_parent_group_by_name(self, group): + for g in self.groups.refs: + if g.name == group or g.has_parent_group(group): return True - return False + def _has_parent_group_by_object(self, group): + for g in self.groups.refs: + if g is group or g.has_parent_group(group): + return True def __getitem__(self, item): try: return self.data[item] except KeyError: - for g in self.groups: - r = g.get(item) - if r: + for g in self.groups.refs: + try: + r = g[item] return r + except KeyError: + continue - r = self.defaults.get(item) + r = self.defaults.data.get(item) if r: return r raise + def __getattribute__(self, name): + if name not in ("hostname", "port", "username", "password", "platform"): + return object.__getattribute__(self, name) + v = object.__getattribute__(self, name) + if v is None: + for g in self.groups.refs: + r = getattr(g, name) + if r is not None: + return r + + return object.__getattribute__(self.defaults, name) + else: + return v + + def __bool__(self): + return bool(self.name) + def __setitem__(self, item, value): self.data[item] = value def __len__(self): - return len(self.keys()) + return len(self._resolve_data().keys()) def __iter__(self): return self.data.__iter__() @@ -148,7 +194,7 @@ def __str__(self): return self.name def __repr__(self): - return "{}: {}".format(self.__class__.__name__, self.name) + return "{}: {}".format(self.__class__.__name__, self.name or "") def get(self, item, default=None): """ @@ -158,188 +204,211 @@ def get(self, item, default=None): item(``str``): The variable to get default(``any``): Return value if item not found """ + if hasattr(self, item): + return getattr(self, item) try: return self.__getitem__(item) except KeyError: return default - @property - def nornir(self): - """Reference to the parent :obj:`nornir.core.Nornir` object""" - return self._nornir - - @nornir.setter - def nornir(self, value): - # If it's already set we don't want to set it again - # because we may lose valuable information - if not getattr(self, "_nornir", None): - self._nornir = value - - @property - def host(self): - """String used to connect to the device. Either ``nornir_host`` or ``self.name``""" - return self.get("nornir_host", self.name) - - @property - def username(self): - """Either ``nornir_username`` or user running the script.""" - return self.get("nornir_username", getpass.getuser()) - - @property - def password(self): - """Either ``nornir_password`` or empty string.""" - return self.get("nornir_password", "") - - @property - def ssh_port(self): - """Either ``nornir_ssh_port`` or ``None``.""" - return self.get("nornir_ssh_port") - - @property - def network_api_port(self): - """ - For network equipment this is the port where the device's API is listening to. - Either ``nornir_network_api_port`` or ``None``. - """ - return self.get("nornir_network_api_port") - - @property - def os(self): - """OS the device is running. Defaults to ``nornir_os``.""" - return self.get("nornir_os") - - @property - def nos(self): - """Network OS the device is running. Defaults to ``nornir_nos``.""" - return self.get("nornir_nos") - - def get_connection(self, connection): + def get_connection_parameters( + self, connection: Optional[str] = None + ) -> ConnectionOptions: + if not connection: + d = ConnectionOptions( + hostname=self.hostname, + port=self.port, + username=self.username, + password=self.password, + platform=self.platform, + extras={}, + ) + else: + r = self._get_connection_options_recursively(connection) + if r is not None: + d = ConnectionOptions( + hostname=r.hostname if r.hostname is not None else self.hostname, + port=r.port if r.port is not None else self.port, + username=r.username if r.username is not None else self.username, + password=r.password if r.password is not None else self.password, + platform=r.platform if r.platform is not None else self.platform, + extras=r.extras if r.extras is not None else {}, + ) + else: + d = ConnectionOptions( + hostname=self.hostname, + port=self.port, + username=self.username, + password=self.password, + platform=self.platform, + extras={}, + ) + return d + + def _get_connection_options_recursively( + self, connection: str + ) -> Optional[ConnectionOptions]: + p = self.connection_options.get(connection) + if p is None: + p = ConnectionOptions() + + for g in self.groups.refs: + sp = g._get_connection_options_recursively(connection) + if sp is not None: + p.hostname = p.hostname if p.hostname is not None else sp.hostname + p.port = p.port if p.port is not None else sp.port + p.username = p.username if p.username is not None else sp.username + p.password = p.password if p.password is not None else sp.password + p.platform = p.platform if p.platform is not None else sp.platform + p.extras = p.extras if p.extras is not None else sp.extras + + sp = self.defaults.connection_options.get(connection, None) + if sp is not None: + p.hostname = p.hostname if p.hostname is not None else sp.hostname + p.port = p.port if p.port is not None else sp.port + p.username = p.username if p.username is not None else sp.username + p.password = p.password if p.password is not None else sp.password + p.platform = p.platform if p.platform is not None else sp.platform + p.extras = p.extras if p.extras is not None else sp.extras + return p + + def get_connection(self, connection: str, configuration: Config) -> Any: """ The function of this method is twofold: 1. If an existing connection is already established for the given type return it - 2. If non exists, establish a new connection of that type with default parameters - and return it + 2. If none exists, establish a new connection of that type with default parameters + and return it Raises: - AttributeError: if it's unknown how to establish a connection for the given - type + AttributeError: if it's unknown how to establish a connection for the given type Arguments: - connection_name (str): Name of the connection, for instance, netmiko, paramiko, - napalm... + connection: Name of the connection, for instance, netmiko, paramiko, napalm... Returns: - An already established connection of type ``connection`` + An already established connection """ if connection not in self.connections: - try: - conn_task = self.nornir.available_connections[connection] - except KeyError: - raise AttributeError( - "not sure how to establish a connection for {}".format(connection) - ) + self.open_connection( + connection=connection, + configuration=configuration, + **self.get_connection_parameters(connection).dict(), + ) + return self.connections[connection].connection + + def get_connection_state(self, connection: str) -> Dict[str, Any]: + """ + For an already established connection return its state. + """ + if connection not in self.connections: + raise ConnectionNotOpen(connection) + + return self.connections[connection].state + + def open_connection( + self, + connection: str, + configuration: Config, + hostname: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + port: Optional[int] = None, + platform: Optional[str] = None, + extras: Optional[Dict[str, Any]] = None, + default_to_host_attributes: bool = True, + ) -> ConnectionPlugin: + """ + Open a new connection. - # We use `filter(name=self.name)` to call the connection task for only - # the given host. We also have to set `num_workers=1` because chances are - # we are already inside a thread - # Task should establish a connection and populate self.connection[connection] - r = self.nornir.filter(name=self.name).run(conn_task, num_workers=1) - if r[self.name].exception: - raise r[self.name].exception + If ``default_to_host_attributes`` is set to ``True`` arguments will default to host + attributes if not specified. - return self.connections[connection] + Raises: + AttributeError: if it's unknown how to establish a connection for the given type + Returns: + An already established connection + """ + if connection in self.connections: + raise ConnectionAlreadyOpen(connection) + + self.connections[connection] = self.connections.get_plugin(connection)() + if default_to_host_attributes: + conn_params = self.get_connection_parameters(connection) + self.connections[connection].open( + hostname=hostname if hostname is not None else conn_params.hostname, + username=username if username is not None else conn_params.username, + password=password if password is not None else conn_params.password, + port=port if port is not None else conn_params.port, + platform=platform if platform is not None else conn_params.platform, + extras=extras if extras is not None else conn_params.extras, + configuration=configuration, + ) + else: + self.connections[connection].open( + hostname=hostname, + username=username, + password=password, + port=port, + platform=platform, + extras=extras, + configuration=configuration, + ) + return self.connections[connection] -class Group(Host): - """Same as :obj:`Host`""" + def close_connection(self, connection: str) -> None: + """ Close the connection""" + if connection not in self.connections: + raise ConnectionNotOpen(connection) - def children(self): - return { - n: h - for n, h in self.nornir.inventory.hosts.items() - if h.has_parent_group(self) - } + self.connections.pop(connection).close() + def close_connections(self) -> None: + # Decouple deleting dictionary elements from iterating over connections dict + existing_conns = list(self.connections.keys()) + for connection in existing_conns: + self.close_connection(connection) -class Inventory(object): - """ - An inventory contains information about hosts and groups. - - Arguments: - hosts (dict): keys are hostnames and values are either :obj:`Host` or a dict - representing the host data. - groups (dict): keys are group names and values are either :obj:`Group` or a dict - representing the group data. - transform_function (callable): we will call this function for each host. This is useful - to manipulate host data and make it more consumable. For instance, if your inventory - has a "user" attribute you could use this function to map it to "nornir_user" - - Attributes: - hosts (dict): keys are hostnames and values are :obj:`Host`. - groups (dict): keys are group names and the values are :obj:`Group`. - """ - def __init__( - self, hosts, groups=None, defaults=None, transform_function=None, nornir=None - ): - self._nornir = nornir +class Group(Host): + pass - self.defaults = defaults or {} - self.groups = groups or {} - for n, g in self.groups.items(): - if isinstance(g, dict): - g = Group(name=n, nornir=nornir, **g) - self.groups[n] = g +class Hosts(Dict[str, Host]): + pass - for group in self.groups.values(): - group.groups = self._resolve_groups(group.groups) - self.hosts = {} - for n, h in hosts.items(): - if isinstance(h, dict): - h = Host(name=n, nornir=nornir, defaults=self.defaults, **h) +class Groups(Dict[str, Group]): + pass - if transform_function: - transform_function(h) - h.groups = self._resolve_groups(h.groups) - self.hosts[n] = h +class Inventory(object): + __slots__ = ("hosts", "groups", "defaults") - def _resolve_groups(self, groups): - r = [] - if len(groups): - if not isinstance(groups[0], Group): - r = [self.groups[g] for g in groups] - else: - r = groups - return r + def __init__( + self, + hosts: Hosts, + groups: Optional[Groups] = None, + defaults: Optional[Defaults] = None, + transform_function=None, + ) -> None: + self.hosts = hosts + self.groups = groups or Groups() + self.defaults = defaults or Defaults() + + for host in self.hosts.values(): + host.groups.refs = [self.groups[p] for p in host.groups] + for group in self.groups.values(): + group.groups.refs = [self.groups[p] for p in group.groups] - def filter(self, filter_func=None, **kwargs): - """ - Returns a new inventory after filtering the hosts by matching the data passed to the - function. For instance, assume an inventory with:: - - --- - host1: - site: bma - role: http - host2: - site: cmh - role: http - host3: - site: bma - role: db - - * ``my_inventory.filter(site="bma")`` will result in ``host1`` and ``host3`` - * ``my_inventory.filter(site="bma", role="db")`` will result in ``host3`` only + if transform_function: + for h in self.hosts.values(): + transform_function(h) - Arguments: - filter_func (callable): if filter_func is passed it will be called against each - device. If the call returns ``True`` the device will be kept in the inventory - """ + def filter(self, filter_obj=None, filter_func=None, *args, **kwargs): + filter_func = filter_obj or filter_func if filter_func: filtered = {n: h for n, h in self.hosts.items() if filter_func(h, **kwargs)} else: @@ -348,31 +417,18 @@ def filter(self, filter_func=None, **kwargs): for n, h in self.hosts.items() if all(h.get(k) == v for k, v in kwargs.items()) } - return Inventory(hosts=filtered, groups=self.groups, nornir=self.nornir) + return Inventory(hosts=filtered, groups=self.groups, defaults=self.defaults) def __len__(self): return self.hosts.__len__() - @property - def nornir(self): - """Reference to the parent :obj:`nornir.core.Nornir` object""" - return self._nornir - - @nornir.setter - def nornir(self, value): - if not getattr(self, "_nornir", None): - self._nornir = value - - for h in self.hosts.values(): - h.nornir = value - - for g in self.groups.values(): - g.nornir = value - - def to_dict(self): - """ Return a dictionary representing the object. """ - groups = {k: v.to_dict() for k, v in self.groups.items()} - groups["defaults"] = self.defaults - return { - "hosts": {k: v.to_dict() for k, v in self.hosts.items()}, "groups": groups - } + def children_of_group(self, group: Union[str, Group]) -> Set[Host]: + """ + Returns set of hosts that belongs to a group including those that belong + indirectly via inheritance + """ + hosts: List[Host] = set() + for host in self.hosts.values(): + if host.has_parent_group(group): + hosts.add(host) + return hosts diff --git a/nornir/core/state.py b/nornir/core/state.py new file mode 100644 index 00000000..e55613a3 --- /dev/null +++ b/nornir/core/state.py @@ -0,0 +1,26 @@ +class GlobalState(object): + """ + This class is just a placeholder to share data amongst different + versions of Nornir after running ``filter`` multiple times. + + Attributes: + failed_hosts: Hosts that have failed to run a task properly + """ + + __slots__ = "dry_run", "failed_hosts" + + def __init__(self, dry_run=None, failed_hosts=None): + self.dry_run = dry_run + self.failed_hosts = failed_hosts or set() + + def recover_host(self, host): + """Remove ``host`` from list of failed hosts.""" + self.failed_hosts.discard(host) + + def reset_failed_hosts(self): + """Reset failed hosts and make all hosts available for future tasks.""" + self.failed_hosts = set() + + def to_dict(self): + """ Return a dictionary representing the object. """ + return self.__dict__ diff --git a/nornir/core/task.py b/nornir/core/task.py index 74e49c9e..b80619bf 100644 --- a/nornir/core/task.py +++ b/nornir/core/task.py @@ -1,6 +1,6 @@ import logging import traceback -from builtins import super +from typing import Optional from nornir.core.exceptions import NornirExecutionError from nornir.core.exceptions import NornirSubTaskError @@ -56,7 +56,7 @@ def start(self, host, nornir): self.host = host self.nornir = nornir - logger = logging.getLogger("nornir") + logger = logging.getLogger(__name__) try: logger.info("{}: {}: running task".format(self.host.name, self.name)) r = self.task(self, **self.params) @@ -117,7 +117,7 @@ def is_dry_run(self, override=None): Arguments: override (bool): Override for current task """ - return override if override is not None else self.nornir.dry_run + return override if override is not None else self.nornir.data.dry_run class Result(object): @@ -163,6 +163,9 @@ def __init__( self.name = None self.severity_level = severity_level + self.stdout: Optional[str] = None + self.stderr: Optional[str] = None + for k, v in kwargs.items(): setattr(self, k, v) diff --git a/nornir/init_nornir.py b/nornir/init_nornir.py new file mode 100644 index 00000000..1779aff7 --- /dev/null +++ b/nornir/init_nornir.py @@ -0,0 +1,56 @@ +from typing import Any, Callable + +from nornir.core import Nornir +from nornir.core.connections import Connections +from nornir.core.deserializer.configuration import Config +from nornir.core.state import GlobalState +from nornir.plugins.connections.napalm import Napalm +from nornir.plugins.connections.netmiko import Netmiko +from nornir.plugins.connections.paramiko import Paramiko + + +def register_default_connection_plugins() -> None: + Connections.register("napalm", Napalm) + Connections.register("netmiko", Netmiko) + Connections.register("paramiko", Paramiko) + + +def cls_to_string(cls: Callable[..., Any]) -> str: + return f"{cls.__module__}.{cls.__name__}" + + +def InitNornir(config_file="", dry_run=False, configure_logging=True, **kwargs): + """ + Arguments: + config_file(str): Path to the configuration file (optional) + dry_run(bool): Whether to simulate changes or not + **kwargs: Extra information to pass to the + :obj:`nornir.core.configuration.Config` object + + Returns: + :obj:`nornir.core.Nornir`: fully instantiated and configured + """ + register_default_connection_plugins() + + if callable(kwargs.get("inventory", {}).get("plugin", "")): + kwargs["inventory"]["plugin"] = cls_to_string(kwargs["inventory"]["plugin"]) + + if callable(kwargs.get("inventory", {}).get("transform_function", "")): + kwargs["inventory"]["transform_function"] = cls_to_string( + kwargs["inventory"]["transform_function"] + ) + + conf = Config.load_from_file(config_file, **kwargs) + + data = GlobalState(dry_run=dry_run) + + if configure_logging: + conf.logging.configure() + + inv = conf.inventory.plugin.deserialize( + transform_function=conf.inventory.transform_function, + config=conf, + **conf.inventory.options, + ) + + return Nornir(inventory=inv, config=conf, data=data) diff --git a/tests/plugins/functions/text/output_data/basic_inventory_one_host_python27.stderr b/nornir/plugins/connections/__init__.py similarity index 100% rename from tests/plugins/functions/text/output_data/basic_inventory_one_host_python27.stderr rename to nornir/plugins/connections/__init__.py diff --git a/nornir/plugins/connections/napalm.py b/nornir/plugins/connections/napalm.py new file mode 100644 index 00000000..36c625e2 --- /dev/null +++ b/nornir/plugins/connections/napalm.py @@ -0,0 +1,47 @@ +from typing import Any, Dict, Optional + +from napalm import get_network_driver + +from nornir.core.configuration import Config +from nornir.core.connections import ConnectionPlugin + + +class Napalm(ConnectionPlugin): + """ + This plugin connects to the device using the NAPALM driver and sets the + relevant connection. + + Inventory: + extras: passed as it is to the napalm driver + """ + + def open( + self, + hostname: Optional[str], + username: Optional[str], + password: Optional[str], + port: Optional[int], + platform: Optional[str], + extras: Optional[Dict[str, Any]] = None, + configuration: Optional[Config] = None, + ) -> None: + extras = extras or {} + + parameters: Dict[str, Any] = { + "hostname": hostname, + "username": username, + "password": password, + "optional_args": {}, + } + parameters.update(extras) + + if port and "port" not in parameters["optional_args"]: + parameters["optional_args"]["port"] = port + + network_driver = get_network_driver(platform) + connection = network_driver(**parameters) + connection.open() + self.connection = connection + + def close(self) -> None: + self.connection.close() diff --git a/nornir/plugins/connections/netmiko.py b/nornir/plugins/connections/netmiko.py new file mode 100644 index 00000000..eb1ee5f9 --- /dev/null +++ b/nornir/plugins/connections/netmiko.py @@ -0,0 +1,54 @@ +from typing import Any, Dict, Optional + +from netmiko import ConnectHandler + +from nornir.core.configuration import Config +from nornir.core.connections import ConnectionPlugin + +napalm_to_netmiko_map = { + "ios": "cisco_ios", + "nxos": "cisco_nxos", + "nxos_ssh": "cisco_nxos", + "eos": "arista_eos", + "junos": "juniper_junos", + "iosxr": "cisco_xr", +} + + +class Netmiko(ConnectionPlugin): + """ + This plugin connects to the device using the NAPALM driver and sets the + relevant connection. + + Inventory: + extras: maps to argument passed to ``ConnectHandler``. + """ + + def open( + self, + hostname: Optional[str], + username: Optional[str], + password: Optional[str], + port: Optional[int], + platform: Optional[str], + extras: Optional[Dict[str, Any]] = None, + configuration: Optional[Config] = None, + ) -> None: + parameters = { + "host": hostname, + "username": username, + "password": password, + "port": port, + } + + if platform is not None: + # Look platform up in corresponding map, if no entry return the host.nos unmodified + platform = napalm_to_netmiko_map.get(platform, platform) + parameters["device_type"] = platform + + extras = extras or {} + parameters.update(extras) + self.connection = ConnectHandler(**parameters) + + def close(self) -> None: + self.connection.disconnect() diff --git a/nornir/plugins/connections/paramiko.py b/nornir/plugins/connections/paramiko.py new file mode 100644 index 00000000..248a5724 --- /dev/null +++ b/nornir/plugins/connections/paramiko.py @@ -0,0 +1,68 @@ +import os +from typing import Any, Dict, Optional + +from nornir.core.configuration import Config +from nornir.core.connections import ConnectionPlugin + +import paramiko + + +class Paramiko(ConnectionPlugin): + """ + This plugin connects to the device with paramiko to the device and sets the + relevant connection. + + Inventory: + extras: maps to argument passed to ``ConnectHandler``. + """ + + def open( + self, + hostname: Optional[str], + username: Optional[str], + password: Optional[str], + port: Optional[int], + platform: Optional[str], + extras: Optional[Dict[str, Any]] = None, + configuration: Optional[Config] = None, + ) -> None: + extras = extras or {} + + client = paramiko.SSHClient() + client._policy = paramiko.WarningPolicy() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + ssh_config = paramiko.SSHConfig() + ssh_config_file = configuration.ssh.config_file # type: ignore + if os.path.exists(ssh_config_file): + with open(ssh_config_file) as f: + ssh_config.parse(f) + parameters = { + "hostname": hostname, + "username": username, + "password": password, + "port": port, + } + + user_config = ssh_config.lookup(hostname) + for k in ("hostname", "username", "port"): + if k in user_config: + parameters[k] = user_config[k] + + if "proxycommand" in user_config: + parameters["sock"] = paramiko.ProxyCommand(user_config["proxycommand"]) + + self.state["ssh_forward_agent"] = user_config.get("forwardagent") == "yes" + + # TODO configurable + # if ssh_key_file: + # parameters['key_filename'] = ssh_key_file + if "identityfile" in user_config: + parameters["key_filename"] = user_config["identityfile"] + + extras.update(parameters) + client.connect(**extras) + self.connection = client + + def close(self) -> None: + self.connection.close() diff --git a/nornir/plugins/functions/text/__init__.py b/nornir/plugins/functions/text/__init__.py index fb1f8c99..85043648 100644 --- a/nornir/plugins/functions/text/__init__.py +++ b/nornir/plugins/functions/text/__init__.py @@ -1,6 +1,7 @@ import logging import pprint import threading +from typing import List, Optional from colorama import Fore, Style, init @@ -13,7 +14,7 @@ init(autoreset=True, convert=False, strip=False) -def print_title(title): +def print_title(title: str) -> None: """ Helper function to print a title. """ @@ -21,7 +22,7 @@ def print_title(title): print("{}{}{}{}".format(Style.BRIGHT, Fore.GREEN, msg, "*" * (80 - len(msg)))) -def _get_color(result, failed): +def _get_color(result: Result, failed: bool) -> str: if result.failed or failed: color = Fore.RED elif result.changed: @@ -32,14 +33,19 @@ def _get_color(result, failed): def _print_individual_result( - result, host, vars, failed, severity_level, task_group=False -): + result: Result, + host: Optional[str], + attrs: List[str], + failed: bool, + severity_level: int, + task_group: bool = False, +) -> None: if result.severity_level < severity_level: return color = _get_color(result, failed) - subtitle = "" if result.changed is None else " ** changed : {} ".format( - result.changed + subtitle = ( + "" if result.changed is None else " ** changed : {} ".format(result.changed) ) level_name = logging.getLevelName(result.severity_level) symbol = "v" if task_group else "-" @@ -49,8 +55,8 @@ def _print_individual_result( Style.BRIGHT, color, msg, symbol * (80 - len(msg)), level_name ) ) - for v in vars: - x = getattr(result, v, "") + for attribute in attrs: + x = getattr(result, attribute, "") if x and not isinstance(x, str): pprint.pprint(x, indent=2) elif x: @@ -58,51 +64,59 @@ def _print_individual_result( def _print_result( - result, host=None, vars=None, failed=None, severity_level=logging.INFO -): - vars = vars or ["diff", "result", "stdout"] - if isinstance(vars, str): - vars = [vars] + result: Result, + host: Optional[str] = None, + attrs: List[str] = None, + failed: bool = False, + severity_level: int = logging.INFO, +) -> None: + attrs = attrs or ["diff", "result", "stdout"] + if isinstance(attrs, str): + attrs = [attrs] if isinstance(result, AggregatedResult): msg = result.name print("{}{}{}{}".format(Style.BRIGHT, Fore.CYAN, msg, "*" * (80 - len(msg)))) for host, host_data in sorted(result.items()): - title = "" if host_data.changed is None else " ** changed : {} ".format( - host_data.changed + title = ( + "" + if host_data.changed is None + else " ** changed : {} ".format(host_data.changed) ) msg = "* {}{}".format(host, title) print( "{}{}{}{}".format(Style.BRIGHT, Fore.BLUE, msg, "*" * (80 - len(msg))) ) - _print_result(host_data, host, vars, failed, severity_level) + _print_result(host_data, host, attrs, failed, severity_level) elif isinstance(result, MultiResult): _print_individual_result( - result[0], host, vars, failed, severity_level, task_group=True + result[0], host, attrs, failed, severity_level, task_group=True ) for r in result[1:]: - _print_result(r, host, vars, failed, severity_level) + _print_result(r, host, attrs, failed, severity_level) color = _get_color(result[0], failed) msg = "^^^^ END {} ".format(result[0].name) print("{}{}{}{}".format(Style.BRIGHT, color, msg, "^" * (80 - len(msg)))) elif isinstance(result, Result): - _print_individual_result(result, host, vars, failed, severity_level) + _print_individual_result(result, host, attrs, failed, severity_level) def print_result( - result, host=None, vars=None, failed=None, severity_level=logging.INFO -): + result: Result, + host: Optional[str] = None, + vars: List[str] = None, + failed: bool = False, + severity_level: int = logging.INFO, +) -> None: """ Prints the :obj:`nornir.core.task.Result` from a previous task to screen Arguments: - result (:obj:`nornir.core.task.Result`): from a previous task - vars (list of str): Which attributes you want to print - failed (``bool``): if ``True`` assume the task failed - severity_level (int): Print only errors with this severity level or higher - - Returns: - :obj:`nornir.core.task.Result`: + result: from a previous task + host: # TODO + vars: Which attributes you want to print + failed: if ``True`` assume the task failed + severity_level: Print only errors with this severity level or higher """ LOCK.acquire() try: diff --git a/nornir/plugins/inventory/ansible.py b/nornir/plugins/inventory/ansible.py index cc16ba17..fdcc1560 100644 --- a/nornir/plugins/inventory/ansible.py +++ b/nornir/plugins/inventory/ansible.py @@ -1,65 +1,118 @@ -try: - import configparser as cp -except ImportError: - import ConfigParser as cp +import configparser as cp import logging import os -from builtins import super +from collections import defaultdict +from pathlib import Path +from typing import Any, DefaultDict, Dict, MutableMapping, Optional, Tuple, Union, cast -from nornir.core.inventory import Inventory +from mypy_extensions import TypedDict + +from nornir.core.deserializer.inventory import ( + DefaultsDict, + GroupsDict, + HostsDict, + Inventory, + InventoryElement, + VarsDict, +) import ruamel.yaml +from ruamel.yaml.composer import ComposerError +from ruamel.yaml.scanner import ScannerError -logger = logging.getLogger("nornir") +VARS_FILENAME_EXTENSIONS = ["", ".yml", ".yaml"] -class AnsibleParser(object): +YAML = ruamel.yaml.YAML(typ="safe") + +logger = logging.getLogger(__name__) + + +AnsibleHostsDict = Dict[str, Optional[VarsDict]] - def __init__(self, hostsfile): +AnsibleGroupDataDict = TypedDict( + "AnsibleGroupDataDict", + {"children": Dict[str, Any], "vars": VarsDict, "hosts": AnsibleHostsDict}, + total=False, +) # bug: https://github.com/python/mypy/issues/5357 + +AnsibleGroupsDict = Dict[str, AnsibleGroupDataDict] + + +class AnsibleParser(object): + def __init__(self, hostsfile: str) -> None: self.hostsfile = hostsfile self.path = os.path.dirname(hostsfile) - self.hosts = {} - self.groups = {} - self.original_data = None + self.hosts: HostsDict = {} + self.groups: GroupsDict = {} + self.defaults: DefaultsDict = {"data": {}} + self.original_data: Optional[AnsibleGroupsDict] = None self.load_hosts_file() - def parse_group(self, group, data, parent=None): + def parse_group( + self, group: str, data: AnsibleGroupDataDict, parent: Optional[str] = None + ) -> None: data = data or {} if group == "defaults": - self.groups[group] = {} group_file = "all" + dest_group = self.defaults else: self.add(group, self.groups) group_file = group + dest_group = self.groups[group] if parent and parent != "defaults": - self.groups[group]["groups"].append(parent) + dest_group["groups"].append(parent) - self.groups[group].update(data.get("vars", {})) - self.groups[group].update(self.read_vars_file(group_file, self.path, False)) - self.groups[group] = self.map_nornir_vars(self.groups[group]) + group_data = data.get("vars", {}) + vars_file_data = self.read_vars_file(group_file, self.path, False) or {} + self.normalize_data(dest_group, group_data, vars_file_data) + self.map_nornir_vars(dest_group) self.parse_hosts(data.get("hosts", {}), parent=group) for children, children_data in data.get("children", {}).items(): - self.parse_group(children, children_data, parent=group) + self.parse_group( + children, cast(AnsibleGroupDataDict, children_data), parent=group + ) - def parse(self): - self.parse_group("defaults", self.original_data["all"]) + def parse(self) -> None: + if self.original_data is not None: + self.parse_group("defaults", self.original_data["all"]) self.sort_groups() - def parse_hosts(self, hosts, parent=None): + def parse_hosts( + self, hosts: AnsibleHostsDict, parent: Optional[str] = None + ) -> None: for host, data in hosts.items(): data = data or {} self.add(host, self.hosts) if parent and parent != "defaults": self.hosts[host]["groups"].append(parent) - self.hosts[host].update(data) - self.hosts[host].update(self.read_vars_file(host, self.path, True)) - self.hosts[host] = self.map_nornir_vars(self.hosts[host]) - def sort_groups(self): + vars_file_data = self.read_vars_file(host, self.path, True) + self.normalize_data(self.hosts[host], data, vars_file_data) + self.map_nornir_vars(self.hosts[host]) + + def normalize_data( + self, host: HostsDict, data: Dict[str, Any], vars_data: Dict[str, Any] + ) -> None: + reserved_fields = InventoryElement.__fields__.keys() + self.map_nornir_vars(data) + for k, v in data.items(): + if k in reserved_fields: + host[k] = v + else: + host["data"][k] = v + self.map_nornir_vars(vars_data) + for k, v in vars_data.items(): + if k in reserved_fields: + host[k] = v + else: + host["data"][k] = v + + def sort_groups(self) -> None: for host in self.hosts.values(): host["groups"].sort() @@ -69,126 +122,148 @@ def sort_groups(self): group["groups"].sort() - def read_vars_file(self, element, path, is_host=True): - subdir = "host_vars" if is_host else "group_vars" - filepath = os.path.join(path, subdir, element) - - if not os.path.exists(filepath): + @staticmethod + def read_vars_file(element: str, path: str, is_host: bool = True) -> VarsDict: + sub_dir = "host_vars" if is_host else "group_vars" + vars_dir = Path(path) / sub_dir + if vars_dir.is_dir(): + vars_file_base = vars_dir / element + for extension in VARS_FILENAME_EXTENSIONS: + vars_file = vars_file_base.with_suffix( + vars_file_base.suffix + extension + ) + if vars_file.is_file(): + with open(vars_file) as f: + logger.debug( + "AnsibleInventory: reading var file: %s", vars_file + ) + return YAML.load(f) logger.debug( - "AnsibleInventory: var file doesn't exist: {}".format(filepath) + "AnsibleInventory: no vars file was found with the path %s " + "and one of the supported extensions: %s", + vars_file_base, + VARS_FILENAME_EXTENSIONS, ) - return {} - - with open(filepath, "r") as f: - logger.debug("AnsibleInventory: reading var file: {}".format(filepath)) - yml = ruamel.yaml.YAML(typ="rt", pure=True) - return yml.load(f) + return {} - def map_nornir_vars(self, obj): + @staticmethod + def map_nornir_vars(obj: VarsDict): mappings = { - "ansible_host": "nornir_host", - "ansible_port": "nornir_ssh_port", - "ansible_user": "nornir_username", - "ansible_password": "nornir_password", + "ansible_host": "hostname", + "ansible_port": "port", + "ansible_user": "username", + "ansible_password": "password", } - result = {} - for k, v in obj.items(): - if k in mappings: - result[mappings[k]] = v - else: - result[k] = v - return result + for ansible_var, nornir_var in mappings.items(): + if ansible_var in obj: + obj[nornir_var] = obj.pop(ansible_var) - def add(self, element, element_dict): + @staticmethod + def add(element: str, element_dict: Dict[str, VarsDict]) -> None: if element not in element_dict: - element_dict[element] = {"groups": []} + element_dict[element] = {"groups": [], "data": {}} + + def load_hosts_file(self) -> None: + raise NotImplementedError class INIParser(AnsibleParser): + @staticmethod + def normalize_value(value: str) -> Union[str, int]: + try: + return int(value) + + except (ValueError, TypeError): + return value - def normalize_content(self, content): - result = {} + @staticmethod + def normalize_content(content: str) -> VarsDict: + result: VarsDict = {} if not content: return result for option in content.split(): - k, v = option.split("=") - try: - v = int(v) - except Exception: - pass - result[k] = v - + key, value = option.split("=") + result[key] = INIParser.normalize_value(value) return result - def normalize(self, data): - result = {} - for k, v in data.items(): - meta = None - if ":" in k: - k, meta = k.split(":") - if k not in result: - result[k] = {} - - if meta not in result[k]: - result[k][meta] = {} - - if meta == "vars": - for data, _ in v.items(): - result[k][meta].update(self.normalize_content(data)) - elif meta == "children": - result[k][meta] = {k: {} for k, _ in v.items()} + @staticmethod + def process_meta( + meta: Optional[str], section: MutableMapping[str, str] + ) -> Dict[str, Any]: + if meta == "vars": + return { + key: INIParser.normalize_value(value) for key, value in section.items() + } + + elif meta == "children": + return {group_name: {} for group_name in section} + + else: + raise ValueError(f"Unknown tag {meta}") + + def normalize(self, data: cp.ConfigParser) -> Dict[str, AnsibleGroupDataDict]: + groups: DefaultDict[str, Dict[str, Any]] = defaultdict(dict) + # Dict[str, AnsibleGroupDataDict] does not work because of + # https://github.com/python/mypy/issues/5359 + result: Dict[str, Dict[str, Dict[str, Dict[str, Any]]]] = { + "all": {"children": groups} + } + + for section_name, section in data.items(): + + if section_name == "DEFAULT": + continue + + if ":" in section_name: + group_name, meta = section_name.split(":") + subsection = self.process_meta(meta, section) + if group_name == "all": + result["all"][meta] = subsection + else: + groups[group_name][meta] = subsection else: - result[k]["hosts"] = { - host: self.normalize_content(data) for host, data in v.items() + groups[section_name]["hosts"] = { + host: self.normalize_content(host_vars) + for host, host_vars in section.items() } - return result + return cast(AnsibleGroupsDict, result) - def load_hosts_file(self): + def load_hosts_file(self) -> None: original_data = cp.ConfigParser( - interpolation=None, allow_no_value=True, delimiters=" " + interpolation=None, allow_no_value=True, delimiters=" =" ) original_data.read(self.hostsfile) - data = self.normalize(original_data) - data.pop("DEFAULT") - if "all" not in data: - self.original_data = {"all": {"children": data}} + self.original_data = self.normalize(original_data) class YAMLParser(AnsibleParser): - - def load_hosts_file(self): + def load_hosts_file(self) -> None: with open(self.hostsfile, "r") as f: - yml = ruamel.yaml.YAML(typ="rt", pure=True) - self.original_data = yml.load(f.read()) + self.original_data = cast(AnsibleGroupsDict, YAML.load(f)) -def parse(hostsfile): +def parse(hostsfile: str) -> Tuple[HostsDict, GroupsDict, DefaultsDict]: try: - parser = INIParser(hostsfile) + parser: AnsibleParser = INIParser(hostsfile) except cp.Error: try: parser = YAMLParser(hostsfile) - except ruamel.yaml.scanner.ScannerError: + except (ScannerError, ComposerError): logger.error( "couldn't parse '{}' as neither a ini nor yaml file".format(hostsfile) ) raise parser.parse() - return parser.hosts, parser.groups + return parser.hosts, parser.groups, parser.defaults -class AnsibleInventory(Inventory): - """ - Inventory plugin that is capable of reading an ansible inventory. - - Arguments: - hostsfile (string): Ansible inventory file to load - """ - def __init__(self, hostsfile="hosts", **kwargs): - host_vars, group_vars = parse(hostsfile) - defaults = group_vars.pop("defaults") - super().__init__(host_vars, group_vars, defaults, **kwargs) +class AnsibleInventory(Inventory): + def __init__(self, hostsfile: str = "hosts", *args: Any, **kwargs: Any) -> None: + host_vars, group_vars, defaults = parse(hostsfile) + super().__init__( + hosts=host_vars, groups=group_vars, defaults=defaults, *args, **kwargs + ) diff --git a/nornir/plugins/inventory/netbox.py b/nornir/plugins/inventory/netbox.py new file mode 100644 index 00000000..1bcd76fe --- /dev/null +++ b/nornir/plugins/inventory/netbox.py @@ -0,0 +1,85 @@ +import os + +from nornir.core.deserializer.inventory import Inventory, HostsDict + + +import requests + + +class NBInventory(Inventory): + def __init__( + self, + nb_url=None, + nb_token=None, + use_slugs=True, + flatten_custom_fields=True, + filter_parameters=None, + **kwargs, + ) -> None: + """ + Netbox plugin + + Arguments: + nb_url: Netbox url, defaults to http://localhost:8080. + You can also use env variable NB_URL + nb_token: Netbokx token. You can also use env variable NB_TOKEN + use_slugs: Whether to use slugs or not + flatten_custom_fields: Whether to assign custom fields directly to the host or not + filter_parameters: Key-value pairs to filter down hosts + """ + filter_parameters = filter_parameters or {} + + nb_url = nb_url or os.environ.get("NB_URL", "http://localhost:8080") + nb_token = nb_token or os.environ.get( + "NB_TOKEN", "0123456789abcdef0123456789abcdef01234567" + ) + headers = {"Authorization": "Token {}".format(nb_token)} + + # Create dict of hosts using 'devices' from NetBox + r = requests.get( + "{}/api/dcim/devices/?limit=0".format(nb_url), + headers=headers, + params=filter_parameters, + ) + r.raise_for_status() + nb_devices = r.json() + + hosts = {} + for d in nb_devices["results"]: + host: HostsDict = {"data": {}} + + # Add value for IP address + if d.get("primary_ip", {}): + host["hostname"] = d["primary_ip"]["address"].split("/")[0] + + # Add values that don't have an option for 'slug' + host["data"]["serial"] = d["serial"] + host["data"]["vendor"] = d["device_type"]["manufacturer"]["name"] + host["data"]["asset_tag"] = d["asset_tag"] + + if flatten_custom_fields: + for cf, value in d["custom_fields"].items(): + host["data"][cf] = value + else: + host["data"]["custom_fields"] = d["custom_fields"] + + # Add values that do have an option for 'slug' + if use_slugs: + host["data"]["site"] = d["site"]["slug"] + host["data"]["role"] = d["device_role"]["slug"] + host["data"]["model"] = d["device_type"]["slug"] + + # Attempt to add 'platform' based of value in 'slug' + host["platform"] = d["platform"]["slug"] if d["platform"] else None + + else: + host["data"]["site"] = d["site"]["name"] + host["data"]["role"] = d["device_role"] + host["data"]["model"] = d["device_type"] + host["platform"] = d["platform"] + + # Assign temporary dict to outer dict + hosts[d["name"]] = host + + # Pass the data back to the parent class + super().__init__(hosts=hosts, groups={}, defaults={}, **kwargs) diff --git a/nornir/plugins/inventory/nsot.py b/nornir/plugins/inventory/nsot.py index 569c7e31..a55d0a5c 100644 --- a/nornir/plugins/inventory/nsot.py +++ b/nornir/plugins/inventory/nsot.py @@ -1,7 +1,7 @@ import os -from builtins import super +from typing import Any -from nornir.core.inventory import Inventory +from nornir.core.deserializer.inventory import Inventory, InventoryElement import requests @@ -21,31 +21,31 @@ class NSOTInventory(Inventory): * ``NSOT_SECRET_KEY``: Corresponds to nsot_secret_key argument Arguments: - flatten_attributes (bool): Assign host attributes to the root object. Useful + flatten_attributes: Assign host attributes to the root object. Useful for filtering hosts. - nsot_url (string): URL to nsot's API (defaults to ``http://localhost:8990/api``) - nsot_email (string): email for authtication (defaults to admin@acme.com) - nsot_auth_header (string): String for auth_header authentication (defaults to X-NSoT-Email) - nsot_secret_key (string): Secret Key for auth_token method. If given auth_token + nsot_url: URL to nsot's API (defaults to ``http://localhost:8990/api``) + nsot_email: email for authtication (defaults to admin@acme.com) + nsot_auth_header: String for auth_header authentication (defaults to X-NSoT-Email) + nsot_secret_key: Secret Key for auth_token method. If given auth_token will be used as auth_method. """ def __init__( self, - nsot_url="", - nsot_email="", - nsot_auth_method="", - nsot_secret_key="", - nsot_auth_header="", - flatten_attributes=True, - **kwargs - ): + nsot_url: str = "", + nsot_email: str = "", + nsot_secret_key: str = "", + nsot_auth_header: str = "", + flatten_attributes: bool = True, + *args: Any, + **kwargs: Any + ) -> None: nsot_url = nsot_url or os.environ.get("NSOT_URL", "http://localhost:8990/api") nsot_email = nsot_email or os.environ.get("NSOT_EMAIL", "admin@acme.com") - nsot_secret_key = nsot_secret_key or os.environ.get("NSOT_SECRET_KEY") + secret_key = nsot_secret_key or os.environ.get("NSOT_SECRET_KEY") - if nsot_secret_key: - data = {"email": nsot_email, "secret_key": nsot_secret_key} + if secret_key: + data = {"email": nsot_email, "secret_key": secret_key} res = requests.post("{}/authenticate/".format(nsot_url), data=data) auth_token = res.json().get("auth_token") headers = { @@ -66,19 +66,25 @@ def __init__( # We resolve site_id and assign "site" variable with the name of the site for d in devices: - d["site"] = sites[d["site_id"] - 1]["name"] - d["interfaces"] = {} + d["data"] = {"site": sites[d["site_id"] - 1]["name"], "interfaces": {}} + + remove_keys = [] + for k, v in d.items(): + if k not in InventoryElement().fields: + remove_keys.append(k) + d["data"][k] = v + for r in remove_keys: + d.pop(r) if flatten_attributes: # We assign attributes to the root - for k, v in d.pop("attributes").items(): - d[k] = v + for k, v in d["data"].pop("attributes").items(): + d["data"][k] = v # We assign the interfaces to the hosts for i in interfaces: - devices[i["device"] - 1]["interfaces"][i["name"]] = i + devices[i["device"] - 1]["data"]["interfaces"][i["name"]] = i # Finally the inventory expects a dict of hosts where the key is the hostname - devices = {d["hostname"]: d for d in devices} - - super().__init__(devices, None, **kwargs) + hosts = {d["hostname"]: d for d in devices} + return super().__init__(hosts=hosts, groups={}, defaults={}, *args, **kwargs) diff --git a/nornir/plugins/inventory/simple.py b/nornir/plugins/inventory/simple.py index cf77c98a..fc434de6 100644 --- a/nornir/plugins/inventory/simple.py +++ b/nornir/plugins/inventory/simple.py @@ -1,134 +1,39 @@ import logging import os -from builtins import super -from nornir.core.inventory import Inventory +from nornir.core.deserializer.inventory import GroupsDict, Inventory, VarsDict -import yaml +import ruamel.yaml class SimpleInventory(Inventory): - """ - This is a very simple file based inventory. Basically you need two yaml files. One - for your host information and another one for your group information. - - * host file:: - - --- - host1.cmh: - site: cmh - role: host - groups: - - cmh-host - nos: linux - - host2.cmh: - site: cmh - role: host - groups: - - cmh-host - nos: linux - - switch00.cmh: - nornir_ip: 127.0.0.1 - nornir_username: vagrant - nornir_password: vagrant - napalm_port: 12443 - site: cmh - role: leaf - groups: - - cmh-leaf - nos: eos - - switch01.cmh: - nornir_ip: 127.0.0.1 - nornir_username: vagrant - nornir_password: "" - napalm_port: 12203 - site: cmh - role: leaf - groups: - - cmh-leaf - nos: junos - - host1.bma: - site: bma - role: host - groups: - - bma-host - nos: linux - - host2.bma: - site: bma - role: host - groups: - - bma-host - nos: linux - - switch00.bma: - nornir_ip: 127.0.0.1 - nornir_username: vagrant - nornir_password: vagrant - napalm_port: 12443 - site: bma - role: leaf - groups: - - bma-leaf - nos: eos - - switch01.bma: - nornir_ip: 127.0.0.1 - nornir_username: vagrant - nornir_password: "" - napalm_port: 12203 - site: bma - role: leaf - groups: - - bma-leaf - nos: junos - - * group file:: - - --- - defaults: - domain: acme.com - - bma-leaf: - groups: - - bma - - bma-host: - groups: - - bma - - bma: - domain: bma.acme.com - - cmh-leaf: - groups: - - cmh - - cmh-host: - groups: - - cmh - - cmh: - domain: cmh.acme.com - """ - - def __init__(self, host_file="hosts.yaml", group_file="groups.yaml", **kwargs): + def __init__( + self, + host_file: str = "hosts.yaml", + group_file: str = "groups.yaml", + defaults_file: str = "defaults.yaml", + *args, + **kwargs + ) -> None: + yml = ruamel.yaml.YAML(typ="safe") with open(host_file, "r") as f: - hosts = yaml.load(f.read()) + hosts = yml.load(f) + groups: GroupsDict = {} if group_file: if os.path.exists(group_file): with open(group_file, "r") as f: - groups = yaml.load(f.read()) + groups = yml.load(f) or {} else: - logging.warning("{}: doesn't exist".format(group_file)) + logging.debug("{}: doesn't exist".format(group_file)) groups = {} - else: - groups = {} - defaults = groups.pop("defaults", {}) - super().__init__(hosts, groups, defaults, **kwargs) + defaults: VarsDict = {} + if defaults_file: + if os.path.exists(defaults_file): + with open(defaults_file, "r") as f: + defaults = yml.load(f) or {} + else: + logging.debug("{}: doesn't exist".format(defaults_file)) + defaults = {} + super().__init__(hosts=hosts, groups=groups, defaults=defaults, *args, **kwargs) diff --git a/nornir/plugins/tasks/apis/http_method.py b/nornir/plugins/tasks/apis/http_method.py index e4c50292..6823184f 100644 --- a/nornir/plugins/tasks/apis/http_method.py +++ b/nornir/plugins/tasks/apis/http_method.py @@ -1,17 +1,24 @@ -from nornir.core.task import Result +from typing import Optional, Any +from nornir.core.task import Result, Task import requests -def http_method(task=None, method="get", url="", raise_for_status=True, **kwargs): +def http_method( + task: Optional[Task] = None, + method: str = "get", + url: str = "", + raise_for_status: bool = True, + **kwargs: Any +) -> Result: """ This is a convenience task that uses `requests `_ to interact with an HTTP server. Arguments: - method (string): HTTP method to call - url (string): URL to connect to - raise_for_status (bool): Whether to call `raise_for_status + method: HTTP method to call + url: URL to connect to + raise_for_status: Whether to call `raise_for_status `_ method automatically or not. For quick reference, raise_for_status will consider an error if the return code is any of 4xx or 5xx @@ -20,10 +27,10 @@ def http_method(task=None, method="get", url="", raise_for_status=True, **kwargs method Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * result (``string/dict``): Body of the response. Either text or a dict if the response was a json object - * reponse (object): Original `Response + * response (``requests.Response``): Original `Response `_ """ r = requests.request(method, url, **kwargs) diff --git a/nornir/plugins/tasks/commands/command.py b/nornir/plugins/tasks/commands/command.py index f97ace74..154e9deb 100644 --- a/nornir/plugins/tasks/commands/command.py +++ b/nornir/plugins/tasks/commands/command.py @@ -3,21 +3,22 @@ from nornir.core.exceptions import CommandError -from nornir.core.task import Result +from nornir.core.task import Result, Task -def command(task, command): +def command(task: Task, command: str) -> Result: """ Executes a command locally Arguments: - command (``str``): command to execute + command: command to execute Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * result (``str``): stderr or stdout * stdout (``str``): stdout - * stderr (``srr``): stderr + * stderr (``str``): stderr + Raises: :obj:`nornir.core.exceptions.CommandError`: when there is a command error """ diff --git a/nornir/plugins/tasks/commands/remote_command.py b/nornir/plugins/tasks/commands/remote_command.py index d4b5567d..dbfbb4a6 100644 --- a/nornir/plugins/tasks/commands/remote_command.py +++ b/nornir/plugins/tasks/commands/remote_command.py @@ -1,30 +1,31 @@ from nornir.core.exceptions import CommandError -from nornir.core.task import Result +from nornir.core.task import Result, Task from paramiko.agent import AgentRequestHandler -def remote_command(task, command): +def remote_command(task: Task, command: str) -> Result: """ - Executes a command locally + Executes a command remotely on the host Arguments: command (``str``): command to execute Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * result (``str``): stderr or stdout * stdout (``str``): stdout - * stderr (``srr``): stderr + * stderr (``str``): stderr Raises: :obj:`nornir.core.exceptions.CommandError`: when there is a command error """ - client = task.host.get_connection("paramiko") + client = task.host.get_connection("paramiko", task.nornir.config) + connection_state = task.host.get_connection_state("paramiko") chan = client.get_transport().open_session() - if task.host._ssh_forward_agent: + if connection_state["ssh_forward_agent"]: AgentRequestHandler(chan) chan.exec_command(command) diff --git a/nornir/plugins/tasks/connections/__init__.py b/nornir/plugins/tasks/connections/__init__.py deleted file mode 100644 index b4a8240e..00000000 --- a/nornir/plugins/tasks/connections/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .napalm_connection import napalm_connection -from .netmiko_connection import netmiko_connection -from .paramiko_connection import paramiko_connection - - -available_connections = { - "napalm": napalm_connection, - "netmiko": netmiko_connection, - "paramiko": paramiko_connection, -} - -__all__ = ("napalm_connection", "netmiko_connection", "paramiko_connection") diff --git a/nornir/plugins/tasks/connections/napalm_connection.py b/nornir/plugins/tasks/connections/napalm_connection.py deleted file mode 100644 index fb4f8d4d..00000000 --- a/nornir/plugins/tasks/connections/napalm_connection.py +++ /dev/null @@ -1,53 +0,0 @@ -from napalm import get_network_driver - - -def napalm_connection(task=None, timeout=60, optional_args=None): - """ - This tasks connects to the device using the NAPALM driver and sets the - relevant connection. - - Arguments: - timeout (int, optional): defaults to 60 - optional_args (dict, optional): defaults to `{"port": task.host["nornir_network_api_port"]}` - - Inventory: - napalm_options: maps directly to ``optional_args`` when establishing the connection - network_api_port: maps to ``optional_args["port"]`` - """ - host = task.host - - parameters = { - "hostname": host.host, - "username": host.username, - "password": host.password, - "timeout": timeout, - "optional_args": optional_args or host.get("napalm_options", {}), - } - - platform = host.nos - api_platforms = ["nxos", "eos", "iosxr", "junos"] - ssh_platforms = ["nxos_ssh", "ios"] - - # If port is set in optional_args that will control the port setting (else look to inventory) - if "port" not in parameters["optional_args"]: - if platform in api_platforms and host.network_api_port: - parameters["optional_args"]["port"] = host.network_api_port - elif platform in ssh_platforms and host.ssh_port: - parameters["optional_args"]["port"] = host.ssh_port - - # Setting host.nos to 'nxos' is potentially ambiguous - if platform == "nxos": - if not host.network_api_port: - if host.ssh_port or parameters["optional_args"].get("port") == 22: - platform == "nxos_ssh" - - # Fallback for community drivers (priority api_port over ssh_port) - if platform not in (api_platforms + ssh_platforms): - if host.network_api_port: - parameters["optional_args"]["port"] = host.network_api_port - elif host.ssh_port: - parameters["optional_args"]["port"] = host.ssh_port - - network_driver = get_network_driver(platform) - host.connections["napalm"] = network_driver(**parameters) - host.connections["napalm"].open() diff --git a/nornir/plugins/tasks/connections/netmiko_connection.py b/nornir/plugins/tasks/connections/netmiko_connection.py deleted file mode 100644 index af33d8e3..00000000 --- a/nornir/plugins/tasks/connections/netmiko_connection.py +++ /dev/null @@ -1,36 +0,0 @@ -from netmiko import ConnectHandler - -napalm_to_netmiko_map = { - "ios": "cisco_ios", - "nxos": "cisco_nxos", - "eos": "arista_eos", - "junos": "juniper_junos", - "iosxr": "cisco_xr", -} - - -def netmiko_connection(task, **netmiko_args): - """Connect to the host using Netmiko and set the relevant connection in the connection map. - - Precedence: ``**netmiko_args`` > discrete inventory attributes > inventory netmiko_options - - Arguments: - ``**netmiko_args``: All supported Netmiko ConnectHandler arguments - """ - host = task.host - parameters = { - "host": host.host, "username": host.username, "password": host.password - } - if host.ssh_port: - parameters["port"] = host.ssh_port - - if host.nos is not None: - # Look device_type up in corresponding map, if no entry return the host.nos unmodified - device_type = napalm_to_netmiko_map.get(host.nos, host.nos) - parameters["device_type"] = device_type - - # Precedence order: **netmiko_args > discrete inventory attributes > inventory netmiko_options - netmiko_connection_args = host.get("netmiko_options", {}) - netmiko_connection_args.update(parameters) - netmiko_connection_args.update(netmiko_args) - host.connections["netmiko"] = ConnectHandler(**netmiko_connection_args) diff --git a/nornir/plugins/tasks/connections/paramiko_connection.py b/nornir/plugins/tasks/connections/paramiko_connection.py deleted file mode 100644 index c494c720..00000000 --- a/nornir/plugins/tasks/connections/paramiko_connection.py +++ /dev/null @@ -1,46 +0,0 @@ -import os - -import paramiko - - -def paramiko_connection(task=None): - """ - This tasks connects to the device with paramiko to the device and sets the - relevant connection. - """ - host = task.host - - client = paramiko.SSHClient() - client._policy = paramiko.WarningPolicy() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - ssh_config = paramiko.SSHConfig() - ssh_config_file = task.nornir.config.ssh_config_file - if os.path.exists(ssh_config_file): - with open(ssh_config_file) as f: - ssh_config.parse(f) - - parameters = { - "hostname": host.host, "username": host.username, "password": host.password - } - if host.ssh_port: - parameters["port"] = host.ssh_port - - user_config = ssh_config.lookup(host.host) - for k in ("hostname", "username", "port"): - if k in user_config: - parameters[k] = user_config[k] - - if "proxycommand" in user_config: - parameters["sock"] = paramiko.ProxyCommand(user_config["proxycommand"]) - - task.host._ssh_forward_agent = user_config.get("forwardagent") == "yes" - - # TODO configurable - # if ssh_key_file: - # parameters['key_filename'] = ssh_key_file - if "identityfile" in user_config: - parameters["key_filename"] = user_config["identityfile"] - - client.connect(**parameters) - host.connections["paramiko"] = client diff --git a/nornir/plugins/tasks/data/__init__.py b/nornir/plugins/tasks/data/__init__.py index 2dbc5c33..b0528cee 100644 --- a/nornir/plugins/tasks/data/__init__.py +++ b/nornir/plugins/tasks/data/__init__.py @@ -1,5 +1,6 @@ from .load_json import load_json from .load_yaml import load_yaml +from .echo_data import echo_data -__all__ = ("load_json", "load_yaml") +__all__ = ("load_json", "load_yaml", "echo_data") diff --git a/nornir/plugins/tasks/data/echo_data.py b/nornir/plugins/tasks/data/echo_data.py new file mode 100644 index 00000000..d225e3b6 --- /dev/null +++ b/nornir/plugins/tasks/data/echo_data.py @@ -0,0 +1,16 @@ +from nornir.core.task import Result, Task + + +def echo_data(task: Task, **kwargs): + """ + Dummy task that echoes the data passed to it. Useful in grouped_tasks + to debug data passed to tasks. + + Arguments: + ``**kwargs``: Any pair you want + + Returns: + Result object with the following attributes set: + * result (``dict``): ``**kwargs`` passed to the task + """ + return Result(host=task.host, result=kwargs) diff --git a/nornir/plugins/tasks/data/load_json.py b/nornir/plugins/tasks/data/load_json.py index 06782000..a0cd30e8 100644 --- a/nornir/plugins/tasks/data/load_json.py +++ b/nornir/plugins/tasks/data/load_json.py @@ -1,20 +1,31 @@ import json +from typing import Any, Dict, MutableMapping, Type -from nornir.core.task import Result +from nornir.core.task import Result, Task -def load_json(task, file): +def load_json(task: Task, file: str) -> Result: """ Loads a json file. Arguments: - file (str): path to the file containing the json file to load + file: path to the file containing the json file to load + + Examples: + + Simple example with ``ordered_dict``:: + + > nr.run(task=load_json, + file="mydata.json") + + file: path to the file containing the json file to load Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * result (``dict``): dictionary with the contents of the file """ + kwargs: Dict[str, Type[MutableMapping[str, Any]]] = {} with open(file, "r") as f: - data = json.loads(f.read()) + data = json.loads(f.read(), **kwargs) return Result(host=task.host, result=data) diff --git a/nornir/plugins/tasks/data/load_yaml.py b/nornir/plugins/tasks/data/load_yaml.py index 2e989097..d762210a 100644 --- a/nornir/plugins/tasks/data/load_yaml.py +++ b/nornir/plugins/tasks/data/load_yaml.py @@ -1,21 +1,28 @@ -from nornir.core.task import Result +from nornir.core.task import Result, Task +import ruamel.yaml -import yaml - -def load_yaml(task, file): +def load_yaml(task: Task, file: str): """ Loads a yaml file. Arguments: - file (str): path to the file containing the yaml file to load + file: path to the file containing the yaml file to load + + Examples: + + Simple example with ``ordered_dict``:: + + > nr.run(task=load_yaml, + file="mydata.yaml") Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * result (``dict``): dictionary with the contents of the file """ with open(file, "r") as f: - data = yaml.load(f.read()) + yml = ruamel.yaml.YAML(typ="safe") + data = yml.load(f) return Result(host=task.host, result=data) diff --git a/nornir/plugins/tasks/files/sftp.py b/nornir/plugins/tasks/files/sftp.py index 3eb97435..66dc62dd 100644 --- a/nornir/plugins/tasks/files/sftp.py +++ b/nornir/plugins/tasks/files/sftp.py @@ -1,9 +1,10 @@ import hashlib import os import stat +from typing import List, Optional from nornir.core.exceptions import CommandError -from nornir.core.task import Result +from nornir.core.task import Result, Task from nornir.plugins.tasks import commands import paramiko @@ -11,7 +12,7 @@ from scp import SCPClient -def get_src_hash(filename): +def get_src_hash(filename: str) -> str: sha1sum = hashlib.sha1() with open(filename, "rb") as f: @@ -22,11 +23,12 @@ def get_src_hash(filename): return sha1sum.hexdigest() -def get_dst_hash(task, filename): +def get_dst_hash(task: Task, filename: str) -> str: command = "sha1sum {}".format(filename) try: result = commands.remote_command(task, command) - return result.stdout.split()[0] + if result.stdout is not None: + return result.stdout.split()[0] except CommandError as e: if "No such file or directory" in e.stderr: @@ -34,8 +36,10 @@ def get_dst_hash(task, filename): raise + return "" -def remote_exists(sftp_client, f): + +def remote_exists(sftp_client: paramiko.SFTPClient, f: str) -> bool: try: sftp_client.stat(f) return True @@ -44,7 +48,9 @@ def remote_exists(sftp_client, f): return False -def compare_put_files(task, sftp_client, src, dst): +def compare_put_files( + task: Task, sftp_client: paramiko.SFTPClient, src: str, dst: str +) -> List[str]: changed = [] if os.path.isfile(src): src_hash = get_src_hash(src) @@ -65,7 +71,9 @@ def compare_put_files(task, sftp_client, src, dst): return changed -def compare_get_files(task, sftp_client, src, dst): +def compare_get_files( + task: Task, sftp_client: paramiko.SFTPClient, src: str, dst: str +) -> List[str]: changed = [] if stat.S_ISREG(sftp_client.stat(src).st_mode): # is a file @@ -87,21 +95,37 @@ def compare_get_files(task, sftp_client, src, dst): return changed -def get(task, scp_client, sftp_client, src, dst, dry_run, *args, **kwargs): +def get( + task: Task, + scp_client: SCPClient, + sftp_client: paramiko.SFTPClient, + src: str, + dst: str, + dry_run: Optional[bool] = None, +) -> List[str]: changed = compare_get_files(task, sftp_client, src, dst) if changed and not dry_run: scp_client.get(src, dst, recursive=True) return changed -def put(task, scp_client, sftp_client, src, dst, dry_run, *args, **kwargs): +def put( + task: Task, + scp_client: SCPClient, + sftp_client: paramiko.SFTPClient, + src: str, + dst: str, + dry_run: Optional[bool] = None, +) -> List[str]: changed = compare_put_files(task, sftp_client, src, dst) if changed and not dry_run: scp_client.put(src, dst, recursive=True) return changed -def sftp(task, src, dst, action, dry_run=None): +def sftp( + task: Task, src: str, dst: str, action: str, dry_run: Optional[bool] = None +) -> Result: """ Transfer files from/to the device using sftp protocol @@ -113,19 +137,19 @@ def sftp(task, src, dst, action, dry_run=None): dst="/tmp/README.md") Arguments: - dry_run (bool): Whether to apply changes or not - src (``str``): source file - dst (``str``): destination - action (``str``): ``put``, ``get``. + dry_run: Whether to apply changes or not + src: source file + dst: destination + action: ``put``, ``get``. Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * changed (``bool``): * files_changed (``list``): list of files that changed """ dry_run = task.is_dry_run(dry_run) actions = {"put": put, "get": get} - client = task.host.get_connection("paramiko") + client = task.host.get_connection("paramiko", task.nornir.config) scp_client = SCPClient(client.get_transport()) sftp_client = paramiko.SFTPClient.from_transport(client.get_transport()) files_changed = actions[action](task, scp_client, sftp_client, src, dst, dry_run) diff --git a/nornir/plugins/tasks/files/write_file.py b/nornir/plugins/tasks/files/write_file.py index a1f2a6b2..23b1b7b3 100644 --- a/nornir/plugins/tasks/files/write_file.py +++ b/nornir/plugins/tasks/files/write_file.py @@ -1,10 +1,11 @@ import difflib import os +from typing import List, Optional -from nornir.core.task import Result +from nornir.core.task import Result, Task -def _read_file(file): +def _read_file(file: str) -> List[str]: if not os.path.exists(file): return [] @@ -12,33 +13,40 @@ def _read_file(file): return f.read().splitlines() -def _generate_diff(filename, content, append): +def _generate_diff(filename: str, content: str, append: bool) -> str: original = _read_file(filename) if append: c = list(original) c.extend(content.splitlines()) - content = c + new_content = c else: - content = content.splitlines() + new_content = content.splitlines() - diff = difflib.unified_diff(original, content, fromfile=filename, tofile="new") + diff = difflib.unified_diff(original, new_content, fromfile=filename, tofile="new") return "\n".join(diff) -def write_file(task, filename, content, append=False, dry_run=None): +def write_file( + task: Task, + filename: str, + content: str, + append: bool = False, + dry_run: Optional[bool] = None, +) -> Result: """ Write contents to a file (locally) Arguments: - dry_run (bool): Whether to apply changes or not - filename (``str``): file you want to write into - conteint (``str``): content you want to write - append (``bool``): whether you want to replace the contents or append to it + dry_run: Whether to apply changes or not + filename: file you want to write into + content: content you want to write + append: whether you want to replace the contents or append to it Returns: - * changed (``bool``): - * diff (``str``): unified diff + Result object with the following attributes set: + * changed (``bool``): + * diff (``str``): unified diff """ diff = _generate_diff(filename, content, append) diff --git a/nornir/plugins/tasks/networking/__init__.py b/nornir/plugins/tasks/networking/__init__.py index 63cfd04b..16efede3 100644 --- a/nornir/plugins/tasks/networking/__init__.py +++ b/nornir/plugins/tasks/networking/__init__.py @@ -5,6 +5,7 @@ from .netmiko_file_transfer import netmiko_file_transfer from .netmiko_send_command import netmiko_send_command from .netmiko_send_config import netmiko_send_config +from .netmiko_save_config import netmiko_save_config from .tcp_ping import tcp_ping __all__ = ( @@ -15,5 +16,6 @@ "netmiko_file_transfer", "netmiko_send_command", "netmiko_send_config", + "netmiko_save_config", "tcp_ping", ) diff --git a/nornir/plugins/tasks/networking/napalm_cli.py b/nornir/plugins/tasks/networking/napalm_cli.py index a1d75921..3e9f70ca 100644 --- a/nornir/plugins/tasks/networking/napalm_cli.py +++ b/nornir/plugins/tasks/networking/napalm_cli.py @@ -1,17 +1,19 @@ -from nornir.core.task import Result +from typing import List +from nornir.core.task import Result, Task -def napalm_cli(task, commands): + +def napalm_cli(task: Task, commands: List[str]) -> Result: """ Run commands on remote devices using napalm Arguments: - commands (``list``): List of commands to execute + commands: commands to execute Returns: - :obj:`nornir.core.task.Result`: - * result (``dict``): dictionary with the result of the commands + Result object with the following attributes set: + * result (``dict``): result of the commands execution """ - device = task.host.get_connection("napalm") + device = task.host.get_connection("napalm", task.nornir.config) result = device.cli(commands) return Result(host=task.host, result=result) diff --git a/nornir/plugins/tasks/networking/napalm_configure.py b/nornir/plugins/tasks/networking/napalm_configure.py index 88ede18c..518aba8d 100644 --- a/nornir/plugins/tasks/networking/napalm_configure.py +++ b/nornir/plugins/tasks/networking/napalm_configure.py @@ -1,24 +1,30 @@ -from nornir.core.task import Result +from typing import Optional + +from nornir.core.task import Result, Task def napalm_configure( - task, dry_run=None, filename=None, configuration=None, replace=False -): + task: Task, + dry_run: Optional[bool] = None, + filename: Optional[str] = None, + configuration: Optional[str] = None, + replace: bool = False, +) -> Result: """ Loads configuration into a network devices using napalm Arguments: - dry_run (bool): Whether to apply changes or not - configuration (str): configuration to load into the device - filename (str): filename containing the configuration to load into the device - replace (bool): whether to replace or merge the configuration + dry_run: Whether to apply changes or not + filename: filename containing the configuration to load into the device + configuration: configuration to load into the device + replace: whether to replace or merge the configuration Returns: - :obj:`nornir.core.task.Result`: - * changed (``bool``): whether if the task is changing the system or not + Result object with the following attributes set: + * changed (``bool``): whether the task is changing the system or not * diff (``string``): change in the system """ - device = task.host.get_connection("napalm") + device = task.host.get_connection("napalm", task.nornir.config) if replace: device.load_replace_candidate(filename=filename, config=configuration) diff --git a/nornir/plugins/tasks/networking/napalm_get.py b/nornir/plugins/tasks/networking/napalm_get.py index 5b2185b0..2c34bf76 100644 --- a/nornir/plugins/tasks/networking/napalm_get.py +++ b/nornir/plugins/tasks/networking/napalm_get.py @@ -1,14 +1,22 @@ import copy +from typing import Any, Dict, List, Optional -from nornir.core.task import Result +from nornir.core.task import Result, Task +GetterOptionsDict = Optional[Dict[str, Dict[str, Any]]] -def napalm_get(task, getters, getters_options=None, **kwargs): + +def napalm_get( + task: Task, + getters: List[str], + getters_options: GetterOptionsDict = None, + **kwargs: Any +) -> Result: """ Gather information from network devices using napalm Arguments: - getters (list of str): getters to use + getters: getters to use getters_options (dict of dicts): When passing multiple getters you pass a dictionary where the outer key is the getter name and the included dictionary represents the options to pass @@ -35,10 +43,10 @@ def napalm_get(task, getters, getters_options=None, **kwargs): > getters_options={"config": {"retrieve": "all"}}) Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * result (``dict``): dictionary with the result of the getter """ - device = task.host.get_connection("napalm") + device = task.host.get_connection("napalm", task.nornir.config) getters_options = getters_options or {} if isinstance(getters, str): diff --git a/nornir/plugins/tasks/networking/napalm_validate.py b/nornir/plugins/tasks/networking/napalm_validate.py index 1149e1a5..19498e99 100644 --- a/nornir/plugins/tasks/networking/napalm_validate.py +++ b/nornir/plugins/tasks/networking/napalm_validate.py @@ -1,7 +1,15 @@ -from nornir.core.task import Result +from typing import Any, Dict, Optional +from nornir.core.task import Result, Task -def napalm_validate(task, src=None, validation_source=None): +ValidationSourceData = Optional[Dict[str, Dict[str, Any]]] + + +def napalm_validate( + task: Task, + src: Optional[str] = None, + validation_source: ValidationSourceData = None, +) -> Result: """ Gather information with napalm and validate it: @@ -9,14 +17,14 @@ def napalm_validate(task, src=None, validation_source=None): Arguments: src: file to use as validation source - validation_source (list): instead of a file data needed to validate device's state + validation_source (list): data to validate device's state Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * result (``dict``): dictionary with the result of the validation * complies (``bool``): Whether the device complies or not """ - device = task.host.get_connection("napalm") + device = task.host.get_connection("napalm", task.nornir.config) r = device.compliance_report( validation_file=src, validation_source=validation_source ) diff --git a/nornir/plugins/tasks/networking/netmiko_file_transfer.py b/nornir/plugins/tasks/networking/netmiko_file_transfer.py index 05bffed2..96951ab9 100644 --- a/nornir/plugins/tasks/networking/netmiko_file_transfer.py +++ b/nornir/plugins/tasks/networking/netmiko_file_transfer.py @@ -1,24 +1,28 @@ -from nornir.core.task import Result +from typing import Any from netmiko import file_transfer +from nornir.core.task import Result, Task -def netmiko_file_transfer(task, source_file, dest_file, **kwargs): + +def netmiko_file_transfer( + task: Task, source_file: str, dest_file: str, **kwargs: Any +) -> Result: """ Execute Netmiko file_transfer method Arguments: - source_file(str): Source file. - dest_file(str): Destination file. - kwargs (dict, optional): Additional arguments to pass to file_transfer + source_file: Source file. + dest_file: Destination file. + kwargs: Additional arguments to pass to file_transfer Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * result (``bool``): file exists and MD5 is valid * changed (``bool``): the destination file was changed """ - net_connect = task.host.get_connection("netmiko") + net_connect = task.host.get_connection("netmiko", task.nornir.config) kwargs.setdefault("direction", "put") scp_result = file_transfer( net_connect, source_file=source_file, dest_file=dest_file, **kwargs diff --git a/nornir/plugins/tasks/networking/netmiko_save_config.py b/nornir/plugins/tasks/networking/netmiko_save_config.py new file mode 100644 index 00000000..feec42cc --- /dev/null +++ b/nornir/plugins/tasks/networking/netmiko_save_config.py @@ -0,0 +1,27 @@ +from __future__ import unicode_literals + +from nornir.core.task import Result, Task + + +def netmiko_save_config( + task: Task, cmd: str = "", confirm: bool = False, confirm_response: str = "" +) -> Result: + """ + Execute Netmiko save_config method + Arguments: + cmd(str, optional): Command used to save the configuration. + confirm(bool, optional): Does device prompt for confirmation before executing save operation + confirm_response(str, optional): Response send to device when it prompts for confirmation + + Returns: + :obj: `nornir.core.task.Result`: + * result (``str``): String showing the CLI output from the save operation + """ + conn = task.host.get_connection("netmiko", task.nornir.config) + if cmd: + result = conn.save_config( + cmd=cmd, confirm=confirm, confirm_response=confirm_response + ) + else: + result = conn.save_config(confirm=confirm, confirm_response=confirm_response) + return Result(host=task.host, result=result, changed=True) diff --git a/nornir/plugins/tasks/networking/netmiko_send_command.py b/nornir/plugins/tasks/networking/netmiko_send_command.py index 3097812b..2b281b77 100644 --- a/nornir/plugins/tasks/networking/netmiko_send_command.py +++ b/nornir/plugins/tasks/networking/netmiko_send_command.py @@ -1,20 +1,31 @@ -from nornir.core.task import Result +from typing import Any +from nornir.core.task import Result, Task -def netmiko_send_command(task, command_string, use_timing=False, **kwargs): + +def netmiko_send_command( + task: Task, + command_string: str, + use_timing: bool = False, + enable: bool = False, + **kwargs: Any +) -> Result: """ Execute Netmiko send_command method (or send_command_timing) Arguments: - command_string(str): Command to execute on the remote network device. - use_timing(bool, optional): Set to True to switch to send_command_timing method. - kwargs (dict, optional): Additional arguments to pass to send_command method. + command_string: Command to execute on the remote network device. + use_timing: Set to True to switch to send_command_timing method. + enable: Set to True to force Netmiko .enable() call. + kwargs: Additional arguments to pass to send_command method. Returns: - :obj:`nornir.core.task.Result`: - * result (``dict``): dictionary with the result of the show command. + Result object with the following attributes set: + * result: Result of the show command (generally a string, but depends on use of TextFSM). """ - net_connect = task.host.get_connection("netmiko") + net_connect = task.host.get_connection("netmiko", task.nornir.config) + if enable: + net_connect.enable() if use_timing: result = net_connect.send_command_timing(command_string, **kwargs) else: diff --git a/nornir/plugins/tasks/networking/netmiko_send_config.py b/nornir/plugins/tasks/networking/netmiko_send_config.py index e200067e..df95c5b8 100644 --- a/nornir/plugins/tasks/networking/netmiko_send_config.py +++ b/nornir/plugins/tasks/networking/netmiko_send_config.py @@ -1,21 +1,28 @@ -from __future__ import unicode_literals +from typing import Any, List, Optional -from nornir.core.task import Result +from nornir.core.task import Result, Task -def netmiko_send_config(task, config_commands=None, config_file=None, **kwargs): +def netmiko_send_config( + task: Task, + config_commands: Optional[List[str]] = None, + config_file: Optional[str] = None, + **kwargs: Any +) -> Result: """ Execute Netmiko send_config_set method (or send_config_from_file) + Arguments: - config_commands(list, optional): Commands to configure on the remote network device. - config_file(str, optional): File to read configuration commands from. - kwargs (dict, optional): Additional arguments to pass to method. + config_commands: Commands to configure on the remote network device. + config_file: File to read configuration commands from. + kwargs: Additional arguments to pass to method. Returns: - :obj:`nornir.core.task.Result`: - * result (``dict``): dictionary showing the CLI from the configuration changes. + Result object with the following attributes set: + * result (``str``): string showing the CLI from the configuration changes. """ - net_connect = task.host.get_connection("netmiko") + net_connect = task.host.get_connection("netmiko", task.nornir.config) + net_connect.enable() if config_commands: result = net_connect.send_config_set(config_commands=config_commands, **kwargs) elif config_file: diff --git a/nornir/plugins/tasks/networking/tcp_ping.py b/nornir/plugins/tasks/networking/tcp_ping.py index 0d7fda15..ab19122c 100644 --- a/nornir/plugins/tasks/networking/tcp_ping.py +++ b/nornir/plugins/tasks/networking/tcp_ping.py @@ -1,22 +1,24 @@ import socket +from typing import Optional, List +from nornir.core.task import Result, Task -from nornir.core.task import Result - -def tcp_ping(task, ports, timeout=2, host=None): +def tcp_ping( + task: Task, ports: List[int], timeout: int = 2, host: Optional[str] = None +) -> Result: """ Tests connection to a tcp port and tries to establish a three way handshake. To be used for network discovery or testing. Arguments: - ports (list of int): tcp port to ping - timeout (int, optional): defaults to 0.5 - host (string, optional): defaults to ``nornir_ip`` + ports (list of int): tcp ports to ping + timeout (int, optional): defaults to 2 + host (string, optional): defaults to ``hostname`` Returns: - :obj:`nornir.core.task.Result`: + Result object with the following attributes set: * result (``dict``): Contains port numbers as keys with True/False as values """ @@ -30,7 +32,7 @@ def tcp_ping(task, ports, timeout=2, host=None): else: raise ValueError("Invalid value for 'ports'") - host = host or task.host.host + host = host or task.host.hostname result = {} for port in ports: diff --git a/nornir/plugins/tasks/text/template_file.py b/nornir/plugins/tasks/text/template_file.py index 38b70054..38d8d8b7 100644 --- a/nornir/plugins/tasks/text/template_file.py +++ b/nornir/plugins/tasks/text/template_file.py @@ -1,28 +1,37 @@ -from nornir.core.helpers import jinja_helper, merge_two_dicts -from nornir.core.task import Result +from typing import Any, Optional, Dict, Callable +from nornir.core.helpers import jinja_helper +from nornir.core.task import Result, Task -def template_file(task, template, path, jinja_filters=None, **kwargs): +FiltersDict = Optional[Dict[str, Callable[..., str]]] + + +def template_file( + task: Task, + template: str, + path: str, + jinja_filters: FiltersDict = None, + **kwargs: Any +): """ Renders contants of a file with jinja2. All the host data is available in the tempalte Arguments: - template (string): filename - path (string): path to dir with templates - jinja_filters (dict): jinja filters to enable. Defaults to nornir.config.jinja_filters + template: filename + path: path to dir with templates + jinja_filters: jinja filters to enable. Defaults to nornir.config.jinja2.filters **kwargs: additional data to pass to the template Returns: - :obj:`nornir.core.task.Result`: - * result (``string``): rendered string + Result object with the following attributes set: + * result (``string``): rendered string """ - jinja_filters = jinja_filters or {} or task.nornir.config.jinja_filters - merged = merge_two_dicts(task.host, kwargs) + jinja_filters = jinja_filters or {} or task.nornir.config.jinja2.filters text = jinja_helper.render_from_file( template=template, path=path, host=task.host, jinja_filters=jinja_filters, - **merged + **kwargs ) return Result(host=task.host, result=text) diff --git a/nornir/plugins/tasks/text/template_string.py b/nornir/plugins/tasks/text/template_string.py index 6da51ecd..103c82c6 100644 --- a/nornir/plugins/tasks/text/template_string.py +++ b/nornir/plugins/tasks/text/template_string.py @@ -1,23 +1,28 @@ -from nornir.core.helpers import jinja_helper, merge_two_dicts -from nornir.core.task import Result +from typing import Any, Optional, Dict, Callable +from nornir.core.helpers import jinja_helper +from nornir.core.task import Result, Task -def template_string(task, template, jinja_filters=None, **kwargs): +FiltersDict = Optional[Dict[str, Callable[..., str]]] + + +def template_string( + task: Task, template: str, jinja_filters: FiltersDict = None, **kwargs: Any +): """ Renders a string with jinja2. All the host data is available in the tempalte Arguments: template (string): template string - jinja_filters (dict): jinja filters to enable. Defaults to nornir.config.jinja_filters + jinja_filters (dict): jinja filters to enable. Defaults to nornir.config.jinja2.filters **kwargs: additional data to pass to the template Returns: - :obj:`nornir.core.task.Result`: - * result (``string``): rendered string + Result object with the following attributes set: + * result (``string``): rendered string """ - jinja_filters = jinja_filters or {} or task.nornir.config.jinja_filters - merged = merge_two_dicts(task.host, kwargs) + jinja_filters = jinja_filters or {} or task.nornir.config.jinja2.filters text = jinja_helper.render_from_string( - template=template, host=task.host, jinja_filters=jinja_filters, **merged + template=template, host=task.host, jinja_filters=jinja_filters, **kwargs ) return Result(host=task.host, result=text) diff --git a/requirements-dev.txt b/requirements-dev.txt index 69797225..0ebd8918 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,11 @@ decorator +nbval pytest pytest-cov pylama flake8-import-order requests-mock tox -black==18.4a1; python_version >= '3.6' +black==18.6b4 +mypy -r requirements.txt diff --git a/requirements-pinned.txt b/requirements-pinned.txt new file mode 100644 index 00000000..3f07827b --- /dev/null +++ b/requirements-pinned.txt @@ -0,0 +1,113 @@ +alabaster==0.7.12 +appdirs==1.4.3 +appnope==0.1.0 +asn1crypto==0.24.0 +atomicwrites==1.2.1 +attrs==18.2.0 +Babel==2.6.0 +backcall==0.1.0 +bcrypt==3.1.5 +black==18.6b4 +bleach==3.0.2 +certifi==2018.11.29 +cffi==1.11.5 +chardet==3.0.4 +Click==7.0 +colorama==0.4.1 +coverage==4.5.2 +cryptography==2.4.2 +decorator==4.3.0 +defusedxml==0.5.0 +docutils==0.14 +entrypoints==0.2.3 +filelock==3.0.10 +flake8-import-order==0.18 +future==0.17.1 +idna==2.8 +imagesize==1.1.0 +ipykernel==5.1.0 +ipython==7.2.0 +ipython-genutils==0.2.0 +ipywidgets==7.4.2 +jedi==0.13.2 +Jinja2==2.10 +jsonschema==2.6.0 +junos-eznc==2.2.0 +jupyter==1.0.0 +jupyter-client==5.2.4 +jupyter-console==6.0.0 +jupyter-core==4.4.0 +lxml==4.2.5 +MarkupSafe==1.1.0 +mccabe==0.6.1 +mistune==0.8.4 +more-itertools==4.3.0 +mypy==0.650 +mypy-extensions==0.4.1 +napalm==2.3.3 +nbconvert==5.4.0 +nbformat==4.4.0 +nbsphinx==0.4.1 +nbval==0.9.1 +ncclient==0.6.3 +netaddr==0.7.19 +netmiko==2.3.0 +notebook==5.7.3 +packaging==18.0 +pandocfilters==1.4.2 +paramiko==2.4.2 +parso==0.3.1 +pexpect==4.6.0 +pickleshare==0.7.5 +pluggy==0.8.0 +pockets==0.7.2 +prometheus-client==0.5.0 +prompt-toolkit==2.0.7 +ptyprocess==0.6.0 +py==1.7.0 +pyasn1==0.4.4 +pycodestyle==2.4.0 +pycparser==2.19 +pydantic==0.16.1 +pydocstyle==3.0.0 +pyeapi==0.8.2 +pyflakes==2.0.0 +Pygments==2.3.1 +pyIOSXR==0.53 +pylama==7.6.6 +PyNaCl==1.3.0 +pynxos==0.0.3 +pyparsing==2.3.0 +pyserial==3.4 +pytest==4.0.2 +pytest-cov==2.6.0 +python-dateutil==2.7.5 +pytz==2018.7 +PyYAML==3.13 +pyzmq==17.1.2 +qtconsole==4.4.3 +requests==2.21.0 +requests-mock==1.5.2 +ruamel.yaml==0.15.81 +scp==0.13.0 +selectors2==2.0.1 +Send2Trash==1.5.0 +six==1.12.0 +snowballstemmer==1.2.1 +Sphinx==1.8.2 +sphinx-rtd-theme==0.4.2 +sphinxcontrib-napoleon==0.7 +sphinxcontrib-websupport==1.1.0 +terminado==0.8.1 +testpath==0.4.2 +textfsm==0.4.1 +toml==0.10.0 +tornado==5.1.1 +tox==3.6.0 +traitlets==4.3.2 +typed-ast==1.1.0 +urllib3==1.24.1 +virtualenv==16.1.0 +wcwidth==0.1.7 +webencodings==0.5.1 +widgetsnbextension==3.4.2 diff --git a/requirements.txt b/requirements.txt index 12396b0b..4ca5aedd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ colorama -pyyaml jinja2 napalm>=2.3.0 netmiko>=2.1.1 @@ -7,3 +6,5 @@ paramiko future requests ruamel.yaml +mypy_extensions +pydantic diff --git a/setup.cfg b/setup.cfg index 91cd4633..14f458eb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,6 +17,61 @@ skip = .tox/* [pylama:pep8] max_line_length = 100 +[pycodestyle] +ignore = D203,C901 +exclude = .git,__pycache__,build,dist +max-complexity = 10 +max-line-length = 100 + [tool:pytest] -addopts = --cov=nornir --cov-report=term-missing -vs +#addopts = --cov=nornir --cov-report=term-missing -vs python_paths = ./ + +[mypy] +# The mypy configurations: http://bit.ly/2zEl9WI +python_version = 3.6 +warn_redundant_casts = True +warn_unused_configs = True +ignore_missing_imports = True + +[mypy-nornir.core.configuration] +check_untyped_defs = True +disallow_any_generics = True +# Turn on the next flag once the whole codebase is annotated (Phase 2) +disallow_untyped_calls = True +strict_optional = True +warn_unused_ignores = True +ignore_errors = False + +[mypy-nornir.core.connections] +check_untyped_defs = True +disallow_any_generics = True +# Turn on the next flag once the whole codebase is annotated (Phase 2) +# disallow_untyped_calls = True +strict_optional = True +warn_unused_ignores = True +ignore_errors = False + +[mypy-nornir.core.deserializer.*] +check_untyped_defs = True +disallow_any_generics = True +# Turn on the next flag once the whole codebase is annotated (Phase 2) +# disallow_untyped_calls = True +strict_optional = True +warn_unused_ignores = True +ignore_errors = False + +[mypy-nornir.plugins.*] +check_untyped_defs = True +disallow_any_generics = True +# Turn on the next flag once the whole codebase is annotated (Phase 2) +# disallow_untyped_calls = True +strict_optional = True +warn_unused_ignores = True +ignore_errors = False + +[mypy-nornir.*] +ignore_errors = True + +[mypy-tests.*] +ignore_errors = True diff --git a/setup.py b/setup.py index 3cbd2c65..0055495c 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ __author__ = "dbarrosop@dravetech.com" __license__ = "Apache License, version 2" -__version__ = "1.1.0" +__version__ = "2.0.0" setup( name="nornir", @@ -27,11 +27,7 @@ test_suite="tests", platforms="any", classifiers=[ - "Development Status :: 4 - Beta", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", ], ) diff --git a/tests/conftest.py b/tests/conftest.py index 5ba15678..97199233 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,12 +2,15 @@ import os import subprocess -from nornir.core import Nornir -from nornir.plugins.inventory.simple import SimpleInventory +from nornir import InitNornir +from nornir.core.state import GlobalState import pytest +global_data = GlobalState(dry_run=True) + + logging.basicConfig( filename="tests.log", filemode="w", @@ -45,11 +48,21 @@ def nornir(request): """Initializes nornir""" dir_path = os.path.dirname(os.path.realpath(__file__)) - nornir = Nornir( - inventory=SimpleInventory( - "{}/inventory_data/hosts.yaml".format(dir_path), - "{}/inventory_data/groups.yaml".format(dir_path), - ), + nornir = InitNornir( + inventory={ + "options": { + "host_file": "{}/inventory_data/hosts.yaml".format(dir_path), + "group_file": "{}/inventory_data/groups.yaml".format(dir_path), + "defaults_file": "{}/inventory_data/defaults.yaml".format(dir_path), + } + }, dry_run=True, ) + nornir.data = global_data return nornir + + +@pytest.fixture(scope="function", autouse=True) +def reset_data(): + global_data.dry_run = True + global_data.reset_failed_hosts() diff --git a/tests/plugins/functions/text/output_data/basic_inventory_one_task_python27.stderr b/tests/core/deserializer/__init__.py similarity index 100% rename from tests/plugins/functions/text/output_data/basic_inventory_one_task_python27.stderr rename to tests/core/deserializer/__init__.py diff --git a/tests/core/deserializer/my_jinja_filters.py b/tests/core/deserializer/my_jinja_filters.py new file mode 100644 index 00000000..554582ae --- /dev/null +++ b/tests/core/deserializer/my_jinja_filters.py @@ -0,0 +1,10 @@ +def upper(blah: str) -> str: + return blah.upper() + + +def lower(blah: str) -> str: + return blah.lower() + + +def jinja_filters(): + return {"upper": upper, "lower": lower} diff --git a/tests/core/deserializer/test_configuration.py b/tests/core/deserializer/test_configuration.py new file mode 100644 index 00000000..e08dfecd --- /dev/null +++ b/tests/core/deserializer/test_configuration.py @@ -0,0 +1,173 @@ +import logging +import os + +from nornir.core.configuration import Config +from nornir.plugins.inventory.simple import SimpleInventory +from nornir.plugins.inventory.ansible import AnsibleInventory +from nornir.core.deserializer.configuration import Config as ConfigDeserializer + +from tests.core.deserializer import my_jinja_filters + +import pytest + +dir_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "test_configuration" +) + + +DEFAULT_LOG_FORMAT = ( + "%(asctime)s - %(name)12s - %(levelname)8s - %(funcName)10s() - %(message)s" +) + + +class Test(object): + def test_config_defaults(self): + c = ConfigDeserializer() + assert c.dict() == { + "inventory": { + "plugin": "nornir.plugins.inventory.simple.SimpleInventory", + "options": {}, + "transform_function": "", + }, + "ssh": {"config_file": "~/.ssh/config"}, + "logging": { + "level": "debug", + "file": "nornir.log", + "format": DEFAULT_LOG_FORMAT, + "to_console": False, + "loggers": ["nornir"], + }, + "jinja2": {"filters": ""}, + "core": {"num_workers": 20, "raise_on_error": False}, + "user_defined": {}, + } + + def test_config_basic(self): + c = ConfigDeserializer( + core={"num_workers": 30}, + logging={"file": ""}, + user_defined={"my_opt": True}, + ) + assert c.dict() == { + "inventory": { + "plugin": "nornir.plugins.inventory.simple.SimpleInventory", + "options": {}, + "transform_function": "", + }, + "ssh": {"config_file": "~/.ssh/config"}, + "logging": { + "level": "debug", + "file": "", + "format": DEFAULT_LOG_FORMAT, + "to_console": False, + "loggers": ["nornir"], + }, + "jinja2": {"filters": ""}, + "core": {"num_workers": 30, "raise_on_error": False}, + "user_defined": {"my_opt": True}, + } + + def test_deserialize_defaults(self): + c = ConfigDeserializer.deserialize() + assert isinstance(c, Config) + + assert c.core.num_workers == 20 + assert not c.core.raise_on_error + assert c.user_defined == {} + + assert c.logging.level == logging.DEBUG + assert c.logging.file == "nornir.log" + assert c.logging.format == DEFAULT_LOG_FORMAT + assert not c.logging.to_console + assert c.logging.loggers == ["nornir"] + + assert c.ssh.config_file == "~/.ssh/config" + + assert c.inventory.plugin == SimpleInventory + assert c.inventory.options == {} + assert c.inventory.transform_function is None + + def test_deserialize_basic(self): + c = ConfigDeserializer.deserialize( + core={"num_workers": 30}, + user_defined={"my_opt": True}, + logging={"file": "", "level": "info"}, + ssh={"config_file": "~/.ssh/alt_config"}, + inventory={"plugin": "nornir.plugins.inventory.ansible.AnsibleInventory"}, + ) + assert isinstance(c, Config) + + assert c.core.num_workers == 30 + assert not c.core.raise_on_error + assert c.user_defined == {"my_opt": True} + + assert c.logging.level == logging.INFO + assert c.logging.file == "" + assert c.logging.format == DEFAULT_LOG_FORMAT + assert not c.logging.to_console + assert c.logging.loggers == ["nornir"] + + assert c.ssh.config_file == "~/.ssh/alt_config" + + assert c.inventory.plugin == AnsibleInventory + assert c.inventory.options == {} + assert c.inventory.transform_function is None + + def test_jinja_filters(self): + c = ConfigDeserializer.deserialize( + jinja2={"filters": "tests.core.deserializer.my_jinja_filters.jinja_filters"} + ) + assert c.jinja2.filters == my_jinja_filters.jinja_filters() + + def test_jinja_filters_error(self): + with pytest.raises(ModuleNotFoundError): + ConfigDeserializer.deserialize(jinja2={"filters": "asdasd.asdasd"}) + + def test_configuration_file_empty(self): + config = ConfigDeserializer.load_from_file( + os.path.join(dir_path, "empty.yaml"), user_defined={"asd": "qwe"} + ) + assert config.user_defined["asd"] == "qwe" + assert config.core.num_workers == 20 + assert not config.core.raise_on_error + assert config.inventory.plugin == SimpleInventory + + def test_configuration_file_normal(self): + config = ConfigDeserializer.load_from_file( + os.path.join(dir_path, "config.yaml") + ) + assert config.core.num_workers == 10 + assert not config.core.raise_on_error + assert config.inventory.plugin == AnsibleInventory + + def test_configuration_file_override_argument(self): + config = ConfigDeserializer.load_from_file( + os.path.join(dir_path, "config.yaml"), + core={"num_workers": 20, "raise_on_error": True}, + ) + assert config.core.num_workers == 20 + assert config.core.raise_on_error + + def test_configuration_file_override_env(self): + os.environ["NORNIR_CORE_NUM_WORKERS"] = "30" + os.environ["NORNIR_CORE_RAISE_ON_ERROR"] = "1" + os.environ["NORNIR_SSH_CONFIG_FILE"] = "/user/ssh_config" + config = ConfigDeserializer.deserialize() + assert config.core.num_workers == 30 + assert config.core.raise_on_error + assert config.ssh.config_file == "/user/ssh_config" + os.environ.pop("NORNIR_CORE_NUM_WORKERS") + os.environ.pop("NORNIR_CORE_RAISE_ON_ERROR") + os.environ.pop("NORNIR_SSH_CONFIG_FILE") + + def test_configuration_bool_env(self): + os.environ["NORNIR_CORE_RAISE_ON_ERROR"] = "0" + config = ConfigDeserializer.deserialize() + assert config.core.num_workers == 20 + assert not config.core.raise_on_error + + def test_get_user_defined_from_file(self): + config = ConfigDeserializer.load_from_file( + os.path.join(dir_path, "config.yaml") + ) + assert config.user_defined["asd"] == "qwe" diff --git a/tests/core/deserializer/test_configuration/config.yaml b/tests/core/deserializer/test_configuration/config.yaml new file mode 100644 index 00000000..c9a37583 --- /dev/null +++ b/tests/core/deserializer/test_configuration/config.yaml @@ -0,0 +1,8 @@ +--- +core: + num_workers: 10 + raise_on_error: false +inventory: + plugin: nornir.plugins.inventory.ansible.AnsibleInventory +user_defined: + asd: qwe diff --git a/tests/plugins/functions/text/output_data/basic_inventory_python27.stderr b/tests/core/deserializer/test_configuration/empty.yaml similarity index 100% rename from tests/plugins/functions/text/output_data/basic_inventory_python27.stderr rename to tests/core/deserializer/test_configuration/empty.yaml diff --git a/tests/core/test_InitNornir.py b/tests/core/test_InitNornir.py index db0e4c9e..b02d17b2 100644 --- a/tests/core/test_InitNornir.py +++ b/tests/core/test_InitNornir.py @@ -1,94 +1,106 @@ import os -from builtins import super -from nornir.core import InitNornir -from nornir.core.inventory import Inventory +from nornir import InitNornir +from nornir.core.deserializer.inventory import Inventory dir_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "test_InitNornir") def transform_func(host): - host.processed_by_transform_function = True + host["processed_by_transform_function"] = True class StringInventory(Inventory): - - def __init__(self, *args, **kwargs): - hosts = {"host1": {}, "host2": {}} - super().__init__(hosts, *args, **kwargs) + def __init__(self, **kwargs): + inv_dict = {"hosts": {"host1": {}, "host2": {}}, "groups": {}, "defaults": {}} + super().__init__(**inv_dict, **kwargs) class Test(object): - def test_InitNornir_defaults(self): os.chdir("tests/inventory_data/") - try: - nr = InitNornir() - finally: - os.chdir("../../") - assert not nr.dry_run - assert nr.config.num_workers == 20 + nr = InitNornir() + os.chdir("../../") + assert not nr.data.dry_run + assert nr.config.core.num_workers == 20 assert len(nr.inventory.hosts) assert len(nr.inventory.groups) def test_InitNornir_file(self): nr = InitNornir(config_file=os.path.join(dir_path, "a_config.yaml")) - assert not nr.dry_run - assert nr.config.num_workers == 100 + assert not nr.data.dry_run + assert nr.config.core.num_workers == 100 assert len(nr.inventory.hosts) assert len(nr.inventory.groups) def test_InitNornir_programmatically(self): nr = InitNornir( - num_workers=100, - inventory="nornir.plugins.inventory.simple.SimpleInventory", - SimpleInventory={ - "host_file": "tests/inventory_data/hosts.yaml", - "group_file": "tests/inventory_data/groups.yaml", + core={"num_workers": 100}, + inventory={ + "plugin": "nornir.plugins.inventory.simple.SimpleInventory", + "options": { + "host_file": "tests/inventory_data/hosts.yaml", + "group_file": "tests/inventory_data/groups.yaml", + }, }, ) - assert not nr.dry_run - assert nr.config.num_workers == 100 + assert not nr.data.dry_run + assert nr.config.core.num_workers == 100 assert len(nr.inventory.hosts) assert len(nr.inventory.groups) def test_InitNornir_combined(self): nr = InitNornir( - config_file=os.path.join(dir_path, "a_config.yaml"), num_workers=200 + config_file=os.path.join(dir_path, "a_config.yaml"), + core={"num_workers": 200}, ) - assert not nr.dry_run - assert nr.config.num_workers == 200 + assert not nr.data.dry_run + assert nr.config.core.num_workers == 200 assert len(nr.inventory.hosts) assert len(nr.inventory.groups) def test_InitNornir_different_inventory_by_string(self): nr = InitNornir( config_file=os.path.join(dir_path, "a_config.yaml"), - inventory="tests.core.test_InitNornir.StringInventory", + inventory={"plugin": "tests.core.test_InitNornir.StringInventory"}, ) - assert isinstance(nr.inventory, StringInventory) + assert "host1" in nr.inventory.hosts def test_InitNornir_different_inventory_imported(self): nr = InitNornir( config_file=os.path.join(dir_path, "a_config.yaml"), - inventory=StringInventory, + inventory={"plugin": StringInventory}, ) - assert isinstance(nr.inventory, StringInventory) + assert "host1" in nr.inventory.hosts def test_InitNornir_different_transform_function_by_string(self): nr = InitNornir( config_file=os.path.join(dir_path, "a_config.yaml"), - transform_function="tests.core.test_InitNornir.transform_func", + inventory={ + "plugin": "nornir.plugins.inventory.simple.SimpleInventory", + "transform_function": "tests.core.test_InitNornir.transform_func", + "options": { + "host_file": "tests/inventory_data/hosts.yaml", + "group_file": "tests/inventory_data/groups.yaml", + }, + }, ) - for value in nr.inventory.hosts.values(): - assert value.processed_by_transform_function + for host in nr.inventory.hosts.values(): + assert host["processed_by_transform_function"] def test_InitNornir_different_transform_function_imported(self): nr = InitNornir( config_file=os.path.join(dir_path, "a_config.yaml"), - transform_function=transform_func, + inventory={ + "plugin": "nornir.plugins.inventory.simple.SimpleInventory", + "transform_function": transform_func, + "options": { + "host_file": "tests/inventory_data/hosts.yaml", + "group_file": "tests/inventory_data/groups.yaml", + }, + }, ) - for value in nr.inventory.hosts.values(): - assert value.processed_by_transform_function + for host in nr.inventory.hosts.values(): + assert host["processed_by_transform_function"] diff --git a/tests/core/test_InitNornir/a_config.yaml b/tests/core/test_InitNornir/a_config.yaml index a0fddd4f..ccf8712b 100644 --- a/tests/core/test_InitNornir/a_config.yaml +++ b/tests/core/test_InitNornir/a_config.yaml @@ -1,6 +1,8 @@ --- -num_workers: 100 -inventory: nornir.plugins.inventory.simple.SimpleInventory -SimpleInventory: - host_file: "tests/inventory_data/hosts.yaml" - group_file: "tests/inventory_data/groups.yaml" +core: + num_workers: 100 +inventory: + plugin: nornir.plugins.inventory.simple.SimpleInventory + options: + host_file: "tests/inventory_data/hosts.yaml" + group_file: "tests/inventory_data/groups.yaml" diff --git a/tests/core/test_configuration.py b/tests/core/test_configuration.py deleted file mode 100644 index 1bc27399..00000000 --- a/tests/core/test_configuration.py +++ /dev/null @@ -1,97 +0,0 @@ -import os - -from nornir.core.configuration import Config - -# import pytest - - -dir_path = os.path.join( - os.path.dirname(os.path.realpath(__file__)), "test_configuration" -) - - -class Test(object): - - def test_configuration_empty(self): - config = Config( - config_file=os.path.join(dir_path, "empty.yaml"), - arg1=1, - arg2=False, - arg3=None, - arg4="asd", - ) - assert config.num_workers == 20 - assert not config.raise_on_error - assert config.arg1 == 1 - assert config.arg2 is False - assert config.arg3 is None - assert config.arg4 == "asd" - - def test_configuration_normal(self): - config = Config( - config_file=os.path.join(dir_path, "config.yaml"), - arg1=1, - arg2=False, - arg3=None, - arg4="asd", - ) - assert config.num_workers == 10 - assert not config.raise_on_error - assert config.arg1 == 1 - assert config.arg2 is False - assert config.arg3 is None - assert config.arg4 == "asd" - - def test_configuration_normal_override_argument(self): - config = Config( - config_file=os.path.join(dir_path, "config.yaml"), - num_workers=20, - raise_on_error=True, - ) - assert config.num_workers == 20 - assert config.raise_on_error - - def test_configuration_normal_override_env(self): - os.environ["BRIGADE_NUM_WORKERS"] = "30" - os.environ["BRIGADE_RAISE_ON_ERROR"] = "1" - config = Config(config_file=os.path.join(dir_path, "config.yaml")) - assert config.num_workers == 30 - assert config.raise_on_error - os.environ.pop("BRIGADE_NUM_WORKERS") - os.environ.pop("BRIGADE_RAISE_ON_ERROR") - - def test_configuration_bool_env(self): - os.environ["BRIGADE_RAISE_ON_ERROR"] = "0" - config = Config() - assert config.num_workers == 20 - assert not config.raise_on_error - - def test_get_user_defined_from_file(self): - config = Config(config_file=os.path.join(dir_path, "config.yaml")) - assert ( - config.get("user_defined", env="USER_DEFINED", default="qweqwe") == "asdasd" - ) - - def test_get_user_defined_from_default(self): - config = Config() - assert ( - config.get("user_defined", env="USER_DEFINED", default="qweqwe") == "qweqwe" - ) - - def test_get_user_defined_from_env(self): - os.environ["USER_DEFINED"] = "zxczxc" - config = Config(config_file=os.path.join(dir_path, "config.yaml")) - assert ( - config.get("user_defined", env="USER_DEFINED", default="qweqwe") == "zxczxc" - ) - os.environ.pop("USER_DEFINED") - - def test_get_user_defined_from_env_bool(self): - os.environ["USER_DEFINED"] = "0" - config = Config() - assert not config.get("user_defined", env="USER_DEFINED", parameter_type="bool") - os.environ.pop("USER_DEFINED") - - def test_get_user_defined_nested(self): - config = Config(config_file=os.path.join(dir_path, "config.yaml")) - assert config.get("user_defined", root="my_root") == "i am nested" diff --git a/tests/core/test_configuration/config.yaml b/tests/core/test_configuration/config.yaml deleted file mode 100644 index 164c4f87..00000000 --- a/tests/core/test_configuration/config.yaml +++ /dev/null @@ -1,6 +0,0 @@ ---- -num_workers: 10 -raise_on_error: no -user_defined: "asdasd" -my_root: - user_defined: "i am nested" diff --git a/tests/core/test_connections.py b/tests/core/test_connections.py new file mode 100644 index 00000000..4674ce10 --- /dev/null +++ b/tests/core/test_connections.py @@ -0,0 +1,207 @@ +from typing import Any, Dict, Optional + +from nornir.core.configuration import Config +from nornir.core.connections import ConnectionPlugin, Connections +from nornir.core.exceptions import ( + ConnectionAlreadyOpen, + ConnectionNotOpen, + ConnectionPluginAlreadyRegistered, + ConnectionPluginNotRegistered, +) +from nornir.init_nornir import register_default_connection_plugins + +import pytest + + +class DummyConnectionPlugin(ConnectionPlugin): + def open( + self, + hostname: Optional[str], + username: Optional[str], + password: Optional[str], + port: Optional[int], + platform: Optional[str], + extras: Optional[Dict[str, Any]] = None, + configuration: Optional[Config] = None, + ) -> None: + self.connection = True + self.state["something"] = "something" + self.hostname = hostname + self.username = username + self.password = password + self.port = port + self.platform = platform + self.extras = extras + self.configuration = configuration + + def close(self) -> None: + self.connection = False + + +class AnotherDummyConnectionPlugin(DummyConnectionPlugin): + pass + + +def open_and_close_connection(task): + task.host.open_connection("dummy", task.nornir.config) + assert "dummy" in task.host.connections + task.host.close_connection("dummy") + assert "dummy" not in task.host.connections + + +def open_connection_twice(task): + task.host.open_connection("dummy", task.nornir.config) + assert "dummy" in task.host.connections + try: + task.host.open_connection("dummy", task.nornir.config) + raise Exception("I shouldn't make it here") + except ConnectionAlreadyOpen: + task.host.close_connection("dummy") + assert "dummy" not in task.host.connections + + +def close_not_opened_connection(task): + assert "dummy" not in task.host.connections + try: + task.host.close_connection("dummy") + raise Exception("I shouldn't make it here") + except ConnectionNotOpen: + assert "dummy" not in task.host.connections + + +def a_task(task): + task.host.get_connection("dummy", task.nornir.config) + + +def validate_params(task, conn, params): + task.host.get_connection(conn, task.nornir.config) + for k, v in params.items(): + assert getattr(task.host.connections[conn], k) == v + + +class Test(object): + @classmethod + def setup_class(cls): + Connections.deregister_all() + Connections.register("dummy", DummyConnectionPlugin) + Connections.register("dummy2", DummyConnectionPlugin) + Connections.register("dummy_no_overrides", DummyConnectionPlugin) + + def test_open_and_close_connection(self, nornir): + nr = nornir.filter(name="dev2.group_1") + r = nr.run(task=open_and_close_connection, num_workers=1) + assert len(r) == 1 + assert not r.failed + + def test_open_connection_twice(self, nornir): + nr = nornir.filter(name="dev2.group_1") + r = nr.run(task=open_connection_twice, num_workers=1) + assert len(r) == 1 + assert not r.failed + + def test_close_not_opened_connection(self, nornir): + nr = nornir.filter(name="dev2.group_1") + r = nr.run(task=close_not_opened_connection, num_workers=1) + assert len(r) == 1 + assert not r.failed + + def test_context_manager(self, nornir): + with nornir.filter(name="dev2.group_1") as nr: + nr.run(task=a_task) + assert "dummy" in nr.inventory.hosts["dev2.group_1"].connections + assert "dummy" not in nr.inventory.hosts["dev2.group_1"].connections + + def test_validate_params_simple(self, nornir): + params = { + "hostname": "127.0.0.1", + "username": "root", + "password": "from_group1", + "port": 65002, + "platform": "junos", + "extras": {}, + } + nr = nornir.filter(name="dev2.group_1") + r = nr.run( + task=validate_params, + conn="dummy_no_overrides", + params=params, + num_workers=1, + ) + assert len(r) == 1 + assert not r.failed + + def test_validate_params_overrides(self, nornir): + params = { + "port": 65002, + "hostname": "dummy_from_parent_group", + "username": "root", + "password": "from_group1", + "platform": "junos", + "extras": {"blah": "from_group"}, + } + nr = nornir.filter(name="dev2.group_1") + r = nr.run(task=validate_params, conn="dummy", params=params, num_workers=1) + assert len(r) == 1 + assert not r.failed + + def test_validate_params_overrides_groups(self, nornir): + params = { + "port": 65002, + "hostname": "dummy2_from_parent_group", + "username": "dummy2_from_host", + "password": "from_group1", + "platform": "junos", + "extras": {"blah": "from_group"}, + } + nr = nornir.filter(name="dev2.group_1") + r = nr.run(task=validate_params, conn="dummy2", params=params, num_workers=1) + assert len(r) == 1 + assert not r.failed + + +class TestConnectionPluginsRegistration(object): + def setup_method(self, method): + Connections.deregister_all() + Connections.register("dummy", DummyConnectionPlugin) + Connections.register("another_dummy", AnotherDummyConnectionPlugin) + + def teardown_method(self, method): + Connections.deregister_all() + register_default_connection_plugins() + + def test_count(self): + assert len(Connections.available) == 2 + + def test_register_new(self): + Connections.register("new_dummy", DummyConnectionPlugin) + assert "new_dummy" in Connections.available + + def test_register_already_registered_same(self): + Connections.register("dummy", DummyConnectionPlugin) + assert Connections.available["dummy"] == DummyConnectionPlugin + + def test_register_already_registered_new(self): + with pytest.raises(ConnectionPluginAlreadyRegistered): + Connections.register("dummy", AnotherDummyConnectionPlugin) + + def test_deregister_existing(self): + Connections.deregister("dummy") + assert len(Connections.available) == 1 + assert "dummy" not in Connections.available + + def test_deregister_nonexistent(self): + with pytest.raises(ConnectionPluginNotRegistered): + Connections.deregister("nonexistent_dummy") + + def test_deregister_all(self): + Connections.deregister_all() + assert Connections.available == {} + + def test_get_plugin(self): + assert Connections.get_plugin("dummy") == DummyConnectionPlugin + assert Connections.get_plugin("another_dummy") == AnotherDummyConnectionPlugin + assert len(Connections.available) == 2 + + def test_nonexistent_plugin(self): + with pytest.raises(ConnectionPluginNotRegistered): + Connections.get_plugin("nonexistent_dummy") diff --git a/tests/core/test_filter.py b/tests/core/test_filter.py new file mode 100644 index 00000000..946a697a --- /dev/null +++ b/tests/core/test_filter.py @@ -0,0 +1,138 @@ +from nornir.core.filter import F + + +class Test(object): + def test_simple(self, nornir): + f = F(site="site1") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1", "dev2.group_1"] + + def test_and(self, nornir): + f = F(site="site1") & F(role="www") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1"] + + def test_or(self, nornir): + f = F(site="site1") | F(role="www") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1", "dev2.group_1", "dev3.group_2"] + + def test_combined(self, nornir): + f = F(site="site2") | (F(role="www") & F(my_var="comes_from_dev1.group_1")) + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1", "dev3.group_2", "dev4.group_2"] + + f = (F(site="site2") | F(role="www")) & F(my_var="comes_from_dev1.group_1") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1"] + + def test_contains(self, nornir): + f = F(groups__contains="group_1") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1", "dev2.group_1"] + + def test_negate(self, nornir): + f = ~F(groups__contains="group_1") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev3.group_2", "dev4.group_2", "dev5.no_group"] + + def test_negate_and_second_negate(self, nornir): + f = F(site="site1") & ~F(role="www") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev2.group_1"] + + def test_negate_or_both_negate(self, nornir): + f = ~F(site="site1") | ~F(role="www") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == [ + "dev2.group_1", + "dev3.group_2", + "dev4.group_2", + "dev5.no_group", + ] + + def test_nested_data_a_string(self, nornir): + f = F(nested_data__a_string="asdasd") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1"] + + def test_nested_data_a_string_contains(self, nornir): + f = F(nested_data__a_string__contains="asd") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1"] + + def test_nested_data_a_dict_contains(self, nornir): + f = F(nested_data__a_dict__contains="a") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1"] + + def test_nested_data_a_dict_element(self, nornir): + f = F(nested_data__a_dict__a=1) + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1"] + + def test_nested_data_a_dict_doesnt_contain(self, nornir): + f = ~F(nested_data__a_dict__contains="a") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == [ + "dev2.group_1", + "dev3.group_2", + "dev4.group_2", + "dev5.no_group", + ] + + def test_nested_data_a_list_contains(self, nornir): + f = F(nested_data__a_list__contains=2) + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1", "dev2.group_1"] + + def test_filtering_by_callable_has_parent_group(self, nornir): + f = F(has_parent_group="parent_group") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1", "dev2.group_1", "dev4.group_2"] + + def test_filtering_by_attribute_name(self, nornir): + f = F(name="dev1.group_1") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1"] + + def test_filtering_string_in_list(self, nornir): + f = F(platform__in=["linux", "mock"]) + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev3.group_2", "dev4.group_2", "dev5.no_group"] + + def test_filtering_list_any(self, nornir): + f = F(nested_data__a_list__any=[1, 3]) + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1", "dev2.group_1"] + + def test_filtering_list_all(self, nornir): + f = F(nested_data__a_list__all=[1, 2]) + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == ["dev1.group_1"] + + def test_filter_wrong_attribute_for_type(self, nornir): + f = F(port__startswith="a") + filtered = sorted(list((nornir.inventory.filter(f).hosts.keys()))) + + assert filtered == [] diff --git a/tests/core/test_inventory.py b/tests/core/test_inventory.py index 055b4a7e..56a90b0a 100644 --- a/tests/core/test_inventory.py +++ b/tests/core/test_inventory.py @@ -1,170 +1,104 @@ import os -from nornir.core.inventory import Group, Host -from nornir.plugins.inventory.simple import SimpleInventory +from nornir.core import inventory +from nornir.core.deserializer import inventory as deserializer -import pytest - - -dir_path = os.path.dirname(os.path.realpath(__file__)) -inventory = SimpleInventory( - "{}/../inventory_data/hosts.yaml".format(dir_path), - "{}/../inventory_data/groups.yaml".format(dir_path), -) +from pydantic import ValidationError +import pytest -def compare_lists(left, right): - result = len(left) == len(right) - if not result: - return result - - def to_sets(l): - if isinstance(l, str): - return l +import ruamel.yaml - r = set() - for x in l: - if isinstance(x, list) or isinstance(x, tuple): - x = frozenset(to_sets(x)) - r.add(x) - return r - left = to_sets(left) - right = to_sets(right) - return left == right +yaml = ruamel.yaml.YAML(typ="safe") +dir_path = os.path.dirname(os.path.realpath(__file__)) +with open(f"{dir_path}/../inventory_data/hosts.yaml") as f: + hosts = yaml.load(f) +with open(f"{dir_path}/../inventory_data/groups.yaml") as f: + groups = yaml.load(f) +with open(f"{dir_path}/../inventory_data/defaults.yaml") as f: + defaults = yaml.load(f) +inv_dict = {"hosts": hosts, "groups": groups, "defaults": defaults} class Test(object): + def test_host(self): + h = inventory.Host(name="host1", hostname="host1") + assert h.hostname == "host1" + assert h.port is None + assert h.username is None + assert h.password is None + assert h.platform is None + assert h.data == {} + + data = {"asn": 65100, "router_id": "1.1.1.1"} + h = inventory.Host( + name="host2", + hostname="host2", + username="user", + port=123, + password="", + platform="fake", + data=data, + ) + assert h.hostname == "host2" + assert h.port == 123 + assert h.username == "user" + assert h.password == "" + assert h.platform == "fake" + assert h.data == data + + def test_inventory(self): + g1 = inventory.Group(name="g1") + g2 = inventory.Group(name="g2", groups=inventory.ParentGroups(["g1"])) + h1 = inventory.Host(name="h1", groups=inventory.ParentGroups(["g1", "g2"])) + h2 = inventory.Host(name="h2") + hosts = {"h1": h1, "h2": h2} + groups = {"g1": g1, "g2": g2} + inv = inventory.Inventory(hosts=hosts, groups=groups) + assert "h1" in inv.hosts + assert "h2" in inv.hosts + assert "g1" in inv.groups + assert "g2" in inv.groups + assert inv.groups["g1"] in inv.hosts["h1"].groups + assert inv.groups["g1"] in inv.groups["g2"].groups + + def test_inventory_deserializer_wrong(self): + with pytest.raises(ValidationError): + deserializer.Inventory.deserialize( + **{"hosts": {"wrong": {"host": "should_be_hostname"}}} + ) - def test_hosts(self): - defaults = {"var4": "ALL"} - g1 = Group(name="g1", var1="1", var2="2", var3="3") - g2 = Group(name="g2", var1="a", var2="b") - g3 = Group(name="g3", var1="A", var4="Z") - g4 = Group(name="g4", groups=[g2, g1], var3="q") - - h1 = Host(name="host1", groups=[g1, g2], defaults=defaults) - assert h1["var1"] == "1" - assert h1["var2"] == "2" - assert h1["var3"] == "3" - assert h1["var4"] == "ALL" - assert compare_lists( - list(h1.keys()), ["name", "groups", "var1", "var2", "var3", "var4"] - ) - assert compare_lists( - list(h1.values()), ["host1", ["g1", "g2"], "1", "2", "3", "ALL"] - ) - assert compare_lists( - list(h1.items()), - [ - ("name", "host1"), - ("groups", ["g1", "g2"]), - ("var1", "1"), - ("var2", "2"), - ("var3", "3"), - ("var4", "ALL"), - ], - ) - - h2 = Host(name="host2", groups=[g2, g1], defaults=defaults) - assert h2["var1"] == "a" - assert h2["var2"] == "b" - assert h2["var3"] == "3" - assert h2["var4"] == "ALL" - assert compare_lists( - list(h2.keys()), ["name", "groups", "var1", "var2", "var3", "var4"] - ) - assert compare_lists( - list(h2.values()), ["host2", ["g2", "g1"], "a", "b", "3", "ALL"] - ) - assert compare_lists( - list(h2.items()), - [ - ("name", "host2"), - ("groups", ["g2", "g1"]), - ("var1", "a"), - ("var2", "b"), - ("var3", "3"), - ("var4", "ALL"), - ], - ) - - h3 = Host(name="host3", groups=[g4, g3], defaults=defaults) - assert h3["var1"] == "a" - assert h3["var2"] == "b" - assert h3["var3"] == "q" - assert h3["var4"] == "Z" - assert compare_lists( - list(h3.keys()), ["name", "groups", "var3", "var1", "var2", "var4"] - ) - assert compare_lists( - list(h3.values()), ["host3", ["g4", "g3"], "q", "a", "b", "Z"] - ) - assert compare_lists( - list(h3.items()), - [ - ("name", "host3"), - ("groups", ["g4", "g3"]), - ("var3", "q"), - ("var1", "a"), - ("var2", "b"), - ("var4", "Z"), - ], - ) - - h4 = Host(name="host4", groups=[g3, g4], defaults=defaults) - assert h4["var1"] == "A" - assert h4["var2"] == "b" - assert h4["var3"] == "q" - assert h4["var4"] == "Z" - assert compare_lists( - list(h4.keys()), ["name", "groups", "var1", "var4", "var3", "var2"] - ) - assert compare_lists( - list(h4.values()), ["host4", ["g3", "g4"], "A", "Z", "q", "b"] - ) - assert compare_lists( - list(h4.items()), - [ - ("name", "host4"), - ("groups", ["g3", "g4"]), - ("var1", "A"), - ("var4", "Z"), - ("var3", "q"), - ("var2", "b"), - ], - ) - - with pytest.raises(KeyError): - assert h2["asdasd"] + def test_inventory_deserializer(self): + inv = deserializer.Inventory.deserialize(**inv_dict) + assert inv.groups["group_1"] in inv.hosts["dev1.group_1"].groups def test_filtering(self): - unfiltered = sorted(list(inventory.hosts.keys())) - assert ( - unfiltered - == ["dev1.group_1", "dev2.group_1", "dev3.group_2", "dev4.group_2"] - ) - - www = sorted(list(inventory.filter(role="www").hosts.keys())) + inv = deserializer.Inventory.deserialize(**inv_dict) + unfiltered = sorted(list(inv.hosts.keys())) + assert unfiltered == [ + "dev1.group_1", + "dev2.group_1", + "dev3.group_2", + "dev4.group_2", + "dev5.no_group", + ] + + www = sorted(list(inv.filter(role="www").hosts.keys())) assert www == ["dev1.group_1", "dev3.group_2"] - www_site1 = sorted( - list(inventory.filter(role="www", site="site1").hosts.keys()) - ) + www_site1 = sorted(list(inv.filter(role="www", site="site1").hosts.keys())) assert www_site1 == ["dev1.group_1"] www_site1 = sorted( - list(inventory.filter(role="www").filter(site="site1").hosts.keys()) + list(inv.filter(role="www").filter(site="site1").hosts.keys()) ) assert www_site1 == ["dev1.group_1"] def test_filtering_func(self): + inv = deserializer.Inventory.deserialize(**inv_dict) long_names = sorted( - list( - inventory.filter( - filter_func=lambda x: len(x["my_var"]) > 20 - ).hosts.keys() - ) + list(inv.filter(filter_func=lambda x: len(x["my_var"]) > 20).hosts.keys()) ) assert long_names == ["dev1.group_1", "dev4.group_2"] @@ -172,68 +106,133 @@ def longer_than(dev, length): return len(dev["my_var"]) > length long_names = sorted( - list(inventory.filter(filter_func=longer_than, length=20).hosts.keys()) + list(inv.filter(filter_func=longer_than, length=20).hosts.keys()) ) assert long_names == ["dev1.group_1", "dev4.group_2"] def test_filter_unique_keys(self): - filtered = sorted(list(inventory.filter(www_server="nginx").hosts.keys())) + inv = deserializer.Inventory.deserialize(**inv_dict) + filtered = sorted(list(inv.filter(www_server="nginx").hosts.keys())) assert filtered == ["dev1.group_1"] def test_var_resolution(self): - assert inventory.hosts["dev1.group_1"]["my_var"] == "comes_from_dev1.group_1" - assert inventory.hosts["dev2.group_1"]["my_var"] == "comes_from_group_1" - assert inventory.hosts["dev3.group_2"]["my_var"] == "comes_from_defaults" - assert inventory.hosts["dev4.group_2"]["my_var"] == "comes_from_dev4.group_2" - - assert ( - inventory.hosts["dev1.group_1"].data["my_var"] == "comes_from_dev1.group_1" - ) + inv = deserializer.Inventory.deserialize(**inv_dict) + assert inv.hosts["dev1.group_1"]["my_var"] == "comes_from_dev1.group_1" + assert inv.hosts["dev2.group_1"]["my_var"] == "comes_from_group_1" + assert inv.hosts["dev3.group_2"]["my_var"] == "comes_from_defaults" + assert inv.hosts["dev4.group_2"]["my_var"] == "comes_from_dev4.group_2" + assert inv.hosts["dev1.group_1"]["a_false_var"] is False + + assert inv.hosts["dev1.group_1"].data["my_var"] == "comes_from_dev1.group_1" with pytest.raises(KeyError): - inventory.hosts["dev2.group_1"].data["my_var"] + inv.hosts["dev2.group_1"].data["my_var"] with pytest.raises(KeyError): - inventory.hosts["dev3.group_2"].data["my_var"] - assert ( - inventory.hosts["dev4.group_2"].data["my_var"] == "comes_from_dev4.group_2" - ) + inv.hosts["dev3.group_2"].data["my_var"] + assert inv.hosts["dev4.group_2"].data["my_var"] == "comes_from_dev4.group_2" + + def test_attributes_resolution(self): + inv = deserializer.Inventory.deserialize(**inv_dict) + assert inv.hosts["dev1.group_1"].password == "a_password" + assert inv.hosts["dev2.group_1"].password == "from_group1" + assert inv.hosts["dev3.group_2"].password == "docker" + assert inv.hosts["dev4.group_2"].password == "from_parent_group" + assert inv.hosts["dev5.no_group"].password == "docker" def test_has_parents(self): - assert inventory.hosts["dev1.group_1"].has_parent_group( - inventory.groups["group_1"] - ) - assert not inventory.hosts["dev1.group_1"].has_parent_group( - inventory.groups["group_2"] - ) + inv = deserializer.Inventory.deserialize(**inv_dict) + assert inv.hosts["dev1.group_1"].has_parent_group(inv.groups["group_1"]) + assert not inv.hosts["dev1.group_1"].has_parent_group(inv.groups["group_2"]) + assert inv.hosts["dev1.group_1"].has_parent_group("group_1") + assert not inv.hosts["dev1.group_1"].has_parent_group("group_2") def test_to_dict(self): - expected = { - "hosts": { - "dev1.group_1": { - "name": "dev1.group_1", - "groups": ["group_1"], - "my_var": "comes_from_dev1.group_1", - "www_server": "nginx", - "role": "www", - "nornir_ssh_port": 65001, - "nornir_nos": "eos", - }, - "dev3.group_2": { - "name": "dev3.group_2", - "groups": ["group_2"], - "www_server": "apache", - "role": "www", - "nornir_ssh_port": 65003, - "nornir_network_api_port": 12443, - "nornir_os": "linux", - "nornir_nos": "mock", - }, - }, - "groups": { - "defaults": {}, - "group_1": { - "name": "group_1", "my_var": "comes_from_group_1", "site": "site1" - }, - "group_2": {"name": "group_2", "site": "site2"}, - }, + inv = deserializer.Inventory.deserialize(**inv_dict) + inv_serialized = deserializer.Inventory.serialize(inv).dict() + for k, v in inv_dict.items(): + assert v == inv_serialized[k] + + def test_get_connection_parameters(self): + inv = deserializer.Inventory.deserialize(**inv_dict) + p1 = inv.hosts["dev1.group_1"].get_connection_parameters("dummy") + assert p1.dict() == { + "port": 65001, + "hostname": "dummy_from_host", + "username": "root", + "password": "a_password", + "platform": "eos", + "extras": {"blah": "from_host"}, + } + p2 = inv.hosts["dev1.group_1"].get_connection_parameters("asd") + assert p2.dict() == { + "port": 65001, + "hostname": "127.0.0.1", + "username": "root", + "password": "a_password", + "platform": "eos", + "extras": {}, + } + p3 = inv.hosts["dev2.group_1"].get_connection_parameters("dummy") + assert p3.dict() == { + "port": 65002, + "hostname": "dummy_from_parent_group", + "username": "root", + "password": "from_group1", + "platform": "junos", + "extras": {"blah": "from_group"}, + } + p4 = inv.hosts["dev3.group_2"].get_connection_parameters("dummy") + assert p4.dict() == { + "port": 65003, + "hostname": "dummy_from_defaults", + "username": "root", + "password": "docker", + "platform": "linux", + "extras": {"blah": "from_defaults"}, + } + + def test_defaults(self): + inv = deserializer.Inventory.deserialize(**inv_dict) + inv.defaults.password = "asd" + assert inv.defaults.password == "asd" + assert inv.hosts["dev2.group_1"].password == "from_group1" + assert inv.hosts["dev3.group_2"].password == "asd" + assert inv.hosts["dev4.group_2"].password == "from_parent_group" + assert inv.hosts["dev5.no_group"].password == "asd" + + def test_children_of_str(self): + inv = deserializer.Inventory.deserialize(**inv_dict) + assert inv.children_of_group("parent_group") == { + inv.hosts["dev1.group_1"], + inv.hosts["dev2.group_1"], + inv.hosts["dev4.group_2"], + } + + assert inv.children_of_group("group_1") == { + inv.hosts["dev1.group_1"], + inv.hosts["dev2.group_1"], + } + + assert inv.children_of_group("group_2") == { + inv.hosts["dev4.group_2"], + inv.hosts["dev3.group_2"], + } + + assert inv.children_of_group("blah") == set() + + def test_children_of_obj(self): + inv = deserializer.Inventory.deserialize(**inv_dict) + assert inv.children_of_group(inv.groups["parent_group"]) == { + inv.hosts["dev1.group_1"], + inv.hosts["dev2.group_1"], + inv.hosts["dev4.group_2"], + } + + assert inv.children_of_group(inv.groups["group_1"]) == { + inv.hosts["dev1.group_1"], + inv.hosts["dev2.group_1"], + } + + assert inv.children_of_group(inv.groups["group_2"]) == { + inv.hosts["dev4.group_2"], + inv.hosts["dev3.group_2"], } - assert inventory.filter(role="www").to_dict() == expected diff --git a/tests/core/test_multithreading.py b/tests/core/test_multithreading.py index ad0915dc..f48de57b 100644 --- a/tests/core/test_multithreading.py +++ b/tests/core/test_multithreading.py @@ -31,7 +31,6 @@ def verify_data_change(task): class Test(object): - def test_blocking_task_single_thread(self, nornir): t1 = datetime.datetime.now() nornir.run(blocking_task, wait=0.5, num_workers=1) @@ -54,7 +53,6 @@ def test_failing_task_simple_singlethread(self, nornir): assert isinstance(k, str), k assert isinstance(v.exception, Exception), v assert processed - nornir.data.reset_failed_hosts() def test_failing_task_simple_multithread(self, nornir): result = nornir.run(failing_task_simple, num_workers=NUM_WORKERS) @@ -64,7 +62,6 @@ def test_failing_task_simple_multithread(self, nornir): assert isinstance(k, str), k assert isinstance(v.exception, Exception), v assert processed - nornir.data.reset_failed_hosts() def test_failing_task_complex_singlethread(self, nornir): result = nornir.run(failing_task_complex, num_workers=1) @@ -74,7 +71,6 @@ def test_failing_task_complex_singlethread(self, nornir): assert isinstance(k, str), k assert isinstance(v.exception, CommandError), v assert processed - nornir.data.reset_failed_hosts() def test_failing_task_complex_multithread(self, nornir): result = nornir.run(failing_task_complex, num_workers=NUM_WORKERS) @@ -84,7 +80,6 @@ def test_failing_task_complex_multithread(self, nornir): assert isinstance(k, str), k assert isinstance(v.exception, CommandError), v assert processed - nornir.data.reset_failed_hosts() def test_failing_task_complex_multithread_raise_on_error(self, nornir): with pytest.raises(NornirExecutionError) as e: diff --git a/tests/core/test_tasks.py b/tests/core/test_tasks.py index bf4001de..f9de585f 100644 --- a/tests/core/test_tasks.py +++ b/tests/core/test_tasks.py @@ -3,6 +3,10 @@ from nornir.plugins.tasks import commands +def a_task_to_test_dry_run(task, expected_dry_run_value, dry_run=None): + assert task.is_dry_run(dry_run) is expected_dry_run_value + + def task_fails_for_some(task): if task.host.name == "dev3.group_2": # let's hardcode a failure @@ -20,7 +24,6 @@ def sub_task(task): class Test(object): - def test_task(self, nornir): result = nornir.run(commands.command, command="echo hi") assert result @@ -51,8 +54,6 @@ def test_skip_failed_host(self, nornir): assert not result.failed assert "dev3.group_2" not in result - nornir.data.reset_failed_hosts() - def test_run_on(self, nornir): result = nornir.run(task_fails_for_some) assert result.failed @@ -74,8 +75,6 @@ def test_run_on(self, nornir): assert "dev3.group_2" not in result assert "dev1.group_1" in result - nornir.data.reset_failed_hosts() - def test_severity(self, nornir): r = nornir.run(commands.command, command="echo blah") for host, result in r.items(): @@ -100,4 +99,20 @@ def test_severity(self, nornir): assert result[0].severity_level == logging.WARN assert result[1].severity_level == logging.DEBUG - nornir.data.reset_failed_hosts() + def test_dry_run(self, nornir): + host = nornir.filter(name="dev3.group_2") + r = host.run(a_task_to_test_dry_run, expected_dry_run_value=True) + assert not r["dev3.group_2"].failed + + r = host.run( + a_task_to_test_dry_run, dry_run=False, expected_dry_run_value=False + ) + assert not r["dev3.group_2"].failed + + nornir.data.dry_run = False + r = host.run(a_task_to_test_dry_run, expected_dry_run_value=False) + assert not r["dev3.group_2"].failed + + nornir.data.dry_run = True + r = host.run(a_task_to_test_dry_run, expected_dry_run_value=False) + assert r["dev3.group_2"].failed diff --git a/tests/inventory_data/containers.sh b/tests/inventory_data/containers.sh index 0498be87..f4fad50f 100755 --- a/tests/inventory_data/containers.sh +++ b/tests/inventory_data/containers.sh @@ -1,12 +1,12 @@ #!/bin/bash start () { - docker run -d -p 65001:22 --name dev1.group_1 --hostname=dev1.group_1 dbarroso/stupid_ssh_container - docker run -d -p 65002:22 --name dev2.group_1 --hostname=dev2.group_1 dbarroso/stupid_ssh_container - docker run -d -p 65003:22 --name dev3.group_2 --hostname=dev3.group_2 dbarroso/stupid_ssh_container - docker run -d -p 65004:22 --name dev4.group_2 --hostname=dev4.group_2 dbarroso/stupid_ssh_container - docker run -d -p 65080:80 --name httpbin bungoume/httpbin-container - sleep 3 + docker run -d -p 65001:22 --rm --name dev1.group_1 --hostname=dev1.group_1 dbarroso/stupid_ssh_container + docker run -d -p 65002:22 --rm --name dev2.group_1 --hostname=dev2.group_1 dbarroso/stupid_ssh_container + docker run -d -p 65003:22 --rm --name dev3.group_2 --hostname=dev3.group_2 dbarroso/stupid_ssh_container + docker run -d -p 65004:22 --rm --name dev4.group_2 --hostname=dev4.group_2 dbarroso/stupid_ssh_container + docker run -d -p 65005:22 --rm --name dev5.no_group --hostname=dev5.no_group dbarroso/stupid_ssh_container + docker run -d -p 65080:80 --rm --name httpbin bungoume/httpbin-container } stop () { @@ -14,6 +14,7 @@ stop () { docker rm -f dev2.group_1 docker rm -f dev3.group_2 docker rm -f dev4.group_2 + docker rm -f dev5.no_group docker rm -f httpbin } diff --git a/tests/inventory_data/defaults.yaml b/tests/inventory_data/defaults.yaml new file mode 100644 index 00000000..ec27b5b1 --- /dev/null +++ b/tests/inventory_data/defaults.yaml @@ -0,0 +1,17 @@ +--- +port: +hostname: 127.0.0.1 +username: root +password: docker +platform: linux +data: + my_var: comes_from_defaults +connection_options: + dummy: + hostname: dummy_from_defaults + port: + username: + password: + platform: + extras: + blah: from_defaults diff --git a/tests/inventory_data/external_hosts.yaml b/tests/inventory_data/external_hosts.yaml index 03b6f6dc..139d98b6 100644 --- a/tests/inventory_data/external_hosts.yaml +++ b/tests/inventory_data/external_hosts.yaml @@ -1,6 +1,6 @@ --- www.github.com: - nornir_ip: www.github.com + hostname: www.github.com dummy: - nornir_ip: 1.1.1.1 + hostname: 1.1.1.1 diff --git a/tests/inventory_data/groups.yaml b/tests/inventory_data/groups.yaml index 81ecaa51..bf996b1e 100644 --- a/tests/inventory_data/groups.yaml +++ b/tests/inventory_data/groups.yaml @@ -1,14 +1,60 @@ --- -defaults: - my_var: comes_from_defaults - nornir_host: 127.0.0.1 - nornir_username: root - nornir_password: docker - nornir_os: linux - +parent_group: + port: + hostname: + username: + password: from_parent_group + platform: + data: + a_var: blah + a_false_var: false + groups: [] + connection_options: + dummy: + hostname: dummy_from_parent_group + port: + username: + password: + platform: + extras: + blah: from_group + dummy2: + hostname: dummy2_from_parent_group + port: + username: + password: + platform: + extras: + blah: from_group group_1: - my_var: comes_from_group_1 - site: site1 - + port: + hostname: + username: + password: from_group1 + platform: + data: + my_var: comes_from_group_1 + site: site1 + groups: + - parent_group + connection_options: {} group_2: - site: site2 + port: + hostname: + username: + password: + platform: + data: + site: site2 + groups: [] + connection_options: {} +group_3: + port: + hostname: + username: + password: + platform: + groups: [] + data: + site: site2 + connection_options: {} diff --git a/tests/inventory_data/hosts.yaml b/tests/inventory_data/hosts.yaml index 86078f08..9924a506 100644 --- a/tests/inventory_data/hosts.yaml +++ b/tests/inventory_data/hosts.yaml @@ -1,37 +1,121 @@ --- dev1.group_1: + port: 65001 + hostname: + username: + password: a_password + platform: eos + data: + my_var: comes_from_dev1.group_1 + www_server: nginx + role: www + nested_data: + a_dict: + a: 1 + b: 2 + a_list: [1, 2] + a_string: asdasd groups: - group_1 - my_var: comes_from_dev1.group_1 - www_server: nginx - role: www - nornir_ssh_port: 65001 - nornir_nos: eos - + connection_options: + paramiko: + port: 65001 + hostname: 127.0.0.1 + username: root + password: docker + platform: linux + extras: {} + dummy: + hostname: dummy_from_host + port: + username: + password: + platform: + extras: + blah: from_host dev2.group_1: + port: 65002 + hostname: + username: + password: + platform: junos + data: + role: db + nested_data: + a_dict: + b: 2 + c: 3 + a_list: [2, 3] + a_string: qwe groups: - group_1 - role: db - nornir_ssh_port: 65002 - nornir_nos: junos - + connection_options: + paramiko: + port: 65002 + hostname: 127.0.0.1 + username: root + password: docker + platform: linux + extras: {} + dummy2: + hostname: + port: + username: dummy2_from_host + password: + platform: + extras: dev3.group_2: + port: 65003 + hostname: + username: + password: + platform: linux + data: + www_server: apache + role: www groups: - group_2 - www_server: apache - role: www - nornir_ssh_port: 65003 - nornir_network_api_port: 12443 - nornir_os: linux - nornir_nos: mock - # nornir_username: vagrant - # nornir_password: vagrant - + connection_options: + napalm: + platform: mock + hostname: + port: + username: + password: + extras: {} dev4.group_2: + port: 65004 + hostname: + username: + password: + platform: linux + data: + my_var: comes_from_dev4.group_2 + role: db groups: - group_2 - my_var: comes_from_dev4.group_2 - role: db - nornir_ssh_port: 65004 - nornir_network_api_port: 65004 - nornir_nos: linux + - parent_group + connection_options: + paramiko: + port: 65004 + hostname: 127.0.0.1 + username: root + password: docker + platform: linux + extras: {} + netmiko: + port: 65004 + hostname: 127.0.0.1 + username: root + password: docker + platform: linux + extras: {} +dev5.no_group: + port: 65005 + hostname: localhost + username: + password: + platform: linux + data: {} + groups: [] + connection_options: {} diff --git a/tests/plugins/functions/text/output_data/basic_inventory.stdout b/tests/plugins/functions/text/output_data/basic_inventory.stdout index e53e05bf..3b61499e 100644 --- a/tests/plugins/functions/text/output_data/basic_inventory.stdout +++ b/tests/plugins/functions/text/output_data/basic_inventory.stdout @@ -15,3 +15,7 @@ Hello from Nornir vvvv echo_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO Hello from Nornir ^^^^ END echo_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +* dev5.no_group ** changed : False ********************************************* +vvvv echo_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO +Hello from Nornir +^^^^ END echo_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/plugins/functions/text/output_data/basic_inventory_one_host_python27.stdout b/tests/plugins/functions/text/output_data/basic_inventory_one_host_python27.stdout deleted file mode 100644 index 94bdebb1..00000000 --- a/tests/plugins/functions/text/output_data/basic_inventory_one_host_python27.stdout +++ /dev/null @@ -1,6 +0,0 @@ -vvvv data_with_greeting ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO ----- echo_task ** changed : False ---------------------------------------------- INFO -Hello from Nornir ----- load_data ** changed : False ---------------------------------------------- INFO -{ 'os': 'Linux', 'services': ['http', 'smtp', 'dns']} -^^^^ END data_with_greeting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/plugins/functions/text/output_data/basic_inventory_one_task_python27.stdout b/tests/plugins/functions/text/output_data/basic_inventory_one_task_python27.stdout deleted file mode 100644 index be49c4a9..00000000 --- a/tests/plugins/functions/text/output_data/basic_inventory_one_task_python27.stdout +++ /dev/null @@ -1,2 +0,0 @@ ----- echo_task ** changed : False ---------------------------------------------- INFO -Hello from Nornir diff --git a/tests/plugins/functions/text/output_data/basic_inventory_python27.stdout b/tests/plugins/functions/text/output_data/basic_inventory_python27.stdout deleted file mode 100644 index e53e05bf..00000000 --- a/tests/plugins/functions/text/output_data/basic_inventory_python27.stdout +++ /dev/null @@ -1,17 +0,0 @@ -echo_task*********************************************************************** -* dev1.group_1 ** changed : False ********************************************** -vvvv echo_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO -Hello from Nornir -^^^^ END echo_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev2.group_1 ** changed : False ********************************************** -vvvv echo_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO -Hello from Nornir -^^^^ END echo_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev3.group_2 ** changed : False ********************************************** -vvvv echo_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO -Hello from Nornir -^^^^ END echo_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev4.group_2 ** changed : False ********************************************** -vvvv echo_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO -Hello from Nornir -^^^^ END echo_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/plugins/functions/text/output_data/basic_single_python27.stdout b/tests/plugins/functions/text/output_data/basic_single_python27.stdout deleted file mode 100644 index 8cbbe534..00000000 --- a/tests/plugins/functions/text/output_data/basic_single_python27.stdout +++ /dev/null @@ -1,5 +0,0 @@ -echo_task*********************************************************************** -* dev1.group_1 ** changed : False ********************************************** -vvvv echo_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO -Hello from Nornir -^^^^ END echo_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/plugins/functions/text/output_data/changed_host_python27.stderr b/tests/plugins/functions/text/output_data/changed_host_python27.stderr deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/plugins/functions/text/output_data/changed_host_python27.stdout b/tests/plugins/functions/text/output_data/changed_host_python27.stdout deleted file mode 100644 index 7c043e9a..00000000 --- a/tests/plugins/functions/text/output_data/changed_host_python27.stdout +++ /dev/null @@ -1,15 +0,0 @@ -read_data*********************************************************************** -* dev1.group_1 ** changed : True *********************************************** -vvvv read_data ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv WARNING ----- echo_task ** changed : False ---------------------------------------------- CRITICAL -Hello from CRITICAL ----- parse_data ** changed : True ---------------------------------------------- WARNING -[1, 2, 3] -^^^^ END read_data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev2.group_1 ** changed : False ********************************************** -vvvv read_data ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv WARNING ----- echo_task ** changed : False ---------------------------------------------- CRITICAL -Hello from CRITICAL ----- parse_data ** changed : False --------------------------------------------- WARNING -[4, 5, 6] -^^^^ END read_data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/plugins/functions/text/output_data/failed_with_severity.stdout b/tests/plugins/functions/text/output_data/failed_with_severity.stdout index a80685c3..391824ef 100644 --- a/tests/plugins/functions/text/output_data/failed_with_severity.stdout +++ b/tests/plugins/functions/text/output_data/failed_with_severity.stdout @@ -19,3 +19,11 @@ Hello from CRITICAL ---- parse_data ** changed : False --------------------------------------------- ERROR Exception('Unknown Error -> Contact your system administrator',) ^^^^ END read_data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +* dev5.no_group ** changed : False ********************************************* +vvvv read_data ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR +NornirSubTaskError() +---- echo_task ** changed : False ---------------------------------------------- CRITICAL +Hello from CRITICAL +---- parse_data ** changed : False --------------------------------------------- ERROR +KeyError('values',) +^^^^ END read_data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/plugins/functions/text/output_data/failed_with_severity_python27.stderr b/tests/plugins/functions/text/output_data/failed_with_severity_python27.stderr deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/plugins/functions/text/output_data/failed_with_severity_python27.stdout b/tests/plugins/functions/text/output_data/failed_with_severity_python27.stdout deleted file mode 100644 index a80685c3..00000000 --- a/tests/plugins/functions/text/output_data/failed_with_severity_python27.stdout +++ /dev/null @@ -1,21 +0,0 @@ -read_data*********************************************************************** -* dev1.group_1 ** changed : True *********************************************** ----- echo_task ** changed : False ---------------------------------------------- CRITICAL -Hello from CRITICAL -^^^^ END read_data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev2.group_1 ** changed : False ********************************************** ----- echo_task ** changed : False ---------------------------------------------- CRITICAL -Hello from CRITICAL -^^^^ END read_data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev3.group_2 ** changed : False ********************************************** ----- echo_task ** changed : False ---------------------------------------------- CRITICAL -Hello from CRITICAL -^^^^ END read_data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev4.group_2 ** changed : False ********************************************** -vvvv read_data ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR -NornirSubTaskError() ----- echo_task ** changed : False ---------------------------------------------- CRITICAL -Hello from CRITICAL ----- parse_data ** changed : False --------------------------------------------- ERROR -Exception('Unknown Error -> Contact your system administrator',) -^^^^ END read_data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/plugins/functions/text/output_data/multiple_tasks.stdout b/tests/plugins/functions/text/output_data/multiple_tasks.stdout index f75e1843..ba555039 100644 --- a/tests/plugins/functions/text/output_data/multiple_tasks.stdout +++ b/tests/plugins/functions/text/output_data/multiple_tasks.stdout @@ -28,3 +28,10 @@ Hello from Nornir ---- load_data ** changed : False ---------------------------------------------- INFO {'os': 'Linux', 'services': ['http', 'smtp', 'dns']} ^^^^ END data_with_greeting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +* dev5.no_group ** changed : False ********************************************* +vvvv data_with_greeting ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO +---- echo_task ** changed : False ---------------------------------------------- INFO +Hello from Nornir +---- load_data ** changed : False ---------------------------------------------- INFO +{'os': 'Linux', 'services': ['http', 'smtp', 'dns']} +^^^^ END data_with_greeting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/plugins/functions/text/output_data/multiple_tasks_python27.stderr b/tests/plugins/functions/text/output_data/multiple_tasks_python27.stderr deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/plugins/functions/text/output_data/multiple_tasks_python27.stdout b/tests/plugins/functions/text/output_data/multiple_tasks_python27.stdout deleted file mode 100644 index 5c1105d5..00000000 --- a/tests/plugins/functions/text/output_data/multiple_tasks_python27.stdout +++ /dev/null @@ -1,30 +0,0 @@ -**** Behold the data! ********************************************************** -data_with_greeting************************************************************** -* dev1.group_1 ** changed : False ********************************************** -vvvv data_with_greeting ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO ----- echo_task ** changed : False ---------------------------------------------- INFO -Hello from Nornir ----- load_data ** changed : False ---------------------------------------------- INFO -{ 'os': 'Linux', 'services': ['http', 'smtp', 'dns']} -^^^^ END data_with_greeting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev2.group_1 ** changed : False ********************************************** -vvvv data_with_greeting ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO ----- echo_task ** changed : False ---------------------------------------------- INFO -Hello from Nornir ----- load_data ** changed : False ---------------------------------------------- INFO -{ 'os': 'Linux', 'services': ['http', 'smtp', 'dns']} -^^^^ END data_with_greeting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev3.group_2 ** changed : False ********************************************** -vvvv data_with_greeting ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO ----- echo_task ** changed : False ---------------------------------------------- INFO -Hello from Nornir ----- load_data ** changed : False ---------------------------------------------- INFO -{ 'os': 'Linux', 'services': ['http', 'smtp', 'dns']} -^^^^ END data_with_greeting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* dev4.group_2 ** changed : False ********************************************** -vvvv data_with_greeting ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO ----- echo_task ** changed : False ---------------------------------------------- INFO -Hello from Nornir ----- load_data ** changed : False ---------------------------------------------- INFO -{ 'os': 'Linux', 'services': ['http', 'smtp', 'dns']} -^^^^ END data_with_greeting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/plugins/functions/text/test_print_result.py b/tests/plugins/functions/text/test_print_result.py index 9d6f6ffc..b7e70ed3 100644 --- a/tests/plugins/functions/text/test_print_result.py +++ b/tests/plugins/functions/text/test_print_result.py @@ -1,9 +1,10 @@ -import os import logging +import os +from nornir.core.task import Result from nornir.plugins.functions.text import print_result from nornir.plugins.functions.text import print_title -from nornir.core.task import Result + from tests.wrapper import wrap_cli_test output_dir = "{}/output_data".format(os.path.dirname(os.path.realpath(__file__))) @@ -61,7 +62,6 @@ def read_data(task): class Test(object): - @wrap_cli_test(output="{}/basic_single".format(output_dir)) def test_print_basic(self, nornir): filter = nornir.filter(name="dev1.group_1") diff --git a/tests/plugins/inventory/ansible/ini/expected/defaults.yaml b/tests/plugins/inventory/ansible/ini/expected/defaults.yaml new file mode 100644 index 00000000..3ea3be93 --- /dev/null +++ b/tests/plugins/inventory/ansible/ini/expected/defaults.yaml @@ -0,0 +1,9 @@ +connection_options: {} +data: + my_other_var: from_all + my_var: from_all +hostname: null +password: null +platform: null +port: null +username: null diff --git a/tests/plugins/inventory/ansible/ini/expected/groups.yaml b/tests/plugins/inventory/ansible/ini/expected/groups.yaml index c0d2d5e4..d39a17b2 100644 --- a/tests/plugins/inventory/ansible/ini/expected/groups.yaml +++ b/tests/plugins/inventory/ansible/ini/expected/groups.yaml @@ -1,19 +1,44 @@ dbservers: + connection_options: {} + data: + my_var: from_dbservers + my_yet_another_var: from_dbservers groups: - servers - my_var: from_dbservers - my_yet_another_var: from_dbservers -defaults: - my_other_var: from_all - my_var: from_all + 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: - escape_pods: 2 + connection_options: {} + data: + escape_pods: 2 + halon_system_timeout: 30 + self_destruct_countdown: 60 + some_server: foo.southeast.example.com groups: [] - halon_system_timeout: 30 - self_destruct_countdown: 60 - some_server: foo.southeast.example.com + 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/plugins/inventory/ansible/ini/expected/hosts.yaml b/tests/plugins/inventory/ansible/ini/expected/hosts.yaml index c8e69867..fc40190c 100644 --- a/tests/plugins/inventory/ansible/ini/expected/hosts.yaml +++ b/tests/plugins/inventory/ansible/ini/expected/hosts.yaml @@ -1,21 +1,54 @@ 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 - my_var: from_one.example.com + hostname: null + password: null + platform: null + port: null + username: null three.example.com: - nornir_host: 192.0.2.50 - nornir_ssh_port: 5555 + 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 - my_var: from_hostfile - whatever: asdasd + hostname: null + password: null + platform: null + port: null + username: null diff --git a/tests/plugins/inventory/ansible/yaml/expected/defaults.yaml b/tests/plugins/inventory/ansible/yaml/expected/defaults.yaml new file mode 100644 index 00000000..3ea3be93 --- /dev/null +++ b/tests/plugins/inventory/ansible/yaml/expected/defaults.yaml @@ -0,0 +1,9 @@ +connection_options: {} +data: + my_other_var: from_all + my_var: from_all +hostname: null +password: null +platform: null +port: null +username: null diff --git a/tests/plugins/inventory/ansible/yaml/expected/groups.yaml b/tests/plugins/inventory/ansible/yaml/expected/groups.yaml index c0d2d5e4..d39a17b2 100644 --- a/tests/plugins/inventory/ansible/yaml/expected/groups.yaml +++ b/tests/plugins/inventory/ansible/yaml/expected/groups.yaml @@ -1,19 +1,44 @@ dbservers: + connection_options: {} + data: + my_var: from_dbservers + my_yet_another_var: from_dbservers groups: - servers - my_var: from_dbservers - my_yet_another_var: from_dbservers -defaults: - my_other_var: from_all - my_var: from_all + 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: - escape_pods: 2 + connection_options: {} + data: + escape_pods: 2 + halon_system_timeout: 30 + self_destruct_countdown: 60 + some_server: foo.southeast.example.com groups: [] - halon_system_timeout: 30 - self_destruct_countdown: 60 - some_server: foo.southeast.example.com + 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/plugins/inventory/ansible/yaml/expected/hosts.yaml b/tests/plugins/inventory/ansible/yaml/expected/hosts.yaml index c8e69867..fc40190c 100644 --- a/tests/plugins/inventory/ansible/yaml/expected/hosts.yaml +++ b/tests/plugins/inventory/ansible/yaml/expected/hosts.yaml @@ -1,21 +1,54 @@ 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 - my_var: from_one.example.com + hostname: null + password: null + platform: null + port: null + username: null three.example.com: - nornir_host: 192.0.2.50 - nornir_ssh_port: 5555 + 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 - my_var: from_hostfile - whatever: asdasd + hostname: null + password: null + platform: null + port: null + username: null diff --git a/tests/plugins/inventory/ansible/yaml/source/group_vars/dbservers b/tests/plugins/inventory/ansible/yaml/source/group_vars/dbservers.yml similarity index 100% rename from tests/plugins/inventory/ansible/yaml/source/group_vars/dbservers rename to tests/plugins/inventory/ansible/yaml/source/group_vars/dbservers.yml diff --git a/tests/plugins/inventory/ansible/yaml/source/host_vars/two.example.com b/tests/plugins/inventory/ansible/yaml/source/host_vars/two.example.com.yaml similarity index 100% rename from tests/plugins/inventory/ansible/yaml/source/host_vars/two.example.com rename to tests/plugins/inventory/ansible/yaml/source/host_vars/two.example.com.yaml diff --git a/tests/plugins/inventory/ansible/yaml2/expected/defaults.yaml b/tests/plugins/inventory/ansible/yaml2/expected/defaults.yaml new file mode 100644 index 00000000..726111d9 --- /dev/null +++ b/tests/plugins/inventory/ansible/yaml2/expected/defaults.yaml @@ -0,0 +1,7 @@ +connection_options: {} +data: {} +hostname: null +password: null +platform: null +port: null +username: null diff --git a/tests/plugins/inventory/ansible/yaml2/expected/groups.yaml b/tests/plugins/inventory/ansible/yaml2/expected/groups.yaml index 914dee95..0967ef42 100644 --- a/tests/plugins/inventory/ansible/yaml2/expected/groups.yaml +++ b/tests/plugins/inventory/ansible/yaml2/expected/groups.yaml @@ -1 +1 @@ -defaults: {} +{} diff --git a/tests/plugins/inventory/ansible/yaml2/expected/hosts.yaml b/tests/plugins/inventory/ansible/yaml2/expected/hosts.yaml index f516beff..fa2a28a8 100644 --- a/tests/plugins/inventory/ansible/yaml2/expected/hosts.yaml +++ b/tests/plugins/inventory/ansible/yaml2/expected/hosts.yaml @@ -1,9 +1,28 @@ one.example.com: + connection_options: {} + data: {} groups: [] + hostname: null + password: null + platform: null + port: null + username: null three.example.com: - nornir_host: 192.0.2.50 - nornir_ssh_port: 5555 + connection_options: {} + data: {} groups: [] + hostname: 192.0.2.50 + password: null + platform: null + port: 5555 + username: null two.example.com: + connection_options: {} + data: + my_var: from_hostfile groups: [] - my_var: from_hostfile + hostname: null + password: null + platform: null + port: null + username: null diff --git a/tests/plugins/inventory/ansible/yaml3/expected/defaults.yaml b/tests/plugins/inventory/ansible/yaml3/expected/defaults.yaml new file mode 100644 index 00000000..c4c39304 --- /dev/null +++ b/tests/plugins/inventory/ansible/yaml3/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/plugins/inventory/ansible/yaml3/expected/groups.yaml b/tests/plugins/inventory/ansible/yaml3/expected/groups.yaml new file mode 100644 index 00000000..49e0ae73 --- /dev/null +++ b/tests/plugins/inventory/ansible/yaml3/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/plugins/inventory/ansible/yaml3/expected/hosts.yaml b/tests/plugins/inventory/ansible/yaml3/expected/hosts.yaml new file mode 100644 index 00000000..8a874e5d --- /dev/null +++ b/tests/plugins/inventory/ansible/yaml3/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/plugins/inventory/ansible/yaml3/source/hosts b/tests/plugins/inventory/ansible/yaml3/source/hosts new file mode 100644 index 00000000..177c4321 --- /dev/null +++ b/tests/plugins/inventory/ansible/yaml3/source/hosts @@ -0,0 +1,45 @@ +--- +all: + vars: + ansible_python_interpreter: python + ansible_connection: network_cli + ansible_network_os: ios + ansible_ssh_common_args: '-o ProxyCommand="ssh -W %h:%p -p 10000 guest@10.105.152.50"' # install ssh public key on proxy host !!! + env: staging + children: + virl: + children: + asa: + vars: + cisco_asa: True + network_os: asa + ansible_network_os: asa + ansible_become: yes + ansible_become_method: enable + ansible_persistent_command_timeout: 30 + hosts: + asa1: + ansible_host: 10.255.0.2 + dot1x: True + ios: + vars: + network_os: ios + nornir_nos: ios + platform: ios + children: + routers: + hosts: + iosv-1: + ansible_host: 10.255.0.12 + iosv-2: + ansible_host: 10.255.0.13 + access: + hosts: + access1: + ansible_host: 10.255.0.6 + dist: + hosts: + dist1: + ansible_host: 10.255.0.5 + dist2: + ansible_host: 10.255.0.11 diff --git a/tests/plugins/functions/text/output_data/basic_single_python27.stderr b/tests/plugins/inventory/helpers/__init__.py similarity index 100% rename from tests/plugins/functions/text/output_data/basic_single_python27.stderr rename to tests/plugins/inventory/helpers/__init__.py diff --git a/tests/plugins/inventory/netbox/2.3.5/expected.json b/tests/plugins/inventory/netbox/2.3.5/expected.json new file mode 100644 index 00000000..1af1e52f --- /dev/null +++ b/tests/plugins/inventory/netbox/2.3.5/expected.json @@ -0,0 +1,66 @@ +{ + "hosts": { + "1-Core": { + "port": null, + "hostname": "10.0.1.1", + "username": null, + "password": null, + "platform": null, + "data": { + "serial": "", + "vendor": "Juniper", + "asset_tag": null, + "site": "sunnyvale-ca", + "role": "rt", + "model": "mx480" + }, + "connection_options": {}, + "groups": [] + }, + "2-Distribution": { + "port": null, + "hostname": "172.16.2.1", + "username": null, + "password": null, + "platform": null, + "data": { + "serial": "", + "vendor": "Juniper", + "asset_tag": null, + "site": "sunnyvale-ca", + "role": "rt", + "model": "ex4550-32f" + }, + "connection_options": {}, + "groups": [] + }, + "3-Access": { + "port": null, + "hostname": "192.168.3.1", + "username": null, + "password": null, + "platform": null, + "data": { + "user_defined": 1, + "serial": "", + "vendor": "Cisco", + "asset_tag": null, + "site": "san-jose-ca", + "role": "sw", + "model": "3650-48tq-l" + }, + "connection_options": {}, + "groups": [] + } + }, + "groups": {}, + "defaults": { + "port": null, + "hostname": null, + "username": null, + "password": null, + "platform": null, + "data": {}, + "connection_options": {} + } +} diff --git a/tests/plugins/inventory/netbox/2.3.5/expected_transform_function.json b/tests/plugins/inventory/netbox/2.3.5/expected_transform_function.json new file mode 100644 index 00000000..2889e0d1 --- /dev/null +++ b/tests/plugins/inventory/netbox/2.3.5/expected_transform_function.json @@ -0,0 +1,69 @@ +{ + "hosts": { + "1-Core": { + "port": null, + "hostname": "10.0.1.1", + "username": null, + "password": null, + "platform": null, + "data": { + "serial": "", + "vendor": "Juniper", + "asset_tag": null, + "site": "sunnyvale-ca", + "role": "rt", + "model": "mx480", + "platform": "junos" + }, + "connection_options": {}, + "groups": [] + }, + "2-Distribution": { + "port": null, + "hostname": "172.16.2.1", + "username": null, + "password": null, + "platform": null, + "data": { + "serial": "", + "vendor": "Juniper", + "asset_tag": null, + "site": "sunnyvale-ca", + "role": "rt", + "model": "ex4550-32f", + "platform": "junos" + }, + "connection_options": {}, + "groups": [] + }, + "3-Access": { + "port": null, + "hostname": "192.168.3.1", + "username": null, + "password": null, + "platform": null, + "data": { + "user_defined": 1, + "serial": "", + "vendor": "Cisco", + "asset_tag": null, + "site": "san-jose-ca", + "role": "sw", + "model": "3650-48tq-l", + "platform": "ios" + }, + "connection_options": {}, + "groups": [] + } + }, + "groups": {}, + "defaults": { + "port": null, + "hostname": null, + "username": null, + "password": null, + "platform": null, + "data": {}, + "connection_options": {} + } +} diff --git a/tests/plugins/inventory/netbox/2.3.5/mocked/devices.json b/tests/plugins/inventory/netbox/2.3.5/mocked/devices.json new file mode 100644 index 00000000..5b316a8a --- /dev/null +++ b/tests/plugins/inventory/netbox/2.3.5/mocked/devices.json @@ -0,0 +1,193 @@ +{ + "count": 3, + "next": "http://localhost:8080/api/dcim/devices/?limit=0&offset=0", + "previous": null, + "results": [ + { + "id": 1, + "name": "1-Core", + "display_name": "1-Core", + "device_type": { + "id": 11, + "url": "http://localhost:8080/api/dcim/device-types/11/", + "manufacturer": { + "id": 3, + "url": "http://localhost:8080/api/dcim/manufacturers/3/", + "name": "Juniper", + "slug": "juniper" + }, + "model": "MX480", + "slug": "mx480" + }, + "device_role": { + "id": 1, + "url": "http://localhost:8080/api/dcim/device-roles/1/", + "name": "Router", + "slug": "rt" + }, + "tenant": null, + "platform": null, + "serial": "", + "asset_tag": null, + "site": { + "id": 3, + "url": "http://localhost:8080/api/dcim/sites/3/", + "name": "Sunnyvale, CA", + "slug": "sunnyvale-ca" + }, + "rack": null, + "position": null, + "face": null, + "parent_device": null, + "status": { + "value": 1, + "label": "Active" + }, + "primary_ip": { + "id": 1, + "url": "http://localhost:8080/api/ipam/ip-addresses/1/", + "family": 4, + "address": "10.0.1.1/32" + }, + "primary_ip4": { + "id": 1, + "url": "http://localhost:8080/api/ipam/ip-addresses/1/", + "family": 4, + "address": "10.0.1.1/32" + }, + "primary_ip6": null, + "cluster": null, + "virtual_chassis": null, + "vc_position": null, + "vc_priority": null, + "comments": "", + "custom_fields": {}, + "created": "2018-07-12", + "last_updated": "2018-07-12T11:53:54.742412Z" + }, + { + "id": 2, + "name": "2-Distribution", + "display_name": "2-Distribution", + "device_type": { + "id": 9, + "url": "http://localhost:8080/api/dcim/device-types/9/", + "manufacturer": { + "id": 3, + "url": "http://localhost:8080/api/dcim/manufacturers/3/", + "name": "Juniper", + "slug": "juniper" + }, + "model": "EX4550-32F", + "slug": "ex4550-32f" + }, + "device_role": { + "id": 1, + "url": "http://localhost:8080/api/dcim/device-roles/1/", + "name": "Router", + "slug": "rt" + }, + "tenant": null, + "platform": null, + "serial": "", + "asset_tag": null, + "site": { + "id": 3, + "url": "http://localhost:8080/api/dcim/sites/3/", + "name": "Sunnyvale, CA", + "slug": "sunnyvale-ca" + }, + "rack": null, + "position": null, + "face": null, + "parent_device": null, + "status": { + "value": 1, + "label": "Active" + }, + "primary_ip": { + "id": 2, + "url": "http://localhost:8080/api/ipam/ip-addresses/2/", + "family": 4, + "address": "172.16.2.1/32" + }, + "primary_ip4": { + "id": 2, + "url": "http://localhost:8080/api/ipam/ip-addresses/2/", + "family": 4, + "address": "172.16.2.1/32" + }, + "primary_ip6": null, + "cluster": null, + "virtual_chassis": null, + "vc_position": null, + "vc_priority": null, + "comments": "", + "custom_fields": {}, + "created": "2018-07-12", + "last_updated": "2018-07-12T11:53:54.802934Z" + }, + { + "id": 3, + "name": "3-Access", + "display_name": "3-Access", + "device_type": { + "id": 2, + "url": "http://localhost:8080/api/dcim/device-types/2/", + "manufacturer": { + "id": 2, + "url": "http://localhost:8080/api/dcim/manufacturers/2/", + "name": "Cisco", + "slug": "cisco" + }, + "model": "3650-48TQ-L", + "slug": "3650-48tq-l" + }, + "device_role": { + "id": 2, + "url": "http://localhost:8080/api/dcim/device-roles/2/", + "name": "Switch", + "slug": "sw" + }, + "tenant": null, + "platform": null, + "serial": "", + "asset_tag": null, + "site": { + "id": 2, + "url": "http://localhost:8080/api/dcim/sites/2/", + "name": "San Jose, CA", + "slug": "san-jose-ca" + }, + "rack": null, + "position": null, + "face": null, + "parent_device": null, + "status": { + "value": 1, + "label": "Active" + }, + "primary_ip": { + "id": 3, + "url": "http://localhost:8080/api/ipam/ip-addresses/3/", + "family": 4, + "address": "192.168.3.1/32" + }, + "primary_ip4": { + "id": 3, + "url": "http://localhost:8080/api/ipam/ip-addresses/3/", + "family": 4, + "address": "192.168.3.1/32" + }, + "primary_ip6": null, + "cluster": null, + "virtual_chassis": null, + "vc_position": null, + "vc_priority": null, + "comments": "", + "custom_fields": {"user_defined": 1}, + "created": "2018-07-12", + "last_updated": "2018-07-12T11:53:54.866133Z" + } + ] +} diff --git a/tests/plugins/inventory/test_ansible.py b/tests/plugins/inventory/test_ansible.py index 4d1cfe01..199b7e34 100644 --- a/tests/plugins/inventory/test_ansible.py +++ b/tests/plugins/inventory/test_ansible.py @@ -5,46 +5,57 @@ import pytest import ruamel.yaml +from ruamel.yaml.scanner import ScannerError BASE_PATH = os.path.join(os.path.dirname(__file__), "ansible") -def save(hosts, groups, hosts_file, groups_file): - yml = ruamel.yaml.YAML(typ="safe", pure=True) +def save(inv_serialized, hosts_file, groups_file, defaults_file): + yml = ruamel.yaml.YAML(typ="safe") + yml.default_flow_style = False with open(hosts_file, "w+") as f: - f.write(yml.dump(hosts, default_flow_style=False)) + yml.dump(inv_serialized["hosts"], f) with open(groups_file, "w+") as f: - f.write(yml.dump(groups, default_flow_style=False)) + yml.dump(inv_serialized["groups"], f) + with open(defaults_file, "w+") as f: + yml.dump(inv_serialized["defaults"], f) -def read(hosts_file, groups_file): +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.read()) + hosts = yml.load(f) with open(groups_file, "r") as f: - groups = yml.load(f.read()) - return hosts, groups + groups = yml.load(f) + with open(defaults_file, "r") as f: + defaults = yml.load(f) + return hosts, groups, defaults class Test(object): - - @pytest.mark.parametrize("case", ["ini", "yaml", "yaml2"]) + @pytest.mark.parametrize("case", ["ini", "yaml", "yaml2", "yaml3"]) def test_inventory(self, case): base_path = os.path.join(BASE_PATH, case) hosts_file = os.path.join(base_path, "expected", "hosts.yaml") groups_file = os.path.join(base_path, "expected", "groups.yaml") + defaults_file = os.path.join(base_path, "expected", "defaults.yaml") - hosts, groups = ansible.parse( + inv = ansible.AnsibleInventory.deserialize( hostsfile=os.path.join(base_path, "source", "hosts") ) - # save(hosts, groups, hosts_file, groups_file) + inv_serialized = ansible.AnsibleInventory.serialize(inv).dict() - expected_hosts, expected_groups = read(hosts_file, groups_file) - assert hosts == expected_hosts - assert groups == expected_groups + # save(inv_serialized, hosts_file, groups_file, defaults_file) + + expected_hosts, expected_groups, expected_defaults = read( + hosts_file, groups_file, defaults_file + ) + assert inv_serialized["hosts"] == expected_hosts + assert inv_serialized["groups"] == expected_groups + assert inv_serialized["defaults"] == expected_defaults def test_parse_error(self): base_path = os.path.join(BASE_PATH, "parse_error") - with pytest.raises(ruamel.yaml.scanner.ScannerError): + with pytest.raises(ScannerError): ansible.parse(hostsfile=os.path.join(base_path, "source", "hosts")) diff --git a/tests/plugins/inventory/test_netbox.py b/tests/plugins/inventory/test_netbox.py new file mode 100644 index 00000000..33158a55 --- /dev/null +++ b/tests/plugins/inventory/test_netbox.py @@ -0,0 +1,48 @@ +import json +import os + +from nornir.core.deserializer.inventory import Inventory +from nornir.plugins.inventory import netbox + +# We need import below to load fixtures +import pytest # noqa + + +BASE_PATH = os.path.join(os.path.dirname(__file__), "netbox") + + +def get_inv(requests_mock, case, **kwargs): + with open("{}/{}/mocked/devices.json".format(BASE_PATH, case), "r") as f: + requests_mock.get( + "http://localhost:8080/api/dcim/devices/?limit=0", + json=json.load(f), + headers={"Content-type": "application/json"}, + ) + return netbox.NBInventory.deserialize(**kwargs) + + +def transform_function(host): + vendor_map = {"Cisco": "ios", "Juniper": "junos"} + host["platform"] = vendor_map[host["vendor"]] + + +class Test(object): + def test_inventory(self, requests_mock): + inv = get_inv(requests_mock, "2.3.5") + # with open("{}/{}/expected.json".format(BASE_PATH, "2.3.5"), "w") as f: + # f.write(InventorySerializer.serialize(inv).json()) + with open("{}/{}/expected.json".format(BASE_PATH, "2.3.5"), "r") as f: + expected = json.load(f) + assert expected == Inventory.serialize(inv).dict() + + def test_transform_function(self, requests_mock): + inv = get_inv(requests_mock, "2.3.5", transform_function=transform_function) + # with open( + # "{}/{}/expected_transform_function.json".format(BASE_PATH, "2.3.5"), "w" + # ) as f: + # f.write(InventorySerializer.serialize(inv).json()) + with open( + "{}/{}/expected_transform_function.json".format(BASE_PATH, "2.3.5"), "r" + ) as f: + expected = json.load(f) + assert expected == Inventory.serialize(inv).dict() diff --git a/tests/plugins/inventory/test_nsot.py b/tests/plugins/inventory/test_nsot.py index 1c46ea42..0e6e7f26 100644 --- a/tests/plugins/inventory/test_nsot.py +++ b/tests/plugins/inventory/test_nsot.py @@ -18,18 +18,17 @@ def get_inv(requests_mock, case, **kwargs): json=json.load(f), headers={"Content-type": "application/json"}, ) - return nsot.NSOTInventory(**kwargs) + return nsot.NSOTInventory.deserialize(**kwargs) def transform_function(host): attrs = ["user", "password"] for a in attrs: if a in host.data: - host["nornir_{}".format(a)] = host.data[a] + host["modified_{}".format(a)] = host.data[a] class Test(object): - def test_inventory(self, requests_mock): inv = get_inv(requests_mock, "1.3.0", transform_function=transform_function) assert len(inv.hosts) == 4 @@ -40,5 +39,5 @@ def test_inventory(self, requests_mock): def test_transform_function(self, requests_mock): inv = get_inv(requests_mock, "1.3.0", transform_function=transform_function) for host in inv.hosts.values(): - assert host["user"] == host["nornir_user"] - assert host["password"] == host["nornir_password"] + assert host["user"] == host["modified_user"] + assert host["password"] == host["modified_password"] diff --git a/tests/plugins/tasks/apis/test_http_method.py b/tests/plugins/tasks/apis/test_http_method.py index 6cc3b2b8..3dc79763 100644 --- a/tests/plugins/tasks/apis/test_http_method.py +++ b/tests/plugins/tasks/apis/test_http_method.py @@ -11,7 +11,6 @@ class Test(object): - def test_simple_get_text(self): url = "{}/encoding/utf8".format(BASE_URL) result = http_method(method="get", url=url) diff --git a/tests/plugins/tasks/commands/test_command.py b/tests/plugins/tasks/commands/test_command.py index a5766664..e0033c47 100644 --- a/tests/plugins/tasks/commands/test_command.py +++ b/tests/plugins/tasks/commands/test_command.py @@ -8,7 +8,6 @@ def echo_hostname(task): class Test(object): - def test_command(self, nornir): result = nornir.run(echo_hostname) assert result @@ -22,7 +21,6 @@ def test_command_error(self, nornir): processed = True assert isinstance(r.exception, OSError) assert processed - nornir.data.reset_failed_hosts() def test_command_error_generic(self, nornir): result = nornir.run(commands.command, command="ls /asdadsd") @@ -31,4 +29,3 @@ def test_command_error_generic(self, nornir): processed = True assert isinstance(r.exception, CommandError) assert processed - nornir.data.reset_failed_hosts() diff --git a/tests/plugins/tasks/commands/test_remote_command.py b/tests/plugins/tasks/commands/test_remote_command.py index 945af852..e6992e2d 100644 --- a/tests/plugins/tasks/commands/test_remote_command.py +++ b/tests/plugins/tasks/commands/test_remote_command.py @@ -3,7 +3,6 @@ class Test(object): - def test_remote_command(self, nornir): result = nornir.run(commands.remote_command, command="hostname") assert result @@ -17,4 +16,3 @@ def test_remote_command_error_generic(self, nornir): processed = True assert isinstance(r.exception, CommandError) assert processed - nornir.data.reset_failed_hosts() diff --git a/tests/plugins/tasks/data/test_data/simple.json b/tests/plugins/tasks/data/test_data/simple.json index 3a17c39d..750df624 100644 --- a/tests/plugins/tasks/data/test_data/simple.json +++ b/tests/plugins/tasks/data/test_data/simple.json @@ -3,5 +3,6 @@ "services": [ "dhcp", "dns" - ] + ], + "a_dict": {"a": 1, "b": 2} } diff --git a/tests/plugins/tasks/data/test_data/simple.yaml b/tests/plugins/tasks/data/test_data/simple.yaml index fe7fdb00..3e9971f7 100644 --- a/tests/plugins/tasks/data/test_data/simple.yaml +++ b/tests/plugins/tasks/data/test_data/simple.yaml @@ -3,3 +3,6 @@ env: test services: - dhcp - dns +a_dict: + a: 1 + b: 2 diff --git a/tests/plugins/tasks/data/test_load_json.py b/tests/plugins/tasks/data/test_load_json.py index 686952fd..598a345c 100644 --- a/tests/plugins/tasks/data/test_load_json.py +++ b/tests/plugins/tasks/data/test_load_json.py @@ -1,5 +1,4 @@ import os -import sys from nornir.plugins.tasks import data @@ -8,7 +7,6 @@ class Test(object): - def test_load_json(self, nornir): test_file = "{}/simple.json".format(data_dir) result = nornir.run(data.load_json, file=test_file) @@ -17,6 +15,7 @@ def test_load_json(self, nornir): d = r.result assert d["env"] == "test" assert d["services"] == ["dhcp", "dns"] + assert isinstance(d["a_dict"], dict) def test_load_json_error_broken_file(self, nornir): test_file = "{}/broken.json".format(data_dir) @@ -26,19 +25,12 @@ def test_load_json_error_broken_file(self, nornir): processed = True assert isinstance(result.exception, ValueError) assert processed - nornir.data.reset_failed_hosts() def test_load_json_error_missing_file(self, nornir): test_file = "{}/missing.json".format(data_dir) - if sys.version_info.major == 2: - not_found = IOError - else: - not_found = FileNotFoundError # noqa - results = nornir.run(data.load_json, file=test_file) processed = False for result in results.values(): processed = True - assert isinstance(result.exception, not_found) + assert isinstance(result.exception, FileNotFoundError) assert processed - nornir.data.reset_failed_hosts() diff --git a/tests/plugins/tasks/data/test_load_yaml.py b/tests/plugins/tasks/data/test_load_yaml.py index de9eba05..951cf0da 100644 --- a/tests/plugins/tasks/data/test_load_yaml.py +++ b/tests/plugins/tasks/data/test_load_yaml.py @@ -1,18 +1,14 @@ import os -import sys - from nornir.plugins.tasks import data - -from yaml.scanner import ScannerError +from ruamel.yaml.scanner import ScannerError data_dir = "{}/test_data".format(os.path.dirname(os.path.realpath(__file__))) class Test(object): - def test_load_yaml(self, nornir): test_file = "{}/simple.yaml".format(data_dir) result = nornir.run(data.load_yaml, file=test_file) @@ -30,20 +26,12 @@ def test_load_yaml_error_broken_file(self, nornir): processed = True assert isinstance(result.exception, ScannerError) assert processed - nornir.data.reset_failed_hosts() def test_load_yaml_error_missing_file(self, nornir): test_file = "{}/missing.yaml".format(data_dir) - - if sys.version_info.major == 2: - not_found = IOError - else: - not_found = FileNotFoundError # noqa - results = nornir.run(data.load_yaml, file=test_file) processed = False for result in results.values(): processed = True - assert isinstance(result.exception, not_found) + assert isinstance(result.exception, FileNotFoundError) assert processed - nornir.data.reset_failed_hosts() diff --git a/tests/plugins/tasks/files/test_sftp.py b/tests/plugins/tasks/files/test_sftp.py index feef3730..f9849c6f 100644 --- a/tests/plugins/tasks/files/test_sftp.py +++ b/tests/plugins/tasks/files/test_sftp.py @@ -49,7 +49,6 @@ def get_directory(task): class Test(object): - def test_sftp_put(self, nornir): result = nornir.run( files.sftp, diff --git a/tests/plugins/tasks/files/test_write_file.py b/tests/plugins/tasks/files/test_write_file.py index 6a2d0a28..618f1db1 100644 --- a/tests/plugins/tasks/files/test_write_file.py +++ b/tests/plugins/tasks/files/test_write_file.py @@ -128,7 +128,6 @@ def _test_append(task): class Test(object): - def test_write_file(self, nornir): nornir.run(_test_write_file) diff --git a/tests/plugins/tasks/networking/test_napalm_cli.py b/tests/plugins/tasks/networking/test_napalm_cli.py index 70507541..da9f13b2 100644 --- a/tests/plugins/tasks/networking/test_napalm_cli.py +++ b/tests/plugins/tasks/networking/test_napalm_cli.py @@ -1,7 +1,7 @@ import os # from nornir.core.exceptions import NornirExecutionError -from nornir.plugins.tasks import connections, networking +from nornir.plugins.tasks import networking # from napalm.base import exceptions @@ -11,12 +11,22 @@ THIS_DIR = os.path.dirname(os.path.realpath(__file__)) + "/mocked/napalm_cli" -class Test(object): +def connect(task, extras): + if "napalm" in task.host.connections: + task.host.close_connection("napalm") + task.host.open_connection( + "napalm", + task.nornir.config, + extras={"optional_args": extras}, + default_to_host_attributes=True, + ) + +class Test(object): def test_napalm_cli(self, nornir): opt = {"path": THIS_DIR + "/test_napalm_cli"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(connect, extras=opt) result = d.run( networking.napalm_cli, commands=["show version", "show interfaces"] ) diff --git a/tests/plugins/tasks/networking/test_napalm_configure.py b/tests/plugins/tasks/networking/test_napalm_configure.py index a699c1d7..805ff309 100644 --- a/tests/plugins/tasks/networking/test_napalm_configure.py +++ b/tests/plugins/tasks/networking/test_napalm_configure.py @@ -1,21 +1,33 @@ import os -from nornir.plugins.tasks import connections, networking - from napalm.base import exceptions +from nornir.plugins.tasks import networking + THIS_DIR = os.path.dirname(os.path.realpath(__file__)) + "/mocked/napalm_configure" -class Test(object): +def connect(task, extras): + if "napalm" in task.host.connections: + task.host.close_connection("napalm") + task.host.open_connection( + "napalm", + task.nornir.config, + extras={"optional_args": extras}, + default_to_host_attributes=True, + ) + +class Test(object): def test_napalm_configure_change_dry_run(self, nornir): opt = {"path": THIS_DIR + "/test_napalm_configure_change_dry_run"} configuration = "hostname changed-hostname" d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) - result = d.run(networking.napalm_configure, configuration=configuration) + d.run(connect, extras=opt) + result = d.run( + networking.napalm_configure, dry_run=True, configuration=configuration + ) assert result for h, r in result.items(): assert "+hostname changed-hostname" in r.diff @@ -25,7 +37,7 @@ def test_napalm_configure_change_commit(self, nornir): opt = {"path": THIS_DIR + "/test_napalm_configure_change_commit/step1"} configuration = "hostname changed-hostname" d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(connect, extras=opt) result = d.run( networking.napalm_configure, dry_run=False, configuration=configuration ) @@ -34,7 +46,7 @@ def test_napalm_configure_change_commit(self, nornir): assert "+hostname changed-hostname" in r.diff assert r.changed opt = {"path": THIS_DIR + "/test_napalm_configure_change_commit/step2"} - d.run(connections.napalm_connection, optional_args=opt) + d.run(connect, extras=opt) result = d.run( networking.napalm_configure, dry_run=True, configuration=configuration ) @@ -48,11 +60,10 @@ def test_napalm_configure_change_error(self, nornir): configuration = "hostname changed_hostname" d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(connect, extras=opt) results = d.run(networking.napalm_configure, configuration=configuration) processed = False for result in results.values(): processed = True assert isinstance(result.exception, exceptions.MergeConfigException) assert processed - nornir.data.reset_failed_hosts() diff --git a/tests/plugins/tasks/networking/test_napalm_get.py b/tests/plugins/tasks/networking/test_napalm_get.py index 77e597dd..559cf753 100644 --- a/tests/plugins/tasks/networking/test_napalm_get.py +++ b/tests/plugins/tasks/networking/test_napalm_get.py @@ -1,17 +1,27 @@ import os -from nornir.plugins.tasks import connections, networking +from nornir.plugins.tasks import networking THIS_DIR = os.path.dirname(os.path.realpath(__file__)) + "/mocked/napalm_get" -class Test(object): +def connect(task, extras): + if "napalm" in task.host.connections: + task.host.close_connection("napalm") + task.host.open_connection( + "napalm", + task.nornir.config, + extras={"optional_args": extras}, + default_to_host_attributes=True, + ) + +class Test(object): def test_napalm_getters(self, nornir): opt = {"path": THIS_DIR + "/test_napalm_getters"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(task=connect, extras=opt) result = d.run(networking.napalm_get, getters=["facts", "interfaces"]) assert result for h, r in result.items(): @@ -21,7 +31,7 @@ def test_napalm_getters(self, nornir): def test_napalm_getters_error(self, nornir): opt = {"path": THIS_DIR + "/test_napalm_getters_error"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(task=connect, extras=opt) results = d.run(networking.napalm_get, getters=["facts", "interfaces"]) processed = False @@ -29,12 +39,11 @@ def test_napalm_getters_error(self, nornir): processed = True assert isinstance(result.exception, KeyError) assert processed - nornir.data.reset_failed_hosts() def test_napalm_getters_with_options_error(self, nornir): opt = {"path": THIS_DIR + "/test_napalm_getters_single_with_options"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(task=connect, extras=opt) result = d.run( task=networking.napalm_get, getters=["config"], nonexistent="asdsa" ) @@ -42,12 +51,11 @@ def test_napalm_getters_with_options_error(self, nornir): assert result.failed for h, r in result.items(): assert "unexpected keyword argument 'nonexistent'" in r.result - nornir.data.reset_failed_hosts() def test_napalm_getters_with_options_error_optional_args(self, nornir): opt = {"path": THIS_DIR + "/test_napalm_getters_single_with_options"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(task=connect, extras=opt) result = d.run( task=networking.napalm_get, getters=["config"], @@ -57,12 +65,11 @@ def test_napalm_getters_with_options_error_optional_args(self, nornir): assert result.failed for h, r in result.items(): assert "unexpected keyword argument 'nonexistent'" in r.result - nornir.data.reset_failed_hosts() def test_napalm_getters_single_with_options(self, nornir): opt = {"path": THIS_DIR + "/test_napalm_getters_single_with_options"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(task=connect, extras=opt) result = d.run( task=networking.napalm_get, getters=["config"], retrieve="candidate" ) @@ -74,7 +81,7 @@ def test_napalm_getters_single_with_options(self, nornir): def test_napalm_getters_multiple_with_options(self, nornir): opt = {"path": THIS_DIR + "/test_napalm_getters_multiple_with_options"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(task=connect, extras=opt) result = d.run( task=networking.napalm_get, getters=["config", "facts"], diff --git a/tests/plugins/tasks/networking/test_napalm_validate.py b/tests/plugins/tasks/networking/test_napalm_validate.py index 213d18a3..c0eca8a8 100644 --- a/tests/plugins/tasks/networking/test_napalm_validate.py +++ b/tests/plugins/tasks/networking/test_napalm_validate.py @@ -1,17 +1,27 @@ import os -from nornir.plugins.tasks import connections, networking +from nornir.plugins.tasks import networking THIS_DIR = os.path.dirname(os.path.realpath(__file__)) -class Test(object): +def connect(task, extras): + if "napalm" in task.host.connections: + task.host.close_connection("napalm") + task.host.open_connection( + "napalm", + task.nornir.config, + extras={"optional_args": extras}, + default_to_host_attributes=True, + ) + +class Test(object): def test_napalm_validate_src_ok(self, nornir): opt = {"path": THIS_DIR + "/mocked/napalm_get/test_napalm_getters"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(connect, extras=opt) result = d.run( networking.napalm_validate, src=THIS_DIR + "/data/validate_ok.yaml" ) @@ -22,7 +32,7 @@ def test_napalm_validate_src_ok(self, nornir): def test_napalm_validate_src_error(self, nornir): opt = {"path": THIS_DIR + "/mocked/napalm_get/test_napalm_getters"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(connect, extras=opt) result = d.run( networking.napalm_validate, src=THIS_DIR + "/data/validate_error.yaml" @@ -35,7 +45,7 @@ def test_napalm_validate_src_error(self, nornir): def test_napalm_validate_src_validate_source(self, nornir): opt = {"path": THIS_DIR + "/mocked/napalm_get/test_napalm_getters"} d = nornir.filter(name="dev3.group_2") - d.run(connections.napalm_connection, optional_args=opt) + d.run(connect, extras=opt) validation_dict = [{"get_interfaces": {"Ethernet1": {"description": ""}}}] diff --git a/tests/plugins/tasks/networking/test_netmiko_file_transfer.py b/tests/plugins/tasks/networking/test_netmiko_file_transfer.py index f70c3014..8c218d36 100644 --- a/tests/plugins/tasks/networking/test_netmiko_file_transfer.py +++ b/tests/plugins/tasks/networking/test_netmiko_file_transfer.py @@ -6,7 +6,6 @@ class Test(object): - def test_netmiko_file_transfer(self, nornir): source_file = os.path.join(THIS_DIR, "data", "test_file.txt") dest_file = "test_file.txt" diff --git a/tests/plugins/tasks/networking/test_netmiko_send_command.py b/tests/plugins/tasks/networking/test_netmiko_send_command.py index 254d73d6..bf38dae4 100644 --- a/tests/plugins/tasks/networking/test_netmiko_send_command.py +++ b/tests/plugins/tasks/networking/test_netmiko_send_command.py @@ -1,17 +1,7 @@ -from nornir.plugins.tasks import connections, networking +from nornir.plugins.tasks import networking class Test(object): - - def test_explicit_netmiko_connection(self, nornir): - nornir.filter(name="dev4.group_2").run(task=connections.netmiko_connection) - result = nornir.filter(name="dev4.group_2").run( - networking.netmiko_send_command, command_string="hostname" - ) - assert result - for h, r in result.items(): - assert h == r.result.strip() - def test_netmiko_send_command(self, nornir): result = nornir.filter(name="dev4.group_2").run( networking.netmiko_send_command, command_string="hostname" diff --git a/tests/plugins/tasks/networking/test_netmiko_send_config.py b/tests/plugins/tasks/networking/test_netmiko_send_config.py index edbf4bcc..5ad1b783 100644 --- a/tests/plugins/tasks/networking/test_netmiko_send_config.py +++ b/tests/plugins/tasks/networking/test_netmiko_send_config.py @@ -1,17 +1,7 @@ -from nornir.plugins.tasks import connections, networking +from nornir.plugins.tasks import networking class Test(object): - - def test_explicit_netmiko_connection(self, nornir): - nornir.filter(name="dev4.group_2").run(task=connections.netmiko_connection) - result = nornir.filter(name="dev4.group_2").run( - networking.netmiko_send_config, config_commands="hostname" - ) - assert result - for h, r in result.items(): - assert h in r.result.strip() - def test_netmiko_send_command(self, nornir): result = nornir.filter(name="dev4.group_2").run( networking.netmiko_send_config, config_commands="hostname" diff --git a/tests/plugins/tasks/networking/test_tcp_ping.py b/tests/plugins/tasks/networking/test_tcp_ping.py index 920e2296..2e15dda0 100644 --- a/tests/plugins/tasks/networking/test_tcp_ping.py +++ b/tests/plugins/tasks/networking/test_tcp_ping.py @@ -1,8 +1,7 @@ import os -from nornir.core import Nornir -from nornir.plugins.inventory.simple import SimpleInventory +from nornir import InitNornir from nornir.plugins.tasks import networking @@ -11,7 +10,6 @@ class Test(object): - def test_tcp_ping_port(self, nornir): filter = nornir.filter(name="dev4.group_2") result = filter.run(networking.tcp_ping, ports=65004) @@ -36,7 +34,6 @@ def test_tcp_ping_invalid_port(self, nornir): processed = True assert isinstance(result.exception, ValueError) assert processed - nornir.data.reset_failed_hosts() def test_tcp_ping_invalid_ports(self, nornir): results = nornir.run(networking.tcp_ping, ports=[22, "web", 443]) @@ -45,18 +42,18 @@ def test_tcp_ping_invalid_ports(self, nornir): processed = True assert isinstance(result.exception, ValueError) assert processed - nornir.data.reset_failed_hosts() - -def test_tcp_ping_external_hosts(): - external = Nornir(inventory=SimpleInventory(ext_inv_file, ""), dry_run=True) - result = external.run(networking.tcp_ping, ports=[23, 443]) + def test_tcp_ping_external_hosts(self): + external = InitNornir( + inventory={"options": {"host_file": ext_inv_file}}, dry_run=True + ) + result = external.run(networking.tcp_ping, ports=[23, 443]) - assert result - for h, r in result.items(): - if h == "www.github.com": - assert r.result[23] is False - assert r.result[443] - else: - assert r.result[23] is False - assert r.result[443] is False + assert result + for h, r in result.items(): + if h == "www.github.com": + assert r.result[23] is False + assert r.result[443] + else: + assert r.result[23] is False + assert r.result[443] diff --git a/tests/plugins/tasks/text/test_template_file.py b/tests/plugins/tasks/text/test_template_file.py index 6f1c7efc..afe0e330 100644 --- a/tests/plugins/tasks/text/test_template_file.py +++ b/tests/plugins/tasks/text/test_template_file.py @@ -10,9 +10,10 @@ class Test(object): - def test_template_file(self, nornir): - result = nornir.run(text.template_file, template="simple.j2", path=data_dir) + result = nornir.run( + text.template_file, template="simple.j2", my_var="asd", path=data_dir + ) assert result for h, r in result.items(): @@ -29,4 +30,3 @@ def test_template_file_error_broken_file(self, nornir): processed = True assert isinstance(result.exception, TemplateSyntaxError) assert processed - nornir.data.reset_failed_hosts() diff --git a/tests/plugins/tasks/text/test_template_string.py b/tests/plugins/tasks/text/test_template_string.py index c02dbd28..920970c9 100644 --- a/tests/plugins/tasks/text/test_template_string.py +++ b/tests/plugins/tasks/text/test_template_string.py @@ -25,10 +25,9 @@ class Test(object): - def test_template_string(self, nornir): - result = nornir.run(text.template_string, template=simple_j2) + result = nornir.run(text.template_string, template=simple_j2, my_var="asd") assert result for h, r in result.items(): @@ -45,4 +44,3 @@ def test_template_string_error_broken_string(self, nornir): processed = True assert isinstance(result.exception, TemplateSyntaxError) assert processed - nornir.data.reset_failed_hosts() diff --git a/tests/wrapper.py b/tests/wrapper.py index 66b83da1..2378485e 100644 --- a/tests/wrapper.py +++ b/tests/wrapper.py @@ -1,10 +1,7 @@ import sys -from decorator import decorator +from io import StringIO -if sys.version_info.major == 2: - from StringIO import StringIO -else: - from io import StringIO +from decorator import decorator def wrap_cli_test(output, save_output=False): @@ -33,8 +30,6 @@ def run_test(func, *args, **kwargs): sys.stderr = backup_stderr output_file = output - if sys.version_info.major == 2: - output_file += "_python27" if save_output: with open("{}.stdout".format(output_file), "w+") as f: diff --git a/tox.ini b/tox.ini index b26595fb..19ab03ec 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py34,py35,py36 +envlist = py36 [testenv] deps = @@ -8,10 +8,10 @@ deps = passenv = * commands = - py.test + py.test --cov=nornir --cov-report=term-missing -vs [testenv:black] -deps = black==18.4a1 +deps = black==18.6b4 basepython = python3.6 commands = @@ -24,6 +24,7 @@ deps = basepython = python3.6 commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html + # TODO REPLACE with: sphinx-build -n -E -q -N -b dummy -d docs/_build/doctrees docs asd [testenv:pylama] deps = @@ -32,3 +33,22 @@ deps = basepython = python3.6 commands = pylama . + +[testenv:mypy] +deps = + -rrequirements-dev.txt + +basepython = python3.6 +commands = + mypy . + +[testenv:nbval] +deps = + -rrequirements-pinned.txt + +basepython = python3.6 +commands = + pytest --nbval docs/plugins + pytest --nbval docs/howto + pytest --nbval docs/tutorials/intro/initializing_nornir.ipynb + pytest --nbval docs/tutorials/intro/inventory.ipynb