From 146b925000025467e9744d8d539e85d5a67b4894 Mon Sep 17 00:00:00 2001 From: Kirk Byers Date: Thu, 15 Apr 2021 13:34:47 -0700 Subject: [PATCH 1/9] New version of black --- napalm_ansible/modules/napalm_cli.py | 2 +- napalm_ansible/modules/napalm_get_facts.py | 2 +- napalm_ansible/modules/napalm_install_config.py | 2 +- napalm_ansible/modules/napalm_parse_yang.py | 2 +- napalm_ansible/modules/napalm_ping.py | 2 +- napalm_ansible/modules/napalm_validate.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/napalm_ansible/modules/napalm_cli.py b/napalm_ansible/modules/napalm_cli.py index d33cba1..0f25c11 100644 --- a/napalm_ansible/modules/napalm_cli.py +++ b/napalm_ansible/modules/napalm_cli.py @@ -5,7 +5,7 @@ # FIX for Ansible 2.8 moving this function and making it private # greatly simplified for napalm-ansible's use def return_values(obj): - """ Return native stringified values from datastructures. + """Return native stringified values from datastructures. For use with removing sensitive values pre-jsonification.""" yield str(obj) diff --git a/napalm_ansible/modules/napalm_get_facts.py b/napalm_ansible/modules/napalm_get_facts.py index 176a202..955c7e9 100644 --- a/napalm_ansible/modules/napalm_get_facts.py +++ b/napalm_ansible/modules/napalm_get_facts.py @@ -24,7 +24,7 @@ # FIX for Ansible 2.8 moving this function and making it private # greatly simplified for napalm-ansible's use def return_values(obj): - """ Return native stringified values from datastructures. + """Return native stringified values from datastructures. For use with removing sensitive values pre-jsonification.""" yield str(obj) diff --git a/napalm_ansible/modules/napalm_install_config.py b/napalm_ansible/modules/napalm_install_config.py index 5f10f55..ff03288 100644 --- a/napalm_ansible/modules/napalm_install_config.py +++ b/napalm_ansible/modules/napalm_install_config.py @@ -26,7 +26,7 @@ # FIX for Ansible 2.8 moving this function and making it private # greatly simplified for napalm-ansible's use def return_values(obj): - """ Return native stringified values from datastructures. + """Return native stringified values from datastructures. For use with removing sensitive values pre-jsonification.""" yield str(obj) diff --git a/napalm_ansible/modules/napalm_parse_yang.py b/napalm_ansible/modules/napalm_parse_yang.py index 4bc939b..65364fa 100644 --- a/napalm_ansible/modules/napalm_parse_yang.py +++ b/napalm_ansible/modules/napalm_parse_yang.py @@ -38,7 +38,7 @@ # FIX for Ansible 2.8 moving this function and making it private # greatly simplified for napalm-ansible's use def return_values(obj): - """ Return native stringified values from datastructures. + """Return native stringified values from datastructures. For use with removing sensitive values pre-jsonification.""" yield str(obj) diff --git a/napalm_ansible/modules/napalm_ping.py b/napalm_ansible/modules/napalm_ping.py index de701c0..f9382a2 100644 --- a/napalm_ansible/modules/napalm_ping.py +++ b/napalm_ansible/modules/napalm_ping.py @@ -20,7 +20,7 @@ # FIX for Ansible 2.8 moving this function and making it private # greatly simplified for napalm-ansible's use def return_values(obj): - """ Return native stringified values from datastructures. + """Return native stringified values from datastructures. For use with removing sensitive values pre-jsonification.""" yield str(obj) diff --git a/napalm_ansible/modules/napalm_validate.py b/napalm_ansible/modules/napalm_validate.py index f4a9f9d..d6d078b 100644 --- a/napalm_ansible/modules/napalm_validate.py +++ b/napalm_ansible/modules/napalm_validate.py @@ -19,7 +19,7 @@ # FIX for Ansible 2.8 moving this function and making it private # greatly simplified for napalm-ansible's use def return_values(obj): - """ Return native stringified values from datastructures. + """Return native stringified values from datastructures. For use with removing sensitive values pre-jsonification.""" yield str(obj) From 964cc387840c2e9cc9eeb04aec88c8c2751230b0 Mon Sep 17 00:00:00 2001 From: Kirk Byers Date: Thu, 15 Apr 2021 13:35:07 -0700 Subject: [PATCH 2/9] Updating to newer versions; eliminating any PY27 support --- requirements-dev.txt | 4 ++-- requirements.txt | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 501e828..04bc21c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ pylama==7.7.1 -pytest -black==18.9b0; python_version >= '3.6' +pytest==5.4.3 +black==20.8b1; python_version >= '3.6' ansible -r requirements.txt diff --git a/requirements.txt b/requirements.txt index 6460676..6785dce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ -netmiko==2.4.2; python_version == '2.7' -netmiko>=3.1.0; python_version >= '3.6' -napalm==2.5.0; python_version == '2.7' -napalm>=3.0.1; python_version >= '3.6' +netmiko>=3.3.0; python_version >= '3.6' +napalm>=3.2.0; python_version >= '3.6' six From e496fdc8dbeac88982fe1e34ff184e3a203d8808 Mon Sep 17 00:00:00 2001 From: Kirk Byers Date: Thu, 15 Apr 2021 13:35:27 -0700 Subject: [PATCH 3/9] New collection stub code --- collection/LICENSE | 201 ++++++++ collection/README.md | 459 ++++++++++++++++++ collection/galaxy.yml | 20 + collection/plugins/action/__init__.py | 0 collection/plugins/action/napalm.py | 34 ++ collection/plugins/action/napalm_get_facts.py | 1 + .../plugins/action/napalm_install_config.py | 1 + .../plugins/action/napalm_parse_yang.py | 1 + collection/plugins/action/napalm_ping.py | 1 + collection/plugins/action/napalm_validate.py | 1 + collection/plugins/modules/__init__.py | 0 collection/plugins/modules/napalm_cli.py | 191 ++++++++ .../plugins/modules/napalm_diff_yang.py | 128 +++++ .../plugins/modules/napalm_get_facts.py | 300 ++++++++++++ .../plugins/modules/napalm_install_config.py | 340 +++++++++++++ .../plugins/modules/napalm_parse_yang.py | 305 ++++++++++++ collection/plugins/modules/napalm_ping.py | 252 ++++++++++ .../plugins/modules/napalm_translate_yang.py | 132 +++++ collection/plugins/modules/napalm_validate.py | 267 ++++++++++ 19 files changed, 2634 insertions(+) create mode 100644 collection/LICENSE create mode 100644 collection/README.md create mode 100644 collection/galaxy.yml create mode 100644 collection/plugins/action/__init__.py create mode 100644 collection/plugins/action/napalm.py create mode 120000 collection/plugins/action/napalm_get_facts.py create mode 120000 collection/plugins/action/napalm_install_config.py create mode 120000 collection/plugins/action/napalm_parse_yang.py create mode 120000 collection/plugins/action/napalm_ping.py create mode 120000 collection/plugins/action/napalm_validate.py create mode 100644 collection/plugins/modules/__init__.py create mode 100644 collection/plugins/modules/napalm_cli.py create mode 100644 collection/plugins/modules/napalm_diff_yang.py create mode 100644 collection/plugins/modules/napalm_get_facts.py create mode 100644 collection/plugins/modules/napalm_install_config.py create mode 100644 collection/plugins/modules/napalm_parse_yang.py create mode 100644 collection/plugins/modules/napalm_ping.py create mode 100644 collection/plugins/modules/napalm_translate_yang.py create mode 100644 collection/plugins/modules/napalm_validate.py diff --git a/collection/LICENSE b/collection/LICENSE new file mode 100644 index 0000000..5c304d1 --- /dev/null +++ b/collection/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/collection/README.md b/collection/README.md new file mode 100644 index 0000000..f1a6512 --- /dev/null +++ b/collection/README.md @@ -0,0 +1,459 @@ +# napalm-ansible + +Collection of ansible modules that use [napalm](https://github.com/napalm-automation/napalm) to retrieve data or modify configuration on networking devices. + +Modules +======= + +The following modules are currently available: + +- ``napalm_cli`` +- ``napalm_diff_yang`` +- ``napalm_get_facts`` +- ``napalm_install_config`` +- ``napalm_parse_yang`` +- ``napalm_ping`` +- ``napalm_translate_yang`` +- ``napalm_validate`` + +Action-Plugins +======= + +Action-Plugins should be used to make napalm-ansible more consistent with the behavior of other Ansible modules (eliminate the need of a provider and of individual task arguments for hostname, username, password, and timeout). + +They provide default parameters for the hostname, username, password and timeout paramters. +* hostname is set to the first of provider {{ hostname }}, provider {{ host }}, play-context remote_addr. +* username is set to the first of provider {{ username }}, play-context connection_user. +* password is set to the first of provider {{ password }}, play-context password (-k argument). +* timeout is set to the provider {{ timeout }}, or else defaults to 60 seconds (can't be passed via command-line). + +Install +======= + +To install run either: + +``` +pip install napalm-ansible +pip install napalm +``` + +Or: + +``` +git clone https://github.com/napalm-automation/napalm-ansible +pip install napalm +``` + +Configuring Ansible +=================== + +Once you have installed ``napalm-ansible`` then you need to add napalm-ansible to your ``library`` and ``action_plugins`` paths in ``ansible.cfg``. If you used pip to install napalm-ansible, then you can just run the ``napalm-ansible`` command and follow the instructions specified there. + +``` +$ cat .ansible.cfg + +[defaults] +library = ~/napalm-ansible/napalm_ansible/modules +action_plugins = ~/napalm-ansible/napalm_ansible/plugins/action +... + +For more details on ansible's configuration file visit: +https://docs.ansible.com/ansible/latest/intro_configuration.html +``` + +Dependencies +======= +* [napalm](https://github.com/napalm-automation/napalm) 2.5.0 or later +* [ansible](https://github.com/ansible/ansible) 2.8.11 or later + + +Examples +======= + +### Cisco IOS + +#### Inventory (IOS) + +```INI +[cisco] +cisco5 ansible_host=cisco5.domain.com + +[cisco:vars] +# Must match Python that NAPALM is installed into. +ansible_python_interpreter=/path/to/venv/bin/python +ansible_network_os=ios +ansible_connection=network_cli +ansible_user=admin +ansible_ssh_pass=my_password +``` + +#### Playbook (IOS) + +```YAML +--- +- name: NAPALM get_facts and get_interfaces + hosts: cisco5 + gather_facts: False + tasks: + - name: napalm get_facts + napalm_get_facts: + filter: facts,interfaces + + - debug: + var: napalm_facts +``` + +#### Playbook Output (IOS) + +```INI +$ ansible-playbook napalm_get_ios.yml + +PLAY [NAPALM get_facts and get_interfaces] ********* + +TASK [napalm get_facts] **************************** +ok: [cisco5] + +TASK [debug] *************************************** +ok: [cisco5] => { + "napalm_facts": { + "fqdn": "cisco5.domain.com", + "hostname": "cisco5", + "interface_list": [ + "GigabitEthernet1", + "GigabitEthernet2", + "GigabitEthernet3", + "GigabitEthernet4", + "GigabitEthernet5", + "GigabitEthernet6", + "GigabitEthernet7" + ], + "model": "CSR1000V", + "os_version": "Virtual XE Software, Version 16.9.3, RELEASE SOFTWARE (fc2)", + "serial_number": "9700000000P", + "uptime": 13999500, + "vendor": "Cisco" + } +} + +PLAY RECAP ***************************************** +cisco5 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +### Arista EOS + +#### Inventory (EOS) + +```INI +[arista] +arista5 ansible_host=arista5.domain.com + +[arista:vars] +# Must match Python that NAPALM is installed into. +ansible_python_interpreter=/path/to/venv/bin/python +ansible_network_os=eos +# Continue using 'network_cli' (NAPALM module itself will use eAPI) +ansible_connection=network_cli +ansible_user=admin +ansible_ssh_pass=my_password +``` + +#### Playbook (EOS) + +```YAML +--- +- name: NAPALM get_facts and get_interfaces + hosts: arista5 + gather_facts: False + tasks: + - name: napalm get_facts + napalm_get_facts: + filter: facts,interfaces + + - debug: + var: napalm_facts +``` + +#### Playbook Output (EOS) + +```INI +$ ansible-playbook napalm_get_arista.yml + +PLAY [NAPALM get_facts and get_interfaces] ********* + +TASK [napalm get_facts] **************************** +ok: [arista5] + +TASK [debug] *************************************** +ok: [arista5] => { + "napalm_facts": { + "fqdn": "arista5", + "hostname": "arista5", + "interface_list": [ + "Ethernet1", + "Ethernet2", + "Ethernet3", + "Ethernet4", + "Ethernet5", + "Ethernet6", + "Ethernet7", + "Management1", + "Vlan1" + ], + "model": "vEOS", + "os_version": "4.20.10M-10040268.42010M", + "serial_number": "", + "uptime": 12858220, + "vendor": "Arista" + } +} + +PLAY RECAP **************************************** +arista5 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Cisco NX-OS + +#### Inventory (NX-OS) + +```INI +[nxos] +nxos1 ansible_host=nxos1.domain.com + +[nxos:vars] +# Must match Python that NAPALM is installed into. +ansible_python_interpreter=/path/to/venv/bin/python +ansible_network_os=nxos +# Continue using 'network_cli' (NAPALM module itself will use NX-API) +ansible_connection=network_cli +ansible_user=admin +ansible_ssh_pass=my_password +``` + +#### Playbook (NX-OS NX-API) + +```YAML +--- +- name: napalm + hosts: nxos1 + gather_facts: False + tasks: + - name: Retrieve get_facts, get_interfaces + napalm_get_facts: + filter: facts,interfaces + # Specify NX-API Port + optional_args: + port: 8443 + + - debug: + var: napalm_facts +``` + +#### Playbook Output (NX-OS NX-API) + +```INI +$ ansible-playbook napalm_get_nxos.yml + +PLAY [napalm] *************************************** + +TASK [Retrieve get_facts, get_interfaces] *********** +ok: [nxos1] + +TASK [debug] **************************************** +ok: [nxos1] => { + "napalm_facts": { + "fqdn": "nxos1.domain.com", + "hostname": "nxos1", + "interface_list": [ + "mgmt0", + "Ethernet1/1", + "Ethernet1/2", + "Ethernet1/3", + "Ethernet1/4", + "Vlan1" + ], + "model": "Nexus9000 9000v Chassis", + "os_version": "", + "serial_number": "9B00000000S", + "uptime": 12767664, + "vendor": "Cisco" + } +} + +PLAY RECAP ****************************************** +nxos1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +#### Playbook (NX-OS SSH) + +```YAML +--- +- name: napalm nxos_ssh + hosts: nxos1 + tasks: + - name: Retrieve get_facts, get_interfaces + napalm_get_facts: + filter: facts,interfaces + # Instruct NAPALM module to use SSH + dev_os: nxos_ssh + + - debug: + var: napalm_facts +``` + +#### Playbook Output (NX-OS SSH) + +```INI +$ ansible-playbook napalm_get_nxos_ssh.yml + +PLAY [napalm nxos_ssh] ******************************** + +TASK [Retrieve get_facts, get_interfaces] ************* +ok: [nxos1] + +TASK [debug] ****************************************** +ok: [nxos1] => { + "napalm_facts": { + "fqdn": "nxos1.domain.com", + "hostname": "nxos1", + "interface_list": [ + "mgmt0", + "Ethernet1/1", + "Ethernet1/2", + "Ethernet1/3", + "Ethernet1/4", + "Vlan1" + ], + "model": "Nexus9000 9000v Chassis", + "os_version": "9.2(3)", + "serial_number": "9000000000S", + "uptime": 12767973, + "vendor": "Cisco" + } +} + +PLAY RECAP ******************************************** +nxos1 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Juniper Junos + +#### Inventory (Junos) + +```INI +[juniper] +juniper1 ansible_host=juniper1.domain.com + +[juniper:vars] +# Must match Python that NAPALM is installed into. +ansible_python_interpreter=/path/to/venv/bin/python +ansible_network_os=junos +# Continue using 'network_cli' (NAPALM module itself will use NETCONF) +ansible_connection=network_cli +ansible_user=admin +ansible_ssh_pass=my_password +``` + +#### Playbook (Junos) + +```YAML +--- +- name: napalm + hosts: juniper + gather_facts: False + tasks: + - name: Retrieve get_facts, get_interfaces + napalm_get_facts: + filter: facts,interfaces + + - debug: + var: napalm_facts +``` + +#### Playbook Output (Junos) + +```INI +$ ansible-playbook napalm_get_junos.yml -i + +PLAY [napalm] ***************************************** + +TASK [Retrieve get_facts, get_interfaces] ************* +ok: [juniper1] + +TASK [debug] ****************************************** +ok: [juniper1] => { + "napalm_facts": { + "fqdn": "juniper1", + "hostname": "juniper1", + "interface_list": [ + "fe-0/0/0", + "gr-0/0/0", + "ip-0/0/0", + "lt-0/0/0", + "mt-0/0/0", + "sp-0/0/0", + "fe-0/0/1", + "fe-0/0/2", + "fe-0/0/3", + "fe-0/0/4", + "fe-0/0/5", + "fe-0/0/6", + "fe-0/0/7", + "gre", + "ipip", + "irb", + "lo0", + "lsi", + "mtun", + "pimd", + "pime", + "pp0", + "ppd0", + "ppe0", + "st0", + "tap", + "vlan" + ], + "model": "SRX100H2", + "os_version": "12.1X44-D35.5", + "serial_number": "BZ0000000008", + "uptime": 119586097, + "vendor": "Juniper" + } +} + +PLAY RECAP ******************************************* +juniper1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Example to install config on a device + +```INI +- assemble: + src=../compiled/{{ inventory_hostname }}/ + dest=../compiled/{{ inventory_hostname }}/running.conf + + - napalm_install_config: + hostname={{ inventory_hostname }} + username={{ user }} + dev_os={{ os }} + password={{ passwd }} + config_file=../compiled/{{ inventory_hostname }}/running.conf + commit_changes={{ commit_changes }} + replace_config={{ replace_config }} + get_diffs=True + diff_file=../compiled/{{ inventory_hostname }}/diff +``` + +### Example to get compliance report + +```YAML +- name: GET VALIDATION REPORT + napalm_validate: + username: "{{ user }}" + password: "{{ passwd }}" + hostname: "{{ inventory_hostname }}" + dev_os: "{{ dev_os }}" + validation_file: validate.yml +``` + diff --git a/collection/galaxy.yml b/collection/galaxy.yml new file mode 100644 index 0000000..af143b4 --- /dev/null +++ b/collection/galaxy.yml @@ -0,0 +1,20 @@ +--- +namespace: napalm-automation +name: napalm +version: 1.2.0 +readme: README.md +authors: + - Kirk Byers + - Mircea Ulinic + - David Barroso +description: NAPALM + Ansible Modules +license_file: LICENSE +tags: + - networking + - napalm +repository: https://github.com/napalm-automation/napalm-ansible +documentation: https://github.com/napalm-automation/napalm-ansible +homepage: https://github.com/napalm-automation/napalm-ansible +issues: https://github.com/napalm-automation/napalm-ansible/issues +build_ignore: + - "*.tar.gz" diff --git a/collection/plugins/action/__init__.py b/collection/plugins/action/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/collection/plugins/action/napalm.py b/collection/plugins/action/napalm.py new file mode 100644 index 0000000..1952b26 --- /dev/null +++ b/collection/plugins/action/napalm.py @@ -0,0 +1,34 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +__metaclass__ = type + +from ansible.plugins.action.normal import ActionModule as _ActionModule + + +class ActionModule(_ActionModule): + def run(self, tmp=None, task_vars=None): + pc = self._play_context + + if hasattr(pc, "connection_user"): + # populate provider values with context values if not set + provider = self._task.args.get("provider", {}) + + provider["hostname"] = provider.get( + "hostname", provider.get("host", pc.remote_addr) + ) + username = provider.get("username", pc.connection_user) + # Try to make ansible_connection=network_cli also work + if not username: + username = provider.get("username", pc.remote_user) + provider["username"] = username + provider["password"] = provider.get("password", pc.password) + # Timeout can't be passed via command-line as Ansible defaults to a 10 second timeout + provider["timeout"] = provider.get("timeout", 60) + + if hasattr(pc, "network_os"): + provider["dev_os"] = provider.get("dev_os", pc.network_os) + + self._task.args["provider"] = provider + + result = super(ActionModule, self).run(tmp, task_vars) + return result diff --git a/collection/plugins/action/napalm_get_facts.py b/collection/plugins/action/napalm_get_facts.py new file mode 120000 index 0000000..20e7be0 --- /dev/null +++ b/collection/plugins/action/napalm_get_facts.py @@ -0,0 +1 @@ +napalm.py \ No newline at end of file diff --git a/collection/plugins/action/napalm_install_config.py b/collection/plugins/action/napalm_install_config.py new file mode 120000 index 0000000..20e7be0 --- /dev/null +++ b/collection/plugins/action/napalm_install_config.py @@ -0,0 +1 @@ +napalm.py \ No newline at end of file diff --git a/collection/plugins/action/napalm_parse_yang.py b/collection/plugins/action/napalm_parse_yang.py new file mode 120000 index 0000000..20e7be0 --- /dev/null +++ b/collection/plugins/action/napalm_parse_yang.py @@ -0,0 +1 @@ +napalm.py \ No newline at end of file diff --git a/collection/plugins/action/napalm_ping.py b/collection/plugins/action/napalm_ping.py new file mode 120000 index 0000000..20e7be0 --- /dev/null +++ b/collection/plugins/action/napalm_ping.py @@ -0,0 +1 @@ +napalm.py \ No newline at end of file diff --git a/collection/plugins/action/napalm_validate.py b/collection/plugins/action/napalm_validate.py new file mode 120000 index 0000000..20e7be0 --- /dev/null +++ b/collection/plugins/action/napalm_validate.py @@ -0,0 +1 @@ +napalm.py \ No newline at end of file diff --git a/collection/plugins/modules/__init__.py b/collection/plugins/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/collection/plugins/modules/napalm_cli.py b/collection/plugins/modules/napalm_cli.py new file mode 100644 index 0000000..0f25c11 --- /dev/null +++ b/collection/plugins/modules/napalm_cli.py @@ -0,0 +1,191 @@ +from __future__ import unicode_literals, print_function +from ansible.module_utils.basic import AnsibleModule + + +# FIX for Ansible 2.8 moving this function and making it private +# greatly simplified for napalm-ansible's use +def return_values(obj): + """Return native stringified values from datastructures. + + For use with removing sensitive values pre-jsonification.""" + yield str(obj) + + +DOCUMENTATION = """ +--- +module: napalm_cli +author: "Charlie Allom" +version_added: "2.2" +short_description: "Executes network device CLI commands and returns response using NAPALM" +description: + - "Executes network device CLI commands and returns response using NAPALM" +requirements: + - napalm +options: + hostname: + description: + - IP or FQDN of the device you want to connect to + required: False + username: + description: + - Username + required: False + password: + description: + - Password + required: False + args: + description: + - Keyword arguments to pass to the `cli` method + required: True + dev_os: + description: + - OS of the device + required: False + provider: + description: + - Dictionary which acts as a collection of arguments used to define the characteristics + of how to connect to the device. + Note - hostname, username, password and dev_os must be defined in either provider + or local param + Note - local param takes precedence, e.g. hostname is preferred to provider['hostname'] + required: False + +""" + +EXAMPLES = """ +- napalm_cli: + hostname: "{{ inventory_hostname }}" + username: "napalm" + password: "napalm" + dev_os: "eos" + args: + commands: + - show version + - show snmp chassis + +- napalm_cli: + provider: "{{ napalm_provider }}" + args: + commands: + - show version + - show snmp chassis +""" + +RETURN = """ +changed: + description: ALWAYS RETURNS FALSE + returned: always + type: bool + sample: True +results: + description: string of command output + returned: always + type: dict + sample: '{ + "show snmp chassis": "Chassis: 1234\n", + "show version": "Arista vEOS\nHardware version: \nSerial number: \nSystem MAC address: 0800.27c3.5f28\n\nSoftware image version: 4.17.5M\nArchitecture: i386\nInternal build version: 4.17.5M-4414219.4175M\nInternal build ID: d02143c6-e42b-4fc3-99b6-97063bddb6b8\n\nUptime: 1 hour and 21 minutes\nTotal memory: 1893416 kB\nFree memory: 956488 kB\n\n" # noqa + }' +""" + +napalm_found = False +try: + from napalm import get_network_driver + from napalm.base import ModuleImportError + + napalm_found = True +except ImportError: + pass + + +def main(): + module = AnsibleModule( + argument_spec=dict( + hostname=dict(type="str", required=False, aliases=["host"]), + username=dict(type="str", required=False), + password=dict(type="str", required=False, no_log=True), + provider=dict(type="dict", required=False), + timeout=dict(type="int", required=False, default=60), + dev_os=dict(type="str", required=False), + optional_args=dict(required=False, type="dict", default=None), + args=dict(required=True, type="dict", default=None), + ), + supports_check_mode=False, + ) + + if not napalm_found: + module.fail_json(msg="the python module napalm is required") + + provider = module.params["provider"] or {} + + no_log = ["password", "secret"] + for param in no_log: + if provider.get(param): + module.no_log_values.update(return_values(provider[param])) + if provider.get("optional_args") and provider["optional_args"].get(param): + module.no_log_values.update( + return_values(provider["optional_args"].get(param)) + ) + if module.params.get("optional_args") and module.params["optional_args"].get( + param + ): + module.no_log_values.update( + return_values(module.params["optional_args"].get(param)) + ) + + # allow host or hostname + provider["hostname"] = provider.get("hostname", None) or provider.get("host", None) + # allow local params to override provider + for param, pvalue in provider.items(): + if module.params.get(param) is not False: + module.params[param] = module.params.get(param) or pvalue + + hostname = module.params["hostname"] + username = module.params["username"] + dev_os = module.params["dev_os"] + password = module.params["password"] + timeout = module.params["timeout"] + args = module.params["args"] + + argument_check = {"hostname": hostname, "username": username, "dev_os": dev_os} + for key, val in argument_check.items(): + if val is None: + module.fail_json(msg=str(key) + " is required") + + if module.params["optional_args"] is None: + optional_args = {} + else: + optional_args = module.params["optional_args"] + + try: + network_driver = get_network_driver(dev_os) + except ModuleImportError as e: + module.fail_json(msg="Failed to import napalm driver: " + str(e)) + + try: + device = network_driver( + hostname=hostname, + username=username, + password=password, + timeout=timeout, + optional_args=optional_args, + ) + device.open() + except Exception as e: + module.fail_json(msg="cannot connect to device: " + str(e)) + + try: + cli_response = device.cli(**args) + except Exception as e: + module.fail_json(msg="{}".format(e)) + + try: + device.close() + except Exception as e: + module.fail_json(msg="cannot close device connection: " + str(e)) + + module.exit_json(changed=False, cli_results=cli_response) + + +if __name__ == "__main__": + main() diff --git a/collection/plugins/modules/napalm_diff_yang.py b/collection/plugins/modules/napalm_diff_yang.py new file mode 100644 index 0000000..8fdcb6a --- /dev/null +++ b/collection/plugins/modules/napalm_diff_yang.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +(c) 2017 David Barroso + +This file is part of Ansible + +Ansible is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Ansible is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Ansible. If not, see . +""" +from __future__ import unicode_literals, print_function +from ansible.module_utils.basic import AnsibleModule + +try: + import napalm_yang +except ImportError: + napalm_yang = None + + +DOCUMENTATION = """ +--- +module: napalm_diff_yang +author: "David Barroso (@dbarrosop)" +version_added: "0.0" +short_description: "Return diff of two YANG objects" +description: + - "Create two YANG objects from dictionaries and runs mehtod" + - "napalm_yang.utils.diff on them." +requirements: + - napalm-yang +options: + models: + description: + - List of models to parse + required: True + first: + description: + - Dictionary with the data to load into the first YANG object + required: True + second: + description: + - Dictionary with the data to load into the second YANG object + required: True +""" + +EXAMPLES = """ +- napalm_diff_yang: + first: "{{ candidate.yang_model }}" + second: "{{ running_config.yang_model }}" + models: + - models.openconfig_interfaces + register: diff +""" + +RETURN = """ +diff: + description: "Same output as the method napalm_yang.utils.diff" + returned: always + type: dict + sample: '{ + "interfaces": { + "interface": { + "both": { + "Port-Channel1": { + "config": { + "description": { + "first": "blah", + "second": "Asadasd" + } + } + } + } + } + }' +""" + + +def get_root_object(models): + """ + Read list of models and returns a Root object with the proper models added. + """ + root = napalm_yang.base.Root() + + for model in models: + current = napalm_yang + for p in model.split("."): + current = getattr(current, p) + root.add_model(current) + + return root + + +def main(): + module = AnsibleModule( + argument_spec=dict( + models=dict(type="list", required=True), + first=dict(type="dict", required=True), + second=dict(type="dict", required=True), + ), + supports_check_mode=True, + ) + + if not napalm_yang: + module.fail_json(msg="the python module napalm-yang is required") + + first = get_root_object(module.params["models"]) + first.load_dict(module.params["first"]) + + second = get_root_object(module.params["models"]) + second.load_dict(module.params["second"]) + + diff = napalm_yang.utils.diff(first, second) + + module.exit_json(yang_diff=diff) + + +if __name__ == "__main__": + main() diff --git a/collection/plugins/modules/napalm_get_facts.py b/collection/plugins/modules/napalm_get_facts.py new file mode 100644 index 0000000..955c7e9 --- /dev/null +++ b/collection/plugins/modules/napalm_get_facts.py @@ -0,0 +1,300 @@ +""" +(c) 2020 Kirk Byers +(c) 2016 Elisa Jasinska + +This file is part of Ansible + +Ansible is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Ansible is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Ansible. If not, see . +""" +from __future__ import unicode_literals, print_function +from ansible.module_utils.basic import AnsibleModule + + +# FIX for Ansible 2.8 moving this function and making it private +# greatly simplified for napalm-ansible's use +def return_values(obj): + """Return native stringified values from datastructures. + + For use with removing sensitive values pre-jsonification.""" + yield str(obj) + + +DOCUMENTATION = """ +--- +module: napalm_get_facts +author: "Elisa Jasinska (@fooelisa)" +version_added: "2.1" +short_description: "Gathers facts from a network device via napalm" +description: + - "Gathers facts from a network device via the Python module napalm" +requirements: + - napalm +options: + hostname: + description: + - IP or FQDN of the device you want to connect to + required: False + username: + description: + - Username + required: False + password: + description: + - Password + required: False + dev_os: + description: + - OS of the device + required: False + provider: + description: + - Dictionary which acts as a collection of arguments used to define the characteristics + of how to connect to the device. + Note - hostname, username, password and dev_os must be defined in either provider + or local param + Note - local param takes precedence, e.g. hostname is preferred to provider['hostname'] + required: False + timeout: + description: + - Time in seconds to wait for the device to respond + required: False + default: 60 + optional_args: + description: + - Dictionary of additional arguments passed to underlying driver + required: False + default: None + ignore_notimplemented: + description: + - "Ignores NotImplementedError for filters which aren't supported by the driver. Returns + invalid filters in a list called: not_implemented" + required: False + default: False + choices: [True, False] + filter: + description: + - "A list of facts to retreive from a device and provided though C(ansible_facts) + The list of facts available are maintained at: + http://napalm.readthedocs.io/en/latest/support/ + Note- not all getters are implemented on all supported device types" + required: False + default: ['facts'] + args: + description: + - dictionary of kwargs arguments to pass to the filter. The outer key is the name of + the getter (same as the filter) + required: False + default: None +""" + +EXAMPLES = """ +- name: get facts from device + napalm_get_facts: + hostname: '{{ inventory_hostname }}' + username: '{{ user }}' + dev_os: '{{ os }}' + password: '{{ passwd }}' + filter: ['facts'] + register: result + +- name: print data + debug: + var: result + +- name: Getters + napalm_get_facts: + provider: "{{ ios_provider }}" + filter: + - "lldp_neighbors_detail" + - "interfaces" + +- name: get facts from device + napalm_get_facts: + hostname: "{{ host }}" + username: "{{ user }}" + dev_os: "{{ os }}" + password: "{{ password }}" + optional_args: + port: "{{ port }}" + filter: ['facts', 'route_to', 'interfaces'] + args: + route_to: + protocol: static + destination: 8.8.8.8 + +""" + +RETURN = """ +changed: + description: "whether the command has been executed on the device" + returned: always + type: bool + sample: True +ansible_facts: + description: "Facts gathered on the device provided via C(ansible_facts)" + returned: certain keys are returned depending on filter + type: dict +""" + +napalm_found = False +try: + from napalm import get_network_driver + from napalm.base import ModuleImportError + + napalm_found = True +except ImportError: + pass + + +def main(): + module = AnsibleModule( + argument_spec=dict( + hostname=dict(type="str", required=False, aliases=["host"]), + username=dict(type="str", required=False), + password=dict(type="str", required=False, no_log=True), + provider=dict(type="dict", required=False), + dev_os=dict(type="str", required=False), + timeout=dict(type="int", required=False, default=60), + ignore_notimplemented=dict(type="bool", required=False, default=False), + args=dict(type="dict", required=False, default=None), + optional_args=dict(type="dict", required=False, default=None), + filter=dict(type="list", required=False, default=["facts"]), + ), + supports_check_mode=True, + ) + + if not napalm_found: + module.fail_json(msg="the python module napalm is required") + + provider = module.params["provider"] or {} + + no_log = ["password", "secret"] + for param in no_log: + if provider.get(param): + module.no_log_values.update(return_values(provider[param])) + if provider.get("optional_args") and provider["optional_args"].get(param): + module.no_log_values.update( + return_values(provider["optional_args"].get(param)) + ) + if module.params.get("optional_args") and module.params["optional_args"].get( + param + ): + module.no_log_values.update( + return_values(module.params["optional_args"].get(param)) + ) + + # allow host or hostname + provider["hostname"] = provider.get("hostname", None) or provider.get("host", None) + # allow local params to override provider + for param, pvalue in provider.items(): + if module.params.get(param) is not False: + module.params[param] = module.params.get(param) or pvalue + + hostname = module.params["hostname"] + username = module.params["username"] + dev_os = module.params["dev_os"] + password = module.params["password"] + timeout = module.params["timeout"] + filter_list = module.params["filter"] + args = module.params["args"] or {} + ignore_notimplemented = module.params["ignore_notimplemented"] + implementation_errors = [] + + argument_check = {"hostname": hostname, "username": username, "dev_os": dev_os} + for key, val in argument_check.items(): + if val is None: + module.fail_json(msg=str(key) + " is required") + + if module.params["optional_args"] is None: + optional_args = {} + else: + optional_args = module.params["optional_args"] + + try: + network_driver = get_network_driver(dev_os) + except ModuleImportError as e: + module.fail_json(msg="Failed to import napalm driver: " + str(e)) + + try: + device = network_driver( + hostname=hostname, + username=username, + password=password, + timeout=timeout, + optional_args=optional_args, + ) + device.open() + except Exception as e: + module.fail_json(msg="cannot connect to device: " + str(e)) + + # retreive data from device + facts = {} + + NAPALM_GETTERS = [ + getter for getter in dir(network_driver) if getter.startswith("get_") + ] + # Allow NX-OS checkpoint file to be retrieved via Ansible for use with replace config + NAPALM_GETTERS.append("get_checkpoint_file") + + for getter in filter_list: + getter_function = "get_{}".format(getter) + if getter_function not in NAPALM_GETTERS: + module.fail_json(msg="filter not recognized: " + getter) + + try: + if getter_function == "get_checkpoint_file": + getter_function = "_get_checkpoint_file" + get_func = getattr(device, getter_function) + result = get_func(**args.get(getter, {})) + facts[getter] = result + except NotImplementedError: + if ignore_notimplemented: + implementation_errors.append(getter) + else: + module.fail_json( + msg="The filter {} is not supported in napalm-{} [get_{}()]".format( + getter, dev_os, getter + ) + ) + except Exception as e: + module.fail_json( + msg="[{}] cannot retrieve device data: ".format(getter) + str(e) + ) + + # close device connection + try: + device.close() + except Exception as e: + module.fail_json(msg="cannot close device connection: " + str(e)) + + new_facts = {} + # Prepend all facts with napalm_ for unique namespace + for filter_name, filter_value in facts.items(): + # Make napalm get_facts to be directly accessible as variables + if filter_name == "facts": + for fact_name, fact_value in filter_value.items(): + napalm_fact_name = "napalm_" + fact_name + new_facts[napalm_fact_name] = fact_value + new_filter_name = "napalm_" + filter_name + new_facts[new_filter_name] = filter_value + results = {"ansible_facts": new_facts} + + if ignore_notimplemented: + results["not_implemented"] = sorted(implementation_errors) + + module.exit_json(**results) + + +if __name__ == "__main__": + main() diff --git a/collection/plugins/modules/napalm_install_config.py b/collection/plugins/modules/napalm_install_config.py new file mode 100644 index 0000000..ff03288 --- /dev/null +++ b/collection/plugins/modules/napalm_install_config.py @@ -0,0 +1,340 @@ +""" +(c) 2020 Kirk Byers +(c) 2016 Elisa Jasinska + Original prototype by David Barroso + +This file is part of Ansible + +Ansible is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Ansible is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Ansible. If not, see . +""" +from __future__ import unicode_literals, print_function +import os.path +from ansible.module_utils.basic import AnsibleModule + + +# FIX for Ansible 2.8 moving this function and making it private +# greatly simplified for napalm-ansible's use +def return_values(obj): + """Return native stringified values from datastructures. + + For use with removing sensitive values pre-jsonification.""" + yield str(obj) + + +DOCUMENTATION = """ +--- +module: napalm_install_config +author: "Elisa Jasinska (@fooelisa)" +version_added: "2.1" +short_description: "Installs the configuration taken from a file on a device supported by NAPALM" +description: + - "This library will take the configuration from a file and load it into a device running any + OS supported by napalm. The old configuration will be replaced or merged with the new one." +requirements: + - napalm +options: + hostname: + description: + - IP or FQDN of the device you want to connect to + required: False + username: + description: + - Username + required: False + password: + description: + - Password + required: False + provider: + description: + - Dictionary which acts as a collection of arguments used to define the characteristics + of how to connect to the device. Connection arguments can be inferred from inventory + and CLI arguments or specified in a provider or specified individually. + required: False + dev_os: + description: + - OS of the device + required: False + timeout: + description: + - Time in seconds to wait for the device to respond + required: False + default: 60 + optional_args: + description: + - Dictionary of additional arguments passed to underlying driver + required: False + default: None + config_file: + description: + - Path to the file to load the configuration from. Either config or config_file is needed. + required: False + config: + description: + - Configuration to load. Either config or config_file is needed. + required: False + commit_changes: + description: + - If set to True the configuration will be actually merged or replaced. If the set to + False, we will not apply the changes, just check and report the diff + choices: [true,false] + required: True + replace_config: + description: + - If set to True, the entire configuration on the device will be replaced during the + commit. If set to False, we will merge the new config with the existing one. + choices: [true,false] + default: False + required: False + diff_file: + description: + - A path to the file where we store the "diff" between the running configuration and the + new configuration. If not set the diff between configurations will not be saved. + default: None + required: False + get_diffs: + description: + - Set to False to not have any diffs generated. Useful if platform does not support + commands being used to generate diffs. Note- By default diffs are generated even + if the diff_file param is not set. + choices: [true,false] + default: True + required: False + archive_file: + description: + - File to store backup of running-configuration from device. Configuration will not be + retrieved if not set. + default: None + required: False + candidate_file: + description: + - Store a backup of candidate config from device prior to a commit. + default: None + required: False +""" + +EXAMPLES = """ +- assemble: + src: '../compiled/{{ inventory_hostname }}/' + dest: '../compiled/{{ inventory_hostname }}/running.conf' + +- name: Install Config and save diff + napalm_install_config: + hostname: '{{ inventory_hostname }}' + username: '{{ user }}' + dev_os: '{{ os }}' + password: '{{ passwd }}' + config_file: '../compiled/{{ inventory_hostname }}/running.conf' + commit_changes: '{{ commit_changes }}' + replace_config: '{{ replace_config }}' + get_diffs: True + diff_file: '../compiled/{{ inventory_hostname }}/diff' + +- name: Install Config using Provider + napalm_install_config: + provider: "{{ ios_provider }}" + config_file: '../compiled/{{ inventory_hostname }}/running.conf' + commit_changes: '{{ commit_changes }}' + replace_config: '{{ replace_config }}' + get_diffs: True + diff_file: '../compiled/{{ inventory_hostname }}/diff' +""" + +RETURN = """ +changed: + description: whether the config on the device was changed + returned: always + type: bool + sample: True +diff: + description: diff of the change + returned: always + type: dict + sample: { + 'prepared': "[edit system]\n- host-name lab-testing;\n+ host-name lab;", + } +""" + +napalm_found = False +try: + from napalm import get_network_driver + from napalm.base import ModuleImportError + + napalm_found = True +except ImportError: + pass + + +def save_to_file(content, filename): + with open(filename, "w") as f: + f.write(content) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + hostname=dict(type="str", required=False, aliases=["host"]), + username=dict(type="str", required=False), + password=dict(type="str", required=False, no_log=True), + provider=dict(type="dict", required=False), + timeout=dict(type="int", required=False, default=60), + optional_args=dict(required=False, type="dict", default=None), + config_file=dict(type="str", required=False), + config=dict(type="str", required=False), + dev_os=dict(type="str", required=False), + commit_changes=dict(type="bool", required=True), + replace_config=dict(type="bool", required=False, default=False), + diff_file=dict(type="str", required=False, default=None), + get_diffs=dict(type="bool", required=False, default=True), + archive_file=dict(type="str", required=False, default=None), + candidate_file=dict(type="str", required=False, default=None), + ), + supports_check_mode=True, + ) + + if not napalm_found: + module.fail_json(msg="the python module napalm is required") + + provider = module.params["provider"] or {} + + no_log = ["password", "secret"] + for param in no_log: + if provider.get(param): + module.no_log_values.update(return_values(provider[param])) + if provider.get("optional_args") and provider["optional_args"].get(param): + module.no_log_values.update( + return_values(provider["optional_args"].get(param)) + ) + if module.params.get("optional_args") and module.params["optional_args"].get( + param + ): + module.no_log_values.update( + return_values(module.params["optional_args"].get(param)) + ) + + # allow host or hostname + provider["hostname"] = provider.get("hostname", None) or provider.get("host", None) + # allow local params to override provider + for param, pvalue in provider.items(): + if module.params.get(param) is not False: + module.params[param] = module.params.get(param) or pvalue + + hostname = module.params["hostname"] + username = module.params["username"] + dev_os = module.params["dev_os"] + password = module.params["password"] + timeout = module.params["timeout"] + config_file = module.params["config_file"] + config = module.params["config"] + commit_changes = module.params["commit_changes"] + replace_config = module.params["replace_config"] + diff_file = module.params["diff_file"] + get_diffs = module.params["get_diffs"] + archive_file = module.params["archive_file"] + candidate_file = module.params["candidate_file"] + if config_file: + config_file = os.path.expanduser(os.path.expandvars(config_file)) + if diff_file: + diff_file = os.path.expanduser(os.path.expandvars(diff_file)) + if archive_file: + archive_file = os.path.expanduser(os.path.expandvars(archive_file)) + if candidate_file: + candidate_file = os.path.expanduser(os.path.expandvars(candidate_file)) + + argument_check = {"hostname": hostname, "username": username, "dev_os": dev_os} + for key, val in argument_check.items(): + if val is None: + module.fail_json(msg=str(key) + " is required") + + if module.params["optional_args"] is None: + optional_args = {} + else: + optional_args = module.params["optional_args"] + + try: + network_driver = get_network_driver(dev_os) + except ModuleImportError as e: + module.fail_json(msg="Failed to import napalm driver: " + str(e)) + + try: + device = network_driver( + hostname=hostname, + username=username, + password=password, + timeout=timeout, + optional_args=optional_args, + ) + device.open() + except Exception as e: + module.fail_json(msg="cannot connect to device: " + str(e)) + + try: + if archive_file is not None: + running_config = device.get_config(retrieve="running")["running"] + save_to_file(running_config, archive_file) + except Exception as e: + module.fail_json(msg="cannot retrieve running config:" + str(e)) + + try: + if replace_config and config_file: + device.load_replace_candidate(filename=config_file) + elif replace_config and config: + device.load_replace_candidate(config=config) + elif not replace_config and config_file: + device.load_merge_candidate(filename=config_file) + elif not replace_config and config: + device.load_merge_candidate(config=config) + else: + module.fail_json(msg="You have to specify either config or config_file") + except Exception as e: + module.fail_json(msg="cannot load config: " + str(e)) + + try: + if get_diffs: + diff = device.compare_config() + changed = len(diff) > 0 + else: + changed = True + diff = None + if diff_file is not None and get_diffs: + save_to_file(diff, diff_file) + except Exception as e: + module.fail_json(msg="cannot diff config: " + str(e)) + + try: + if candidate_file is not None: + running_config = device.get_config(retrieve="candidate")["candidate"] + save_to_file(running_config, candidate_file) + except Exception as e: + module.fail_json(msg="cannot retrieve running config:" + str(e)) + + try: + if module.check_mode or not commit_changes: + device.discard_config() + else: + if changed: + device.commit_config() + except Exception as e: + module.fail_json(msg="cannot install config: " + str(e)) + + try: + device.close() + except Exception as e: + module.fail_json(msg="cannot close device connection: " + str(e)) + + module.exit_json(changed=changed, diff={"prepared": diff}, msg=diff) + + +if __name__ == "__main__": + main() diff --git a/collection/plugins/modules/napalm_parse_yang.py b/collection/plugins/modules/napalm_parse_yang.py new file mode 100644 index 0000000..65364fa --- /dev/null +++ b/collection/plugins/modules/napalm_parse_yang.py @@ -0,0 +1,305 @@ +""" +(c) 2017 David Barroso + +This file is part of Ansible + +Ansible is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Ansible is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Ansible. If not, see . +""" +from __future__ import unicode_literals, print_function +from ansible.module_utils.basic import AnsibleModule +import json + +napalm_found = False +try: + from napalm import get_network_driver + from napalm.base import ModuleImportError + + napalm_found = True +except ImportError: + pass + +try: + import napalm_yang +except ImportError: + napalm_yang = None + + +# FIX for Ansible 2.8 moving this function and making it private +# greatly simplified for napalm-ansible's use +def return_values(obj): + """Return native stringified values from datastructures. + + For use with removing sensitive values pre-jsonification.""" + yield str(obj) + + +DOCUMENTATION = """ +--- +module: napalm_parse_yang +author: "David Barroso (@dbarrosop)" +version_added: "0.0" +short_description: "Parse native config/state from a file or device." +description: + - "Parse configuration/state from a file or device and returns a dict that" + - "represents a valid YANG object." +requirements: + - napalm +options: + hostname: + description: + - IP or FQDN of the device you want to connect to + required: False + username: + description: + - Username + required: False + password: + description: + - Password + required: False + dev_os: + description: + - OS of the device + required: False + provider: + description: + - Dictionary which acts as a collection of arguments used to define + the characteristics of how to connect to the device. + Note - hostname, username, password and dev_os must be defined in + either provider or local param if we want to parse from a device + Note - local param takes precedence, e.g. hostname is preferred to + provider['hostname'] + required: False + timeout: + description: + - Time in seconds to wait for the device to respond + required: False + default: 60 + optional_args: + description: + - Dictionary of additional arguments passed to underlying driver + required: False + default: None + file_path: + description: + - "Path to a file to load native config/state from. + Note: Either file_path or data to connect to a device must be + provided. + Note: file_path takes precedence over a live device" + required: False + defaut: None + mode: + description: + - "Whether to parse config/state or both. + Note: `both` is not supported in combination with `file_path`." + required: True + choices: ['config', 'state', 'both'] + models: + description: + - A list that should match the SUPPORTED_MODELS in napalm-yang + required: True + choices: "" + profiles: + description: + - A list profiles + required: False + choices: "" +""" + +EXAMPLES = """ +- name: Parse from device + napalm_parse_yang: + hostname: '{{ inventory_hostname }}' + username: '{{ user }}' + dev_os: '{{ os }}' + password: '{{ passwd }}' + mode: "config" + profiles: ["eos"] + models: + - models.openconfig_interfaces + register: running + +- name: Parse from file + napalm_parse_yang: + file_path: "eos.config" + mode: "config" + profiles: ["eos"] + models: + - models.openconfig_interfaces + register: config +""" + +RETURN = """ +changed: + description: "Dict the representes a valid YANG object" + returned: always + type: dict + sample: "{'interfaces': {'interface':'Et1': {...}, ... }}" +""" + + +def update_module_provider_data(module): + provider = module.params["provider"] or {} + + no_log = ["password", "secret"] + for param in no_log: + if provider.get(param): + module.no_log_values.update(return_values(provider[param])) + if provider.get("optional_args") and provider["optional_args"].get(param): + module.no_log_values.update( + return_values(provider["optional_args"].get(param)) + ) + if module.params.get("optional_args") and module.params["optional_args"].get( + param + ): + module.no_log_values.update( + return_values(module.params["optional_args"].get(param)) + ) + + # allow host or hostname + provider["hostname"] = provider.get("hostname", None) or provider.get("host", None) + # allow local params to override provider + for param, pvalue in provider.items(): + if module.params.get(param) is not False: + module.params[param] = module.params.get(param) or pvalue + + +def get_root_object(models): + """ + Read list of models and returns a Root object with the proper models added. + """ + root = napalm_yang.base.Root() + + for model in models: + current = napalm_yang + for p in model.split("."): + current = getattr(current, p) + root.add_model(current) + + return root + + +def parse_from_file(module): + file_path = module.params["file_path"] + models = module.params["models"] + mode = module.params["mode"] + profiles = module.params["profiles"] + + root = get_root_object(models) + + with open(file_path, "r") as f: + native = f.read() + try: + native = json.loads(native) + except ValueError: + native = [native] + + if mode == "config": + root.parse_config(native=native, profile=profiles) + elif mode == "state": + root.parse_state(native=native, profile=profiles) + else: + module.fail_json(msg="You can't parse both at the same time from a file") + return root + + +def parse_from_device(module): + update_module_provider_data(module) + + hostname = module.params["hostname"] + username = module.params["username"] + password = module.params["password"] + timeout = module.params["timeout"] + models = module.params["models"] + mode = module.params["mode"] + profiles = module.params["profiles"] + + dev_os = module.params["dev_os"] + argument_check = {"hostname": hostname, "username": username, "dev_os": dev_os} + for key, val in argument_check.items(): + if val is None: + module.fail_json(msg=str(key) + " is required") + + if module.params["optional_args"] is None: + optional_args = {} + else: + optional_args = module.params["optional_args"] + + try: + network_driver = get_network_driver(dev_os) + except ModuleImportError as e: + module.fail_json(msg="Failed to import napalm driver: " + str(e)) + + try: + device = network_driver( + hostname=hostname, + username=username, + password=password, + timeout=timeout, + optional_args=optional_args, + ) + device.open() + except Exception as e: + module.fail_json(msg="cannot connect to device: {}".format(e)) + + root = get_root_object(models) + + if mode in ["config", "both"]: + root.parse_config(device=device, profile=profiles or device.profile) + + if mode in ["state", "both"]: + root.parse_state(device=device, profile=profiles or device.profile) + + # close device connection + try: + device.close() + except Exception as e: + module.fail_json(msg="cannot close device connection: {}".format(e)) + + return root + + +def main(): + module = AnsibleModule( + argument_spec=dict( + hostname=dict(type="str", required=False, aliases=["host"]), + username=dict(type="str", required=False), + password=dict(type="str", required=False, no_log=True), + provider=dict(type="dict", required=False), + file_path=dict(type="str", required=False), + mode=dict(type="str", required=True, choices=["config", "state", "both"]), + models=dict(type="list", required=True), + profiles=dict(type="list", required=False), + dev_os=dict(type="str", required=False), + timeout=dict(type="int", required=False, default=60), + optional_args=dict(type="dict", required=False, default=None), + ), + supports_check_mode=True, + ) + + if not napalm_found: + module.fail_json(msg="the python module napalm is required") + if not napalm_yang: + module.fail_json(msg="the python module napalm-yang is required") + + if module.params["file_path"]: + yang_model = parse_from_file(module) + else: + yang_model = parse_from_device(module) + + module.exit_json(yang_model=yang_model.to_dict(filter=True)) + + +if __name__ == "__main__": + main() diff --git a/collection/plugins/modules/napalm_ping.py b/collection/plugins/modules/napalm_ping.py new file mode 100644 index 0000000..f9382a2 --- /dev/null +++ b/collection/plugins/modules/napalm_ping.py @@ -0,0 +1,252 @@ +""" +(c) 2017 Jason Edelman + +This file is part of Ansible +Ansible is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. +Ansible is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. +You should have received a copy of the GNU General Public License +along with Ansible. If not, see . +""" +from __future__ import unicode_literals, print_function +from ansible.module_utils.basic import AnsibleModule + + +# FIX for Ansible 2.8 moving this function and making it private +# greatly simplified for napalm-ansible's use +def return_values(obj): + """Return native stringified values from datastructures. + + For use with removing sensitive values pre-jsonification.""" + yield str(obj) + + +DOCUMENTATION = """ +--- +module: napalm_ping +author: "Jason Edelman (@jedelman8)" +version_added: "2.2" +short_description: "Executes ping on the device and returns response using NAPALM" +description: + - "This module logs into the device, issues a ping request, and returns the response" +requirements: + - napalm +options: + hostname: + description: + - IP or FQDN of the device you want to connect to + required: False + username: + description: + - Username + required: False + password: + description: + - Password + required: False + provider: + description: + - Dictionary which acts as a collection of arguments used to define the characteristics + of how to connect to the device. + Note - hostname, username, password and dev_os must be defined in either provider + or local param + Note - local param takes precedence, e.g. hostname is preferred to provider['hostname'] + required: False + dev_os: + description: + - OS of the device + required: False + timeout: + description: + - Time in seconds to wait for the device to respond + required: False + default: 60 + optional_args: + description: + - Dictionary of additional arguments passed to underlying driver + required: False + default: None + destination: + description: Host or IP Address of the destination + required: True + source: + description: Source address of echo request + required: False + ttl: + description: Maximum number of hops + required: False + ping_timeout: + description: Maximum seconds to wait after sending final packet + required: False + size: + description: Size of request (bytes) + required: False + count: + description: Number of ping request to send + required: False + vrf: + description: vrf to source the echo request + required: False + +""" + +EXAMPLES = """ +- napalm_ping: + hostname: "{{ inventory_hostname }}" + username: "napalm" + password: "napalm" + dev_os: "eos" + destination: 10.0.0.5 + vrf: MANAGEMENT + count: 2 + +- napalm_ping: + provider: "{{ napalm_provider }}" + destination: 8.8.8.8 + count: 2 +""" + +RETURN = """ +changed: + description: ALWAYS RETURNS FALSE + returned: always + type: bool + sample: True + +results: + description: structure response data of ping + returned: always + type: dict + # when echo request succeeds + sample: '{"success": {"packet_loss": 0, "probes_sent": 2, + "results": [{"ip_address": "10.0.0.5:", "rtt": 1.71}, + {"ip_address": "10.0.0.5:", "rtt": 0.733}], + "rtt_avg": 1.225, "rtt_max": 1.718, "rtt_min": 0.733, + "rtt_stddev": 0.493}}' + +alt_results: + description: Example results key on failure + returned: always + type: dict + # when echo request succeeds + sample: '{"error": "connect: Network is unreachable\n"}}' +""" + +napalm_found = False +try: + from napalm import get_network_driver + from napalm.base import ModuleImportError + + napalm_found = True +except ImportError: + pass + + +def main(): + module = AnsibleModule( + argument_spec=dict( + hostname=dict(type="str", required=False, aliases=["host"]), + username=dict(type="str", required=False), + password=dict(type="str", required=False, no_log=True), + provider=dict(type="dict", required=False), + timeout=dict(type="int", required=False, default=60), + optional_args=dict(required=False, type="dict", default=None), + dev_os=dict(type="str", required=False), + destination=dict(type="str", required=True), + source=dict(type="str", required=False), + ttl=dict(type="str", required=False), + ping_timeout=dict(type="str", required=False), + size=dict(type="str", required=False), + count=dict(type="str", required=False), + vrf=dict(type="str", required=False), + ), + supports_check_mode=True, + ) + + if not napalm_found: + module.fail_json(msg="the python module napalm is required") + + provider = module.params["provider"] or {} + + no_log = ["password", "secret"] + for param in no_log: + if provider.get(param): + module.no_log_values.update(return_values(provider[param])) + if provider.get("optional_args") and provider["optional_args"].get(param): + module.no_log_values.update( + return_values(provider["optional_args"].get(param)) + ) + if module.params.get("optional_args") and module.params["optional_args"].get( + param + ): + module.no_log_values.update( + return_values(module.params["optional_args"].get(param)) + ) + + # allow host or hostname + provider["hostname"] = provider.get("hostname", None) or provider.get("host", None) + # allow local params to override provider + for param, pvalue in provider.items(): + if module.params.get(param) is not False: + module.params[param] = module.params.get(param) or pvalue + + hostname = module.params["hostname"] + username = module.params["username"] + dev_os = module.params["dev_os"] + password = module.params["password"] + timeout = module.params["timeout"] + destination = module.params["destination"] + + ping_optional_args = {} + ping_args = ["source", "ttl", "ping_timeout", "size", "count", "vrf"] + for param, pvalue in module.params.items(): + if param in ping_args and pvalue is not None: + ping_optional_args[param] = pvalue + if "ping_timeout" in ping_optional_args: + ping_optional_args["timeout"] = ping_optional_args["ping_timeout"] + ping_optional_args.pop("ping_timeout") + + argument_check = {"hostname": hostname, "username": username, "dev_os": dev_os} + for key, val in argument_check.items(): + if val is None: + module.fail_json(msg=str(key) + " is required") + + if module.params["optional_args"] is None: + optional_args = {} + else: + optional_args = module.params["optional_args"] + + try: + network_driver = get_network_driver(dev_os) + except ModuleImportError as e: + module.fail_json(msg="Failed to import napalm driver: " + str(e)) + + try: + device = network_driver( + hostname=hostname, + username=username, + password=password, + timeout=timeout, + optional_args=optional_args, + ) + device.open() + except Exception as e: + module.fail_json(msg="cannot connect to device: " + str(e)) + + ping_response = device.ping(destination, **ping_optional_args) + + try: + device.close() + except Exception as e: + module.fail_json(msg="cannot close device connection: " + str(e)) + + module.exit_json(changed=False, ping_results=ping_response) + + +if __name__ == "__main__": + main() diff --git a/collection/plugins/modules/napalm_translate_yang.py b/collection/plugins/modules/napalm_translate_yang.py new file mode 100644 index 0000000..c39f840 --- /dev/null +++ b/collection/plugins/modules/napalm_translate_yang.py @@ -0,0 +1,132 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +(c) 2017 David Barroso + +This file is part of Ansible + +Ansible is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Ansible is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Ansible. If not, see . +""" +from __future__ import unicode_literals, print_function +from ansible.module_utils.basic import AnsibleModule + +try: + import napalm_yang +except ImportError: + napalm_yang = None + + +DOCUMENTATION = """ +--- +module: napalm_translate_yang +author: "David Barroso (@dbarrosop)" +version_added: "0.0" +short_description: "Translate a YANG object to native configuration" +description: + - "Load a YANG object from a dict and translates the object to native" + - "configuration" +requirements: + - napalm-yang +options: + models: + description: + - List of models to parse + required: True + profiles: + description: + - List of profiles to use to translate the object + required: True + data: + description: + - dict to load into the YANG object + required: True + merge: + description: + - When translating config, merge resulting config here + required: False + replace: + description: + - When translating config, replace resulting config here + required: False +""" + +EXAMPLES = """ +- name: "Translate config" + napalm_translate_yang: + data: "{{ interfaces.yang_model }}" + profiles: ["eos"] + models: + - models.openconfig_interfaces + register: config +""" + +RETURN = """ +config: + description: "Native configuration" + returned: always + type: string + sample: "interface Ethernet2\n no switchport\n ip address 192.168.0.1/24 \n" +""" + + +def get_root_object(models): + """ + Read list of models and returns a Root object with the proper models added. + """ + root = napalm_yang.base.Root() + + for model in models: + current = napalm_yang + for p in model.split("."): + current = getattr(current, p) + root.add_model(current) + + return root + + +def main(): + module = AnsibleModule( + argument_spec=dict( + models=dict(type="list", required=True), + profiles=dict(type="list", required=False), + data=dict(type="dict", required=True), + merge=dict(type="dict", required=False), + replace=dict(type="dict", required=False), + ), + supports_check_mode=True, + ) + + if not napalm_yang: + module.fail_json(msg="the python module napalm-yang is required") + + root = get_root_object(module.params["models"]) + root.load_dict(module.params["data"]) + + running = get_root_object(module.params["models"]) + args = {} + + if module.params["merge"]: + args["merge"] = running + running.load_dict(module.params["merge"]) + elif module.params["replace"]: + args["replace"] = running + running.load_dict(module.params["replace"]) + + config = root.translate_config(profile=module.params["profiles"], **args) + + module.exit_json(config=config) + + +if __name__ == "__main__": + main() diff --git a/collection/plugins/modules/napalm_validate.py b/collection/plugins/modules/napalm_validate.py new file mode 100644 index 0000000..d6d078b --- /dev/null +++ b/collection/plugins/modules/napalm_validate.py @@ -0,0 +1,267 @@ +from __future__ import unicode_literals, print_function +from ansible.module_utils.basic import AnsibleModule + +napalm_found = False +try: + from napalm import get_network_driver + from napalm.base import ModuleImportError + + napalm_found = True +except ImportError: + pass + +try: + import napalm_yang +except ImportError: + napalm_yang = None + + +# FIX for Ansible 2.8 moving this function and making it private +# greatly simplified for napalm-ansible's use +def return_values(obj): + """Return native stringified values from datastructures. + + For use with removing sensitive values pre-jsonification.""" + yield str(obj) + + +DOCUMENTATION = """ +--- +module: napalm_validate +author: Gabriele Gerbino (@GGabriele) +short_description: Performs deployment validation via napalm. +description: + - Performs deployment validation via napalm. +requirements: + - napalm +options: + hostname: + description: + - IP or FQDN of the device you want to connect to. + required: False + username: + description: + - Username. + required: False + password: + description: + - Password. + required: False + dev_os: + description: + - OS of the device. + required: False + provider: + description: + - Dictionary which acts as a collection of arguments used to define + the characteristics of how to connect to the device. + Note - hostname, username, password and dev_os must be defined in + either provider or local param + Note - local param takes precedence, e.g. hostname is preferred + to provider['hostname'] + required: False + timeout: + description: + - Time in seconds to wait for the device to respond. + required: False + default: 60 + optional_args: + description: + - Dictionary of additional arguments passed to underlying driver. + required: False + default: None + validation_file: + description: + - YAML Validation file containing resources desired states. + required: True + models: + description: + - List of models to parse + Note - data to connect to the device is not necessary when using + YANG models + required: True + data: + description: + - dict to load into the YANG object + required: False +""" + +EXAMPLES = """ +- name: GET VALIDATION REPORT + napalm_validate: + username: "{{ un }}" + password: "{{ pwd }}" + hostname: "{{ inventory_hostname }}" + dev_os: "{{ dev_os }}" + validation_file: validate.yml + +- name: GET VALIDATION REPORT USING PROVIDER + napalm_validate: + provider: "{{ ios_provider }}" + validation_file: validate.yml + +# USING YANG +- name: Let's gather state of interfaces + napalm_parse_yang: + dev_os: "{{ dev_os }}" + hostname: "{{ hostname }}" + username: "{{ username }}" + password: "{{ password }}" + mode: "state" + optional_args: + port: "{{ port }}" + models: + - models.openconfig_interfaces + register: interfaces + +- name: Check all interfaces are up + napalm_validate: + data: "{{ interfaces.yang_model }}" + models: + - models.openconfig_interfaces + validation_file: "validate.yaml" + register: report +""" + +RETURN = """ +changed: + description: check to see if a change was made on the device. + returned: always + type: bool + sample: false +compliance_report: + description: validation report obtained via napalm. + returned: always + type: dict +""" + + +def get_compliance_report(module, device): + return device.compliance_report(module.params["validation_file"]) + + +def get_device_instance(module): + + provider = module.params["provider"] or {} + + no_log = ["password", "secret"] + for param in no_log: + if provider.get(param): + module.no_log_values.update(return_values(provider[param])) + if provider.get("optional_args") and provider["optional_args"].get(param): + module.no_log_values.update( + return_values(provider["optional_args"].get(param)) + ) + if module.params.get("optional_args") and module.params["optional_args"].get( + param + ): + module.no_log_values.update( + return_values(module.params["optional_args"].get(param)) + ) + + # allow host or hostname + provider["hostname"] = provider.get("hostname", None) or provider.get("host", None) + # allow local params to override provider + for param, pvalue in provider.items(): + if module.params.get(param) is not False: + module.params[param] = module.params.get(param) or pvalue + + hostname = module.params["hostname"] + username = module.params["username"] + dev_os = module.params["dev_os"] + password = module.params["password"] + timeout = module.params["timeout"] + + argument_check = {"hostname": hostname, "username": username, "dev_os": dev_os} + for key, val in argument_check.items(): + if val is None: + module.fail_json(msg=str(key) + " is required") + + optional_args = module.params["optional_args"] or {} + + try: + network_driver = get_network_driver(dev_os) + except ModuleImportError as e: + module.fail_json(msg="Failed to import napalm driver: " + str(e)) + + try: + device = network_driver( + hostname=hostname, + username=username, + password=password, + timeout=timeout, + optional_args=optional_args, + ) + device.open() + except Exception as err: + module.fail_json(msg="cannot connect to device: {0}".format(str(err))) + return device + + +def get_root_object(models): + """ + Read list of models and returns a Root object with the proper models added. + """ + root = napalm_yang.base.Root() + + for model in models: + current = napalm_yang + for p in model.split("."): + current = getattr(current, p) + root.add_model(current) + + return root + + +def main(): + module = AnsibleModule( + argument_spec=dict( + models=dict(type="list", required=False), + data=dict(type="dict", required=False), + hostname=dict(type="str", required=False, aliases=["host"]), + username=dict(type="str", required=False), + password=dict(type="str", required=False, no_log=True), + provider=dict(type="dict", required=False), + dev_os=dict(type="str", required=False), + timeout=dict(type="int", required=False, default=60), + optional_args=dict(type="dict", required=False, default=None), + validation_file=dict(type="str", required=True), + ), + supports_check_mode=False, + ) + if not napalm_found: + module.fail_json(msg="the python module napalm is required") + + if module.params["models"]: + if not napalm_yang: + module.fail_json(msg="the python module napalm-yang is required") + + device = get_root_object(module.params["models"]) + + if not module.params["data"]: + module.fail_json(msg="You need to pass the data for the YANG obj") + + device.load_dict(module.params["data"]) + else: + device = get_device_instance(module) + compliance_report = get_compliance_report(module, device) + + if not module.params["models"]: + # close device connection + try: + device.close() + except Exception as err: + module.fail_json(msg="cannot close device connection: {0}".format(str(err))) + + results = {} + results["compliance_report"] = compliance_report + if not compliance_report["complies"]: + msg = "Device does not comply with policy" + results["msg"] = msg + module.fail_json(**results) + + module.exit_json(**results) + + +if __name__ == "__main__": + main() From 5e41a17499ef426b503a1c02fc3fe40de2fc9e97 Mon Sep 17 00:00:00 2001 From: Kirk Byers Date: Thu, 15 Apr 2021 13:53:28 -0700 Subject: [PATCH 4/9] Adding redirects --- collection/galaxy.yml | 2 +- collection/meta/runtime.yml | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 collection/meta/runtime.yml diff --git a/collection/galaxy.yml b/collection/galaxy.yml index af143b4..79d1bb8 100644 --- a/collection/galaxy.yml +++ b/collection/galaxy.yml @@ -1,5 +1,5 @@ --- -namespace: napalm-automation +namespace: napalm name: napalm version: 1.2.0 readme: README.md diff --git a/collection/meta/runtime.yml b/collection/meta/runtime.yml new file mode 100644 index 0000000..6154584 --- /dev/null +++ b/collection/meta/runtime.yml @@ -0,0 +1,21 @@ +--- +requires_ansible: '>=2.9.10' +plugin_routing: + action: + napalm_get_facts: + redirect: napalm.napalm.napalm + get_facts: + redirect: napalm.napalm.napalm + napalm_install_config: + redirect: napalm.napalm.napalm + install_config: + redirect: napalm.napalm.napalm + modules: + napalm_get_facts: + redirect: napalm.napalm.napalm_get_facts + get_facts: + redirect: napalm.napalm.napalm_get_facts + napalm_install_config: + redirect: napalm.napalm.napalm_get_facts + install_config: + redirect: napalm.napalm.napalm_get_facts From 944a626ccdda2aef06f2d2052cc6ea16728e8d80 Mon Sep 17 00:00:00 2001 From: Kirk Byers Date: Thu, 15 Apr 2021 15:01:21 -0700 Subject: [PATCH 5/9] Trying to workout what is possible with the plugin routing --- collection/galaxy.yml | 2 +- collection/meta/runtime.yml | 14 +------------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/collection/galaxy.yml b/collection/galaxy.yml index 79d1bb8..f2c5562 100644 --- a/collection/galaxy.yml +++ b/collection/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: napalm name: napalm -version: 1.2.0 +version: 0.9.9 readme: README.md authors: - Kirk Byers diff --git a/collection/meta/runtime.yml b/collection/meta/runtime.yml index 6154584..4d52a62 100644 --- a/collection/meta/runtime.yml +++ b/collection/meta/runtime.yml @@ -2,20 +2,8 @@ requires_ansible: '>=2.9.10' plugin_routing: action: - napalm_get_facts: - redirect: napalm.napalm.napalm get_facts: - redirect: napalm.napalm.napalm - napalm_install_config: - redirect: napalm.napalm.napalm - install_config: - redirect: napalm.napalm.napalm - modules: - napalm_get_facts: redirect: napalm.napalm.napalm_get_facts + modules: get_facts: redirect: napalm.napalm.napalm_get_facts - napalm_install_config: - redirect: napalm.napalm.napalm_get_facts - install_config: - redirect: napalm.napalm.napalm_get_facts From 704f7c31de4c4db4f9d0129029aed45da7aea5bc Mon Sep 17 00:00:00 2001 From: Kirk Byers Date: Fri, 16 Apr 2021 09:27:53 -0700 Subject: [PATCH 6/9] Initial collection test release --- collection/build.sh | 4 ++++ collection/galaxy.yml | 3 ++- collection/meta/runtime.yml | 28 ++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 collection/build.sh diff --git a/collection/build.sh b/collection/build.sh new file mode 100644 index 0000000..1d6e6b9 --- /dev/null +++ b/collection/build.sh @@ -0,0 +1,4 @@ +ansible-galaxy collection build +mv napalm-napalm-0.9.12.tar.gz build/ +# rm -r ~/.ansible/collections/ansible_collections/napalm +ansible-galaxy collection install build/napalm-napalm-0.9.12.tar.gz diff --git a/collection/galaxy.yml b/collection/galaxy.yml index f2c5562..7b33732 100644 --- a/collection/galaxy.yml +++ b/collection/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: napalm name: napalm -version: 0.9.9 +version: 0.9.12 readme: README.md authors: - Kirk Byers @@ -18,3 +18,4 @@ homepage: https://github.com/napalm-automation/napalm-ansible issues: https://github.com/napalm-automation/napalm-ansible/issues build_ignore: - "*.tar.gz" + - "build.sh" diff --git a/collection/meta/runtime.yml b/collection/meta/runtime.yml index 4d52a62..349176d 100644 --- a/collection/meta/runtime.yml +++ b/collection/meta/runtime.yml @@ -4,6 +4,34 @@ plugin_routing: action: get_facts: redirect: napalm.napalm.napalm_get_facts + install_config: + redirect: napalm.napalm.napalm_install_config + ping: + redirect: napalm.napalm.napalm_ping + validate: + redirect: napalm.napalm.napalm_validate + cli: + redirect: napalm.napalm.napalm_cli + parse_yang: + redirect: napalm.napalm.napalm_parse_yang + translate_yang: + redirect: napalm.napalm.napalm_translate_yang + diff_yang: + redirect: napalm.napalm.napalm_diff_yang modules: get_facts: redirect: napalm.napalm.napalm_get_facts + install_config: + redirect: napalm.napalm.napalm_install_config + ping: + redirect: napalm.napalm.napalm_ping + validate: + redirect: napalm.napalm.napalm_validate + cli: + redirect: napalm.napalm.napalm_cli + parse_yang: + redirect: napalm.napalm.napalm_parse_yang + translate_yang: + redirect: napalm.napalm.napalm_translate_yang + diff_yang: + redirect: napalm.napalm.napalm_diff_yang From 905062f39b608e402389f65d47aa977a8059e7be Mon Sep 17 00:00:00 2001 From: Kirk Byers Date: Fri, 16 Apr 2021 09:36:09 -0700 Subject: [PATCH 7/9] Add collection build artifact --- .gitignore | 4 ++-- collection/build/napalm-napalm-0.9.12.tar.gz | Bin 0 -> 17197 bytes 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 collection/build/napalm-napalm-0.9.12.tar.gz diff --git a/.gitignore b/.gitignore index 6f8f41d..5101508 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ __pycache__/ .Python env/ bin/ -build/ +./build/ develop-eggs/ dist/ eggs/ @@ -63,4 +63,4 @@ tags .compiled -module_docs/ \ No newline at end of file +module_docs/ diff --git a/collection/build/napalm-napalm-0.9.12.tar.gz b/collection/build/napalm-napalm-0.9.12.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..20ede3435d98195693362429923fbfe35864eb0b GIT binary patch literal 17197 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

