Skip to content

Commit

Permalink
Merge branch 'main' into close-transport-on-exception
Browse files Browse the repository at this point in the history
  • Loading branch information
GeoffAliro authored Jan 17, 2024
2 parents b5c0109 + f02c349 commit 8b6d463
Show file tree
Hide file tree
Showing 12 changed files with 537 additions and 307 deletions.
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- run: git submodule update --init --recursive
- uses: actions/setup-python@v4
with:
python-version: '3.10'
Expand All @@ -17,6 +16,16 @@ jobs:
- run: poetry run pytest
- run: poetry run black . --check
- run: poetry build
- run: poetry run sphinx-build -b html docs docs_output
- name: Upload coverage data to coveralls.io
run: poetry run coveralls --service=github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy GH Pages
if: startsWith(github.ref, 'refs/heads/main')
uses: JamesIves/[email protected]
with:
folder: docs_output
- name: Publish distribution to PyPI
if: startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
![Build Status](https://github.com/ADTRAN/netconf_client/workflows/CI%20Checks/badge.svg)
[![PyPI version](https://badge.fury.io/py/netconf-client.svg)](https://badge.fury.io/py/netconf-client)
[![Documentation Status](https://readthedocs.org/projects/netconf-client/badge/?version=latest)](https://netconf-client.readthedocs.io/en/latest/?badge=latest)
[![Coverage Status](https://coveralls.io/repos/github/ADTRAN/netconf_client/badge.svg?branch=main)](https://coveralls.io/github/ADTRAN/netconf_client?branch=main)

# netconf_client

Expand Down Expand Up @@ -40,4 +40,4 @@ And a few disadvantages:
for testing edge-case behavior of a server)


[User Guide]: https://netconf-client.readthedocs.io/en/latest/
[User Guide]: https://adtran.github.io/netconf_client/
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
language = "en"

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
Expand All @@ -84,7 +84,7 @@
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
html_static_path = []

# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
Expand Down
40 changes: 24 additions & 16 deletions docs/migration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,33 @@ To this::
Then you will need to migrate your connection code. For example, if
your old connection method looked like this::

def mgr():
from ncclient import manager, operations
m = manager.connect_ssh(host='localhost', port=830,
username='root', password='password',
hostkey_verify=False,
timeout=120,
)
m.raise_mode = operations.RaiseMode.ALL
return m
def mgr():
from ncclient import manager, operations

m = manager.connect_ssh(
host="localhost",
port=830,
username="root",
password="password",
hostkey_verify=False,
timeout=120,
)
m.raise_mode = operations.RaiseMode.ALL
return m

Then your new connection code should look like this::

def mgr():
from netconf_client.connect import connect_ssh
from netconf_client.ncclient import Manager

s = connect_ssh(host='localhost', port=830,
username='root', password='password')
return Manager(s, timeout=120)
def mgr():
from netconf_client.connect import connect_ssh
from netconf_client.ncclient import Manager

s = connect_ssh(
host="localhost",
port=830,
username="root",
password="password",
)
return Manager(s, timeout=120)

As long as the existing code isn't doing anything too crazy, these
should be the only changes needed.
52 changes: 30 additions & 22 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ In order to connect to a NETCONF server we can use one of the
our example we will connect to a NETCONF server running over SSH, so
we will use the :func:`connect_ssh <netconf_client.connect.connect_ssh>` function.::

from netconf_client.connect import connect_ssh
from netconf_client.connect import connect_ssh

with connect_ssh(host='192.0.2.1',
port=830,
username='admin',
password='password') as sesssion:
# TODO: Do things with the session object
pass
with connect_ssh(
host="192.0.2.1",
port=830,
username="admin",
password="password",
) as sesssion:
# TODO: Do things with the session object
pass

The object returned from any of the ``connect`` functions is a
:class:`Session <netconf_client.session.Session>` object. It acts as a
Expand All @@ -39,21 +41,27 @@ functions of this class.
In this example we will perform an ``<edit-config>`` for a single
node, and then run a ``<get-config>`` to see the change.::

from netconf_client.connect import connect_ssh
from netconf_client.ncclient import Manager

with connect_ssh(host='192.0.2.1',
port=830,
username='admin',
password='password') as session:
mgr = Manager(session, timeout=120)
mgr.edit_config(target='running', '''
<config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
<service-alpha xmlns="http://example.com">
<simple-string>Foo</simple-string>
</service-alpha>
</config>''')
print(mgr.get_config(source='running').data_xml)
from netconf_client.connect import connect_ssh
from netconf_client.ncclient import Manager

with connect_ssh(
host="192.0.2.1",
port=830,
username="admin",
password="password",
) as session:
mgr = Manager(session, timeout=120)
mgr.edit_config(
target="running",
config="""
<config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
<service-alpha xmlns="http://example.com">
<simple-string>Foo</simple-string>
</service-alpha>
</config>
""",
)
print(mgr.get_config(source="running").data_xml)

An instance of the :class:`Manager <netconf_client.ncclient.Manager>`
class should be a drop-in replacement for a manager object from
Expand Down
43 changes: 37 additions & 6 deletions netconf_client/connect.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import socket
import ssl
from base64 import b64decode

import paramiko

from netconf_client.error import InvalidSSHHostkey
from netconf_client.session import Session
from netconf_client.log import logger

Expand All @@ -15,6 +17,9 @@ def connect_ssh(
key_filename=None,
sock=None,
timeout=120,
hostkey_b64=None,
initial_timeout=None,
general_timeout=None,
):
"""Connect to a NETCONF server over SSH.
Expand All @@ -35,18 +40,28 @@ def connect_ssh(
:param sock: An already-open TCP socket; SSH will be setup on top
of it
:param int timeout: Seconds to wait when connecting the socket
:param int initial_timeout: Seconds to wait when first connecting the socket.
:param int general_timeout: Seconds to wait for a response from the server after connecting.
:param int timeout: (Deprecated) Seconds to wait when connecting the socket if initial_timeout is None. This will
be ignored if initial_timeout is not None, and will be removed in the next major release.
:param str hostkey_b64: base64 encoded hostkey.
:return: :class:`Session` object
:rtype: :class:`netconf_client.session.Session`
"""
if not sock:
sock = socket.socket()
sock.settimeout(timeout)
sock.settimeout(initial_timeout or timeout)
sock.connect((host, port))
sock.settimeout(None)
sock.settimeout(general_timeout)
transport = paramiko.transport.Transport(sock)
pkey = _try_load_pkey(key_filename) if key_filename else None
hostkey = _try_load_hostkey_b64(hostkey_b64) if hostkey_b64 else None
transport.connect(username=username, password=password, pkey=pkey)
try:
channel = transport.open_session()
Expand All @@ -71,6 +86,8 @@ def connect_tls(
ca_certs=None,
sock=None,
timeout=120,
initial_timeout=None,
general_timeout=None,
):
"""Connect to a NETCONF server over TLS.
Expand All @@ -90,16 +107,21 @@ def connect_tls(
:param sock: An already-open TCP socket; TLS will be setup on top
of it
:param int timeout: Seconds to wait when connecting the socket
:param int initial_timeout: Seconds to wait when first connecting the socket.
:param int general_timeout: Seconds to wait for a response from the server after connecting.
:param int timeout: (Deprecated) Seconds to wait when connecting the socket if initial_timeout is None. This will
be ignored if initial_timeout is not None, and will be removed in the next major release.
:rtype: :class:`netconf_client.session.Session`
"""
if not sock:
sock = socket.socket()
sock.settimeout(timeout)
sock.settimeout(initial_timeout or timeout)
sock.connect((host, port))
sock.settimeout(None)
sock.settimeout(general_timeout)
cert_reqs = ssl.CERT_REQUIRED if ca_certs else ssl.CERT_NONE
ssl_sock = ssl.wrap_socket( # pylint: disable=W1505
sock, keyfile=keyfile, certfile=certfile, cert_reqs=cert_reqs, ca_certs=ca_certs
Expand Down Expand Up @@ -181,6 +203,15 @@ def __exit__(self, _, __, ___):
self.server_socket.close()


def _try_load_hostkey_b64(data):
for cls in (paramiko.RSAKey, paramiko.DSSKey, paramiko.ECDSAKey):
try:
return cls(data=b64decode(data))
except paramiko.SSHException:
pass
raise InvalidSSHHostkey()


def _try_load_pkey(path):
for cls in (paramiko.RSAKey, paramiko.DSSKey, paramiko.ECDSAKey):
try:
Expand Down
6 changes: 6 additions & 0 deletions netconf_client/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,9 @@ class NetconfProtocolError(NetconfClientException):
"""This exception is raised on any NETCONF protocol error"""

pass


class InvalidSSHHostkey(NetconfClientException):
"""This exception is raised if the SSH hostkey isn't valid"""

pass
61 changes: 61 additions & 0 deletions netconf_client/ncclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
edit_config,
get,
get_config,
get_data,
copy_config,
discard_changes,
commit,
Expand Down Expand Up @@ -320,6 +321,62 @@ def get_config(self, source="running", filter=None, with_defaults=None):
(raw, ele) = self._send_rpc(rpc_xml)
return DataReply(raw, ele)

def get_data(
self,
datastore="ds:operational",
filter=None,
config_filter=None,
origin_filters=[],
negate_origin_filters=False,
max_depth=None,
with_origin=False,
with_defaults=None,
):
"""Send a ``<get-data>`` request
:param str datastore: The datastore to retrieve the data from.
:param str filter: Either the ``<subtree-filter>`` or the
``xpath-filter`` node to use in the request.
:param bool config_filter: Specifies if only "config true" or only
"config false" nodes are returned. Both
are returned if unspecified.
:param dict origin_filters: A list of origins (e.g., "or:intended").
No origin filters are applied if unspecified.
:param bool negate_origin_filters: Specifies if origin_filters are negated.
:param int max_depth: A 16-bit unsigned integer. If unspecified,
the max-depth is unbounded.
:param bool with_origin: Specifies if the 'origin' annotation
should be returned for nodes having one.
:param str with_defaults: Specify the mode of default
reporting. See :rfc:`6243`. Can be
``None`` (i.e., omit the
with-defaults tag in the request),
'report-all', 'report-all-tagged',
'trim', or 'explicit'.
:rtype: :class:`DataReply`
"""
rpc_xml = get_data(
datastore=datastore,
filter=filter,
config_filter=config_filter,
origin_filters=origin_filters,
negate_origin_filters=negate_origin_filters,
max_depth=max_depth,
with_origin=with_origin,
with_defaults=with_defaults,
)
(raw, ele) = self._send_rpc(rpc_xml)
return DataReply(raw, ele)

def copy_config(self, target, source, with_defaults=None):
"""Send a ``<copy-config>`` request
Expand Down Expand Up @@ -491,6 +548,10 @@ class DataReply:

def __init__(self, raw, ele):
self.data_ele = ele.find("{urn:ietf:params:xml:ns:netconf:base:1.0}data")
if self.data_ele is None:
self.data_ele = ele.find(
"{urn:ietf:params:xml:ns:yang:ietf-netconf-nmda}data"
)
self.data_xml = etree.tostring(self.data_ele)
self.raw_reply = raw

Expand Down
Loading

0 comments on commit 8b6d463

Please sign in to comment.