diff --git a/python/nav/portadmin/cnaas_nms/__init__.py b/python/nav/portadmin/cnaas_nms/__init__.py
new file mode 100644
index 0000000000..7d2fc33d94
--- /dev/null
+++ b/python/nav/portadmin/cnaas_nms/__init__.py
@@ -0,0 +1,21 @@
+#
+# Copyright (C) 2021 UNINETT
+#
+# This file is part of Network Administration Visualized (NAV).
+#
+# NAV is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details. You should have received a copy of the GNU General Public License
+# along with NAV. If not, see .
+#
+"""This package implements functionality to proxy all PortAdmin configuration
+operations through the CNaaS-NMS API.
+
+See https://github.com/SUNET/cnaas-nms for reference
+
+"""
diff --git a/python/nav/portadmin/cnaas_nms/lowlevel.py b/python/nav/portadmin/cnaas_nms/lowlevel.py
new file mode 100644
index 0000000000..604dce99be
--- /dev/null
+++ b/python/nav/portadmin/cnaas_nms/lowlevel.py
@@ -0,0 +1,91 @@
+#
+# Copyright (C) 2021 UNINETT
+#
+# This file is part of Network Administration Visualized (NAV).
+#
+# NAV is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details. You should have received a copy of the GNU General Public License
+# along with NAV. If not, see .
+#
+"""Low-level CNaaS-NMS REST API access using simple_rest_client"""
+from simple_rest_client.api import API
+from simple_rest_client.resource import Resource
+from simple_rest_client.exceptions import ClientError
+
+
+def get_api(url, token, ssl_verify=None):
+ """Returns a CNaaS NMS API instance from a URL and a token"""
+ default_headers = {
+ "Authorization": "Bearer {}".format(token),
+ }
+ api = API(
+ api_root_url=url,
+ headers=default_headers,
+ params={},
+ timeout=2,
+ append_slash=False,
+ json_encode_body=True,
+ ssl_verify=ssl_verify,
+ )
+ api.add_resource(resource_name="devices", resource_class=DeviceResource)
+ api.add_resource(resource_name="interfaces", resource_class=InterfaceResource)
+ api.add_resource(
+ resource_name="interface_status", resource_class=InterfaceStatusResource
+ )
+ api.add_resource(resource_name="device_sync", resource_class=DeviceSyncResource)
+ api.add_resource(resource_name="jobs", resource_class=JobResource)
+
+ return api
+
+
+class DeviceResource(Resource):
+ """Defines operations on the devices endpoint"""
+
+ actions = {
+ "retrieve": {"method": "GET", "url": "/devices?filter[management_ip]={}"},
+ }
+
+
+class InterfaceResource(Resource):
+ """Defines operations on the interface sub-resource of a device"""
+
+ actions = {
+ "list": {"method": "GET", "url": "/device/{}/interfaces"},
+ "configure": {
+ "method": "PUT",
+ "url": "/device/{}/interfaces",
+ },
+ }
+
+
+class InterfaceStatusResource(Resource):
+ """Defines operations on the interface_status sub-resource of a device"""
+
+ actions = {
+ "list": {"method": "GET", "url": "/device/{}/interface_status"},
+ "update": {
+ "method": "PUT",
+ "url": "/device/{}/interface_status",
+ },
+ }
+
+
+class DeviceSyncResource(Resource):
+ """Defines the API syncto operations"""
+
+ actions = {"syncto": {"method": "POST", "url": "/device_syncto"}}
+
+
+class JobResource(Resource):
+ """Defines operations on the job/jobs endpoints"""
+
+ actions = {
+ "list": {"method": "GET", "url": "/jobs"},
+ "retrieve": {"method": "GET", "url": "/job/{}"},
+ }
diff --git a/python/nav/portadmin/cnaas_nms/proxy.py b/python/nav/portadmin/cnaas_nms/proxy.py
new file mode 100644
index 0000000000..a66bebec87
--- /dev/null
+++ b/python/nav/portadmin/cnaas_nms/proxy.py
@@ -0,0 +1,190 @@
+#
+# Copyright (C) 2021 UNINETT
+#
+# This file is part of Network Administration Visualized (NAV).
+#
+# NAV is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details. You should have received a copy of the GNU General Public License
+# along with NAV. If not, see .
+#
+import time
+from typing import Sequence
+import logging
+
+from nav.models import manage
+from nav.portadmin.config import CONFIG
+from nav.portadmin.cnaas_nms.lowlevel import get_api, ClientError
+from nav.portadmin.handlers import (
+ ManagementHandler,
+ DeviceNotConfigurableError,
+ ProtocolError,
+)
+
+_logger = logging.getLogger(__name__)
+
+
+class CNaaSNMSMixIn(ManagementHandler):
+ """MixIn to override all write-operations from
+ nav.portadmin.handlers.ManagementHandler and instead direct them through a CNaaS
+ NMS instance's REST API.
+
+ """
+
+ def __init__(self, netbox: manage.Netbox, **kwargs):
+ super().__init__(netbox, **kwargs)
+ config = CONFIG.get_cnaas_nms_config()
+ self._api = get_api(config.url, config.token, config.ssl_verify)
+ self._device = None
+
+ def set_interface_description(self, interface: manage.Interface, description: str):
+ data = {"description": interface.ifalias}
+ payload = {"interfaces": {interface.ifdescr: data}}
+ self._api.interfaces.configure(self.device_name, body=payload)
+
+ def set_interface_down(self, interface: manage.Interface):
+ self._set_interface_enabled(interface, enabled=False)
+
+ def set_interface_up(self, interface: manage.Interface):
+ self._set_interface_enabled(interface, enabled=True)
+
+ def _set_interface_enabled(self, interface: manage.Interface, enabled=True):
+ data = {"enabled": enabled}
+ payload = {"interfaces": {interface.ifdescr: data}}
+ self._api.interfaces.configure(self.device_name, body=payload)
+
+ def _bounce_interfaces(
+ self,
+ interfaces: Sequence[manage.Interface],
+ wait: float = 5.0,
+ commit: bool = False,
+ ):
+ """Offloads the entire bounce operation to CNaaS NMS. CNaaS NMS doesn't need
+ or care about the wait and commit arguments, so these are flatly ignored.
+ """
+ payload = {"bounce_interfaces": [ifc.ifdescr for ifc in interfaces]}
+ self._api.interface_status.update(self.device_name, body=payload)
+
+ def commit_configuration(self):
+ job_id = self._device_syncto()
+ job = self._poll_job_until_finished(job_id)
+ self._raise_on_job_failure(job)
+
+ def _device_syncto(self) -> int:
+ """Runs the CNaaS-NMS device_syncto operation and returns its job number"""
+ payload = {"hostname": self.device_name, "dry_run": False, "auto_push": True}
+ response = self._api.device_sync.syncto(body=payload)
+ assert response.body.get("status") == "success"
+ job_id = response.body.get("job_id")
+ message = response.body.get("data")
+ _logger.debug(
+ "%s device_syncto response (job_id: %s): %s",
+ self.device_name,
+ job_id,
+ message,
+ )
+ return job_id
+
+ def _poll_job_until_finished(self, job_id: int, retry_delay: float = 1.0) -> dict:
+ job = None
+ status = "SCHEDULED"
+ while status.upper() in ("SCHEDULED", "RUNNING"):
+ time.sleep(retry_delay)
+ job = self._get_job(job_id)
+ status = job.get("status")
+ _logger.debug(
+ "%s polled job status for job_id=%s: %s",
+ self.device_name,
+ job_id,
+ status,
+ )
+ return job
+
+ def _get_job(self, job_id: int) -> dict:
+ response = self._api.jobs.retrieve(job_id)
+ return response.body.get("data").get("jobs")[0]
+
+ def _raise_on_job_failure(self, job: dict):
+ if job.get("status") != "FINISHED":
+ message = job.get("result", {}).get("message")
+ if not message:
+ message = "Unknown error from CNaaS-NMS job %s, please see logs".format(
+ job.get("id")
+ )
+ _logger.error("%s device_syncto job failed: %r", job)
+ raise ProtocolError(message)
+
+ def raise_if_not_configurable(self):
+ """Raises an exception if this device cannot be configured by CNaaS-NMS for
+ some reason.
+ """
+ try:
+ device = self._get_device_record()
+ self.raise_on_unmatched_criteria(device)
+ except CNaaSNMSApiError as error:
+ raise DeviceNotConfigurableError(str(error)) from error
+ except ClientError as error:
+ raise DeviceNotConfigurableError(
+ "Unexpected error talking to the CNaaS-NMS backend: " + str(error)
+ ) from error
+
+ def raise_on_unmatched_criteria(self, device_record):
+ """Raises an exception if the device's CNaaS-NMS attributes do not match the
+ preset criteria for being managed via the API.
+ """
+ state = device_record.get("state")
+ device_type = device_record.get("device_type")
+ synchronized = device_record.get("synchronized")
+
+ if state != "MANAGED":
+ raise DeviceNotConfigurableError(
+ "CNaaS-NMS does not list this device as managed ({})".format(state)
+ )
+ if device_type != "ACCESS":
+ raise DeviceNotConfigurableError(
+ "Cannot use CNaaS-NMS to configure {} type devices".format(device_type)
+ )
+ if not synchronized:
+ raise DeviceNotConfigurableError(
+ "Device configuration is not synchronized with CNaaS-NMS, cannot make "
+ "changes at the moment. Please try againt later."
+ )
+
+ @property
+ def device_name(self) -> str:
+ """This returns the name used for this device in CNaaS NMS. It does not
+ necessarily correspond to the sysname NAV got from DNS, but is necessary to
+ construct most API operations against the device.
+ """
+ if not self._device:
+ self._device = self._get_device_record()
+ return self._device.get("hostname")
+
+ def _get_device_record(self):
+ response = self._api.devices.retrieve(self.netbox.ip)
+ payload = response.body
+ if response.status_code == 200 and payload.get("status") == "success":
+ data = payload.get("data", {})
+ if len(data.get("devices", [])) < 0:
+ raise CNaaSNMSApiError(
+ "No devices matched {} in CNaaS-NMS".format(self.netbox.ip)
+ )
+ device = data["devices"][0]
+ return device
+ else:
+ raise CNaaSNMSApiError(
+ "Unknown failure when talking to CNaaS-NMS (code={}, status={})".format(
+ response.status_code, payload.get("status")
+ )
+ )
+
+
+class CNaaSNMSApiError(ProtocolError):
+ """An exception raised whenever there is a problem with the responses from the
+ CNaaS NMS API
+ """
diff --git a/python/nav/portadmin/config.py b/python/nav/portadmin/config.py
index fe85dc8600..affa1fdbdc 100644
--- a/python/nav/portadmin/config.py
+++ b/python/nav/portadmin/config.py
@@ -15,10 +15,13 @@
#
"""Tools to handle PortAdmin configuration database/file"""
from os.path import join
+from collections import namedtuple
-from nav.config import NAVConfigParser
+from nav.config import NAVConfigParser, ConfigurationError
from nav.portadmin.vlan import FantasyVlan
+CNaaSNMSConfig = namedtuple("CNaasNMSConfig", ["url", "token", "ssl_verify"])
+
class PortAdminConfig(NAVConfigParser):
""""PortAdmin config parser"""
@@ -42,6 +45,15 @@ class PortAdminConfig(NAVConfigParser):
[dot1x]
enabled = false
+
+[cnaas-nms]
+# These options can be used to configure PortAdmin to proxy all device write
+# operations through a CNaaS-NMS instance.
+# Refer to https://github.com/SUNET/cnaas-nms
+
+enabled = off
+#url=https://cnaas-nms.example.org/api/v1.0
+#token=very_long_and_secret_api_access_token
"""
def is_vlan_authorization_enabled(self):
@@ -105,5 +117,27 @@ def is_cisco_voice_cdp_enabled(self):
"""Checks if the CDP config option is enabled"""
return self.getboolean("general", "cisco_voice_cdp", fallback=False)
+ def is_cnaas_nms_enabled(self):
+ return self.getboolean("cnaas-nms", "enabled", fallback=False)
+
+ def get_cnaas_nms_config(
+ self,
+ section="cnaas-nms",
+ url_option="url",
+ token_option="token",
+ ssl_verify=None,
+ ):
+ """Returns a CNaaSNMSConfig namedtuple if a CNaaS-NMS proxy is enabled"""
+ if not self.has_option(section, url_option):
+ raise ConfigurationError("Missing CNaaS-NMS API URL in configuration")
+ if not self.has_option(section, token_option):
+ raise ConfigurationError("Missing CNaaS-NMS API token in configuration")
+
+ return CNaaSNMSConfig(
+ self.get(section, url_option),
+ self.get(section, token_option),
+ self.getboolean(section, "ssl_verify", fallback=ssl_verify),
+ )
+
CONFIG = PortAdminConfig()
diff --git a/python/nav/portadmin/handlers.py b/python/nav/portadmin/handlers.py
index 2f79c3b505..85918bc0f3 100644
--- a/python/nav/portadmin/handlers.py
+++ b/python/nav/portadmin/handlers.py
@@ -105,12 +105,22 @@ def cycle_interfaces(
assert netbox == self.netbox, "Interfaces belong to wrong netbox"
to_cycle = self._filter_oper_up_interfaces(interfaces)
- if not to_cycle:
- _logger.debug("No interfaces to cycle on %s", netbox.sysname)
- return
+ if to_cycle:
+ self._bounce_interface(to_cycle)
+
+ def _bounce_interfaces(
+ self,
+ interfaces: Sequence[manage.Interface],
+ wait: float = 5.0,
+ commit: bool = False,
+ ):
+ """The actual implementation of cycle_interfaces(), without the verification
+ code.
+ Override this in subclasses to keep the common verification code path.
+ """
_logger.debug("Taking interfaces administratively down")
- for ifc in to_cycle:
+ for ifc in interfaces:
self.set_interface_down(ifc)
_logger.debug(ifc.ifname)
if commit:
@@ -120,7 +130,7 @@ def cycle_interfaces(
time.sleep(wait)
_logger.debug("Taking interfaces administratively up again")
- for ifc in to_cycle:
+ for ifc in interfaces:
self.set_interface_up(ifc)
_logger.debug(ifc.ifname)
if commit:
diff --git a/python/nav/portadmin/management.py b/python/nav/portadmin/management.py
index 6018ac0742..7f1fc9e122 100644
--- a/python/nav/portadmin/management.py
+++ b/python/nav/portadmin/management.py
@@ -17,6 +17,8 @@
from nav.errors import NoNetboxTypeError
from nav.models import manage
from nav.portadmin.handlers import ManagementHandler
+from nav.portadmin.cnaas_nms.proxy import CNaaSNMSMixIn
+from nav.portadmin.config import CONFIG
from nav.portadmin.snmp.base import SNMPHandler
from nav.portadmin.snmp.cisco import Cisco
from nav.portadmin.snmp.dell import Dell
@@ -33,13 +35,36 @@ class ManagementFactory(object):
@classmethod
def get_instance(cls, netbox: manage.Netbox, **kwargs) -> ManagementHandler:
- """Get and SNMP-handle depending on vendor type"""
+ """Returns a ManagementHandler implementation, depending on Netbox vendor and
+ configured management protocol.
+ """
if not netbox.type:
raise NoNetboxTypeError()
vendor_id = netbox.type.get_enterprise_id()
handler = VENDOR_MAP.get(vendor_id, SNMPHandler)
+
+ if CONFIG.is_cnaas_nms_enabled():
+ handler = cls._hybridize_cnaas_nms_handler(handler)
+
return handler(netbox, **kwargs)
+ @classmethod
+ def _hybridize_cnaas_nms_handler(cls, handler):
+ """Builds and returns a hybrid ManagementHandler class.
+
+ The class will have two base classes, the CNaaSNMSMixIn class and handler,
+ thereby letting the CNaaSMixIn implementation override methods from handler
+ as it sees fit.
+ """
+
+ # This class is constructed on the fly, no need to warn about missing
+ # implementations:
+ # pylint: disable=abstract-method
+ class HybridProxyHandler(CNaaSNMSMixIn, handler):
+ pass
+
+ return HybridProxyHandler
+
def __init__(self):
pass
diff --git a/python/pylint.rc b/python/pylint.rc
index ef862f093d..bf5d730dda 100644
--- a/python/pylint.rc
+++ b/python/pylint.rc
@@ -166,7 +166,7 @@ ignored-modules=ldap,django.utils.six.moves,_MovedItems,nav.junos.nav_views,jnpr
# List of classes names for which member attributes should not be checked
# (useful for classes with attributes dynamically set).
-ignored-classes=twisted.internet.reactor,Meta,Account,Arp,Cam,Category,Device,GwPortPrefix,Interface,Location,Module,NetType,Netbox,NetboxInfo,NetboxCategory,NetboxSnmpOid,NetboxType,Organization,Prefix,Room,Shadow,SwPortAllowedVlan,SwPortVlan,SwPortBlocked,Usage,Vendor,Vlan,CharField,OneToOneField,ForeignKey,Filter,Subcategory,EventType,Memory,AlertQueue,AlertQueueMessage,AlertType,Memory,AccountGroup,AlertProfile,AlertSubscription,Expression,Operator,AccountAlertQueue,AlertSender,AccountProperty,AlertAddress,TimePeriod,AlertPreference,MatchField,FilterGroup,FilterGroupContent,AccountNavbar,AlertHistory,AlertHistoryMessage,AlertHistoryVariable,Cabling,EventQueue,EventQueueVar,Message,NavbarLink,Patch,Privilege,SMSQueue,Service,ServiceProperty,StatusPreference,StatusPreferenceCategory,StatusPreferenceOrganization,Subsystem,AdjacencyCandidate,UnrecognizedNeighbor,ThresholdRule,AlertHistoryManager,HStoreManager,PrefixManager,MaintenanceTaskManager,APIToken,AccountDashboard,GatewayPeerSession,Sensor,RackManager,Rack,POEGroup
+ignored-classes=twisted.internet.reactor,Meta,Account,Arp,Cam,Category,Device,GwPortPrefix,Interface,Location,Module,NetType,Netbox,NetboxInfo,NetboxCategory,NetboxSnmpOid,NetboxType,Organization,Prefix,Room,Shadow,SwPortAllowedVlan,SwPortVlan,SwPortBlocked,Usage,Vendor,Vlan,CharField,OneToOneField,ForeignKey,Filter,Subcategory,EventType,Memory,AlertQueue,AlertQueueMessage,AlertType,Memory,AccountGroup,AlertProfile,AlertSubscription,Expression,Operator,AccountAlertQueue,AlertSender,AccountProperty,AlertAddress,TimePeriod,AlertPreference,MatchField,FilterGroup,FilterGroupContent,AccountNavbar,AlertHistory,AlertHistoryMessage,AlertHistoryVariable,Cabling,EventQueue,EventQueueVar,Message,NavbarLink,Patch,Privilege,SMSQueue,Service,ServiceProperty,StatusPreference,StatusPreferenceCategory,StatusPreferenceOrganization,Subsystem,AdjacencyCandidate,UnrecognizedNeighbor,ThresholdRule,AlertHistoryManager,HStoreManager,PrefixManager,MaintenanceTaskManager,APIToken,AccountDashboard,GatewayPeerSession,Sensor,RackManager,Rack,POEGroup,simple_rest_client.api.API
# When zope mode is activated, add a predefined set of Zope acquired attributes
# to generated-members.
diff --git a/requirements/base.txt b/requirements/base.txt
index ad76311084..9e40ac3384 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -25,3 +25,6 @@ pynetsnmp-2==0.1.5
libsass==0.15.1
napalm==3.0.1
+
+# For 3rd party REST API access
+simple_rest_client==1.0.8