giwKh^51c=8CXQoau>h*J7Vqb5R4`^`ix1{ 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 A5C8xG literal 0 HcmV?d00001 From 9c381189c5179602144dfa1e69594f67e985f0c4 Mon Sep 17 00:00:00 2001 From: Kirk Byers Date: Tue, 27 Apr 2021 17:24:34 -0700 Subject: [PATCH 8/9] Updating the collection README --- collection/README.md | 57 +++++++++----------------------------------- 1 file changed, 11 insertions(+), 46 deletions(-) diff --git a/collection/README.md b/collection/README.md index f1a6512..8709ad6 100644 --- a/collection/README.md +++ b/collection/README.md @@ -16,55 +16,20 @@ The following modules are currently available: - ``napalm_translate_yang`` - ``napalm_validate`` -Action-Plugins -======= - -Action-Plugins should be used to make napalm-ansible more consistent with the behavior of other Ansible modules (eliminate the need of a provider and of individual task arguments for hostname, username, password, and timeout). - -They provide default parameters for the hostname, username, password and timeout paramters. -* hostname is set to the first of provider {{ hostname }}, provider {{ host }}, play-context remote_addr. -* username is set to the first of provider {{ username }}, play-context connection_user. -* password is set to the first of provider {{ password }}, play-context password (-k argument). -* timeout is set to the provider {{ timeout }}, or else defaults to 60 seconds (can't be passed via command-line). - Install ======= -To install run either: - -``` -pip install napalm-ansible -pip install napalm -``` - -Or: +To install run: ``` -git clone https://github.com/napalm-automation/napalm-ansible pip install napalm -``` - -Configuring Ansible -=================== - -Once you have installed ``napalm-ansible`` then you need to add napalm-ansible to your ``library`` and ``action_plugins`` paths in ``ansible.cfg``. If you used pip to install napalm-ansible, then you can just run the ``napalm-ansible`` command and follow the instructions specified there. - -``` -$ cat .ansible.cfg - -[defaults] -library = ~/napalm-ansible/napalm_ansible/modules -action_plugins = ~/napalm-ansible/napalm_ansible/plugins/action -... - -For more details on ansible's configuration file visit: -https://docs.ansible.com/ansible/latest/intro_configuration.html +ansible-galaxy collection install napalm.napalm ``` Dependencies ======= -* [napalm](https://github.com/napalm-automation/napalm) 2.5.0 or later -* [ansible](https://github.com/ansible/ansible) 2.8.11 or later +* [napalm](https://github.com/napalm-automation/napalm) 3.2.0 or later +* [ansible](https://github.com/ansible/ansible) 2.9.20 or later Examples @@ -96,7 +61,7 @@ ansible_ssh_pass=my_password gather_facts: False tasks: - name: napalm get_facts - napalm_get_facts: + napalm.napalm.get_facts: filter: facts,interfaces - debug: @@ -167,7 +132,7 @@ ansible_ssh_pass=my_password gather_facts: False tasks: - name: napalm get_facts - napalm_get_facts: + napalm.napalm.get_facts: filter: facts,interfaces - debug: @@ -239,7 +204,7 @@ ansible_ssh_pass=my_password gather_facts: False tasks: - name: Retrieve get_facts, get_interfaces - napalm_get_facts: + napalm.napalm.get_facts: filter: facts,interfaces # Specify NX-API Port optional_args: @@ -292,7 +257,7 @@ nxos1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 hosts: nxos1 tasks: - name: Retrieve get_facts, get_interfaces - napalm_get_facts: + napalm.napalm.get_facts: filter: facts,interfaces # Instruct NAPALM module to use SSH dev_os: nxos_ssh @@ -363,7 +328,7 @@ ansible_ssh_pass=my_password gather_facts: False tasks: - name: Retrieve get_facts, get_interfaces - napalm_get_facts: + napalm.napalm.get_facts: filter: facts,interfaces - debug: @@ -433,7 +398,7 @@ juniper1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 src=../compiled/{{ inventory_hostname }}/ dest=../compiled/{{ inventory_hostname }}/running.conf - - napalm_install_config: + - napalm.napalm.install_config: hostname={{ inventory_hostname }} username={{ user }} dev_os={{ os }} @@ -449,7 +414,7 @@ juniper1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ```YAML - name: GET VALIDATION REPORT - napalm_validate: + napalm.napalm.validate: username: "{{ user }}" password: "{{ passwd }}" hostname: "{{ inventory_hostname }}" From af792d3598546b41f14c487b35e3003789cc3503 Mon Sep 17 00:00:00 2001 From: Kirk Byers Date: Tue, 27 Apr 2021 17:33:23 -0700 Subject: [PATCH 9/9] Creating a 0.9.13 build of the collection --- collection/build/napalm-napalm-0.9.13.tar.gz | Bin 0 -> 17295 bytes collection/galaxy.yml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 collection/build/napalm-napalm-0.9.13.tar.gz diff --git a/collection/build/napalm-napalm-0.9.13.tar.gz b/collection/build/napalm-napalm-0.9.13.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..87a82f7d98d838c86e050eea9d602b405479ef0f GIT binary patch literal 17295 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 literal 0 HcmV?d00001 diff --git a/collection/galaxy.yml b/collection/galaxy.yml index 7b33732..1034eb1 100644 --- a/collection/galaxy.yml +++ b/collection/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: napalm name: napalm -version: 0.9.12 +version: 0.9.13 readme: README.md authors: - Kirk Byers