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