Files
infix/test/infamy/netconf.py
T
Joachim Wiberg 47f0917660 test: fix NETCONF backend to propagate RPC errors
The put_config() retry loop was catching and printing RpcError
exceptions but never re-raising them after exhausting retries.
This caused tests to silently continue despite configuration
failures, masking validation errors.

Now properly propagates errors after all retries are exhausted,
matching the error handling behavior of the RESTCONF backend.

Fixes #1250

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2025-11-14 08:16:01 +01:00

420 lines
14 KiB
Python

from collections import namedtuple
from dataclasses import dataclass
import logging
import socket
import sys
import time
import uuid # For _ncc_get_data() extension
import os
import copy
import re
import libyang
import lxml
import netconf_client.connect
import netconf_client.ncclient
from infamy.transport import Transport,infer_put_dict
from netconf_client.error import RpcError
from . import env, netutil
def netconf_syn(addr):
if netutil.tcp_port_is_open(addr, 830):
return True
else:
return False
modinfo_fields = ("identifier", "version", "format", "namespace")
ModInfoTuple = namedtuple("ModInfoTuple", modinfo_fields)
class ModInfo(ModInfoTuple):
def xmlns(self):
return f"xmlns:{self.identifier}=\"{self.namespace}\""
NS = {
"ietf-netconf-monitoring": "urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring",
"nc": "urn:ietf:params:xml:ns:netconf:base:1.0",
}
class Manager(netconf_client.ncclient.Manager):
"""Wrapper for the real manager
Just ensures that we can enable debugging without issues when
operating on an IPv6 socket.
"""
def _fetch_connection_ip(self):
"""Retrieves and stores the connection's local and remote IP"""
self._local_ip = None
self._peer_ip = None
try:
self._local_ip = self.session.sock.sock.getsockname()[0]
self._peer_ip = self.session.sock.sock.getpeername()[0]
print(f"Connection status\n\tLocal IP: {self._local_ip}\n"
"\tPeer IP: {self._peer_ip}")
except (AttributeError, socket.error) as err:
print(f"Failed connecting, status: {err}")
pass
def _debug(self):
self.set_logger_level(logging.DEBUG)
self.logger().addHandler(logging.StreamHandler(sys.stderr))
class NccGetDataReply:
"""Fold in to DataReply class when upstreaming"""
def __init__(self, raw, ele):
self.data_ele = ele.find("{urn:ietf:params:xml:ns:yang:ietf-netconf-nmda}data")
self.data_xml = lxml.etree.tostring(self.data_ele)
self.raw_reply = raw
class NccGetSchemaReply:
def __init__(self, raw):
self.ele = lxml.etree.fromstring(raw.xml.decode())
self.ele = self.ele.find("{urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring}data")
self.schema = self.ele.text
@dataclass
class Location:
interface: str
host: str
username: str
password: str
port: int = 830
class Device(Transport):
def __init__(self,
name: str,
location: Location,
mapping: dict,
yangdir: None | str = None):
print("Testing using NETCONF")
self.name = name
self.location = location
self.mapping = mapping
self.location = location
self.ly = libyang.Context(yangdir)
self._ncc_init(location)
# self.ncc._fetch_connection_ip()
# self.ncc._debug()
self.modules = {}
self._ly_bootstrap(yangdir)
del self.ly
self.ly = libyang.Context(yangdir)
self._ly_init(yangdir)
def __str__(self):
nm = f"{self.name}"
if env.ENV.ltop:
nm += f"({env.ENV.ltop.xlate(self.name)})"
return nm + " [NETCONF]"
def _ncc_init(self, location):
ai = socket.getaddrinfo(location.host, location.port,
0, 0, socket.SOL_TCP)
sock = socket.socket(ai[0][0], ai[0][1], 0)
sock.settimeout(60)
print(f"Connecting to mgmt IP {location.host}:{location.port} ...")
try:
sock.connect(ai[0][4])
except InterruptedError as err:
print(f"Connection interrupted: {err}")
raise err
except TimeoutError as err:
print(f"Connection timeout: {err}")
raise err
sock.settimeout(None)
session = netconf_client.connect.connect_ssh(sock=sock,
username=location.username,
password=location.password)
self.ncc = Manager(session)
def _ly_bootstrap(self, yangdir):
self.modules["ietf-netconf-monitoring"] = {
"name": "ietf-netconf-monitoring"
}
for val in self.modules.values():
mod = self.ly.load_module(val["name"])
mod.feature_enable_all()
schemas = self.get_schemas_list()
for schema in schemas:
if os.path.exists(yangdir + "/" + schema['filename']) is False:
self.get_schema(schema, yangdir)
print("YANG models downloaded.")
def _ly_init(self, yangdir):
self.ly = libyang.Context(yangdir)
lib = self.ly.load_module("ietf-yang-library")
ns = libyang.util.c2str(lib.cdata.ns)
xml = lxml.etree.tostring(self.ncc.get(filter=f"""
<filter type="subtree">
<modules-state xmlns="{ns}" />
</filter>""").data_ele[0])
data = self.ly.parse_data("xml", libyang.IOType.MEMORY,
xml, parse_only=True).print_dict()
self.modules = {m["name"]: m for m in data["modules-state"]["module"]}
for ms in self.modules.values():
if ms["conformance-type"] != "implement":
continue
mod = self.ly.load_module(ms["name"])
# TODO: ms["feature"] contains the list of enabled
# features, so ideally we should only enable the supported
# ones. However, features can depend on each other, so the
# naïve looping approach doesn't work.
mod.feature_enable_all()
def _modules_in_xpath(self, xpath):
modnames = []
# Find all referenced models
for seg in xpath.split("/"):
if ":" in seg:
modname, _ = seg.split(":")
modnames.append(modname)
return list(filter(lambda m: m["name"] in modnames,
self.modules.values()))
def _build_xpath_filter(self, xpath, get_data_xpath=False):
"""Helper function to build the XPath filter with the necessary xmlns."""
if xpath:
xmlns = " ".join([f"xmlns:{m['name']}=\"{m['namespace']}\""
for m in self._modules_in_xpath(xpath)])
if get_data_xpath is True:
return f'<xpath-filter {xmlns}>{xpath}</xpath-filter>'
else:
return f"<filter type=\"xpath\" select=\"{xpath}\" {xmlns} />"
return None
def _parse_response(self, response, parse):
"""Helper function to handle XML response parsing."""
if not response:
return None
if not parse:
return response.raw_reply
if len(response.data_ele) == 0:
return None
cfg = lxml.etree.tostring(response.data_ele[0])
parsed_data = self.ly.parse_data_mem(cfg, "xml", parse_only=True)
return parsed_data
def _ncc_make_rpc(self, guts, msg_id=None):
if not msg_id:
msg_id = uuid.uuid4()
return '<rpc message-id="{id}" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">{guts}</rpc>' \
.format(guts=guts, id=msg_id).encode("utf-8")
def copy(self, source, target):
cmd = f'''<copy-config>
<target>
<{target}/>
</target>
<source>
<{source}/>
</source>
</copy-config>'''
self.ncc._send_rpc(self._ncc_make_rpc(cmd))
def reboot(self):
"""<system-restart xmlns="urn:ietf:params:xml:ns:yang:ietf-system"/>"""
return self.call_dict("ietf-system", {
"system-restart": {}
})
def get(self, xpath, parse=True):
xpath_filter = self._build_xpath_filter(xpath)
response = self.ncc.get(filter=xpath_filter)
return self._parse_response(response, parse)
def get_dict(self, xpath):
"""Return Python dictionary of <get> RPC data"""
data = self.get(xpath)
if not data:
return None
return data.print_dict()
def get_data(self, xpath=None, parse=True):
xpath_filter = self._build_xpath_filter(xpath, get_data_xpath=True)
response = self.ncc.get_data(datastore="ds:operational", filter=xpath_filter)
parsed_data = self._parse_response(response, parse)
if parse and parsed_data:
return parsed_data.print_dict()
return parsed_data
def get_config(self, xpath):
xpath_filter = self._build_xpath_filter(xpath)
response = self.ncc.get_config(source="running", filter=xpath_filter)
return self._parse_response(response, True)
def get_config_dict(self, xpath):
"""Get Python dictionary version of XML configuration"""
return self.get_config(xpath).print_dict()
def put_config(self, edit):
"""Send XML configuration over NETCONF"""
yang2nc = {
"none": None,
"delete": "delete",
}
xml = f"<config xmlns=\"{NS['nc']}\" xmlns:nc=\"{NS['nc']}\">" \
+ edit + "</config>"
# Translate any edit operations from the yang format generated
# by diffing trees with libyang, to their NETCONF equivalents.
for src, dst in yang2nc.items():
xml = xml.replace(f"yang:operation=\"{src}\"",
f"nc:operation=\"{dst}\"" if dst else "")
last_error = None
for _ in range(0, 3):
try:
self.ncc.edit_config(xml, default_operation='merge')
last_error = None
break
except RpcError as _e:
last_error = _e
print(f"Failed sending edit-config RPC: {_e} Retrying ...")
time.sleep(1)
continue
# If we exhausted all retries, raise the last error
if last_error is not None:
raise last_error
def put_config_dicts(self, models):
"""PUT full configuration of all models to running-config"""
config = ""
infer_put_dict(self.name, models)
for model in models.keys():
mod = self.ly.get_module(model)
lyd = mod.parse_data_dict(models[model], no_state=True, validate=False)
config += lyd.print_mem("xml", with_siblings=True, pretty=False) + "\n"
# print(f"Send new XML config: {config}")
return self.put_config(config)
def put_config_dict(self, modname, edit):
"""Convert Python dictionary to XMl and send as configuration"""
mod = self.ly.get_module(modname)
lyd = mod.parse_data_dict(edit, no_state=True, validate=False)
config = lyd.print_mem("xml", with_siblings=True, pretty=False)
# print(f"Send new XML config: {config}")
return self.put_config(config)
def call(self, call):
"""Call RPC, XML version"""
return self.ncc.dispatch(call)
def call_dict(self, modname, call):
"""Call RPC, Python dictionary version"""
mod = self.ly.get_module(modname)
lyd = mod.parse_data_dict(call, rpc=True)
return self.call(lyd.print_mem("xml", with_siblings=True, pretty=False))
def call_action(self, xpath):
"""Call NETCONF action (contextualized RPC), XML version"""
action={}
pattern = r"^/(?P<module>[^:]+):(?P<path>[^/]+)"
match = re.search(pattern, xpath)
module = match.group('module')
modpath = f"/{match.group('module')}:{match.group('path')}"
libyang.xpath_set(action, xpath, {})
mod = self.ly.get_module(module)
lyd = mod.parse_data_dict(action, rpc=True)
xml = "<action xmlns=\"urn:ietf:params:xml:ns:yang:1\">" + lyd.print_mem("xml", with_siblings=True, pretty=False) + "</action>"
return self.ncc.dispatch(xml)
def get_schemas_list(self):
schemas = []
data = self.get_dict("/netconf-state")
for d in data["netconf-state"]["schemas"]["schema"]:
schema = {}
schema["identifier"] = d['identifier']
schema["format"] = d["format"]
if d['version']:
schema["version"] = d['version']
schema["filename"] = f"{d['identifier']}@{d['version']}.yang"
else:
schema["filename"] = f"{d['identifier']}.yang"
schemas.append(schema)
return schemas
def get_schema(self, schema, outdir):
query = {
"get-schema": {
"identifier": schema['identifier'],
"version": schema['version'],
"format": "yang"
}
}
rpc_reply = self.call_dict("ietf-netconf-monitoring", query)
data = NccGetSchemaReply(rpc_reply)
with open(outdir+"/"+schema["filename"], "w") as f:
f.write(data.schema)
def delete_xpath(self, xpath):
# Split out the model and the container from xpath'
pattern = r"^/(?P<module>[^:]+):(?P<path>[^/]+)"
match = re.search(pattern, xpath)
if not match:
raise ValueError(f"Failed parsing xpath:{xpath}")
module = match.group('module')
path = match.group('path')
modpath = f"/{module}:{path}"
# Fetch current config
old = self.get_config_dict(modpath)
new = copy.deepcopy(old)
# Perform deletion
if not libyang.xpath_del(new, xpath):
raise ValueError(f"Failed to delete specified xpath: {xpath}")
# Parse old and new data to generate the diff
mod = self.ly.get_module(module)
oldd = mod.parse_data_dict(old, no_state=True, validate=False)
newd = mod.parse_data_dict(new, no_state=True, validate=False)
lyd = oldd.diff(newd)
if lyd is None:
raise ValueError(f"Failed generating diff for xpath:{xpath}")
# Apply the configuration change
return self.put_config(lyd.print_mem("xml", with_siblings=True,
pretty=False))