├── docs ├── .gitkeep ├── .nojekyll ├── index.html ├── Makefile ├── make.bat ├── index.rst ├── additional │ ├── dataclasses.md │ ├── use-examples.md │ └── rpc.md ├── conf.py └── changelog.md ├── examples ├── .gitkeep ├── README.md └── example-project │ ├── logs │ └── .gitkeep │ ├── inventory │ ├── hosts.yml │ └── groups.yml │ ├── config.yml │ ├── nr_get_capabilities.py │ ├── nr_get_config.py │ ├── nr_get_schemas.py │ └── nr_edit_config_global_lock.py ├── tests ├── __init__.py ├── test_data │ ├── .gitkeep │ └── configs │ │ └── .gitkeep ├── inventory_data │ ├── ssh_config │ ├── hosts.yml │ └── groups.yml ├── unit │ ├── test_nornir_hosts_unit.py │ ├── test_general_unit.py │ ├── test_netconf_validate.py │ ├── test_netconf_commit.py │ ├── test_netconf_lock_unit.py │ ├── test_netconf_rpc.py │ ├── test_netconf_schemas_unit.py │ ├── test_netconf_edit_config_unit.py │ └── test_helpers_unit.py ├── integration │ ├── iosxe │ │ ├── test_iosxe_get.py │ │ ├── test_iosxe_edit_config.py │ │ └── test_iosxe_get_config.py │ ├── iosxr │ │ ├── test_iosxr_get.py │ │ ├── test_iosxr_get_config.py │ │ └── test_iosxr_edit_config.py │ ├── arista │ │ ├── test_arista_get.py │ │ ├── test_arista_get_config.py │ │ ├── test_arista_lock.py │ │ ├── test_arista_edit_config.py │ │ ├── test_arista_rpc.py │ │ └── test_arista_connection.py │ ├── sros │ │ ├── test_sros_get.py │ │ ├── test_sros_get_config.py │ │ └── test_sros_edit_config.py │ └── common │ │ ├── test_capabilities.py │ │ ├── test_schemas.py │ │ └── test_lock_operations.py └── conftest.py ├── nornir_netconf ├── plugins │ ├── tasks │ │ ├── flowmon │ │ │ └── .gitkeep │ │ ├── capabilities │ │ │ └── netconf_capabilities.py │ │ ├── __init__.py │ │ ├── editing │ │ │ ├── netconf_validate.py │ │ │ ├── netconf_commit.py │ │ │ └── netconf_edit_config.py │ │ ├── retrieval │ │ │ ├── netconf_get.py │ │ │ ├── netconf_get_schemas.py │ │ │ └── netconf_get_config.py │ │ ├── rpc │ │ │ └── netconf_rpc.py │ │ └── locking │ │ │ └── netconf_lock.py │ ├── connections │ │ ├── __init__.py │ │ └── netconf.py │ └── helpers │ │ ├── __init__.py │ │ ├── rpc.py │ │ ├── models.py │ │ └── general.py └── __init__.py ├── clab-files ├── interfaces.json ├── clab-arista.yml └── clab-topo-netconf.yml ├── .yamllint.yml ├── .dockerignore ├── .coveragerc ├── .github └── workflows │ ├── documentation.yml │ └── ci.yml ├── Dockerfile ├── docker-compose.yml ├── .gitignore ├── pyproject.toml ├── LICENSE └── README.md /docs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_data/configs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/example-project/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/flowmon/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/inventory_data/ssh_config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/example-project/inventory/hosts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | nokia_rtr: 3 | hostname: "172.200.100.12" 4 | port: 830 5 | groups: 6 | - "sros" 7 | data: 8 | region: "west-region" 9 | -------------------------------------------------------------------------------- /tests/unit/test_nornir_hosts_unit.py: -------------------------------------------------------------------------------- 1 | """Test inventory DEVICE_NAMEs.""" 2 | 3 | 4 | def test_netconf_device_name(nornir): 5 | nr = nornir.filter(name="ceos") 6 | assert "ceos" in nr.inventory.hosts 7 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/connections/__init__.py: -------------------------------------------------------------------------------- 1 | """Netconf Connection Plugin.""" 2 | 3 | from nornir_netconf.plugins.connections.netconf import CONNECTION_NAME, Netconf 4 | 5 | __all__ = ("Netconf", "CONNECTION_NAME") 6 | -------------------------------------------------------------------------------- /tests/unit/test_general_unit.py: -------------------------------------------------------------------------------- 1 | """Helpers Unit Tests.""" 2 | 3 | from nornir_netconf.plugins.helpers import check_file 4 | 5 | 6 | def test_check_file(): 7 | """Test false check_file.""" 8 | assert not check_file("somebadpath.json") 9 | -------------------------------------------------------------------------------- /clab-files/interfaces.json: -------------------------------------------------------------------------------- 1 | { 2 | "ManagementIntf": { 3 | "eth0": "Management1" 4 | }, 5 | "EthernetIntf": { 6 | "eth1": "Ethernet1/1", 7 | "eth2": "Ethernet2/1", 8 | "eth3": "Ethernet27/1", 9 | "eth4": "Ethernet28/1", 10 | "eth5": "Ethernet3/1/1", 11 | "eth6": "Ethernet5/2/1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Helper Functions.""" 2 | 3 | from .general import check_file, create_folder, write_output 4 | from .models import RpcResult, SchemaResult 5 | from .rpc import check_capability 6 | 7 | __all__ = ("RpcResult", "check_file", "write_output", "create_folder", "check_capability", "SchemaResult") 8 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: "default" 3 | rules: 4 | comments: "enable" 5 | empty-values: "enable" 6 | indentation: 7 | indent-sequences: "consistent" 8 | line-length: "disable" 9 | quoted-strings: 10 | quote-type: "double" 11 | 12 | ignore: | 13 | .github/ 14 | clab-files 15 | clab-arista-testing.yml 16 | -------------------------------------------------------------------------------- /examples/example-project/inventory/groups.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sros: 3 | username: "admin" 4 | password: "admin" 5 | port: 830 6 | platform: "sros" 7 | connection_options: 8 | netconf: 9 | extras: 10 | hostkey_verify: false 11 | timeout: 300 12 | allow_agent: false 13 | look_for_keys: false 14 | -------------------------------------------------------------------------------- /nornir_netconf/__init__.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | """Nornir NETCONF Plugin.""" 3 | try: 4 | from importlib import metadata 5 | except ImportError: 6 | # Python version < 3.8 7 | import importlib_metadata as metadata 8 | 9 | # This will read version from pyproject.toml 10 | __version__ = metadata.version("nornir_netconf") 11 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/helpers/rpc.py: -------------------------------------------------------------------------------- 1 | """Helper to extract info from RPC reply.""" 2 | 3 | from typing import List 4 | 5 | 6 | def check_capability(capabilities: List[str], capability: str) -> bool: 7 | """Evaluate capabilities and return True if capability is available.""" 8 | return any(True for cap in capabilities if capability in cap) 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Docker related 2 | *.env 3 | environments/ 4 | 5 | # Python 6 | **/*.pyc 7 | **/*.pyo 8 | **/__pycache__/ 9 | **/.pytest_cache/ 10 | **/.venv/ 11 | 12 | 13 | # Other 14 | docs/_build 15 | FAQ.md 16 | .git/ 17 | .gitignore 18 | .github 19 | tasks.py 20 | LICENSE 21 | **/*.log 22 | **/.vscode/ 23 | invoke*.yml 24 | tasks.py 25 | clab-files 26 | clab-* 27 | -------------------------------------------------------------------------------- /examples/example-project/config.yml: -------------------------------------------------------------------------------- 1 | # Nornir Config File. 2 | --- 3 | runner: 4 | plugin: "threaded" 5 | options: 6 | num_workers: 100 7 | 8 | inventory: 9 | plugin: "SimpleInventory" 10 | options: 11 | host_file: "inventory/hosts.yml" 12 | group_file: "inventory/groups.yml" 13 | defaults_file: "inventory/defaults.yml" 14 | 15 | logging: 16 | log_file: "logs/nornir.log" 17 | level: "DEBUG" 18 | -------------------------------------------------------------------------------- /clab-files/clab-arista.yml: -------------------------------------------------------------------------------- 1 | # clab/clab@123 2 | --- 3 | name: "arista-testing.yml" 4 | mgmt: 5 | network: "nornir-netconf-testing-arista" 6 | ipv4_subnet: "172.200.101.0/24" 7 | topology: 8 | kinds: 9 | ceos: 10 | image: "h4ndzdatm0ld/ceosimage:4.28.0F" 11 | binds: 12 | - "interfaces.json:/mnt/flash/EosIntfMapping.json:ro" 13 | nodes: 14 | ceos: 15 | kind: "ceos" 16 | mgmt_ipv4: "172.200.101.11" 17 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | 5 | [report] 6 | show_missing = True 7 | 8 | exclude_lines = 9 | __init__.py 10 | if self.debug: 11 | self.connection.close_session() 12 | parameters["ssh_config"] = ssh_config_file 13 | pragma: no cover 14 | raise NotImplementedError 15 | if __name__ == .__main__.: 16 | 17 | omit = 18 | nornir_netconf/__init__.py 19 | tests/* 20 | 21 | ignore_errors = True 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/helpers/models.py: -------------------------------------------------------------------------------- 1 | """Data Models.""" 2 | 3 | from dataclasses import dataclass, field 4 | from typing import List, Optional 5 | 6 | from ncclient.manager import Manager 7 | from ncclient.operations.rpc import RPCReply 8 | 9 | 10 | @dataclass 11 | class RpcResult: 12 | """RPC Reply Result Model.""" 13 | 14 | rpc: Optional[RPCReply] = field(default=None, repr=True) 15 | manager: Optional[Manager] = field(default=None, repr=False) 16 | 17 | 18 | @dataclass 19 | class SchemaResult: 20 | """Get Schema Result.""" 21 | 22 | directory: str = field(repr=True) 23 | errors: List[str] = field(repr=False, default_factory=list) 24 | files: List[str] = field(repr=False, default_factory=list) 25 | -------------------------------------------------------------------------------- /clab-files/clab-topo-netconf.yml: -------------------------------------------------------------------------------- 1 | # clab/clab@123 2 | --- 3 | name: "clab-topo-netconf.yml" 4 | 5 | mgmt: 6 | network: "nornir-netconf-testing" 7 | ipv4_subnet: "172.200.100.0/24" 8 | topology: 9 | kinds: 10 | vr-xrv: 11 | image: "h4ndzdatm0ld/vr-xrv:6.1.3" 12 | vr-sros: 13 | image: "h4ndzdatm0ld/sros:latest" 14 | vr-csr: 15 | image: "h4ndzdatm0ld/vr-csr:17.03.02" 16 | nodes: 17 | xrv-p1: 18 | kind: "vr-xrv" 19 | mgmt_ipv4: "172.200.100.11" 20 | sros-p2: 21 | kind: "vr-sros" 22 | mgmt_ipv4: "172.200.100.12" 23 | vr-csr-1: 24 | kind: "vr-csr" 25 | mgmt_ipv4: "172.200.100.13" 26 | links: 27 | # p1 XR port 1 is connected to p2 SROS port 1 28 | - endpoints: ["xrv-p1:eth1", "sros-p2:eth1"] 29 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Documentation" 3 | 4 | on: 5 | push: 6 | branches: 7 | - "develop" 8 | 9 | jobs: 10 | documentation: 11 | name: "Documentation" 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: "Check out repository code" 15 | uses: "actions/checkout@v2" 16 | - name: "Setup environment" 17 | uses: "networktocode/gh-action-setup-poetry-environment@v3" 18 | - name: "Sphinx Build" 19 | run: "poetry run sphinx-build -vvv -b html ./docs ./docs/public" 20 | - name: "Deploy auto generated documentation to GH-Pages" 21 | uses: "peaceiris/actions-gh-pages@v3" 22 | with: 23 | github_token: ${{ secrets.GITHUB_TOKEN }} 24 | publish_dir: "./docs/public" 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VER=3.9 2 | 3 | FROM python:${PYTHON_VER} AS base 4 | 5 | WORKDIR /usr/src/app 6 | 7 | RUN pip install -U pip && \ 8 | curl -sSL https://install.python-poetry.org | python3 - 9 | ENV PATH="/root/.local/bin:$PATH" 10 | 11 | RUN poetry config virtualenvs.create false 12 | 13 | COPY poetry.lock pyproject.toml ./ 14 | 15 | RUN poetry install --no-root 16 | 17 | FROM base AS test 18 | 19 | COPY . . 20 | 21 | RUN poetry install --no-interaction 22 | 23 | RUN echo 'Rnning Ruff' && \ 24 | ruff check . && \ 25 | echo 'Running Black' && \ 26 | black --check --diff . && \ 27 | echo 'Running Yamllint' && \ 28 | yamllint . && \ 29 | echo 'Running Bandit' && \ 30 | bandit --recursive ./ --configfile pyproject.toml && \ 31 | echo 'Running MyPy' && \ 32 | mypy . 33 | 34 | ENTRYPOINT ["pytest"] 35 | 36 | CMD ["--cov=nornir_netconf/", "tests/", "-vvv"] 37 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/capabilities/netconf_capabilities.py: -------------------------------------------------------------------------------- 1 | """NETCONF capabilities.""" 2 | 3 | from nornir.core.task import Result, Task 4 | 5 | from nornir_netconf.plugins.connections import CONNECTION_NAME 6 | from nornir_netconf.plugins.helpers import RpcResult 7 | 8 | 9 | def netconf_capabilities(task: Task) -> Result: 10 | """Gather Netconf capabilities from device. 11 | 12 | Examples: 13 | Simple example:: 14 | 15 | > nr.run(task=netconf_capabilities) 16 | 17 | Returns: 18 | Result object with the following attributes set:: 19 | 20 | * result (RpcResult): Rpc and Manager 21 | """ 22 | manager = task.host.get_connection(CONNECTION_NAME, task.nornir.config) 23 | capabilities = manager.server_capabilities 24 | rpc_result = RpcResult(rpc=capabilities, manager=manager) 25 | return Result(host=task.host, result=rpc_result) 26 | -------------------------------------------------------------------------------- /tests/integration/iosxe/test_iosxe_get.py: -------------------------------------------------------------------------------- 1 | """Integration test against IOSXE device.""" 2 | 3 | # from nornir_utils.plugins.functions import print_result 4 | 5 | from nornir_netconf.plugins.tasks import netconf_get 6 | from tests.conftest import skip_integration_tests, xml_dict 7 | 8 | DEVICE_NAME = "iosxe_rtr" 9 | 10 | 11 | @skip_integration_tests 12 | def test_iosxe_netconf_get(nornir): 13 | """Test NETCONF get operation.""" 14 | nr = nornir.filter(name=DEVICE_NAME) 15 | filter = """ 16 | 17 | 18 | 19 | 20 | 21 | """ 22 | result = nr.run(netconf_get, filter_type="subtree", path=filter) 23 | parsed = xml_dict(result[DEVICE_NAME].result.rpc) 24 | 25 | assert result[DEVICE_NAME].result 26 | assert parsed["rpc-reply"]["data"]["native"]["ip"]["domain"]["name"] == "example.com" 27 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | """Nornir Netconf Tasks. 2 | 3 | Operations are separated into their own categorized folder. 4 | """ 5 | 6 | from .capabilities.netconf_capabilities import netconf_capabilities 7 | from .editing.netconf_commit import netconf_commit 8 | from .editing.netconf_edit_config import netconf_edit_config 9 | from .editing.netconf_validate import netconf_validate 10 | from .locking.netconf_lock import netconf_lock 11 | from .retrieval.netconf_get import netconf_get 12 | from .retrieval.netconf_get_config import netconf_get_config 13 | from .retrieval.netconf_get_schemas import netconf_get_schemas 14 | from .rpc.netconf_rpc import netconf_rpc 15 | 16 | __all__ = ( 17 | "netconf_capabilities", 18 | "netconf_edit_config", 19 | "netconf_commit", 20 | "netconf_get", 21 | "netconf_get_config", 22 | "netconf_lock", 23 | "netconf_get_schemas", 24 | "netconf_validate", 25 | "netconf_rpc", 26 | ) 27 | -------------------------------------------------------------------------------- /tests/integration/iosxr/test_iosxr_get.py: -------------------------------------------------------------------------------- 1 | """Integration test against IOSXR device.""" 2 | 3 | from nornir_netconf.plugins.tasks import netconf_get 4 | from tests.conftest import skip_integration_tests, xml_dict 5 | 6 | DEVICE_NAME = "iosxr_rtr" 7 | 8 | 9 | @skip_integration_tests 10 | def test_iosxr_netconf_get(nornir): 11 | """Test NETCONF get operation.""" 12 | nr = nornir.filter(name=DEVICE_NAME) 13 | filter = """ 14 | 15 | 16 | MgmtEth0/0/CPU0/0 17 | 18 | 19 | """ 20 | result = nr.run(netconf_get, filter_type="subtree", path=filter) 21 | assert result[DEVICE_NAME].result 22 | assert result[DEVICE_NAME].result.rpc.data_xml 23 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 24 | assert "true" == parsed["data"]["interfaces"]["interface"]["state"]["enabled"] 25 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /tests/inventory_data/hosts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | nokia_rtr: 3 | hostname: "172.200.100.12" 4 | port: 830 5 | groups: 6 | - "integration" 7 | - "sros" 8 | data: 9 | lock_datastore: "candidate" 10 | iosxr_rtr: 11 | hostname: "172.200.100.11" 12 | port: 830 13 | groups: 14 | - "integration" 15 | - "iosxr" 16 | data: 17 | lock_datastore: "candidate" 18 | iosxe_rtr: 19 | hostname: "172.200.100.13" 20 | groups: 21 | - "integration" 22 | - "csr" 23 | data: 24 | lock_datastore: "running" 25 | ceos: 26 | hostname: "172.200.101.11" 27 | groups: 28 | - "integration" 29 | - "ceos" 30 | data: 31 | vendor: "arista" 32 | lock_datastore: "running" 33 | ceos_empty_ssh_file: 34 | hostname: "172.200.101.11" 35 | groups: 36 | - "empty_ssh_file_group" 37 | devnet_iosxe_rtr: 38 | hostname: "sandbox-iosxe-recomm-1.cisco.com" 39 | username: "developer" 40 | password: "C1sco12345" 41 | groups: 42 | - "csr" 43 | -------------------------------------------------------------------------------- /examples/example-project/nr_get_capabilities.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | """Nornir NETCONF Example Task: 'capabilities'.""" 3 | from nornir import InitNornir 4 | from nornir.core.task import Task 5 | from nornir_utils.plugins.functions import print_result 6 | 7 | from nornir_netconf.plugins.tasks import netconf_capabilities 8 | 9 | __author__ = "Hugo Tinoco" 10 | __email__ = "hugotinoco@icloud.com" 11 | 12 | nr = InitNornir("config.yml") 13 | 14 | # Filter the hosts by 'west-region' assignment 15 | west_region = nr.filter(region="west-region") 16 | 17 | 18 | def example_netconf_get_capabilities(task: Task) -> str: 19 | """Test get capabilities.""" 20 | capabilities = task.run(netconf_capabilities) 21 | # This may be a lot, so for example we'll just print the first one 22 | return [cap for cap in capabilities.result.rpc][0] 23 | 24 | 25 | def main(): 26 | """Execute Nornir Script.""" 27 | print_result(west_region.run(task=example_netconf_get_capabilities)) 28 | 29 | 30 | if __name__ == "__main__": 31 | main() 32 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Nornir NETCONF documentation master file, created by 2 | sphinx-quickstart on Fri Oct 15 08:36:34 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Nornir NETCONF's documentation! 7 | ========================================== 8 | 9 | Overview 10 | ======== 11 | 12 | Nornir NETCONF is a Nornir plugin to handle NETCONF operations using the ncclient library. 13 | 14 | Contents 15 | ======== 16 | 17 | .. toctree:: 18 | :maxdepth: 4 19 | 20 | Home 21 | Readme 22 | 23 | Additional 24 | ======== 25 | 26 | .. toctree:: 27 | :glob: 28 | :maxdepth: 2 29 | :titlesonly: 30 | :includehidden: 31 | 32 | additional/* 33 | 34 | Changelog 35 | ========= 36 | .. toctree:: 37 | :maxdepth: 1 38 | 39 | Changelog 40 | 41 | API Reference 42 | ============= 43 | .. toctree:: 44 | :maxdepth: 2 45 | 46 | Indices and tables 47 | ================== 48 | 49 | * :ref:`genindex` 50 | * :ref:`modindex` 51 | * :ref:`search` 52 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3.8" 3 | services: 4 | test: 5 | network_mode: "host" 6 | container_name: "test" 7 | hostname: "test" 8 | environment: 9 | SKIP_INTEGRATION_TESTS: "${SKIP_INTEGRATION_TESTS:-True}" 10 | NORNIR_LOG: "${NORNIR_LOG:-False}" 11 | build: 12 | context: "." 13 | target: "test" 14 | volumes: 15 | - "./:/usr/src/app" 16 | clab: 17 | image: "ghcr.io/srl-labs/clab" 18 | working_dir: "/src" 19 | network_mode: "host" 20 | volumes: 21 | - "/var/run/docker.sock:/var/run/docker.sock" 22 | - "/var/run/netns:/var/run/netns" 23 | - "/etc/hosts:/etc/hosts" 24 | - "./:/src" 25 | pid: "host" 26 | # command: "containerlab deploy -t ./clab-files/clab-topo-netconf.yml" 27 | command: "containerlab deploy -t ./clab-files/clab-arista.yml --reconfigure" 28 | # If you want to destroy the lab, ovveride the command while executing docker-compose service 29 | # docker-compose run clab containerlab destroy -t clab-topo-netconf.yml 30 | privileged: true 31 | tty: true 32 | -------------------------------------------------------------------------------- /tests/integration/arista/test_arista_get.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF get.""" 2 | 3 | from nornir_netconf.plugins.tasks import netconf_get 4 | from tests.conftest import xml_dict 5 | 6 | DEVICE_NAME = "ceos" 7 | 8 | 9 | def test_netconf_get(nornir): 10 | """Test NETCONF get operation.""" 11 | nr = nornir.filter(name=DEVICE_NAME) 12 | result = nr.run(netconf_get) 13 | parsed = xml_dict(result[DEVICE_NAME].result.rpc) 14 | assert result[DEVICE_NAME].result.rpc.ok 15 | assert parsed["rpc-reply"]["data"]["system"]["config"]["hostname"] == "ceos" 16 | 17 | 18 | def test_netconf_get_subtree(nornir): 19 | """Test NETCONF get with subtree. 20 | 21 | Subtree filter is used to get specific data from the device which returns a smaller RPC Reply. 22 | """ 23 | nr = nornir.filter(name=DEVICE_NAME) 24 | 25 | path = "" 26 | result = nr.run(netconf_get, path=path, filter_type="subtree") 27 | parsed = xml_dict(result[DEVICE_NAME].result.rpc) 28 | 29 | assert parsed["rpc-reply"]["data"]["acl"]["state"]["counter-capability"] == "AGGREGATE_ONLY" 30 | -------------------------------------------------------------------------------- /tests/integration/iosxr/test_iosxr_get_config.py: -------------------------------------------------------------------------------- 1 | """Integration test against IOSXR device.""" 2 | 3 | from nornir_netconf.plugins.tasks import netconf_get_config 4 | from tests.conftest import CONFIGS_DIR, skip_integration_tests, xml_dict 5 | 6 | DEVICE_NAME = "iosxr_rtr" 7 | 8 | 9 | @skip_integration_tests 10 | def test_iosxr_netconf_get_config(nornir): 11 | """Test NETCONF get config.""" 12 | nr = nornir.filter(name=DEVICE_NAME) 13 | 14 | result = nr.run( 15 | netconf_get_config, 16 | source="running", 17 | path=""" 18 | 19 | 20 | """, 21 | filter_type="subtree", 22 | ) 23 | assert result[DEVICE_NAME].result.rpc 24 | assert result[DEVICE_NAME].result.rpc.data_xml 25 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 26 | assert "MgmtEth0/0/CPU0/0" == parsed["data"]["interfaces"]["interface"][0]["name"] 27 | 28 | with open(f"{CONFIGS_DIR}/iosxr-interfaces.xml", "w+") as file: 29 | file.write(result[DEVICE_NAME].result.rpc.data_xml) 30 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/editing/netconf_validate.py: -------------------------------------------------------------------------------- 1 | """NETCONF validate config.""" 2 | 3 | from typing import Optional 4 | 5 | from ncclient.manager import Manager 6 | from nornir.core.task import Result, Task 7 | 8 | from nornir_netconf.plugins.connections import CONNECTION_NAME 9 | from nornir_netconf.plugins.helpers import RpcResult 10 | 11 | 12 | def netconf_validate( 13 | task: Task, 14 | source: Optional[str] = "candidate", 15 | manager: Optional[Manager] = None, 16 | ) -> Result: 17 | """Validate the datastore configuration. 18 | 19 | Arguments: 20 | source (str): Source configuration store 21 | manager (Manager): NETCONF Manager 22 | 23 | Examples: 24 | Simple example:: 25 | 26 | > nr.run(task=netconf_validate) 27 | 28 | Returns: 29 | Result object with the following attributes set:: 30 | 31 | * result (RpcResult): Rpc and Manager 32 | """ 33 | if not manager: 34 | manager = task.host.get_connection(CONNECTION_NAME, task.nornir.config) 35 | result = manager.validate(source=source) 36 | rpc_result = RpcResult(rpc=result, manager=manager) 37 | return Result(host=task.host, result=rpc_result) 38 | -------------------------------------------------------------------------------- /examples/example-project/nr_get_config.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | """Nornir NETCONF Example Task: 'get-config'.""" 3 | from nornir import InitNornir 4 | from nornir.core.task import Task 5 | from nornir_utils.plugins.functions import print_result 6 | 7 | from nornir_netconf.plugins.tasks import netconf_get_config 8 | 9 | __author__ = "Hugo Tinoco" 10 | __email__ = "hugotinoco@icloud.com" 11 | 12 | nr = InitNornir("config.yml") 13 | 14 | # Filter the hosts by 'west-region' assignment 15 | west_region = nr.filter(region="west-region") 16 | 17 | 18 | def example_netconf_get_config(task: Task) -> str: 19 | """Test get config.""" 20 | config = task.run( 21 | netconf_get_config, 22 | source="running", 23 | path=""" 24 | 25 | 26 | Base 27 | 28 | 29 | """, 30 | filter_type="subtree", 31 | ) 32 | return config.result.rpc.data_xml 33 | 34 | 35 | def main(): 36 | """Execute Nornir Script.""" 37 | print_result(west_region.run(task=example_netconf_get_config)) 38 | 39 | 40 | if __name__ == "__main__": 41 | main() 42 | -------------------------------------------------------------------------------- /tests/integration/sros/test_sros_get.py: -------------------------------------------------------------------------------- 1 | """Integration test against SROS device.""" 2 | 3 | # from nornir_utils.plugins.functions import print_result 4 | 5 | from nornir_netconf.plugins.tasks import netconf_get 6 | from tests.conftest import CONFIGS_DIR, skip_integration_tests, xml_dict 7 | 8 | DEVICE_NAME = "nokia_rtr" 9 | 10 | 11 | @skip_integration_tests 12 | def test_sros_netconf_get(nornir): 13 | """Test NETCONF get operation.""" 14 | nr = nornir.filter(name=DEVICE_NAME) 15 | filter = """ 16 | 17 | 18 | 1 19 | 20 | 21 | """ 22 | 23 | result = nr.run(netconf_get, filter_type="subtree", path=filter) 24 | with open(f"{CONFIGS_DIR}/{DEVICE_NAME}-router-get.xml", "w+") as file: 25 | file.write(result[DEVICE_NAME].result.rpc.data_xml) 26 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 27 | assert result[DEVICE_NAME].result 28 | assert ( 29 | "85f24527c381450e926892441835ad7f" 30 | == parsed["rpc-reply"]["data"]["state"]["card"]["hardware-data"]["part-number"] 31 | ) 32 | assert "state" in list(parsed["rpc-reply"]["data"].keys()) 33 | -------------------------------------------------------------------------------- /tests/unit/test_netconf_validate.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF validate unit test.""" 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | from nornir_netconf.plugins.tasks import netconf_validate 6 | 7 | DEVICE_NAME = "nokia_rtr" 8 | 9 | 10 | @patch("ncclient.manager.connect_ssh") 11 | def test_netconf_netconf_validate_success(ssh, nornir): 12 | """Test NETCONF netconf_validate, no defined manager.""" 13 | response_rpc = MagicMock() 14 | response = MagicMock() 15 | response.validate.return_value = response_rpc 16 | 17 | nr = nornir.filter(name=DEVICE_NAME) 18 | result = nr.run(netconf_validate, source="running") 19 | assert not result[DEVICE_NAME].failed 20 | assert result[DEVICE_NAME].result.rpc.ok 21 | 22 | 23 | @patch("ncclient.manager.connect_ssh") 24 | def test_netconf_validate_manager_set(ssh, nornir): 25 | """Test NETCONF edit-config, with manager option set.""" 26 | response_rpc = MagicMock() 27 | manager = MagicMock() 28 | manager.validate.return_value = response_rpc 29 | 30 | nr = nornir.filter(name=DEVICE_NAME) 31 | result = nr.run(netconf_validate, source="candidate", manager=manager) 32 | assert not result[DEVICE_NAME].failed 33 | assert result[DEVICE_NAME].result.rpc.ok 34 | -------------------------------------------------------------------------------- /tests/integration/arista/test_arista_get_config.py: -------------------------------------------------------------------------------- 1 | from nornir_netconf.plugins.tasks import netconf_get_config 2 | from tests.conftest import xml_dict 3 | 4 | DEVICE_NAME = "ceos" 5 | 6 | 7 | def test_netconf_get_config_running(nornir): 8 | """Test get running config as default.""" 9 | nr = nornir.filter(name=DEVICE_NAME) 10 | 11 | result = nr.run(netconf_get_config, source="running") 12 | assert result[DEVICE_NAME].result.rpc.ok 13 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 14 | assert parsed["data"]["system"]["config"]["hostname"] == "ceos" 15 | 16 | 17 | def test_netconf_get_config_subtree(nornir): 18 | """Test filter subtree of get_config.""" 19 | nr = nornir.filter(name=DEVICE_NAME) 20 | eth3 = """ 21 | 22 | 23 | Management1 24 | 25 | 26 | """ 27 | result = nr.run( 28 | netconf_get_config, 29 | source="running", 30 | path=eth3, 31 | filter_type="subtree", 32 | ) 33 | assert result[DEVICE_NAME].result.rpc.ok 34 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 35 | assert parsed["data"]["interfaces"]["interface"]["name"] == "Management1" 36 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/helpers/general.py: -------------------------------------------------------------------------------- 1 | """General Helpers.""" 2 | 3 | import logging 4 | import os.path 5 | from pathlib import Path 6 | 7 | 8 | def check_file(file_name: str) -> bool: 9 | """Check file_name exists based on input. 10 | 11 | Args: 12 | file_name (str): file name to check 13 | """ 14 | try: 15 | file_path = Path(file_name) 16 | return file_path.exists() 17 | except TypeError: 18 | return False 19 | 20 | 21 | def create_folder(directory: str) -> None: 22 | """Create a directory. 23 | 24 | Args: 25 | directory (str): Directory path to create 26 | """ 27 | try: 28 | if not os.path.exists(directory): 29 | os.makedirs(directory) 30 | except OSError as err_ex: 31 | logging.info("Error when creating %s, %s", directory, err_ex) 32 | 33 | 34 | def write_output(text: str, path: str, filename: str, ext: str = "txt") -> None: 35 | """Take input and path and write a file. 36 | 37 | Args: 38 | text (str): text to write 39 | path (str): directory path 40 | filename (str): filename 41 | """ 42 | if not os.path.isdir(path): 43 | create_folder(path) 44 | with open(f"{path}/{filename}.{ext}", "w+", encoding="utf-8") as file: 45 | file.write(str(text)) 46 | -------------------------------------------------------------------------------- /tests/integration/arista/test_arista_lock.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF lock - integration.""" 2 | 3 | from ncclient.manager import Manager 4 | from ncclient.operations.rpc import RPCReply 5 | 6 | from nornir_netconf.plugins.helpers import RpcResult 7 | from nornir_netconf.plugins.tasks import netconf_lock 8 | 9 | # from nornir_utils.plugins.functions import print_result 10 | 11 | 12 | DEVICE_NAME = "ceos" 13 | 14 | 15 | def test_netconf_lock(nornir): 16 | """Test Netconf Lock.""" 17 | nr = nornir.filter(name=DEVICE_NAME) 18 | result = nr.run(netconf_lock, datastore="candidate", operation="lock") 19 | assert result[DEVICE_NAME].result.rpc.ok 20 | assert isinstance(result[DEVICE_NAME].result, RpcResult) 21 | assert isinstance(result[DEVICE_NAME].result.manager, Manager) 22 | assert isinstance(result[DEVICE_NAME].result.rpc, RPCReply) 23 | result = nr.run(netconf_lock, datastore="candidate", operation="unlock") 24 | assert result[DEVICE_NAME].result.rpc.ok 25 | 26 | 27 | # TODO: Fix this test 28 | # def test_netconf_lock_failed(nornir): 29 | # """Test Netconf Lock - failed.""" 30 | # nr = nornir.filter(name=DEVICE_NAME) 31 | # lock_operation = nr.run(netconf_lock, datastore="candidate", operation="lock") 32 | # assert lock_operation[DEVICE_NAME].result.rpc.ok 33 | # failed_lock = nr.run(netconf_lock, datastore="candidate", operation="lock") 34 | # assert failed_lock[DEVICE_NAME].failed 35 | -------------------------------------------------------------------------------- /tests/unit/test_netconf_commit.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF Commit. 2 | 3 | that conflicts with patching SSH on the next set of tests for edit_config. 4 | Context manager doesn't help, but using a different DEVICE_NAME does. 5 | """ 6 | 7 | from unittest.mock import MagicMock, patch 8 | 9 | from nornir_netconf.plugins.helpers import RpcResult 10 | from nornir_netconf.plugins.tasks import netconf_commit 11 | 12 | DEVICE_NAME = "ceos" 13 | 14 | 15 | @patch("ncclient.manager.connect_ssh") 16 | def test_netconf_commit_success(ssh, nornir): 17 | """Test success.""" 18 | response_rpc = MagicMock() 19 | response = MagicMock() 20 | response.commit.return_value = response_rpc 21 | ssh.return_value = response 22 | nr = nornir.filter(name=DEVICE_NAME) 23 | result = nr.run(netconf_commit) 24 | assert not result[DEVICE_NAME].failed 25 | assert result[DEVICE_NAME].result.rpc 26 | assert isinstance(result[DEVICE_NAME].result, RpcResult) 27 | 28 | 29 | @patch("ncclient.manager.connect_ssh") 30 | def test_netconf_commit_success_with_manager(ssh, nornir): 31 | """Test success with manager.""" 32 | response_rpc = MagicMock() 33 | manager = MagicMock() 34 | manager.commit.return_value = response_rpc 35 | # Run Nornir 36 | nr = nornir.filter(name=DEVICE_NAME) 37 | result = nr.run(netconf_commit, manager=manager) 38 | assert not result[DEVICE_NAME].failed 39 | assert result[DEVICE_NAME].result.rpc 40 | ssh.reset_mock() 41 | -------------------------------------------------------------------------------- /docs/additional/dataclasses.md: -------------------------------------------------------------------------------- 1 | # DataClasses Implementation 2 | 3 | As of version 2.0, there will be an introduction of `RpcResult` and `SchemaResult`. Going forward, any task will return a dataclass to ensure a good experience for the developers and users of this project. 4 | 5 | Please view the source code to ensure this is the most update to date information on the implementations. 6 | 7 | > SOURCE: `nornir_netconf/plugins/helpers/rpc.py` 8 | 9 | ## RpcResult 10 | 11 | This will be the object that will mostly be presented back to users as the return value to the `Result.result` attribute. 12 | 13 | ```python 14 | @dataclass 15 | class RpcResult: 16 | """RPC Reply Result Model.""" 17 | 18 | rpc: Optional[RPCReply] = field(default=None, repr=True) 19 | manager: Optional[Manager] = field(default=None, repr=False) 20 | ``` 21 | 22 | ## SchemaResult 23 | 24 | This will provide users with information about valid schemas which were created and in what `files` they were outputted to. Additionally, the `directory` in which the files where aggregated and written to. if any errors were encountered during the writing of the files or retrieval of the schema, they will be aggregated into the `errors` attribute. 25 | 26 | ```python 27 | @dataclass 28 | class SchemaResult: 29 | """Get Schema Result.""" 30 | 31 | directory: str = field(repr=True) 32 | errors: List[str] = field(repr=False, default_factory=list) 33 | files: List[str] = field(repr=False, default_factory=list) 34 | ``` 35 | -------------------------------------------------------------------------------- /tests/integration/arista/test_arista_edit_config.py: -------------------------------------------------------------------------------- 1 | """Test Edit Config on Arista.""" 2 | 3 | from random import randint 4 | from string import Template 5 | 6 | from nornir_utils.plugins.functions import print_result 7 | 8 | from nornir_netconf.plugins.tasks import ( 9 | netconf_commit, 10 | netconf_edit_config, 11 | netconf_get_config, 12 | ) 13 | from tests.conftest import xml_dict 14 | 15 | DEVICE_NAME = "ceos" 16 | 17 | BFD_STATE = str(bool(randint(0, 1))).lower() 18 | CONFIG_TEMPLATE = """ 19 | 20 | 21 | 22 | ${bfd_state} 23 | 24 | 25 | 26 | """ 27 | CONFIG = Template(CONFIG_TEMPLATE).substitute(bfd_state=BFD_STATE) 28 | 29 | 30 | def test_edit_ceos_config(nornir): 31 | """Edit Config and then pull config to validate the change.""" 32 | nr = nornir.filter(name=DEVICE_NAME) 33 | result = nr.run(task=netconf_edit_config, config=CONFIG, target="candidate") 34 | print_result(result) 35 | result = nr.run(task=netconf_commit) 36 | print_result(result) 37 | # Pull config and assert the default 'enabled' is set to dynamic variable `BFD_STATE` 38 | result = nr.run( 39 | netconf_get_config, 40 | source="running", 41 | ) 42 | assert result[DEVICE_NAME].result.rpc 43 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 44 | assert BFD_STATE == parsed["data"]["bfd"]["config"]["enabled"] 45 | -------------------------------------------------------------------------------- /tests/inventory_data/groups.yml: -------------------------------------------------------------------------------- 1 | --- 2 | empty_ssh_file_group: 3 | username: "admin" 4 | password: "admin" 5 | port: 830 6 | connection_options: 7 | netconf: 8 | extras: 9 | hostkey_verify: false 10 | timeout: 300 11 | allow_agent: false 12 | look_for_keys: false 13 | ssh_config: "tests/inventory_data/ssh_config" 14 | sros: 15 | username: "admin" 16 | password: "admin" 17 | port: 830 18 | platform: "alcatel_sros" 19 | connection_options: 20 | netconf: 21 | extras: 22 | hostkey_verify: false 23 | timeout: 300 24 | allow_agent: false 25 | look_for_keys: false 26 | device_params: 27 | name: "sros" 28 | iosxr: 29 | username: "clab" 30 | password: "clab@123" 31 | port: 830 32 | platform: "iosxr" 33 | connection_options: 34 | netconf: 35 | extras: 36 | hostkey_verify: false 37 | timeout: 300 38 | allow_agent: false 39 | look_for_keys: false 40 | csr: 41 | username: "admin" 42 | password: "admin" 43 | port: 830 44 | platform: "csr" 45 | connection_options: 46 | netconf: 47 | extras: 48 | hostkey_verify: false 49 | timeout: 300 50 | allow_agent: false 51 | look_for_keys: false 52 | ceos: 53 | username: "admin" 54 | password: "admin" 55 | port: 830 56 | connection_options: 57 | netconf: 58 | extras: 59 | hostkey_verify: false 60 | timeout: 300 61 | allow_agent: false 62 | look_for_keys: false 63 | integration: {} 64 | -------------------------------------------------------------------------------- /examples/example-project/nr_get_schemas.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | """Get Schemas from NETCONF device.""" 3 | from nornir import InitNornir 4 | from nornir.core import Task 5 | from nornir.core.task import Result 6 | from nornir_utils.plugins.functions import print_result 7 | 8 | from nornir_netconf.plugins.tasks import netconf_get, netconf_get_schemas 9 | from tests.conftest import xml_dict 10 | 11 | __author__ = "Hugo Tinoco" 12 | __email__ = "hugotinoco@icloud.com" 13 | 14 | nr = InitNornir("config.yml") 15 | 16 | 17 | # Filter the hosts by 'west-region' assignment 18 | west_region = nr.filter(region="west-region") 19 | 20 | SCHEMA_FILTER = """ 21 | 22 | 23 | 24 | 25 | """ 26 | 27 | 28 | def example_task_get_schemas(task: Task) -> Result: 29 | """Get Schemas from NETCONF device.""" 30 | result = task.run(netconf_get, path=SCHEMA_FILTER, filter_type="subtree") 31 | # xml_dict is a custom function to convert XML to Python dictionary. Not part of Nornir Plugin. 32 | # See the code example if you want to use it. 33 | parsed = xml_dict(result.result.rpc.data_xml) 34 | first_schema = parsed["rpc-reply"]["data"]["netconf-state"]["schemas"]["schema"][0] 35 | return task.run(netconf_get_schemas, schemas=[first_schema["identifier"]], schema_path="./output/schemas") 36 | 37 | 38 | def main(): 39 | """Execute Nornir Script.""" 40 | print_result(west_region.run(task=example_task_get_schemas)) 41 | 42 | 43 | if __name__ == "__main__": 44 | main() 45 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/editing/netconf_commit.py: -------------------------------------------------------------------------------- 1 | """NETCONF commit.""" 2 | 3 | from typing import Optional 4 | 5 | from ncclient.manager import Manager 6 | from nornir.core.task import Result, Task 7 | 8 | from nornir_netconf.plugins.connections import CONNECTION_NAME 9 | from nornir_netconf.plugins.helpers import RpcResult 10 | 11 | 12 | def netconf_commit( 13 | task: Task, 14 | manager: Optional[Manager] = None, 15 | confirmed: Optional[bool] = False, 16 | timeout: Optional[int] = 60, 17 | persist: Optional[int] = None, 18 | persist_id: Optional[int] = None, 19 | ) -> Result: 20 | """Commit operation. 21 | 22 | Arguments: 23 | manager (Manager): NETCONF Manager 24 | confirmed (boolean): Commit confirm 25 | timeout (int): commit confirm timeout 26 | persist (int): survive a session termination 27 | persist_id (int): must equal given value of persist in original commit operation 28 | 29 | Examples: 30 | Simple example:: 31 | 32 | > nr.run(task=netconf_commit) 33 | 34 | With a carried manager session:: 35 | > nr.run(task=netconf_commit, manager=manager) 36 | 37 | Returns: 38 | Result object with the following attributes set:: 39 | 40 | * result (RpcResult): Rpc and Manager 41 | """ 42 | if not manager: 43 | manager = task.host.get_connection(CONNECTION_NAME, task.nornir.config) 44 | result = manager.commit(confirmed, timeout, persist, persist_id) 45 | result = RpcResult(rpc=result, manager=manager) 46 | return Result(host=task.host, result=result) 47 | -------------------------------------------------------------------------------- /tests/integration/common/test_capabilities.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF capabilities.""" 2 | 3 | from typing import Dict 4 | 5 | from nornir.core.filter import F 6 | 7 | from nornir_netconf.plugins.tasks import netconf_capabilities 8 | from tests.conftest import eval_multi_task_result, skip_integration_tests 9 | 10 | CEOS_EXPECTED_CAPABILITY = ( 11 | "http://openconfig.net/yang/policy-forwarding?module=openconfig-policy-forwarding&revision=2021-08-06" 12 | ) 13 | IOSXR_EXPECTED_CAPABILITY = ( 14 | "http://cisco.com/ns/yang/Cisco-IOS-XR-es-acl-datatypes?module=Cisco-IOS-XR-es-acl-datatypes&revision=2015-11-09" 15 | ) 16 | IOSXE_EXPECTED_CAPABILITY = ( 17 | "http://cisco.com/ns/yang/Cisco-IOS-XE-device-tracking?module=Cisco-IOS-XE-device-tracking&revision=2020-03-01" 18 | ) 19 | SROS_EXPECTED_CAPABILITY = "urn:nokia.com:sros:ns:yang:sr:types-rsvp?module=nokia-types-rsvp&revision=2018-02-08" 20 | 21 | 22 | CAPABILITIES: Dict = { 23 | "ceos": CEOS_EXPECTED_CAPABILITY, 24 | "iosxe_rtr": IOSXE_EXPECTED_CAPABILITY, 25 | "nokia_rtr": SROS_EXPECTED_CAPABILITY, 26 | "iosxr_rtr": IOSXR_EXPECTED_CAPABILITY, 27 | } 28 | 29 | 30 | @skip_integration_tests 31 | def test_netconf_capabilities(nornir, schema_path): 32 | """Test NETCONF Capabilities.""" 33 | nr = nornir.filter(F(groups__contains="integration")) 34 | hosts = list(nr.inventory.hosts.keys()) 35 | result = nr.run(netconf_capabilities) 36 | eval_multi_task_result(hosts=hosts, result=result) 37 | for host in hosts: 38 | capabilities = [cap for cap in result[host][0].result.rpc] 39 | assert CAPABILITIES[host] in capabilities 40 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/retrieval/netconf_get.py: -------------------------------------------------------------------------------- 1 | """NETCONF get.""" 2 | 3 | from nornir.core.task import Optional, Result, Task 4 | 5 | from nornir_netconf.plugins.connections import CONNECTION_NAME 6 | from nornir_netconf.plugins.helpers import RpcResult 7 | 8 | 9 | def netconf_get(task: Task, path: Optional[str] = "", filter_type: Optional[str] = "xpath") -> Result: 10 | """Get configuration and state information over Netconf from device. 11 | 12 | Arguments: 13 | path (Optional[str]): `Subtree` or `xpath` to filter 14 | filter_type (Optional[str]): Type of filtering to use, `xpath or `subtree` 15 | 16 | Examples: 17 | Simple example:: 18 | 19 | > nr.run(task=netconf_get) 20 | 21 | Passing options using ``xpath``:: 22 | 23 | > xpath = "/devices/device" 24 | > nr.run(task=netconf_get, 25 | > path=xpath) 26 | 27 | Passing options using ``subtree``:: 28 | 29 | > subtree = "" 30 | > nr.run(task=netconf_get, 31 | > filter_type="subtree", 32 | > path=subtree) 33 | 34 | 35 | Returns: 36 | Result object with the following attributes set:: 37 | 38 | * result (RpcResult): Rpc and Manager 39 | """ 40 | params = {} 41 | manager = task.host.get_connection(CONNECTION_NAME, task.nornir.config) 42 | if path: 43 | params["filter"] = (filter_type, path) 44 | result = manager.get(**params) 45 | 46 | result = RpcResult(rpc=result, manager=manager) 47 | return Result(host=task.host, result=result) 48 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/rpc/netconf_rpc.py: -------------------------------------------------------------------------------- 1 | """NETCONF rpc generic call.""" 2 | 3 | import re 4 | from typing import Optional 5 | 6 | from ncclient import xml_ 7 | from ncclient.manager import Manager 8 | from nornir.core.task import Result, Task 9 | 10 | from nornir_netconf.plugins.connections import CONNECTION_NAME 11 | from nornir_netconf.plugins.helpers import RpcResult 12 | 13 | 14 | def netconf_rpc( 15 | task: Task, 16 | payload: str, 17 | manager: Optional[Manager] = None, 18 | ) -> Result: 19 | """This method is a "bare-bones" rpc call which does not apply any 20 | formatting/standardization beyond the outer most rpc tag. 21 | 22 | Arguments: 23 | payload (str): Payload snippet to apply 24 | manager (Manager): NETCONF Manager 25 | 26 | Examples: 27 | Simple example:: 28 | > desired_payload='' 29 | > nr.run(task= netconf_rpc, payload=desired_payload) 30 | 31 | 32 | Returns: 33 | Result object with the following attributes set:: 34 | 35 | * result (RpcResult): Rpc and Manager 36 | """ 37 | if not manager: 38 | manager = task.host.get_connection(CONNECTION_NAME, task.nornir.config) 39 | 40 | # convert payload to element, set namespace (if not set) and send RPC 41 | data = xml_.to_ele(payload) 42 | if not re.match(r"{.*}.*", xml_.parse_root(payload)[0]): 43 | data.set("xmlns", xml_.BASE_NS_1_0) 44 | 45 | result = manager.rpc(data) 46 | 47 | result = RpcResult(rpc=result, manager=manager) 48 | return Result(host=task.host, result=result) 49 | -------------------------------------------------------------------------------- /tests/integration/arista/test_arista_rpc.py: -------------------------------------------------------------------------------- 1 | """Test Edit Config on Arista.""" 2 | 3 | from random import randint 4 | from string import Template 5 | 6 | from nornir_utils.plugins.functions import print_result 7 | 8 | from nornir_netconf.plugins.tasks import ( 9 | netconf_commit, 10 | netconf_get_config, 11 | netconf_rpc, 12 | ) 13 | from tests.conftest import xml_dict 14 | 15 | DEVICE_NAME = "ceos" 16 | 17 | BFD_STATE = str(bool(randint(0, 1))).lower() 18 | RPC_TEMPLATE = """ 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ${bfd_state} 27 | 28 | 29 | 30 | 31 | """ 32 | RPC = Template(RPC_TEMPLATE).substitute(bfd_state=BFD_STATE) 33 | 34 | 35 | def test_rpc_ceos(nornir): 36 | """Edit Config (via bare RPC call) and then pull config to validate the change.""" 37 | nr = nornir.filter(name=DEVICE_NAME) 38 | result = nr.run(task=netconf_rpc, payload=RPC) 39 | print_result(result) 40 | result = nr.run(task=netconf_commit) 41 | print_result(result) 42 | 43 | # Pull config and assert the default 'enabled' is set to dynamic variable `BFD_STATE` 44 | result = nr.run( 45 | netconf_get_config, 46 | source="running", 47 | ) 48 | print_result(result) 49 | 50 | assert result[DEVICE_NAME].result.rpc 51 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 52 | assert BFD_STATE == parsed["data"]["bfd"]["config"]["enabled"] 53 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/retrieval/netconf_get_schemas.py: -------------------------------------------------------------------------------- 1 | """NETCONF Schemas.""" 2 | 3 | from ncclient.operations.rpc import RPCError 4 | from nornir.core.task import List, Result, Task 5 | 6 | from nornir_netconf.plugins.connections import CONNECTION_NAME 7 | from nornir_netconf.plugins.helpers import SchemaResult, write_output 8 | 9 | 10 | def netconf_get_schemas(task: Task, schemas: List[str], schema_path: str) -> Result: # nosec 11 | """Fetch provided schemas and write to a file inside of a given directory path, `schema_path`. 12 | 13 | All schemas will be written to a file in the `schema_path` directory provided and 14 | named by the schema name. 15 | 16 | Any errors on extracting the schema will be logged in the result object. 17 | 18 | Args: 19 | schemas (List[str]): List of schemas to fetch. 20 | schema_path (str): Directory path to save schemas output. 21 | 22 | Simple Example :: 23 | 24 | > nr.run(task=netconf_schemas, schemas=["schema1", "schema2"], schema_path="workdir/schemas") 25 | 26 | Returns: 27 | Result object with the following attributes set:: 28 | 29 | * result (SchemaResult): List of files created, errors, if any and base directory path. 30 | """ 31 | manager = task.host.get_connection(CONNECTION_NAME, task.nornir.config) 32 | result = SchemaResult(directory=schema_path) 33 | 34 | for schema in schemas: 35 | try: 36 | write_output(manager.get_schema(schema), path=schema_path, filename=schema, ext="yang") 37 | result.files.append(f"{schema_path}/{schema}.yang") 38 | except RPCError as err_ex: 39 | result.errors.append(str(err_ex).strip()) 40 | 41 | return Result(host=task.host, result=result) 42 | -------------------------------------------------------------------------------- /tests/integration/sros/test_sros_get_config.py: -------------------------------------------------------------------------------- 1 | """Integration test against SROS device.""" 2 | 3 | # from nornir_utils.plugins.functions import print_result 4 | 5 | from nornir_netconf.plugins.tasks import netconf_get_config 6 | from tests.conftest import CONFIGS_DIR, skip_integration_tests, xml_dict 7 | 8 | DEVICE_NAME = "nokia_rtr" 9 | 10 | 11 | @skip_integration_tests 12 | def test_sros_netconf_get_config(nornir): 13 | """Test get config with subtree.""" 14 | nr = nornir.filter(name=DEVICE_NAME) 15 | 16 | result = nr.run( 17 | netconf_get_config, 18 | source="running", 19 | path=""" 20 | 21 | 22 | 23 | """, 24 | filter_type="subtree", 25 | ) 26 | assert result[DEVICE_NAME].result.rpc 27 | assert result[DEVICE_NAME].result.rpc.data_xml 28 | with open(f"{CONFIGS_DIR}/{DEVICE_NAME}-xpath-router-config.xml", "w+") as file: 29 | file.write(result[DEVICE_NAME].result.rpc.data_xml) 30 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 31 | assert "me12-100gb-qsfp28" == parsed["rpc-reply"]["data"]["configure"]["card"]["mda"][0]["mda-type"] 32 | 33 | 34 | @skip_integration_tests 35 | def test_sros_netconf_get_full_config(nornir): 36 | """Test get full config.""" 37 | nr = nornir.filter(name=DEVICE_NAME) 38 | result = nr.run( 39 | netconf_get_config, 40 | source="running", 41 | ) 42 | assert result[DEVICE_NAME].result.rpc 43 | assert result[DEVICE_NAME].result.rpc.data_xml 44 | assert not result[DEVICE_NAME].failed 45 | 46 | with open(f"{CONFIGS_DIR}/{DEVICE_NAME}-config.xml", "w+") as file: 47 | file.write(result[DEVICE_NAME].result.rpc.data_xml) 48 | -------------------------------------------------------------------------------- /tests/integration/iosxe/test_iosxe_edit_config.py: -------------------------------------------------------------------------------- 1 | """Integration test configuration edits against IOSXE device.""" 2 | 3 | from random import randint 4 | from string import Template 5 | 6 | from nornir_netconf.plugins.tasks import netconf_edit_config, netconf_get_config 7 | from tests.conftest import skip_integration_tests, xml_dict 8 | 9 | # from nornir_utils.plugins.functions import print_result 10 | 11 | 12 | DEVICE_NAME = "iosxe_rtr" 13 | 14 | RANDOM_DESCRIPTION = f"NORNIR-NETCONF-DESCRIPTION-{randint(0, 100)}" 15 | CONFIG_TEMPLATE = """ 16 | 17 | 18 | 19 | GigabitEthernet1 20 | 21 | GigabitEthernet1 22 | ${random_description} 23 | 24 | 25 | 26 | 27 | """ 28 | CONFIG = Template(CONFIG_TEMPLATE).substitute(random_description=RANDOM_DESCRIPTION) 29 | 30 | 31 | @skip_integration_tests 32 | def test_netconf_edit_config(nornir): 33 | """Test Edit Config.""" 34 | nr = nornir.filter(name=DEVICE_NAME) 35 | result = nr.run(task=netconf_edit_config, config=CONFIG, target="running") 36 | assert result[DEVICE_NAME].result.rpc.ok 37 | 38 | # Validate config change is in running config datastore 39 | result = nr.run( 40 | netconf_get_config, 41 | source="running", 42 | ) 43 | assert result[DEVICE_NAME].result.rpc 44 | assert result[DEVICE_NAME].result.rpc.data_xml 45 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 46 | assert RANDOM_DESCRIPTION == parsed["data"]["interfaces"][0]["interface"]["description"] 47 | -------------------------------------------------------------------------------- /tests/integration/iosxr/test_iosxr_edit_config.py: -------------------------------------------------------------------------------- 1 | """Integration test against IOSXR device.""" 2 | 3 | from random import randint 4 | from string import Template 5 | 6 | from nornir_utils.plugins.functions import print_result 7 | 8 | from nornir_netconf.plugins.tasks import ( 9 | netconf_commit, 10 | netconf_edit_config, 11 | netconf_get_config, 12 | ) 13 | from tests.conftest import skip_integration_tests, xml_dict 14 | 15 | DEVICE_NAME = "iosxr_rtr" 16 | RANDOM_TIMER = randint(10, 100) 17 | CONFIG_TEMPLATE = """ 18 | 19 | 20 | ${timer} 21 | true 22 | 23 | 200 24 | 25 | 26 | 27 | """ 28 | CONFIG = Template(CONFIG_TEMPLATE).substitute(timer=RANDOM_TIMER) 29 | 30 | 31 | @skip_integration_tests 32 | def test_iosxr_netconf_edit_config(nornir): 33 | """Test NETCONF edit-config from candidate datastore and commit.""" 34 | nr = nornir.filter(name=DEVICE_NAME) 35 | result = nr.run(netconf_edit_config, config=CONFIG, target="candidate") 36 | assert result[DEVICE_NAME].result.rpc.ok 37 | print_result(result) 38 | 39 | # Commit Config 40 | result = nr.run(netconf_commit) 41 | assert result[DEVICE_NAME].result.rpc.ok 42 | print_result(result) 43 | 44 | result = nr.run( 45 | netconf_get_config, 46 | source="running", 47 | ) 48 | print_result(result) 49 | 50 | assert result[DEVICE_NAME].result.rpc 51 | assert result[DEVICE_NAME].result.rpc.data_xml 52 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 53 | assert str(RANDOM_TIMER) == parsed["data"]["cdp"]["timer"] 54 | -------------------------------------------------------------------------------- /tests/unit/test_netconf_lock_unit.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF Lock - unit-tests.""" 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | from nornir_netconf.plugins.tasks import netconf_lock 6 | 7 | DEVICE_NAME = "ceos" 8 | 9 | 10 | def test_netconf_lock(nornir): 11 | """Test Netconf Lock, operation not found.""" 12 | nr = nornir.filter(name=DEVICE_NAME) 13 | result = nr.run(netconf_lock, datastore="candidate", operation="kock") 14 | assert result[DEVICE_NAME].failed 15 | 16 | 17 | @patch("ncclient.manager.Manager") 18 | @patch("ncclient.manager.connect_ssh") 19 | def test_netconf_lock_strip_lower(ssh, manager, nornir): 20 | """Test Netconf Lock, operation lock success.""" 21 | response_rpc = MagicMock() 22 | manager.lock.return_value = response_rpc 23 | nr = nornir.filter(name=DEVICE_NAME) 24 | result = nr.run(netconf_lock, datastore="candidate", operation=" Lock", manager=manager) 25 | assert not result[DEVICE_NAME].failed 26 | assert result[DEVICE_NAME].result.rpc 27 | 28 | 29 | @patch("ncclient.manager.Manager") 30 | @patch("ncclient.manager.connect_ssh") 31 | def test_netconf_with_manager(ssh, manager, nornir): 32 | """Test Netconf Lock, custom manager.""" 33 | nr = nornir.filter(name=DEVICE_NAME) 34 | result = nr.run(netconf_lock, datastore="candidate", operation=" LOCK ", manager=manager) 35 | assert not result[DEVICE_NAME].failed 36 | assert result[DEVICE_NAME].result.rpc 37 | 38 | 39 | @patch("ncclient.manager.Manager") 40 | @patch("ncclient.manager.connect_ssh") 41 | def test_netconf_unlock(ssh, manager, nornir): 42 | """Test Netconf UnLock.""" 43 | nr = nornir.filter(name=DEVICE_NAME) 44 | result = nr.run(netconf_lock, datastore="candidate", operation="unlock") 45 | assert not result[DEVICE_NAME].failed 46 | assert result[DEVICE_NAME].result.rpc 47 | assert result[DEVICE_NAME][0].name == "netconf_unlock" 48 | -------------------------------------------------------------------------------- /tests/integration/iosxe/test_iosxe_get_config.py: -------------------------------------------------------------------------------- 1 | """Integration test against IOSXE device.""" 2 | 3 | # from nornir_utils.plugins.functions import print_result 4 | 5 | from nornir_netconf.plugins.tasks import netconf_get_config 6 | from tests.conftest import CONFIGS_DIR, skip_integration_tests, xml_dict 7 | 8 | DEVICE_NAME = "iosxe_rtr" 9 | 10 | 11 | @skip_integration_tests 12 | def test_iosxe_netconf_get_config(nornir): 13 | """Test NETCONF get config.""" 14 | nr = nornir.filter(name=DEVICE_NAME) 15 | 16 | result = nr.run( 17 | netconf_get_config, 18 | source="running", 19 | path=""" 20 | 21 | 22 | GigabitEthernet1 23 | 24 | 25 | """, 26 | filter_type="subtree", 27 | ) 28 | 29 | assert result[DEVICE_NAME].result.rpc 30 | assert result[DEVICE_NAME].result.rpc.data_xml 31 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 32 | assert "10.0.0.15" == parsed["data"]["interfaces"]["interface"]["ipv4"]["address"]["ip"] 33 | 34 | with open(f"{CONFIGS_DIR}/iosxe-interface-gigabitethernet1.xml", "w+") as file: 35 | file.write(result[DEVICE_NAME].result.rpc.data_xml) 36 | 37 | 38 | @skip_integration_tests 39 | def test_iosxe_netconf_get_full_config(nornir): 40 | """Test NETCONF get full config.""" 41 | nr = nornir.filter(name=DEVICE_NAME) 42 | 43 | result = nr.run( 44 | netconf_get_config, 45 | source="running", 46 | ) 47 | assert result[DEVICE_NAME].result.rpc 48 | assert result[DEVICE_NAME].result.rpc.data_xml 49 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 50 | assert "vr-csr-1" == parsed["data"]["native"]["hostname"] 51 | with open(f"{CONFIGS_DIR}/iosxe-full-config.xml", "w+") as file: 52 | file.write(result[DEVICE_NAME].result.rpc.data_xml) 53 | -------------------------------------------------------------------------------- /tests/integration/common/test_schemas.py: -------------------------------------------------------------------------------- 1 | """Test Get Schemas from all vendors.""" 2 | 3 | from typing import Dict 4 | 5 | from nornir.core.filter import F 6 | 7 | from nornir_netconf.plugins.tasks import netconf_get, netconf_get_schemas 8 | from tests.conftest import skip_integration_tests, xml_dict 9 | 10 | # from nornir_utils.plugins.functions import print_result 11 | 12 | 13 | @skip_integration_tests 14 | def test_netconf_capabilities_get_schema(nornir, schema_path): 15 | """Test NETCONF Capabilities + Get Schemas success.""" 16 | nr = nornir.filter(F(groups__contains="integration")) 17 | hosts = list(nr.inventory.hosts.keys()) 18 | schema_map: Dict[str, str] = {} 19 | 20 | filter = """ 21 | 22 | 23 | 24 | 25 | """ 26 | result = nr.run(netconf_get, path=filter, filter_type="subtree") 27 | for host in hosts: 28 | assert not result[host][0].failed 29 | parsed = xml_dict(result[host][0].result.rpc.data_xml) 30 | if "rpc-reply" in list(parsed.keys()): 31 | first_schema = parsed["rpc-reply"]["data"]["netconf-state"]["schemas"]["schema"][0] 32 | 33 | else: 34 | first_schema = parsed["data"]["netconf-state"]["schemas"]["schema"][0] 35 | schema_map.setdefault(host, first_schema["identifier"]) 36 | # example = { 37 | # "identifier": "iana-if-type", 38 | # "version": "2014-05-08", 39 | # "format": "yang", 40 | # "namespace": "urn:ietf:params:xml:ns:yang:iana-if-type", 41 | # "location": "NETCONF", 42 | # } 43 | for host in hosts: 44 | nr = nornir.filter(name=host) 45 | schema = nr.run(netconf_get_schemas, schemas=[schema_map[host]], schema_path=schema_path) 46 | assert schema[host].result.files 47 | assert not schema[host].result.errors 48 | assert schema[host].result.directory 49 | -------------------------------------------------------------------------------- /tests/integration/arista/test_arista_connection.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF Connection.""" 2 | 3 | import os 4 | 5 | from ncclient.capabilities import Capabilities 6 | 7 | from nornir_netconf.plugins.helpers.models import RpcResult 8 | from nornir_netconf.plugins.tasks import netconf_capabilities 9 | 10 | DIR_PATH = os.path.dirname(os.path.realpath(__file__)) 11 | DEVICE_NAME = "ceos" 12 | CAP = "http://openconfig.net/yang/policy-forwarding?module=openconfig-policy-forwarding&revision=2021-08-06" 13 | 14 | 15 | def test_netconf_connection_missing_ssh_keyfile(nornir): 16 | """Test netconf connection - no ssh config file.""" 17 | nr = nornir.filter(name=DEVICE_NAME) 18 | result = nr.run(netconf_capabilities) 19 | 20 | assert isinstance(result[DEVICE_NAME].result, RpcResult) 21 | assert isinstance(result[DEVICE_NAME].result.rpc, Capabilities) 22 | 23 | 24 | def test_netconf_connection_non_existent_ssh_config(nornir): 25 | """Test netconf connection - bad ssh config path.""" 26 | nr = nornir.filter(name=DEVICE_NAME) 27 | nr.config.ssh.config_file = "i dont exist" 28 | result = nr.run(netconf_capabilities) 29 | assert nr.config.ssh.config_file == "i dont exist" 30 | assert isinstance(result[DEVICE_NAME].result, RpcResult) 31 | assert CAP in result[DEVICE_NAME].result.rpc 32 | 33 | 34 | def test_netconf_connection_ssh_config_exists(nornir): 35 | nr = nornir.filter(name=DEVICE_NAME) 36 | nr.config.ssh.config_file = f"{DIR_PATH}/inventory_data/ssh_config" 37 | result = nr.run(netconf_capabilities) 38 | 39 | assert isinstance(result[DEVICE_NAME].result, RpcResult) 40 | assert CAP in [cap for cap in result[DEVICE_NAME].result.rpc] 41 | 42 | 43 | def test_netconf_connection_ssh_keyfile(nornir): 44 | """Test netconf connection - with shh config file.""" 45 | device_name = "ceos_empty_ssh_file" 46 | nr = nornir.filter(name=device_name) 47 | result = nr.run(netconf_capabilities) 48 | assert isinstance(result[device_name].result, RpcResult) 49 | assert isinstance(result[device_name].result.rpc, Capabilities) 50 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/retrieval/netconf_get_config.py: -------------------------------------------------------------------------------- 1 | """NETCONF get config.""" 2 | 3 | from typing import Any, Dict, Optional 4 | 5 | from nornir.core.task import Result, Task 6 | 7 | from nornir_netconf.plugins.connections import CONNECTION_NAME 8 | from nornir_netconf.plugins.helpers import RpcResult 9 | 10 | 11 | # - > Prob don't need to add defaults. 12 | def netconf_get_config( 13 | task: Task, source: Optional[str] = "running", path: Optional[str] = "", filter_type: Optional[str] = "xpath" 14 | ) -> Result: 15 | """Get configuration over Netconf from device. 16 | 17 | Arguments: 18 | source (Optional[str]): Configuration datastore to collect from. Defaults to `running` 19 | path (Optional[str]): Subtree or xpath to filter. Defaults to `''` 20 | filter_type (Optional[str]): Type of filtering to use, 'xpath' or 'subtree'. Defaults to `xpath` 21 | 22 | Examples: 23 | Simple example:: 24 | 25 | > nr.run(task=netconf_get_config) 26 | 27 | Collect startup config:: 28 | 29 | > nr.run(task=netconf_get_config, source="startup") 30 | 31 | 32 | Passing options using ``xpath``:: 33 | 34 | > xpath = "/devices/device" 35 | > nr.run(task=netconf_get_config, 36 | > path=xpath) 37 | 38 | Passing options using ``subtree``:: 39 | 40 | > subtree = "" 41 | > nr.run(task=netconf_get_config, 42 | > filter_type="subtree", 43 | > path=subtree) 44 | 45 | 46 | Returns: 47 | Result object with the following attributes set:: 48 | 49 | * result (RpcResult): Rpc and Manager 50 | """ 51 | manager = task.host.get_connection(CONNECTION_NAME, task.nornir.config) 52 | params: Dict[str, Any] = {"source": source} 53 | if path: 54 | params["filter"] = (filter_type, path) 55 | result = manager.get_config(**params) 56 | 57 | result = RpcResult(rpc=result, manager=manager) 58 | return Result(host=task.host, result=result) 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .pytest_cache/ 6 | .mypy_cache/ 7 | 8 | # Testing 9 | *.log 10 | *.xml 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *,cover 52 | .hypothesis/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | *.log.* 61 | local_settings.py 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | docs/configuration/generated/*.rst 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # IPython Notebook 78 | .ipynb_checkpoints 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # dotenv 87 | .env 88 | .envrc 89 | 90 | # virtualenv 91 | .venv/ 92 | venv/ 93 | ENV/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | *.swp 102 | tags 103 | 104 | .vagrant 105 | .vars 106 | output/ 107 | 108 | .DS_Store 109 | .vscode 110 | .idea 111 | 112 | pip-wheel-metadata/ 113 | 114 | ## Type hints 115 | tests/stubs/*.pyi 116 | *sql* 117 | 118 | # Docs 119 | docs/README.md 120 | docs/public/* 121 | 122 | # containerlab 123 | .clab-topo-netconf.yml.bak 124 | clab-clab-topo-netconf.yml/ 125 | .clab-arista.yml.bak 126 | clab-arista-testing.yml 127 | test/test_data/schemas 128 | tests/test_data/schema_path/nokia-conf-aaa.yang 129 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/editing/netconf_edit_config.py: -------------------------------------------------------------------------------- 1 | """NETCONF edit config.""" 2 | 3 | from typing import Optional 4 | 5 | from ncclient.manager import Manager 6 | from nornir.core.task import Result, Task 7 | 8 | from nornir_netconf.plugins.connections import CONNECTION_NAME 9 | from nornir_netconf.plugins.helpers import RpcResult, check_capability 10 | 11 | 12 | def netconf_edit_config( 13 | task: Task, 14 | config: str, 15 | target: Optional[str] = "running", 16 | manager: Optional[Manager] = None, 17 | default_operation: Optional[str] = "merge", 18 | ) -> Result: 19 | """Edit configuration of the device using Netconf. 20 | 21 | Arguments: 22 | config (str): Configuration snippet to apply 23 | target (str): Target configuration store 24 | manager (Manager): NETCONF Manager 25 | default_operation (str): merge or replace 26 | 27 | Examples: 28 | Simple example:: 29 | 30 | > nr.run(task=netconf_edit_config, config=desired_config) 31 | 32 | Changing Default Operation:: 33 | 34 | > nr.run(task=netconf_edit_config, config=desired_config, default_operation="replace") 35 | 36 | Changing Default Target of `running` to `candidate`:: 37 | 38 | > nr.run(task=netconf_edit_config, target="candidate", config=desired_config, default_operation="replace") 39 | 40 | Returns: 41 | Result object with the following attributes set:: 42 | 43 | * result (RpcResult): Rpc and Manager 44 | """ 45 | if default_operation not in ["merge", "replace"]: 46 | raise ValueError(f"{default_operation} not supported.") 47 | if not manager: 48 | manager = task.host.get_connection(CONNECTION_NAME, task.nornir.config) 49 | if target in ["candidate", "startup"]: 50 | capabilities = list(manager.server_capabilities) 51 | if not check_capability(capabilities, target): 52 | raise ValueError(f"{target} datastore is not supported.") 53 | result = manager.edit_config(config, target=target, default_operation=default_operation) 54 | 55 | result = RpcResult(rpc=result, manager=manager) 56 | return Result(host=task.host, result=result) 57 | -------------------------------------------------------------------------------- /docs/additional/use-examples.md: -------------------------------------------------------------------------------- 1 | # How to use the `Examples` directory 2 | 3 | The `examples` directory contains a project folder that's setup to quickly test some functionalities of `NORNIR Netconf` Plugin. This presents the users and/or developers the ability to execute tasks and see how the plugin responds. However, this plugin has tons of tests so feel free to experiment. 4 | 5 | Start the ContainerLab Nodes. 6 | 7 | ```bash 8 | docker-compose up -d 9 | ``` 10 | 11 | Install the project locally 12 | 13 | ```bash 14 | poetry install 15 | ``` 16 | 17 | Activate 18 | 19 | ```bash 20 | poetry shell 21 | ``` 22 | 23 | From the `examples-project` directory, execute a script against the Nokia SROS device. 24 | 25 | ```bash 26 | (nornir-netconf-Ky5gYI2O-py3.9) ➜ example-project git:(sros-integration) ✗ pwd 27 | /home/htinoco/Dropbox/py-progz/nornir_plugins/nornir_netconf/examples/example-project 28 | ``` 29 | 30 | ```bash 31 | (nornir-netconf-Ky5gYI2O-py3.10) ➜ example-project git:(feat/docs/update) ✗ python3 nr_get_config.py 32 | example_netconf_get_config****************************************************** 33 | * nokia_rtr ** changed : False ************************************************* 34 | vvvv example_netconf_get_config ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO 35 | 36 | 37 | 38 | 39 | Base 40 | 41 | L3-OAM-eNodeB069420-X1 42 | disable 43 | false 44 | 45 | 46 | 47 | 48 | 49 | ---- netconf_get_config ** changed : False ------------------------------------- INFO 50 | RpcResult(rpc=) 51 | ^^^^ END example_netconf_get_config ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 52 | (nornir-netconf-Ky5gYI2O-py3.10) ➜ example-project git:(feat/docs/update) ✗ 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/additional/rpc.md: -------------------------------------------------------------------------------- 1 | # Key Differences in RPC response objects 2 | 3 | Different vendor implementations return back different attributes in the RPC response. 4 | 5 | The `ok` response is not always a present attribute, unfortunately. The `data_xml` or `xml` attribute could be parsed to find this XML representation at times 6 | 7 | The `rpc` attribute that's part of the `Result.RpcResult` response object is the actual RPC response from the server. 8 | 9 | Lets compare the attributes from an `SROS`device and a `Cisco IOSXR` device. The following shows the attributes and the type for the RPC object. 10 | 11 | `Nokia SROS 7750` 12 | 13 | ```py 14 | ['_NCElement__doc', '_NCElement__huge_tree', '_NCElement__result', '_NCElement__transform_reply', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'data_xml', 'find', 'findall', 'findtext', 'remove_namespaces', 'tostring', 'xpath'] 15 | 16 | 17 | 18 | 19 | 20 | 21 | ``` 22 | 23 | `Cisco IOSxR` 24 | 25 | ```py 26 | ['ERROR_CLS', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_errors', '_huge_tree', '_parsed', '_parsing_error_transform', '_parsing_hook', '_raw', '_root', 'error', 'errors', 'ok', 'parse', 'set_parsing_error_transform', 'xml'] 27 | 28 | 29 | 30 | 31 | 32 | 33 | ``` 34 | 35 | Hopefully this helps understand the differences in some RPC responses. 36 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/tasks/locking/netconf_lock.py: -------------------------------------------------------------------------------- 1 | """NETCONF lock.""" 2 | 3 | from typing import Optional 4 | 5 | from ncclient.manager import Manager 6 | from nornir.core.task import Result, Task 7 | 8 | from nornir_netconf.plugins.connections import CONNECTION_NAME 9 | from nornir_netconf.plugins.helpers import RpcResult 10 | 11 | 12 | def netconf_lock( 13 | task: Task, 14 | datastore: Optional[str] = "candidate", 15 | manager: Optional[Manager] = None, 16 | operation: str = "lock", 17 | ) -> Result: 18 | """NETCONF locking operations for a specified datastore. 19 | 20 | Task name dynamically updated based on operation of `lock` or `unlock`. 21 | 22 | Arguments: 23 | datastore (str): Target Datastore 24 | manager (Manager): Manager to use if operation=='unlock' and the lock is carried. 25 | operation (str): Unlock or Lock 26 | 27 | Examples: 28 | Simple example:: 29 | 30 | > nr.run(task=netconf_lock) 31 | 32 | Lock candidate datestore:: 33 | 34 | > nr.run(task=netconf_lock, 35 | > operation="lock", 36 | > datastore="candidate") 37 | 38 | Unlock candidate datestore:: 39 | 40 | > nr.run(task=netconf_lock, 41 | > operation="unlock", 42 | > datastore="candidate") 43 | 44 | Unlock candidate datestore with a session:: 45 | 46 | > task.run(task=netconf_lock, 47 | > operation="unlock", 48 | > datastore="candidate", 49 | > manager=task.host["manager"]) 50 | 51 | Returns: 52 | Result object with the following attributes set:: 53 | 54 | * result (RpcResult): Rpc and Manager 55 | """ 56 | operation = operation.strip().lower() 57 | if operation not in ["lock", "unlock"]: 58 | raise ValueError("Supported operations are: 'lock' or 'unlock'.") 59 | if not manager: 60 | manager = task.host.get_connection(CONNECTION_NAME, task.nornir.config) 61 | if operation == "lock": 62 | result = manager.lock(target=datastore) 63 | else: 64 | result = manager.unlock(target=datastore) 65 | task.name = "netconf_unlock" 66 | result = RpcResult(manager=manager, rpc=result) 67 | return Result(host=task.host, result=result) 68 | -------------------------------------------------------------------------------- /tests/unit/test_netconf_rpc.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF rpc unit test.""" 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | from nornir_netconf.plugins.tasks import netconf_rpc 6 | 7 | # from nornir_utils.plugins.functions import print_result 8 | 9 | 10 | DEVICE_NAME = "nokia_rtr" 11 | 12 | 13 | @patch("ncclient.manager.connect_ssh") 14 | def test_netconf_rpc_success(ssh, nornir, sros_rpc_payload): 15 | """Test NETCONF rpc, no defined manager.""" 16 | response_rpc = MagicMock() 17 | response = MagicMock() 18 | response.rpc.return_value = response_rpc 19 | ssh.return_value = response 20 | 21 | nr = nornir.filter(name=DEVICE_NAME) 22 | result = nr.run(netconf_rpc, payload=sros_rpc_payload) 23 | assert not result[DEVICE_NAME].failed 24 | assert result[DEVICE_NAME].result.rpc.ok 25 | 26 | 27 | @patch("ncclient.manager.connect_ssh") 28 | def test_netconf_rpc_success_action(ssh, nornir, sros_rpc_payload_action): 29 | """Test NETCONF rpc action (namespace set), no defined manager.""" 30 | response_rpc = MagicMock() 31 | response = MagicMock() 32 | response.rpc.return_value = response_rpc 33 | ssh.return_value = response 34 | 35 | nr = nornir.filter(name=DEVICE_NAME) 36 | result = nr.run(netconf_rpc, payload=sros_rpc_payload_action) 37 | assert not result[DEVICE_NAME].failed 38 | assert result[DEVICE_NAME].result.rpc.ok 39 | 40 | 41 | @patch("ncclient.manager.connect_ssh") 42 | def test_netconf_rpc_manager_set(ssh, nornir, sros_rpc_payload): 43 | """Test NETCONF rpc, with manager option set.""" 44 | response_rpc = MagicMock() 45 | manager = MagicMock() 46 | manager.rpc.return_value = response_rpc 47 | 48 | nr = nornir.filter(name=DEVICE_NAME) 49 | result = nr.run(netconf_rpc, payload=sros_rpc_payload, manager=manager) 50 | assert not result[DEVICE_NAME].failed 51 | assert result[DEVICE_NAME].result.rpc.ok 52 | 53 | 54 | @patch("ncclient.manager.connect_ssh") 55 | def test_netconf_rpc_bad_operation(ssh, nornir, sros_rpc_payload): 56 | """Test NETCONF rpc, unsupported default operation.""" 57 | response_rpc = MagicMock(0) 58 | response = MagicMock() 59 | response.rpc.return_value = response_rpc 60 | ssh.return_value = response 61 | 62 | nr = nornir.filter(name=DEVICE_NAME) 63 | result = nr.run(netconf_rpc, payload=sros_rpc_payload, default_operation="MARGE") 64 | assert result[DEVICE_NAME].failed 65 | -------------------------------------------------------------------------------- /examples/example-project/nr_edit_config_global_lock.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | """Nornir NETCONF Example Task: 'edit-config', 'netconf_lock', `netconf_commit`, and `netconf_validate""" 3 | from nornir import InitNornir 4 | from nornir.core.task import Result, Task 5 | from nornir_utils.plugins.functions import print_result 6 | 7 | from nornir_netconf.plugins.tasks import ( 8 | netconf_commit, 9 | netconf_edit_config, 10 | netconf_lock, 11 | netconf_validate, 12 | ) 13 | 14 | __author__ = "Hugo Tinoco" 15 | __email__ = "hugotinoco@icloud.com" 16 | 17 | nr = InitNornir("config.yml") 18 | 19 | # Filter the hosts by 'west-region' assignment 20 | west_region = nr.filter(region="west-region") 21 | 22 | 23 | def example_global_lock(task: Task) -> Result: 24 | """Test global lock operation of 'candidate' datastore.""" 25 | lock = task.run(netconf_lock, datastore="candidate", operation="lock") 26 | # Retrieve the Manager(agent) from lock operation and store for further 27 | # operations. 28 | task.host["manager"] = lock.result.manager 29 | 30 | 31 | def example_edit_config(task: Task) -> Result: 32 | """Test edit-config with global lock using manager agent.""" 33 | config_payload = """ 34 | 35 | 36 | 37 | Base 38 | 39 | L3-OAM-eNodeB069420-X1 40 | disable 41 | false 42 | 43 | 44 | 45 | 46 | """ 47 | 48 | task.run(netconf_edit_config, config=config_payload, target="candidate", manager=task.host["manager"]) 49 | # Validate the candidate configuration 50 | task.run(netconf_validate) 51 | # Commit configuration 52 | task.run(netconf_commit, manager=task.host["manager"]) 53 | 54 | 55 | def example_unlock(task: Task) -> Result: 56 | """Unlock candidate datastore.""" 57 | task.run(netconf_lock, datastore="candidate", operation="unlock", manager=task.host["manager"]) 58 | 59 | 60 | def main(): 61 | """Execute Nornir Script.""" 62 | print_result(west_region.run(task=example_global_lock)) 63 | print_result(west_region.run(task=example_edit_config)) 64 | print_result(west_region.run(task=example_unlock)) 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration for Documentation.""" 2 | 3 | # pylint: disable-all 4 | # Configuration file for the Sphinx documentation builder. 5 | # 6 | # This file only contains a selection of the most common options. For a full 7 | # list see the documentation: 8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 9 | 10 | # -- Path setup -------------------------------------------------------------- 11 | 12 | # If extensions (or modules to document with autodoc) are in another directory, 13 | # add these directories to sys.path here. If the directory is relative to the 14 | # documentation root, use os.path.abspath to make it absolute, like shown here. 15 | # 16 | from shutil import copyfile 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "Nornir NETCONF" 21 | copyright = "2021, Hugo Tinoco" 22 | author = "Hugo Tinoco" 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = "0.1.0" 26 | 27 | # -- General configuration --------------------------------------------------- 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = ["autoapi.extension", "myst_parser", "sphinx.ext.napoleon"] 33 | autoapi_type = "python" 34 | autoapi_dirs = ["../nornir_netconf/"] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ["_templates"] 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = "sphinx_pdj_theme" 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ["_static"] 55 | 56 | # The suffix of source filenames. 57 | # source_suffix = [".rst", ".md"] 58 | source_suffix = { 59 | ".rst": "restructuredtext", 60 | ".txt": "markdown", 61 | ".md": "markdown", 62 | } 63 | 64 | # Copy README every time we build 65 | src = "../README.md" 66 | dst = "./README.md" 67 | copyfile(src, dst) 68 | -------------------------------------------------------------------------------- /tests/unit/test_netconf_schemas_unit.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF get schemas unit test.""" 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | from ncclient.operations.rpc import RPCError, to_ele 6 | 7 | from nornir_netconf.plugins.tasks import netconf_get_schemas 8 | 9 | DEVICE_NAME = "nokia_rtr" 10 | 11 | 12 | xml_resp = """ 13 | 14 | 15 | 16 | error 17 | 18 | system1 19 | 20 | syntax error 21 | 22 | 23 | error 24 | 25 | } 26 | 27 | error recovery ignores input until this point 28 | 29 | 30 | 31 | """ 32 | 33 | 34 | @patch("ncclient.manager.connect_ssh") 35 | @patch("ncclient.manager.Manager") 36 | def test_netconf_get_schema_schema_path(manager, ssh, nornir): 37 | """Test NETCONF Capabilities + Get Schemas success.""" 38 | nr = nornir.filter(name=DEVICE_NAME) 39 | result = nr.run(netconf_get_schemas, schemas=["nokia-conf-aaa"], schema_path="tests/test_data/schema_path") 40 | assert not result[DEVICE_NAME].failed 41 | assert result[DEVICE_NAME].result.files[0] == "tests/test_data/schema_path/nokia-conf-aaa.yang" 42 | 43 | 44 | @patch("ncclient.manager.connect_ssh") 45 | @patch("ncclient.manager.Manager") 46 | def test_netconf_get_schema(manager, ssh, nornir): 47 | """Test NETCONF get_schema, missing path""" 48 | manager.get_schema.return_value = str("SCHEMA") 49 | nr = nornir.filter(name=DEVICE_NAME) 50 | result = nr.run(netconf_get_schemas, schemas=["nokia-conf-aaa"], schema_path="/tmp") 51 | assert result[DEVICE_NAME].result.directory == "/tmp" 52 | 53 | 54 | @patch("ncclient.manager.connect_ssh") 55 | def test_netconf_get_schema_exception(ssh, nornir): 56 | """Test NETCONF Capabilities + Get Schemas failure, exception.""" 57 | response = MagicMock() 58 | response.get_schema.side_effect = RPCError(to_ele(xml_resp)) 59 | # Assign the side_effect to trigger on get_schema call and hit exception. 60 | ssh.side_effect = [response] 61 | 62 | nr = nornir.filter(name=DEVICE_NAME) 63 | result = nr.run( 64 | netconf_get_schemas, schemas=["nokia-conf-aaa", "some-other"], schema_path="tests/test_data/schema_path" 65 | ) 66 | expected_results = 2 67 | assert len(result[DEVICE_NAME].result.errors) == expected_results 68 | -------------------------------------------------------------------------------- /tests/integration/sros/test_sros_edit_config.py: -------------------------------------------------------------------------------- 1 | """Integration Testing Deploying L3VPN via Netconf to candidate datastore and committing.""" 2 | 3 | from nornir_utils.plugins.functions import print_result 4 | 5 | from nornir_netconf.plugins.tasks import ( 6 | netconf_commit, 7 | netconf_edit_config, 8 | netconf_get_config, 9 | netconf_validate, 10 | ) 11 | from tests.conftest import CONFIGS_DIR, skip_integration_tests, xml_dict 12 | 13 | DEVICE_NAME = "nokia_rtr" 14 | 15 | DEPLOY_SERVICE = """ 16 | 17 | 18 | 19 | 20 | AVIFI-CO 21 | 200 22 | 23 | 24 | AVIFI 25 | 100 26 | enable 27 | AVIFI-CO 28 | 64500 29 | regular 30 | 31 | 32 | enable 33 | 64500:100 34 | 35 | target:64500:100 36 | 37 | 38 | any 39 | 40 | 41 | 42 | 43 | TEST-LOOPBACK 44 | true 45 | 46 | 47 |
3.3.3.3
48 | 32 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | """ 57 | 58 | 59 | @skip_integration_tests 60 | def test_sros_netconf_edit_config_service(nornir): 61 | """Test NETCONF edit-config.""" 62 | nr = nornir.filter(name=DEVICE_NAME) 63 | # Edit Candidate Config 64 | result = nr.run(task=netconf_edit_config, target="candidate", config=DEPLOY_SERVICE) 65 | assert not result[DEVICE_NAME].failed 66 | validate = nr.run(task=netconf_validate) 67 | print_result(validate) 68 | # Commit Config into `Running` datastore 69 | result = nr.run(netconf_commit) 70 | assert not result[DEVICE_NAME].failed 71 | # Grab Full Config from datastore 72 | result = nr.run( 73 | netconf_get_config, 74 | source="running", 75 | ) 76 | with open(f"{CONFIGS_DIR}/{DEVICE_NAME}-full-config-post.xml", "w+") as file: 77 | file.write(result[DEVICE_NAME].result.rpc.data_xml) 78 | parsed = xml_dict(result[DEVICE_NAME].result.rpc.data_xml) 79 | assert "AVIFI-CO" == parsed["rpc-reply"]["data"]["configure"]["service"]["customer"]["customer-name"] 80 | -------------------------------------------------------------------------------- /tests/integration/common/test_lock_operations.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF lock - integration.""" 2 | 3 | import pytest 4 | 5 | from nornir_netconf.plugins.tasks import netconf_lock 6 | from tests.conftest import ( 7 | eval_multi_result, 8 | eval_multi_task_result, 9 | skip_integration_tests, 10 | ) 11 | 12 | GROUP_NAME = "integration" 13 | 14 | 15 | @skip_integration_tests 16 | @pytest.mark.parametrize( 17 | "datastore, expected_hosts", [("running", ["ceos", "iosxe_rtr"]), ("candidate", ["iosxr_rtr", "nokia_rtr"])] 18 | ) 19 | def test_netconf_lock_and_unlock_datastore(nornir, datastore, expected_hosts): 20 | """Test Netconf Lock and Unlock with manager carrying.""" 21 | nr = nornir.filter(lock_datastore=datastore) 22 | result = nr.run(netconf_lock, datastore=datastore, operation="lock") 23 | eval_multi_result(expected_hosts, result) 24 | result = nr.run(netconf_lock, datastore=datastore, operation="unlock") 25 | assert set(expected_hosts) == set(list(result.keys())) 26 | eval_multi_result(expected_hosts, result) 27 | 28 | 29 | def global_lock(task, datastore: str, operation: str): 30 | """Test global lock operation of 'running' datastore.""" 31 | if operation == "unlock": 32 | manager = task.host["manager"] 33 | print(manager) 34 | else: 35 | manager = None 36 | result = task.run(netconf_lock, datastore=datastore, operation=operation, manager=manager) 37 | task.host["manager"] = result.result.manager 38 | if hasattr(result.result.rpc, "ok"): 39 | assert result.result.rpc.ok 40 | assert not result.failed 41 | 42 | 43 | @skip_integration_tests 44 | @pytest.mark.parametrize( 45 | "datastore, expected_hosts", [("running", ["ceos", "iosxe_rtr"]), ("candidate", ["iosxr_rtr", "nokia_rtr"])] 46 | ) 47 | def test_netconf_global_lock(datastore, expected_hosts, nornir): 48 | """Test Netconf Lock and Unlock with carried manager session.""" 49 | nr = nornir.filter(lock_datastore=datastore) 50 | result = nr.run(global_lock, datastore=datastore, operation="lock") 51 | eval_multi_task_result(expected_hosts, result) 52 | result = nr.run(global_lock, datastore=datastore, operation="unlock") 53 | eval_multi_task_result(expected_hosts, result) 54 | 55 | 56 | @skip_integration_tests 57 | @pytest.mark.parametrize( 58 | "datastore, expected_hosts", [("running", ["ceos", "iosxe_rtr"]), ("candidate", ["iosxr_rtr", "nokia_rtr"])] 59 | ) 60 | def test_netconf_lock_lock_failed(datastore, expected_hosts, nornir): 61 | """Test Netconf Lock and attempting second lock - failed.""" 62 | nr = nornir.filter(lock_datastore=datastore) 63 | result = nr.run(global_lock, datastore=datastore, operation="lock") 64 | eval_multi_task_result(expected_hosts, result) 65 | result = nr.run(global_lock, datastore=datastore, operation="lock") 66 | assert set(expected_hosts) == set(list(result.keys())) 67 | for host in expected_hosts: 68 | for task in range(len(result[host])): 69 | assert result[host][task].failed 70 | result = nr.run(global_lock, datastore=datastore, operation="unlock") 71 | -------------------------------------------------------------------------------- /tests/unit/test_netconf_edit_config_unit.py: -------------------------------------------------------------------------------- 1 | """Test NETCONF edit-config unit test.""" 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | from nornir_netconf.plugins.tasks import netconf_edit_config 6 | 7 | # from nornir_utils.plugins.functions import print_result 8 | 9 | 10 | DEVICE_NAME = "nokia_rtr" 11 | 12 | 13 | @patch("ncclient.manager.connect_ssh") 14 | def test_netconf_edit_config_success(ssh, nornir, sros_config_payload): 15 | """Test NETCONF edit-config, no defined manager.""" 16 | response_rpc = MagicMock() 17 | response = MagicMock() 18 | response.server_capabilities = ["netconf:capability:candidate"] 19 | response.edit_config.return_value = response_rpc 20 | ssh.return_value = response 21 | 22 | nr = nornir.filter(name=DEVICE_NAME) 23 | result = nr.run(netconf_edit_config, target="running", config=sros_config_payload) 24 | assert not result[DEVICE_NAME].failed 25 | assert result[DEVICE_NAME].result.rpc.ok 26 | 27 | 28 | @patch("ncclient.manager.connect_ssh") 29 | def test_netconf_edit_config_manager_set(ssh, nornir, sros_config_payload): 30 | """Test NETCONF edit-config, with manager option set.""" 31 | response_rpc = MagicMock() 32 | manager = MagicMock() 33 | manager.server_capabilities = ["netconf:capability:candidate"] 34 | manager.edit_config.return_value = response_rpc 35 | 36 | nr = nornir.filter(name=DEVICE_NAME) 37 | result = nr.run(netconf_edit_config, target="candidate", config=sros_config_payload, manager=manager) 38 | assert not result[DEVICE_NAME].failed 39 | assert result[DEVICE_NAME].result.rpc.ok 40 | 41 | 42 | @patch("ncclient.manager.connect_ssh") 43 | def test_netconf_edit_config_bad_operation(ssh, nornir, sros_config_payload): 44 | """Test NETCONF edit-config, unsupported default operation.""" 45 | response_rpc = MagicMock(0) 46 | response = MagicMock() 47 | response.edit_config.return_value = response_rpc 48 | ssh.return_value = response 49 | 50 | nr = nornir.filter(name=DEVICE_NAME) 51 | result = nr.run(netconf_edit_config, target="candidate", config=sros_config_payload, default_operation="MARGE") 52 | assert result[DEVICE_NAME].failed 53 | 54 | 55 | @patch("ncclient.manager.connect_ssh") 56 | def test_netconf_edit_config_success_running(ssh, nornir, sros_config_payload): 57 | """Test NETCONF edit-config, no defined manager, no candidate.""" 58 | response_rpc = MagicMock() 59 | response_rpc.set_ok(set=True) 60 | response = MagicMock() 61 | response.edit_config.return_value = response_rpc 62 | ssh.return_value = response 63 | 64 | nr = nornir.filter(name=DEVICE_NAME) 65 | result = nr.run(netconf_edit_config, target="running", config=sros_config_payload) 66 | assert not result[DEVICE_NAME].failed 67 | assert result[DEVICE_NAME].result.rpc.ok 68 | 69 | 70 | @patch("ncclient.manager.connect_ssh") 71 | def test_netconf_edit_config_no_capability(ssh, nornir, sros_config_payload): 72 | """Test NETCONF edit-config, candidate not supported.""" 73 | response_rpc = MagicMock() 74 | response = MagicMock() 75 | response.server_capabilities = ["netconf:capability:validate:"] 76 | response.edit_config.return_value = response_rpc 77 | ssh.return_value = response 78 | 79 | nr = nornir.filter(name=DEVICE_NAME) 80 | result = nr.run(netconf_edit_config, target="startup", config=sros_config_payload) 81 | assert result[DEVICE_NAME].failed 82 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [2.1.0] - 2024-10-13 8 | 9 | - Deprecation of Python 3.8 10 | - Formatting Changes with Black 11 | - Updated CI/CD to use `ruff check` 12 | 13 | ## [2.0.0] - 2022-03-14 14 | 15 | ### Added 16 | 17 | - `netconf_validate` task has been implemented. This is tested in integration against SROS devices that support the capability. 18 | - `ruff` linter 19 | - Integration tests for IOSXE with ContainerLab. (Not in CI) 20 | - Standardized on tests for all platforms part of integration tests. Added a common section for common NETCONF operations. 21 | - Added examples and updated previous ones 22 | 23 | ### Changed 24 | 25 | - `sysrepo` tests all got replaced by a containerized instance of Arista CEOS 26 | - `mypy` settings were moved into pyproject.toml file 27 | - `get_schema` doesn't pull `.data_xml` attribute and just dumps the output. 28 | - `write_output` internal helper allows for custom file extension (used for yang schema dumps) 29 | - `pylint` is now driven by `ruff` 30 | - `bandit` is now configured by pyproject.toml | updated docker file for linter + github CI 31 | - Added `is_truthy` helper and refactored `SKIP_INTEGRATION_TESTS` 32 | 33 | ### Removed 34 | 35 | - Dropped Python3.7 - Only 3.8 and above is supported. 36 | - `sysrepo` container and dependencies. No tests or reliance on this container anymore. 37 | - `xmltodict` library has been removed. The user should parse XML as they please. 38 | - `Flake8` (Replaced by `Ruff` as a plugin) 39 | - `Pydocstyle` (Replaced by `Ruff` as a plugin) 40 | - `pylint` (Replaced by `Ruff` as a plugin) 41 | 42 | ## [1.1.0] - 2022-10-06 43 | 44 | ### Added 45 | 46 | - Normalized the result output between vendors to include 'ok' key 47 | - Pipeline to publish to pypi/github 48 | 49 | ### Changed 50 | 51 | - Containerlab IP addresses for local integration testings changed 52 | - Added env variable to docker-compose to run integration tests in containers 53 | - Ability to run all tests from pytest or container 54 | - Tests showing how to use the `extras` for defining the `platform` as the key of `name` 55 | 56 | ### Fixed 57 | 58 | - GH Actions Badge was pointing to previous fork on the Nornir organization 59 | 60 | ## [1.0.1] - 2022-02-08 61 | 62 | ### Added 63 | 64 | - Local integration tests with ContainerLab 65 | 66 | ### Changed 67 | 68 | - Lowered requirement version for `ncclient` 69 | 70 | ### Fixed 71 | 72 | - Several integration tests are OS versions changed from previous eve-ng lab 73 | 74 | ## [1.0.0] - 2022-01-17 75 | 76 | ### Changed 77 | 78 | - Removed dependencies locking python version between a range. 79 | - Removed unused dev env dependencies 80 | - Removed version pinning on dev dependencies 81 | - Dropped support for python3.6 82 | 83 | ## [0.1.0] - 2020-09-10 84 | 85 | ### Added 86 | 87 | - netconf_capabilities - Return server capabilities from target 88 | - netconf_get - Returns state data based on the supplied xpath 89 | - netconf_get_config - Returns configuration from specified configuration store (default="running") 90 | - netconf_edit_config - Edits configuration on specified datastore (default="running") 91 | - netconf_lock - Locks or Unlocks a specified datastore (default="lock") 92 | - netconf_commit - Commits a change 93 | - readme 94 | - Integration tests 95 | -------------------------------------------------------------------------------- /tests/unit/test_helpers_unit.py: -------------------------------------------------------------------------------- 1 | """Test Helper functions.""" 2 | 3 | import os 4 | import pathlib 5 | from unittest.mock import patch 6 | 7 | from nornir_netconf.plugins.helpers import ( 8 | check_capability, 9 | check_file, 10 | create_folder, 11 | write_output, 12 | ) 13 | 14 | TEST_FOLDER = "tests/test_data/test_folder_success" 15 | 16 | SRC = str(pathlib.Path(__file__).parent.parent.absolute()) 17 | 18 | 19 | def test_check_file_false(): 20 | """Test check_file false, no file is there..""" 21 | assert not check_file(f"{SRC}/tests/test_data/no_file_here.txt") 22 | 23 | 24 | def test_check_file_success(): 25 | """Test check_file true.""" 26 | assert not check_file(f"{SRC}/tests/test_data/.gitkeep") 27 | 28 | 29 | # Ignore type as this function catches typerrors exceptions 30 | def test_check_file_type(): 31 | """Test check_file typeerror.""" 32 | assert not check_file(False) # type: ignore 33 | 34 | 35 | def test_create_folder(test_folder): 36 | """Test create_folder success.""" 37 | create_folder(test_folder) 38 | assert os.path.exists(test_folder) 39 | 40 | 41 | def test_create_folder_exists(test_folder): 42 | """Test create_folder already exists success.""" 43 | create_folder(test_folder) 44 | assert os.path.exists(test_folder) 45 | 46 | 47 | @patch("os.makedirs", side_effect=OSError) 48 | def test_create_folder_exception(os_mock, test_folder): 49 | """Test create_folder failure.""" 50 | folder = f"{test_folder}/test" 51 | create_folder(folder) 52 | 53 | # using pathlib as we patched OS 54 | path = pathlib.Path(folder) 55 | assert not path.exists() 56 | 57 | 58 | # Test Write Output 59 | 60 | 61 | def test_write_output_success_new_path(test_folder): 62 | """Test write output success.""" 63 | test_folder = f"{test_folder}/folder" 64 | write_output("test-text", test_folder, "file-name") 65 | assert os.path.exists(f"{test_folder}/file-name.txt") 66 | 67 | 68 | def test_write_output_success_already_exists(test_folder): 69 | """Test write output success.""" 70 | write_output("test-text", test_folder, "file-name") 71 | assert os.path.exists(f"{test_folder}/file-name.txt") 72 | 73 | 74 | capabilities = [ 75 | "urn:ietf:params:netconf:base:1.0", 76 | "urn:ietf:params:netconf:base:1.1", 77 | "urn:ietf:params:netconf:capability:candidate:1.0", 78 | "urn:ietf:params:netconf:capability:confirmed-commit:1.1", 79 | "urn:ietf:params:netconf:capability:rollback-on-error:1.0", 80 | "urn:ietf:params:netconf:capability:notification:1.0", 81 | "urn:ietf:params:netconf:capability:interleave:1.0", 82 | "urn:ietf:params:netconf:capability:validate:1.0", 83 | "urn:ietf:params:netconf:capability:validate:1.1", 84 | "urn:ietf:params:netconf:capability:startup:1.0", 85 | "urn:ietf:params:netconf:capability:url:1.0?scheme=ftp,tftp,file", 86 | "urn:ietf:params:netconf:capability:with-defaults:1.0?basic-mode=explicit&also-supported=report-all", 87 | "urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring", 88 | "urn:ietf:params:netconf:capability:yang-library:1.0?revision=2016-06-21&module-set-id=20.5.R2", 89 | "urn:nokia.com:sros:ns:yang:sr:major-release-20", 90 | "urn:ietf:params:xml:ns:yang:iana-if-type?module=iana-if-type&revision=2014-05-08", 91 | "urn:ietf:params:xml:ns:yang:ietf-inet-types?module=ietf-inet-types&revision=2013-07-15", 92 | ] 93 | 94 | 95 | def test_check_capability_true(): 96 | """Test check_capability success.""" 97 | assert check_capability(capabilities, "candidate") 98 | 99 | 100 | def test_check_capability_false(): 101 | """Test check_capability failure. 102 | 103 | Remove candidate from list. 104 | """ 105 | capabilities.pop(2) 106 | assert not check_capability(capabilities, "candidate") 107 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "nornir_netconf" 3 | version = "2.0.0" 4 | description = "NETCONF plugin for Nornir" 5 | authors = ["Hugo Tinoco ", "Patrick Ogenstad "] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | repository = "https://github.com/h4ndzdatm0ld/nornir_netconf" 9 | keywords = ["nornir", "netconf", "ncclient"] 10 | documentation = "https://h4ndzdatm0ld.github.io/nornir_netconf/" 11 | packages = [ 12 | { include = "nornir_netconf" }, 13 | ] 14 | 15 | [tool.poetry.plugins."nornir.plugins.connections"] 16 | "netconf" = "nornir_netconf.plugins.connections:Netconf" 17 | 18 | [tool.poetry.dependencies] 19 | ncclient = "^0.6.9" 20 | python = "^3.9" 21 | nornir = {version = "^3.0.0", allow-prereleases = true} 22 | 23 | 24 | [tool.poetry.dev-dependencies] 25 | black = "*" 26 | pytest-cov = "*" 27 | bandit = "*" 28 | coverage = "*" 29 | yamllint = "*" 30 | nornir-utils = "*" 31 | isort = "*" 32 | mypy = "*" 33 | Sphinx = "*" 34 | sphinx-autoapi = "*" 35 | sphinx-pdj-theme = "*" 36 | sphinxcontrib-napoleon = "*" 37 | pytest = "*" 38 | xmltodict = "*" 39 | ruff = "*" 40 | myst-parser = "^1.0.0" 41 | 42 | [build-system] 43 | requires = ["poetry_core>=1.0.0"] 44 | build-backend = "poetry.core.masonry.api" 45 | 46 | [tool.isort] 47 | profile = "black" 48 | multi_line_output = 3 49 | 50 | [tool.black] 51 | line-length = 120 52 | target-version = ['py38'] 53 | include = '\.pyi?$' 54 | exclude = ''' 55 | ( 56 | /( 57 | \.eggs # exclude a few common directories in the 58 | | \.git # root of the project 59 | | \.hg 60 | | \.mypy_cache 61 | | \.tox 62 | | \.venv 63 | | _build 64 | | buck-out 65 | | build 66 | | dist 67 | | clab-arista-testing.yml 68 | | clab-files 69 | )/ 70 | ) 71 | ''' 72 | 73 | [tool.ruff] 74 | # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. 75 | select = [ 76 | # Pyflakes 77 | "F", 78 | # Pycodestyle 79 | "E", 80 | "W", 81 | # isort 82 | "I001", 83 | # Pylint 84 | "PL", 85 | ] 86 | ignore = ["PLR0913"] 87 | 88 | # Allow autofix for all enabled rules (when `--fix`) is provided. 89 | fixable = ["A", "B", "D", "E", "F"] 90 | unfixable = [] 91 | 92 | # Exclude a variety of commonly ignored directories. 93 | exclude = [ 94 | ".bzr", 95 | ".direnv", 96 | ".eggs", 97 | ".git", 98 | ".hg", 99 | ".mypy_cache", 100 | ".nox", 101 | ".pants.d", 102 | ".pytype", 103 | ".ruff_cache", 104 | ".svn", 105 | ".tox", 106 | ".venv", 107 | "__pypackages__", 108 | "_build", 109 | "buck-out", 110 | "build", 111 | "dist", 112 | "node_modules", 113 | "venv", 114 | ] 115 | per-file-ignores = {} 116 | 117 | # Same as Black. 118 | line-length = 120 119 | 120 | # Allow unused variables when underscore-prefixed. 121 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 122 | 123 | target-version = "py38" 124 | 125 | [tool.ruff.mccabe] 126 | # Unlike Flake8, default to a complexity level of 10. 127 | max-complexity = 10 128 | 129 | [tool.ruff.pydocstyle] 130 | convention = "google" 131 | 132 | [tool.pytest.ini_options] 133 | testpaths = [ 134 | "tests", 135 | ] 136 | addopts = "-p no:warnings" 137 | 138 | [tool.mypy] 139 | namespace_packages = true 140 | explicit_package_bases = true 141 | show_error_codes = true 142 | enable_error_code = [ 143 | "ignore-without-code", 144 | "truthy-bool", 145 | ] 146 | check_untyped_defs = true 147 | ignore_errors = false 148 | ignore_missing_imports = true 149 | strict_optional = true 150 | warn_unused_ignores = true 151 | warn_redundant_casts = true 152 | warn_unused_configs = true 153 | disallow_untyped_calls = true 154 | disallow_untyped_defs = true 155 | disallow_incomplete_defs = true 156 | disallow_untyped_decorators = true 157 | disallow_any_generics = true 158 | warn_return_any = true 159 | python_version = 3.8 160 | disallow_subclassing_any = true 161 | no_implicit_optional = true 162 | implicit_reexport = true 163 | strict_equality = true 164 | exclude = "tests/" 165 | 166 | [tool.bandit] 167 | exclude_dirs = ["tests"] 168 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CI" 3 | on: # yamllint disable-line rule:truthy rule:comments 4 | - "push" 5 | - "pull_request" 6 | 7 | jobs: 8 | linters: 9 | name: "Code Quality - Linting" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Setup Python 14 | uses: actions/setup-python@v2 15 | 16 | - name: Install dependencies 17 | run: | 18 | pip install poetry 19 | poetry install 20 | 21 | - name: Lint & Code Format 22 | run: | 23 | echo 'Rnning Ruff' && \ 24 | poetry run ruff check . && \ 25 | echo 'Running Black' && \ 26 | poetry run black --check --diff . && \ 27 | echo 'Running Yamllint' && \ 28 | poetry run yamllint . && \ 29 | echo 'Running Bandit' && \ 30 | poetry run bandit --recursive ./ --configfile pyproject.toml && \ 31 | echo 'Running MyPy' && \ 32 | poetry run mypy . 33 | 34 | test: 35 | name: Testing on Python ${{ matrix.python-version }} 36 | runs-on: ubuntu-latest 37 | needs: 38 | - "linters" 39 | strategy: 40 | matrix: 41 | python-version: ["3.9", "3.10", "3.11"] 42 | steps: 43 | - uses: actions/checkout@v2 44 | - name: Setup python 45 | uses: actions/setup-python@v2 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | architecture: x64 49 | 50 | - name: "Install Containerlab" 51 | run: | 52 | sudo bash -c "$(curl -sL https://get.containerlab.dev)" 53 | 54 | - name: "Start Arista CEOS" 55 | run: "sudo containerlab deploy -t clab-files/clab-arista.yml" 56 | 57 | - name: "Wait for Arista CEOS to be ready" 58 | uses: "jakejarvis/wait-action@master" 59 | with: 60 | time: "10" 61 | 62 | - name: "Change ownership of Containerlab files" 63 | run: "sudo chown -R $USER clab-arista-testing.yml" 64 | 65 | - name: Install dependencies 66 | run: | 67 | pip install poetry 68 | poetry install --no-interaction 69 | 70 | - name: Pytest 71 | run: | 72 | poetry run pytest --cov=nornir_netconf --cov-report=xml -vv 73 | 74 | - name: Upload coverage to Codecov 75 | uses: codecov/codecov-action@v1 76 | with: 77 | token: ${{ secrets.CODECOV_TOKEN }} 78 | publish_gh: 79 | needs: 80 | - "test" 81 | name: "Publish to GitHub" 82 | runs-on: "ubuntu-20.04" 83 | if: "startsWith(github.ref, 'refs/tags/v')" 84 | steps: 85 | - name: "Check out repository code" 86 | uses: "actions/checkout@v2" 87 | - name: "Set up Python" 88 | uses: "actions/setup-python@v2" 89 | with: 90 | python-version: "3.9" 91 | - name: "Install Python Packages" 92 | run: "pip install poetry" 93 | - name: "Set env" 94 | run: "echo RELEASE_VERSION=${GITHUB_REF:10} >> $GITHUB_ENV" 95 | - name: "Run Poetry Version" 96 | run: "poetry version $RELEASE_VERSION" 97 | - name: "Run Poetry Build" 98 | run: "poetry build" 99 | - name: "Upload binaries to release" 100 | uses: "svenstaro/upload-release-action@v2" 101 | with: 102 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 103 | file: "dist/*" 104 | tag: "${{ github.ref }}" 105 | overwrite: true 106 | file_glob: true 107 | publish_pypi: 108 | needs: 109 | - "test" 110 | name: "Push Package to PyPI" 111 | runs-on: "ubuntu-20.04" 112 | if: "startsWith(github.ref, 'refs/tags/v')" 113 | steps: 114 | - name: "Check out repository code" 115 | uses: "actions/checkout@v2" 116 | - name: "Set up Python" 117 | uses: "actions/setup-python@v2" 118 | with: 119 | python-version: "3.9" 120 | - name: "Install Python Packages" 121 | run: "pip install poetry" 122 | - name: "Set env" 123 | run: "echo RELEASE_VERSION=${GITHUB_REF:10} >> $GITHUB_ENV" 124 | - name: "Run Poetry Version" 125 | run: "poetry version $RELEASE_VERSION" 126 | - name: "Run Poetry Build" 127 | run: "poetry build" 128 | - name: "Push to PyPI" 129 | uses: "pypa/gh-action-pypi-publish@release/v1" 130 | with: 131 | user: "__token__" 132 | password: "${{ secrets.PYPI_API_TOKEN }}" 133 | -------------------------------------------------------------------------------- /nornir_netconf/plugins/connections/netconf.py: -------------------------------------------------------------------------------- 1 | """Netconf Connection Plugin.""" 2 | 3 | from typing import Any, Dict, Optional 4 | 5 | from ncclient import manager 6 | 7 | from nornir_netconf.plugins.helpers import check_file 8 | 9 | CONNECTION_NAME = "netconf" 10 | 11 | 12 | class Netconf: 13 | """This plugin connects to the device via NETCONF using ncclient library. 14 | 15 | Inventory: 16 | `_ 17 | Example on how to configure a device to use netconfig without using an ssh agent and without verifying the keys:: 18 | --- 19 | nc_device: 20 | hostname: "192.168.16.20" 21 | username: "admin" 22 | password: "admin" 23 | port: 2022 24 | connection_options: 25 | netconf: 26 | extras: 27 | allow_agent: False 28 | hostkey_verify: False 29 | device_params: 30 | name: "sros" 31 | 32 | Then it can be used like:: 33 | >>> from nornir import InitNornir 34 | >>> from nornir.core.task import Result, Task 35 | >>> 36 | >>> nr = InitNornir( 37 | >>> inventory={ 38 | >>> "options": { 39 | >>> "hosts": { 40 | >>> "rtr00": { 41 | >>> "hostname": "localhost", 42 | >>> "username": "admin", 43 | >>> "password": "admin", 44 | >>> "port": 65030, 45 | >>> "platform": "whatever", 46 | >>> "connection_options": { 47 | >>> "netconf": {"extras": {"hostkey_verify": False}} 48 | >>> }, 49 | >>> } 50 | >>> } 51 | >>> } 52 | >>> } 53 | >>>) 54 | >>> 55 | >>> 56 | >>> def netconf_code(task: Task) -> Result: 57 | >>> manager = task.host.get_connection("netconf", task.nornir.config) 58 | >>> 59 | >>> # get running config and system state 60 | >>> print(manager.get()) 61 | >>> 62 | >>> # get only hostname 63 | >>> print(manager.get(filter=("xpath", "/sys:system/sys:hostname"))) 64 | >>> 65 | >>> # get candidate config 66 | >>> print(manager.get_config("candidate")) 67 | >>> 68 | >>> # lock 69 | >>> print(manager.lock("candidate")) 70 | >>> 71 | >>> # edit configuration 72 | >>> res = manager.edit_config( 73 | >>> "candidate", 74 | >>> "asd", 75 | >>> default_operation="merge", 76 | >>> ) 77 | >>> print(res) 78 | >>> 79 | >>> print(manager.commit()) 80 | >>> 81 | >>> # unlock 82 | >>> print(manager.unlock("candidate")) 83 | >>> 84 | >>> return Result(result="ok", host=task.host) 85 | >>> 86 | >>> 87 | >>> nr.run(task=netconf_code) 88 | """ # noqa 89 | 90 | def open( 91 | self, 92 | hostname: str, 93 | username: str, 94 | password: Optional[str], 95 | port: Optional[int] = 830, 96 | platform: Optional[str] = "default", 97 | extras: Optional[Dict[str, Any]] = None, 98 | configuration: Optional[Dict[str, Any]] = None, 99 | ) -> None: 100 | """Open NETCONF connection.""" 101 | extras = extras if extras is not None else {} 102 | parameters: Dict[str, Any] = { 103 | "host": hostname, 104 | "username": username, 105 | "password": password, 106 | "port": port, 107 | "device_params": {"name": platform if platform else "default"}, 108 | } 109 | ssh_config_file = extras.get("ssh_config", configuration.ssh.config_file) # type: ignore[union-attr] 110 | if check_file(ssh_config_file): 111 | parameters["ssh_config"] = ssh_config_file 112 | # If `device_params` exist in extras, the name can be overriden. 113 | parameters.update(extras) 114 | self.connection = manager.connect_ssh(**parameters) # pylint: disable=W0201 115 | 116 | def close(self) -> None: 117 | """Close.""" 118 | self.connection.close_session() 119 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Conftest for nornir_netconf UnitTests.""" 2 | 3 | import os 4 | import shutil 5 | import time 6 | from distutils.util import strtobool 7 | from typing import Any, Dict, List 8 | 9 | import pytest 10 | import xmltodict 11 | from nornir import InitNornir 12 | from nornir.core.state import GlobalState 13 | from nornir.core.task import Result 14 | from nornir_utils.plugins.functions import print_result 15 | 16 | 17 | def is_truthy(value: str) -> bool: 18 | """Evaluate arg and determine truthy value.""" 19 | if isinstance(value, bool): 20 | return value 21 | return bool(strtobool(str(value))) 22 | 23 | 24 | SKIP_INTEGRATION_TESTS = is_truthy(os.environ.get("SKIP_INTEGRATION_TESTS", True)) 25 | 26 | skip_integration_tests = pytest.mark.skipif( 27 | SKIP_INTEGRATION_TESTS, 28 | reason="Integration tests require virtual devices running.", 29 | ) 30 | 31 | global_data = GlobalState(dry_run=True) 32 | DIR_PATH = os.path.dirname(os.path.realpath(__file__)) 33 | CONFIGS_DIR = f"{DIR_PATH}/test_data/configs" 34 | 35 | # If NORNIR_LOG set to True, the log won't be deleted in teardown. 36 | nornir_logfile = os.environ.get("NORNIR_LOG", False) 37 | 38 | 39 | @pytest.fixture() 40 | def nornir(): 41 | """Initializes nornir""" 42 | nornir = InitNornir( 43 | inventory={ 44 | "plugin": "SimpleInventory", 45 | "options": { 46 | "host_file": f"{DIR_PATH}/inventory_data/hosts.yml", 47 | "group_file": f"{DIR_PATH}/inventory_data/groups.yml", 48 | "defaults_file": f"{DIR_PATH}/inventory_data/defaults.yml", 49 | }, 50 | }, 51 | logging={"log_file": f"{DIR_PATH}/test_data/nornir_test.log", "level": "DEBUG"}, 52 | dry_run=True, 53 | ) 54 | nornir.data = global_data 55 | return nornir 56 | 57 | 58 | @pytest.fixture(scope="session", autouse=True) 59 | def schema_path(): 60 | """Schema path, test data.""" 61 | return f"{DIR_PATH}/test_data/schemas" 62 | 63 | 64 | @pytest.fixture(scope="session", autouse=True) 65 | def test_folder(): 66 | """Test folder.""" 67 | return "tests/test_data/test_folder" 68 | 69 | 70 | @pytest.fixture(scope="module", autouse=True) 71 | def teardown_class(schema_path, test_folder): 72 | """Teardown the random artifacts created by pytesting.""" 73 | if not nornir_logfile: 74 | nornir_log = f"{DIR_PATH}/test_data/nornir_test.log" 75 | if os.path.exists(nornir_log): 76 | os.remove(nornir_log) 77 | 78 | # Remove test data folders 79 | folders = [test_folder, schema_path] 80 | for folder in folders: 81 | if os.path.exists(folder): 82 | shutil.rmtree(folder) 83 | 84 | 85 | @pytest.fixture(scope="function", autouse=True) 86 | def reset_data(): 87 | """Reset Data.""" 88 | global_data.dry_run = True 89 | global_data.reset_failed_hosts() 90 | 91 | 92 | # PAYLOADS 93 | 94 | 95 | @pytest.fixture(scope="function", autouse=True) 96 | def sros_config_payload(): 97 | return """ 98 | 99 | 100 | 101 | Base 102 | 103 | L3-OAM-eNodeB069420-W1 104 | disable 105 | false 106 | 107 | 108 | 109 | 110 | """ 111 | 112 | 113 | @pytest.fixture(scope="function", autouse=True) 114 | def sros_rpc_payload(): 115 | return """ 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | Base 124 | 125 | L3-OAM-eNodeB069420-W1 126 | disable 127 | false 128 | 129 | 130 | 131 | 132 | 133 | """ 134 | 135 | 136 | @pytest.fixture(scope="function", autouse=True) 137 | def sros_rpc_payload_action(): 138 | return """ 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | """ 151 | 152 | 153 | def xml_dict(xml: str) -> Dict[str, Any]: 154 | """Convert XML to Dict. 155 | 156 | Args: 157 | xml (str): XML string 158 | 159 | Returns: 160 | Dict: XML converted to Dict 161 | """ 162 | return xmltodict.parse(str(xml)) 163 | 164 | 165 | def eval_multi_task_result(hosts: List, result: Result) -> None: 166 | """Repeatable multi host common test operation when running multi tasks.""" 167 | print_result(result) 168 | assert set(hosts) == set(list(result.keys())) 169 | for host in hosts: 170 | for task in range(len(result[host])): 171 | assert not result[host][task].failed 172 | 173 | 174 | def eval_multi_result(hosts: List, result: Result) -> None: 175 | """Repeatable multi host common test operation.""" 176 | print_result(result) 177 | assert set(hosts) == set(list(result.keys())) 178 | for host in hosts: 179 | if hasattr(result[host].result.rpc, "ok"): 180 | assert result[host].result.rpc.ok 181 | assert not result[host].failed 182 | 183 | 184 | @pytest.fixture(autouse=True, scope="module") 185 | def slow_down_tests(): 186 | yield 187 | if SKIP_INTEGRATION_TESTS: 188 | return 189 | else: 190 | time.sleep(3) 191 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nornir NETCONF 2 | 3 | [![codecov](https://codecov.io/gh/h4ndzdatm0ld/nornir_netconf/branch/develop/graph/badge.svg?token=MRI39YHOOR)](https://codecov.io/gh/h4ndzdatm0ld/nornir_netconf) [![CI](https://github.com/h4ndzdatm0ld/nornir_netconf/actions/workflows/ci.yml/badge.svg)](https://github.com/h4ndzdatm0ld/nornir_netconf/actions/workflows/ci.yml) 4 | 5 | Collection of NETCONF tasks and connection plugin for [Nornir](https://github.com/nornir-automation/nornir) 6 | 7 | ## Installation 8 | 9 | --- 10 | 11 | ```bash 12 | pip install nornir_netconf 13 | ``` 14 | 15 | ## Plugins 16 | 17 | --- 18 | 19 | ### Connections 20 | 21 | --- 22 | 23 | - **netconf** - Connect to network devices using [ncclient](https://github.com/ncclient/ncclient) 24 | 25 | ### Tasks 26 | 27 | --- 28 | 29 | - **netconf_capabilities** - Return server capabilities from target -> `Result.result -> RpcResult` 30 | - **netconf_commit** - Commits a change -> `Result.result -> RpcResult` 31 | - **netconf_edit_config** - Edits configuration on specified datastore (default="running") -> `Result.result -> RpcResult` 32 | - **netconf_get** - Returns state data based on the supplied xpath -> `Result.result -> RpcResult` 33 | - **netconf_get_config** - Returns configuration from specified configuration store (default="running") -> `Result.result -> RpcResult` 34 | - **netconf_get_schemas** - Retrieves schemas and saves aggregates content into a directory with schema output -> `Result.result -> SchemaResult` 35 | - **netconf_lock** - Locks or Unlocks a specified datastore (default="lock") -> `Result.result -> RpcResult` 36 | - **netconf_validate** - Validates configuration datastore. Requires the `validate` capability. -> `Result.result -> RpcResult` 37 | 38 | ## Response Result 39 | 40 | The goal of the task results is to put the NETCONF RPC-reply back in your hands. In most cases, the Nornir `Result.result` attribute will return back a `dataclass` depending on the task operation. It's important that you understand the object you will be working with. Please see the `dataclasses` section below and review the code if you want to see what attributes to expect. 41 | 42 | ### Dataclasses 43 | 44 | > Defined in `nornir_netconf/plugins/helpers/models.py` 45 | 46 | - `RpcResult` -> This will return an attribute of `rpc` and `manager`. You will encounter this object in most Nornir `Results` as the return value to the `result` attribute. NETCONF / XML payloads can be overwhelming, especially with large configurations and it's just not efficient or useful to display thousands of lines of code in any result. 47 | - `SchemaResult` -> An aggregation of interesting information when grabbing schemas from NETCONF servers. 48 | 49 | ## Global Lock 50 | 51 | The `netconf_lock` task will always return the Manager object, which is the established (and locked) agent used to send RPC's back and forth. The idea of retrieving the Manager is to carry this established locked session from task to task and only lock and unlock once during a run of tasks. Please review the examples below to see how to extract the manager and store it under the `task.host` dictionary as a variable that can be used across multiple tasks. The Manager is passed into other tasks and re-used to send RPCs to the remote server. 52 | 53 | ## Examples 54 | 55 | Head over to the [Examples directory](https://github.com/h4ndzdatm0ld/nornir_netconf/tree/develop/examples) if you'd like to review the files. 56 | 57 |
Directory Structure 58 | 59 | ```bash 60 | ├── example-project 61 | │ ├── config.yml 62 | │ ├── inventory 63 | │ │ ├── groups.yml 64 | │ │ ├── hosts-local.yml 65 | │ │ └── ssh_config 66 | │ ├── logs 67 | │ │ └── nornir.log 68 | │ └── nr-get-config.py 69 | └── README.md 70 | ``` 71 | 72 |
73 | 74 |
Netconf Connection Plugin 75 | 76 | Below is the snippet of a host inside the host-local.yml file and its associated group, `sros`. 77 | 78 | ```yaml 79 | nokia_rtr: 80 | hostname: "192.168.1.205" 81 | port: 830 82 | groups: 83 | - "sros" 84 | ``` 85 | 86 | ```yaml 87 | sros: 88 | username: "netconf" 89 | password: "NCadmin123" 90 | port: 830 91 | platform: "sros" 92 | connection_options: 93 | netconf: 94 | extras: 95 | hostkey_verify: false 96 | timeout: 300 97 | allow_agent: false 98 | look_for_keys: false 99 | ``` 100 | 101 |
102 | 103 |
Task: Get Config 104 | 105 | ```python 106 | """Nornir NETCONF Example Task: 'get-config'.""" 107 | from nornir import InitNornir 108 | from nornir.core.task import Task 109 | from nornir_utils.plugins.functions import print_result 110 | 111 | from nornir_netconf.plugins.tasks import netconf_get_config 112 | 113 | __author__ = "Hugo Tinoco" 114 | __email__ = "hugotinoco@icloud.com" 115 | 116 | nr = InitNornir("config.yml") 117 | 118 | # Filter the hosts by 'west-region' assignment 119 | west_region = nr.filter(region="west-region") 120 | 121 | 122 | def example_netconf_get_config(task: Task) -> str: 123 | """Test get config.""" 124 | config = task.run( 125 | netconf_get_config, 126 | source="running", 127 | path=""" 128 | 129 | 130 | Base 131 | 132 | 133 | """, 134 | filter_type="subtree", 135 | ) 136 | return config.result.rpc.data_xml 137 | 138 | 139 | def main(): 140 | """Execute Nornir Script.""" 141 | print_result(west_region.run(task=example_netconf_get_config)) 142 | 143 | 144 | if __name__ == "__main__": 145 | main() 146 | ``` 147 | 148 | This returns the following 149 | 150 | ```bash 151 | vvvv example_netconf_get_config ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO 152 | 153 | 154 | 155 | 156 | Base 157 | 158 | L3-OAM-eNodeB069420-X1 159 | disable 160 | false 161 | 162 | 163 | 164 | 165 | 166 | ---- netconf_get_config ** changed : False ------------------------------------- INFO 167 | RpcResult(rpc=) 168 | ^^^^ END example_netconf_get_config ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 169 | (nornir-netconf-Ky5gYI2O-py3.10) ➜ example-project git:(feature/validate-tasks) ✗ 170 | ``` 171 | 172 |
173 | 174 |
Task: Get Capabilities 175 | 176 | ```python 177 | """Nornir NETCONF Example Task: 'capabilities'.""" 178 | from nornir import InitNornir 179 | from nornir.core.task import Task 180 | from nornir_utils.plugins.functions import print_result 181 | 182 | from nornir_netconf.plugins.tasks import netconf_capabilities 183 | 184 | __author__ = "Hugo Tinoco" 185 | __email__ = "hugotinoco@icloud.com" 186 | 187 | nr = InitNornir("config.yml") 188 | 189 | # Filter the hosts by 'west-region' assignment 190 | west_region = nr.filter(region="west-region") 191 | 192 | 193 | def example_netconf_get_capabilities(task: Task) -> str: 194 | """Test get capabilities.""" 195 | capabilities = task.run(netconf_capabilities) 196 | # This may be a lot, so for example we'll just print the first one 197 | return [cap for cap in capabilities.result.rpc][0] 198 | 199 | 200 | def main(): 201 | """Execute Nornir Script.""" 202 | print_result(west_region.run(task=example_netconf_get_capabilities)) 203 | 204 | 205 | if __name__ == "__main__": 206 | main() 207 | ``` 208 | 209 | This returns the following 210 | 211 | ```bash 212 | (nornir-netconf-Ky5gYI2O-py3.10) ➜ example-project git:(feature/validate-tasks) ✗ python3 nr_get_capabilities.py 213 | example_netconf_get_capabilities************************************************ 214 | * nokia_rtr ** changed : False ************************************************* 215 | vvvv example_netconf_get_capabilities ** changed : False vvvvvvvvvvvvvvvvvvvvvvv INFO 216 | urn:ietf:params:netconf:base:1.0 217 | ---- netconf_capabilities ** changed : False ----------------------------------- INFO 218 | RpcResult(rpc=) 219 | ^^^^ END example_netconf_get_capabilities ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 220 | (nornir-netconf-Ky5gYI2O-py3.10) ➜ example-project git:(feature/validate-tasks) ✗ 221 | ``` 222 | 223 |
224 | 225 |
Task: Edit-Config with Global Lock 226 | 227 | ```python 228 | """Nornir NETCONF Example Task: 'edit-config', 'netconf_lock'.""" 229 | from nornir import InitNornir 230 | from nornir_utils.plugins.functions import print_result 231 | from nornir_netconf.plugins.tasks import netconf_edit_config, netconf_lock, netconf_commit 232 | 233 | 234 | __author__ = "Hugo Tinoco" 235 | __email__ = "hugotinoco@icloud.com" 236 | 237 | nr = InitNornir("config.yml") 238 | 239 | # Filter the hosts by 'west-region' assignment 240 | west_region = nr.filter(region="west-region") 241 | 242 | 243 | def example_global_lock(task): 244 | """Test global lock operation of 'candidate' datastore.""" 245 | lock = task.run(netconf_lock, datastore="candidate", operation="lock") 246 | # Retrieve the Manager(agent) from lock operation and store for further 247 | # operations. 248 | task.host["manager"] = lock.result.manager 249 | 250 | 251 | def example_edit_config(task): 252 | """Test edit-config with global lock using manager agent.""" 253 | 254 | config_payload = """ 255 | 256 | 257 | 258 | Base 259 | 260 | L3-OAM-eNodeB069420-X1 261 | disable 262 | false 263 | 264 | 265 | 266 | 267 | """ 268 | 269 | result = task.run( 270 | netconf_edit_config, config=config_payload, target="candidate", manager=task.host["manager"] 271 | ) 272 | # Validate configuration 273 | task.run(netconf_validate) 274 | # Commit 275 | task.run(netconf_commit, manager=task.host["manager"]) 276 | 277 | def example_unlock(task): 278 | """Unlock candidate datastore.""" 279 | task.run(netconf_lock, datastore="candidate", operation="unlock", manager=task.host["manager"]) 280 | 281 | 282 | def main(): 283 | """Execute Nornir Script.""" 284 | print_result(west_region.run(task=example_global_lock)) 285 | print_result(west_region.run(task=example_edit_config)) 286 | print_result(west_region.run(task=example_unlock)) 287 | 288 | 289 | if __name__ == "__main__": 290 | main() 291 | 292 | ``` 293 | 294 |
295 | 296 |
Task: Get Schemas 297 | 298 | ```python 299 | """Get Schemas from NETCONF device.""" 300 | from nornir import InitNornir 301 | from nornir.core import Task 302 | from nornir.core.task import Result 303 | from nornir_utils.plugins.functions import print_result 304 | 305 | from nornir_netconf.plugins.tasks import netconf_get, netconf_get_schemas 306 | from tests.conftest import xml_dict 307 | 308 | __author__ = "Hugo Tinoco" 309 | __email__ = "hugotinoco@icloud.com" 310 | 311 | nr = InitNornir("config.yml") 312 | 313 | 314 | # Filter the hosts by 'west-region' assignment 315 | west_region = nr.filter(region="west-region") 316 | 317 | SCHEMA_FILTER = """ 318 | 319 | 320 | 321 | 322 | """ 323 | 324 | 325 | def example_task_get_schemas(task: Task) -> Result: 326 | """Get Schemas from NETCONF device.""" 327 | result = task.run(netconf_get, path=SCHEMA_FILTER, filter_type="subtree") 328 | # xml_dict is a custom function to convert XML to Python dictionary. Not part of Nornir Plugin. 329 | # See the code example if you want to use it. 330 | parsed = xml_dict(result.result.rpc.data_xml) 331 | first_schema = parsed["rpc-reply"]["data"]["netconf-state"]["schemas"]["schema"][0] 332 | return task.run(netconf_get_schemas, schemas=[first_schema["identifier"]], schema_path="./output/schemas") 333 | 334 | 335 | def main(): 336 | """Execute Nornir Script.""" 337 | print_result(west_region.run(task=example_task_get_schemas)) 338 | 339 | 340 | if __name__ == "__main__": 341 | main() 342 | 343 | ``` 344 | 345 | This returns the following 346 | 347 | ```bash 348 | (nornir-netconf-Ky5gYI2O-py3.10) ➜ example-project git:(feature/validate-tasks) ✗ python3 nr_get_schemas.py 349 | example_task_get_schemas******************************************************** 350 | * nokia_rtr ** changed : False ************************************************* 351 | vvvv example_task_get_schemas ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO 352 | ---- netconf_get ** changed : False -------------------------------------------- INFO 353 | RpcResult(rpc=) 354 | ---- netconf_get_schemas ** changed : False ------------------------------------ INFO 355 | SchemaResult(directory='./output/schemas') 356 | ^^^^ END example_task_get_schemas ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 357 | ``` 358 | 359 |
360 | 361 | ## Additional Documentation 362 | 363 | - [NCClient](https://ncclient.readthedocs.io/en/latest/) 364 | 365 | ## Contributions 366 | 367 | > Github actions spins up a Containerlab instance to do full integration tests once linting has been satisfied. 368 | 369 | --- 370 | 371 | No line of code shall go untested! Any contribution will need to be accounted for by the coverage report and satisfy all linting. 372 | 373 | Linters: 374 | 375 | - Ruff (Flake8/Pydocstyle) 376 | - Black 377 | - Yamllint 378 | - Pylint 379 | - Bandit 380 | - MyPy 381 | 382 | ## Testing 383 | 384 | To test within a local docker environment 385 | 386 | ```bash 387 | git clone https://github.com/h4ndzdatm0ld/nornir_netconf 388 | ``` 389 | 390 | ```bash 391 | docker-compose build && docker-compose run test 392 | ``` 393 | 394 | To test locally with pytest 395 | 396 | If you'd like to run integration tests with ContainerLab 397 | 398 | ```bash 399 | export SKIP_INTEGRATION_TESTS=False 400 | ``` 401 | 402 | ```bash 403 | docker-compose up -d 404 | ``` 405 | 406 | ```bash 407 | poetry install && poetry shell 408 | ``` 409 | 410 | ```bash 411 | pytest --cov=nornir_netconf --color=yes --disable-pytest-warnings -vvv 412 | ``` 413 | 414 | ### Integration Tests 415 | 416 | Devices with full integration tests with ContainerLab 417 | 418 | - Nokia SROS - TiMOS-B-21.2.R1 419 | - Cisco IOSxR - Cisco IOS XR Software, Version 6.1.3 420 | - Cisco IOSXE - Cisco IOS XE Software, Version 17.03.02 421 | - Arista CEOS - 4.28.0F-26924507.4280F (engineering build) 422 | 423 | ## Documentation 424 | 425 | Documentation is generated with Sphinx and hosted with Github Pages. [Documentation](https://h4ndzdatm0ld.github.io/nornir_netconf/) 426 | --------------------------------------------------------------------------------