├── 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 | [](https://codecov.io/gh/h4ndzdatm0ld/nornir_netconf) [](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 |
--------------------------------------------------------------------------------