diff --git a/README.rst b/README.rst index 61c3ae37c..3da2d19be 100644 --- a/README.rst +++ b/README.rst @@ -103,6 +103,7 @@ Available Features * Extensible metrics and charts: it's possible to define new metrics and new charts * API to retrieve the chart metrics and status information of each device based on `NetJSON DeviceMonitoring `_ +* Collection of monitoring information via `SNMP <#snmp>`_ ------------ @@ -893,6 +894,69 @@ configuration status of a device changes, this ensures the check reacts quickly to events happening in the network and informs the user promptly if there's anything that is not working as intended. +SNMP +~~~~ + +This check provides an alternative way to collect monitoring information from devices that don't have +the ability to use `openwisp-monitoring agent `_. +The information is collected via SNMP using `netengine `_. The devices +need to have an SNMP daemon in order for this to work. +This check is disabled by default, you may choose to enable auto creation of this check by setting +`OPENWISP_MONITORING_AUTO_SNMP <#OPENWISP_MONITORING_AUTO_SNMP>`_ to ``True``. + +Instructions to configure an SNMP Check +--------------------------------------- + +The following steps will help you use the SNMP Check feature: + +1. Register your device to OpenWISP +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Register your device making sure the ``mac address`` and ``management ip`` are correct: + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-monitoring/issues/297-snmp-check/docs/snmp-check/snmp-check-1.png + :alt: Creating a device example + +It is recommended to keep the `OPENWISP_MONITORING_AUTO_CLEAR_MANAGEMENT_IP <#OPENWISP_MONITORING_AUTO_CLEAR_MANAGEMENT_IP>`_ +setting off to avoid losing the ``management ip``. This is important for the check to work. + +1. Create SNMP access credentials and add them to your device +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In the admin, go to ``Access Credentials > Add Access Credentials`` and select a type suitable for your device's backend. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-monitoring/issues/297-snmp-check/docs/snmp-check/snmp-check-2.png + :alt: Creating access credentials example + +Then either enable ``Auto Add`` (it needs to have the same organization as the device for this to work), or add it to +your device manually as shown below. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-monitoring/issues/297-snmp-check/docs/snmp-check/snmp-check-3.png + :alt: Adding access credentials to device example + +3. Create an SNMP check +~~~~~~~~~~~~~~~~~~~~~~~ + +You can skip this step and let OpenWISP do this automatically if you have the setting +`OPENWISP_MONITORING_AUTO_SNMP <#OPENWISP_MONITORING_AUTO_SNMP>`_ enabled. + +To create the check manually, in the admin, go to ``Check > Add Check``. Now create a check as shown below: + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-monitoring/issues/297-snmp-check/docs/snmp-check/snmp-check-4.png + :alt: Creating SNMP check example + +Here, ``Object id`` is the UUID of the device you just created. + +4. Run the check +~~~~~~~~~~~~~~~~ + +This should happen automatically if you have celery running in the background. For testing, you can +run this check manually using the `run_checks <#run_checks>`_ command. After that, you should see the +device charts and status instantly. + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-monitoring/issues/297-snmp-check/docs/snmp-check/snmp-check-5.png + :alt: Device status collected by snmp example + Settings -------- @@ -976,6 +1040,18 @@ validating custom parameters of a ``Check`` object. This setting allows you to choose whether `config_applied <#configuration-applied>`_ checks should be created automatically for newly registered devices. It's enabled by default. +``OPENWISP_MONITORING_AUTO_SNMP`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------+ +| **type**: | ``bool`` | ++--------------+-------------+ +| **default**: | ``False`` | ++--------------+-------------+ + +Whether `SNMP <#snmp>`_ checks are created automatically for devices. The devices need to have an snmp daemon +installed in order for this check to work. + ``OPENWISP_MONITORING_AUTO_CHARTS`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/snmp-check/snmp-check-1.png b/docs/snmp-check/snmp-check-1.png new file mode 100644 index 000000000..083be3ee4 Binary files /dev/null and b/docs/snmp-check/snmp-check-1.png differ diff --git a/docs/snmp-check/snmp-check-2.png b/docs/snmp-check/snmp-check-2.png new file mode 100644 index 000000000..cb3a990b5 Binary files /dev/null and b/docs/snmp-check/snmp-check-2.png differ diff --git a/docs/snmp-check/snmp-check-3.png b/docs/snmp-check/snmp-check-3.png new file mode 100644 index 000000000..1e5ba34eb Binary files /dev/null and b/docs/snmp-check/snmp-check-3.png differ diff --git a/docs/snmp-check/snmp-check-4.png b/docs/snmp-check/snmp-check-4.png new file mode 100644 index 000000000..ef66588dc Binary files /dev/null and b/docs/snmp-check/snmp-check-4.png differ diff --git a/docs/snmp-check/snmp-check-5.png b/docs/snmp-check/snmp-check-5.png new file mode 100644 index 000000000..06df4e588 Binary files /dev/null and b/docs/snmp-check/snmp-check-5.png differ diff --git a/openwisp_monitoring/check/apps.py b/openwisp_monitoring/check/apps.py index 15c9832f0..c6b21d27e 100644 --- a/openwisp_monitoring/check/apps.py +++ b/openwisp_monitoring/check/apps.py @@ -32,3 +32,12 @@ def _connect_signals(self): sender=load_model('config', 'Device'), dispatch_uid='auto_config_check', ) + + if app_settings.AUTO_SNMP: + from .base.models import auto_snmp_receiver + + post_save.connect( + auto_snmp_receiver, + sender=load_model('config', 'Device'), + dispatch_uid='auto_snmp', + ) diff --git a/openwisp_monitoring/check/base/models.py b/openwisp_monitoring/check/base/models.py index a0bab9e66..21bea7dd6 100644 --- a/openwisp_monitoring/check/base/models.py +++ b/openwisp_monitoring/check/base/models.py @@ -9,7 +9,11 @@ from jsonfield import JSONField from openwisp_monitoring.check import settings as app_settings -from openwisp_monitoring.check.tasks import auto_create_config_check, auto_create_ping +from openwisp_monitoring.check.tasks import ( + auto_create_config_check, + auto_create_ping, + auto_create_snmp_devicemonitoring, +) from openwisp_utils.base import TimeStampedEditableModel from ...utils import transaction_on_commit @@ -116,3 +120,21 @@ def auto_config_check_receiver(sender, instance, created, **kwargs): object_id=str(instance.pk), ) ) + + +def auto_snmp_receiver(sender, instance, created, **kwargs): + """ + Implements OPENWISP_MONITORING_AUTO_SNMP + The creation step is executed in the background + """ + # we need to skip this otherwise this task will be executed + # every time the configuration is requested via checksum + if not created: + return + transaction_on_commit( + lambda: auto_create_snmp_devicemonitoring.delay( + model=sender.__name__.lower(), + app_label=sender._meta.app_label, + object_id=str(instance.pk), + ) + ) diff --git a/openwisp_monitoring/check/classes/__init__.py b/openwisp_monitoring/check/classes/__init__.py index 33bf8293c..75c2e3320 100644 --- a/openwisp_monitoring/check/classes/__init__.py +++ b/openwisp_monitoring/check/classes/__init__.py @@ -1,2 +1,3 @@ from .config_applied import ConfigApplied # noqa from .ping import Ping # noqa +from .snmp_devicemonitoring import Snmp # noqa diff --git a/openwisp_monitoring/check/classes/base.py b/openwisp_monitoring/check/classes/base.py index 71ef7582d..1f4967556 100644 --- a/openwisp_monitoring/check/classes/base.py +++ b/openwisp_monitoring/check/classes/base.py @@ -2,9 +2,12 @@ from django.core.exceptions import ValidationError from swapper import load_model +from .. import settings as app_settings + Check = load_model('check', 'Check') Metric = load_model('monitoring', 'Metric') Device = load_model('config', 'Device') +DeviceData = load_model('device_monitoring', 'DeviceData') class BaseCheck(object): @@ -48,3 +51,13 @@ def _get_or_create_metric(self, configuration=None): ) metric, created = Metric._get_or_create(**options) return metric, created + + def _get_ip(self): + """ + Figures out ip to use or fails raising OperationalError + """ + device = self.related_object + ip = device.management_ip + if not ip and not app_settings.MANAGEMENT_IP_ONLY: + ip = device.last_ip + return ip diff --git a/openwisp_monitoring/check/classes/ping.py b/openwisp_monitoring/check/classes/ping.py index 2e4b2dab9..a478bf63d 100644 --- a/openwisp_monitoring/check/classes/ping.py +++ b/openwisp_monitoring/check/classes/ping.py @@ -134,16 +134,6 @@ def _get_param(self, param): """ return self.params.get(param, self.schema['properties'][param]['default']) - def _get_ip(self): - """ - Figures out ip to use or fails raising OperationalError - """ - device = self.related_object - ip = device.management_ip - if not ip and not app_settings.MANAGEMENT_IP_ONLY: - ip = device.last_ip - return ip - def _command(self, command): """ Executes command (easier to mock) diff --git a/openwisp_monitoring/check/classes/snmp_devicemonitoring.py b/openwisp_monitoring/check/classes/snmp_devicemonitoring.py new file mode 100644 index 000000000..3689a31bf --- /dev/null +++ b/openwisp_monitoring/check/classes/snmp_devicemonitoring.py @@ -0,0 +1,62 @@ +from django.utils.functional import cached_property +from netengine.backends.snmp.airos import AirOS +from netengine.backends.snmp.openwrt import OpenWRT +from swapper import load_model + +from openwisp_monitoring.device.api.views import MetricChartsMixin + +from .base import BaseCheck + +Chart = load_model('monitoring', 'Chart') +Metric = load_model('monitoring', 'Metric') +Device = load_model('config', 'Device') +DeviceData = load_model('device_monitoring', 'DeviceData') +Credentials = load_model('connection', 'Credentials') +AlertSettings = load_model('monitoring', 'AlertSettings') + + +class Snmp(BaseCheck, MetricChartsMixin): + def check(self, store=True): + result = self.netengine_instance.to_dict() + self._init_previous_data() + self.related_object.data = result + if store: + self.store_result(result) + return result + + def store_result(self, data): + """ + store result in the DB + """ + pk = self.related_object.pk + device_data = DeviceData.objects.get(pk=pk) + device_data.data = data + device_data.save_data() + self._write(pk) + + @cached_property + def netengine_instance(self): + ip = self._get_ip() + connector = self._get_connnector() + return connector(host=ip, **self._get_credential_params()) + + @cached_property + def credential_instance(self): + return Credentials.objects.filter( + deviceconnection__device_id=self.related_object, + connector__endswith='OpenWRTSnmp', + ).last() + + def _get_connnector(self): + connectors = { + 'openwisp_controller.connection.connectors.openwrt.snmp.OpenWRTSnmp': OpenWRT, + 'openwisp_controller.connection.connectors.airos.snmp.AirOsSnmp': AirOS, + } + try: + return connectors.get(self.credential_instance.connector, OpenWRT) + except AttributeError: + # in case credentials are not available + return OpenWRT + + def _get_credential_params(self): + return getattr(self.credential_instance, 'params', {}) diff --git a/openwisp_monitoring/check/settings.py b/openwisp_monitoring/check/settings.py index 4575c8eca..a76888ac2 100644 --- a/openwisp_monitoring/check/settings.py +++ b/openwisp_monitoring/check/settings.py @@ -5,9 +5,14 @@ ( ('openwisp_monitoring.check.classes.Ping', 'Ping'), ('openwisp_monitoring.check.classes.ConfigApplied', 'Configuration Applied'), + ( + 'openwisp_monitoring.check.classes.Snmp', + 'SNMP Device Monitoring', + ), ), ) AUTO_PING = get_settings_value('AUTO_PING', True) AUTO_CONFIG_CHECK = get_settings_value('AUTO_DEVICE_CONFIG_CHECK', True) +AUTO_SNMP = get_settings_value('AUTO_SNMP', False) MANAGEMENT_IP_ONLY = get_settings_value('MANAGEMENT_IP_ONLY', True) PING_CHECK_CONFIG = get_settings_value('PING_CHECK_CONFIG', {}) diff --git a/openwisp_monitoring/check/tasks.py b/openwisp_monitoring/check/tasks.py index 2ae62bc0d..53832a0df 100644 --- a/openwisp_monitoring/check/tasks.py +++ b/openwisp_monitoring/check/tasks.py @@ -100,3 +100,32 @@ def auto_create_config_check( ) check.full_clean() check.save() + + +@shared_task +def auto_create_snmp_devicemonitoring( + model, app_label, object_id, check_model=None, content_type_model=None +): + """ + Called by openwisp_monitoring.check.models.auto_snmp_receiver + """ + Check = check_model or get_check_model() + devicemonitoring_path = 'openwisp_monitoring.check.classes.Snmp' + has_check = Check.objects.filter( + object_id=object_id, + content_type__model='device', + check_type=devicemonitoring_path, + ).exists() + # create new check only if necessary + if has_check: + return + content_type_model = content_type_model or ContentType + ct = content_type_model.objects.get(app_label=app_label, model=model) + check = Check( + name='SNMP Device Monitoring', + check_type=devicemonitoring_path, + content_type=ct, + object_id=object_id, + ) + check.full_clean() + check.save() diff --git a/openwisp_monitoring/check/tests/test_models.py b/openwisp_monitoring/check/tests/test_models.py index abbf8ed13..34f38ace9 100644 --- a/openwisp_monitoring/check/tests/test_models.py +++ b/openwisp_monitoring/check/tests/test_models.py @@ -9,8 +9,12 @@ from ...device.tests import TestDeviceMonitoringMixin from .. import settings as app_settings -from ..classes import ConfigApplied, Ping -from ..tasks import auto_create_config_check, auto_create_ping +from ..classes import ConfigApplied, Ping, Snmp +from ..tasks import ( + auto_create_config_check, + auto_create_ping, + auto_create_snmp_devicemonitoring, +) Check = load_model('check', 'Check') Metric = load_model('monitoring', 'Metric') @@ -22,6 +26,7 @@ class TestModels(TestDeviceMonitoringMixin, TransactionTestCase): _PING = app_settings.CHECK_CLASSES[0][0] _CONFIG_APPLIED = app_settings.CHECK_CLASSES[1][0] + _SNMP_DEVICEMONITORING = app_settings.CHECK_CLASSES[2][0] def test_check_str(self): c = Check(name='Test check') @@ -48,6 +53,12 @@ def test_check_class(self): check_type=self._CONFIG_APPLIED, ) self.assertEqual(c.check_class, ConfigApplied) + with self.subTest('Test DeviceMonitoring check Class'): + c = Check( + name='Snmp class check', + check_type=self._SNMP_DEVICEMONITORING, + ) + self.assertEqual(c.check_class, Snmp) def test_base_check_class(self): path = 'openwisp_monitoring.check.classes.base.BaseCheck' @@ -81,6 +92,17 @@ def test_check_instance(self): self.assertIsInstance(i, ConfigApplied) self.assertEqual(i.related_object, obj) self.assertEqual(i.params, c.params) + with self.subTest('Test DeviceMonitoring check instance'): + c = Check( + name='DeviceMonitoring class check', + check_type=self._SNMP_DEVICEMONITORING, + content_object=obj, + params={}, + ) + i = c.check_instance + self.assertIsInstance(i, Snmp) + self.assertEqual(i.related_object, obj) + self.assertEqual(i.params, c.params) def test_validation(self): with self.subTest('Test Ping check validation'): @@ -105,7 +127,8 @@ def test_validation(self): def test_auto_check_creation(self): self.assertEqual(Check.objects.count(), 0) d = self._create_device(organization=self._create_org()) - self.assertEqual(Check.objects.count(), 2) + # check is automatically created via django signal + self.assertEqual(Check.objects.count(), 3) with self.subTest('Test AUTO_PING'): c1 = Check.objects.filter(check_type=self._PING).first() self.assertEqual(c1.content_object, d) @@ -114,11 +137,15 @@ def test_auto_check_creation(self): c2 = Check.objects.filter(check_type=self._CONFIG_APPLIED).first() self.assertEqual(c2.content_object, d) self.assertEqual(self._CONFIG_APPLIED, c2.check_type) + with self.subTest('Test AUTO_SNMP'): + c3 = Check.objects.filter(check_type=self._SNMP_DEVICEMONITORING).first() + self.assertEqual(c3.content_object, d) + self.assertEqual(self._SNMP_DEVICEMONITORING, c3.check_type) def test_device_deleted(self): self.assertEqual(Check.objects.count(), 0) d = self._create_device(organization=self._create_org()) - self.assertEqual(Check.objects.count(), 2) + self.assertEqual(Check.objects.count(), 3) d.delete() self.assertEqual(Check.objects.count(), 0) @@ -129,7 +156,7 @@ def test_config_modified_device_problem(self): self._create_config(status='modified', organization=self._create_org()) d = Device.objects.first() d.monitoring.update_status('ok') - self.assertEqual(Check.objects.count(), 2) + self.assertEqual(Check.objects.count(), 3) self.assertEqual(Metric.objects.count(), 0) self.assertEqual(AlertSettings.objects.count(), 0) check = Check.objects.filter(check_type=self._CONFIG_APPLIED).first() @@ -159,7 +186,7 @@ def test_config_error(self): self._create_config(status='error', organization=self._create_org()) dm = Device.objects.first().monitoring dm.update_status('ok') - self.assertEqual(Check.objects.count(), 2) + self.assertEqual(Check.objects.count(), 3) self.assertEqual(Metric.objects.count(), 0) self.assertEqual(AlertSettings.objects.count(), 0) check = Check.objects.filter(check_type=self._CONFIG_APPLIED).first() @@ -192,7 +219,7 @@ def test_config_error(self): @patch('openwisp_monitoring.check.settings.AUTO_PING', False) def test_config_check_critical_metric(self): self._create_config(status='modified', organization=self._create_org()) - self.assertEqual(Check.objects.count(), 2) + self.assertEqual(Check.objects.count(), 3) d = Device.objects.first() dm = d.monitoring dm.update_status('ok') @@ -211,7 +238,7 @@ def test_config_check_critical_metric(self): def test_no_duplicate_check_created(self): self._create_config(organization=self._create_org()) - self.assertEqual(Check.objects.count(), 2) + self.assertEqual(Check.objects.count(), 3) d = Device.objects.first() auto_create_config_check.delay( model=Device.__name__.lower(), @@ -223,13 +250,18 @@ def test_no_duplicate_check_created(self): app_label=Device._meta.app_label, object_id=str(d.pk), ) - self.assertEqual(Check.objects.count(), 2) + auto_create_snmp_devicemonitoring.delay( + model=Device.__name__.lower(), + app_label=Device._meta.app_label, + object_id=str(d.pk), + ) + self.assertEqual(Check.objects.count(), 3) def test_device_unreachable_no_config_check(self): self._create_config(status='modified', organization=self._create_org()) d = self.device_model.objects.first() d.monitoring.update_status('critical') - self.assertEqual(Check.objects.count(), 2) + self.assertEqual(Check.objects.count(), 3) c2 = Check.objects.filter(check_type=self._CONFIG_APPLIED).first() c2.perform_check() self.assertEqual(Metric.objects.count(), 0) @@ -240,7 +272,7 @@ def test_device_unknown_no_config_check(self): self._create_config(status='modified', organization=self._create_org()) d = self.device_model.objects.first() d.monitoring.update_status('unknown') - self.assertEqual(Check.objects.count(), 2) + self.assertEqual(Check.objects.count(), 3) c2 = Check.objects.filter(check_type=self._CONFIG_APPLIED).first() c2.perform_check() self.assertEqual(Metric.objects.count(), 0) diff --git a/openwisp_monitoring/check/tests/test_ping.py b/openwisp_monitoring/check/tests/test_ping.py index 11b9ee47b..295984a26 100644 --- a/openwisp_monitoring/check/tests/test_ping.py +++ b/openwisp_monitoring/check/tests/test_ping.py @@ -239,7 +239,7 @@ def test_store_result(self, mocked_method): device.management_ip = '10.40.0.1' device.save() # check created automatically by autoping - self.assertEqual(Check.objects.count(), 2) + self.assertEqual(Check.objects.count(), 3) self.assertEqual(Metric.objects.count(), 0) self.assertEqual(Chart.objects.count(), 0) self.assertEqual(AlertSettings.objects.count(), 0) @@ -265,7 +265,7 @@ def test_auto_chart_disabled(self, *args): device = self._create_device(organization=self._create_org()) device.last_ip = '127.0.0.1' device.save() - check = Check.objects.first() + check = Check.objects.filter(check_type=self._PING).first() self.assertEqual(Chart.objects.count(), 0) check.perform_check() self.assertEqual(Chart.objects.count(), 0) diff --git a/openwisp_monitoring/check/tests/test_snmp.py b/openwisp_monitoring/check/tests/test_snmp.py new file mode 100644 index 000000000..a195b9a12 --- /dev/null +++ b/openwisp_monitoring/check/tests/test_snmp.py @@ -0,0 +1,56 @@ +from unittest.mock import patch + +from django.test import TransactionTestCase +from swapper import load_model + +from openwisp_controller.connection.settings import UPDATE_STRATEGIES +from openwisp_controller.connection.tests.base import CreateConnectionsMixin + +from ...device.tests import TestDeviceMonitoringMixin +from .. import settings +from .utils import MockOpenWRT + +Check = load_model('check', 'Check') +Credentials = load_model('connection', 'Credentials') + + +class TestSnmp(CreateConnectionsMixin, TestDeviceMonitoringMixin, TransactionTestCase): + _SNMPDEVICEMONITORING = settings.CHECK_CLASSES[2][0] + + def test_snmp_perform_check(self): + device = self._create_device() + device.management_ip = '192.168.1.1' + check = Check( + name='SNMP check', + check_type=self._SNMPDEVICEMONITORING, + content_object=device, + ) + with patch( + 'openwisp_monitoring.check.classes.snmp_devicemonitoring.OpenWRT' + ) as p: + p.side_effect = MockOpenWRT + check.perform_check(store=False) + p.assert_called_once_with(host='192.168.1.1') + + def test_snmp_perform_check_with_credentials(self): + device = self._create_device() + device.management_ip = '192.168.1.1' + check = Check( + name='SNMP check', + check_type=self._SNMPDEVICEMONITORING, + content_object=device, + ) + params = {'community': 'public', 'agent': 'my-agent', 'port': 161} + cred = self._create_credentials( + params=params, + connector='openwisp_controller.connection.connectors.openwrt.snmp.OpenWRTSnmp', + ) + self._create_device_connection( + credentials=cred, device=device, update_strategy=UPDATE_STRATEGIES[1][0] + ) + with patch( + 'openwisp_monitoring.check.classes.snmp_devicemonitoring.OpenWRT' + ) as p: + p.side_effect = MockOpenWRT + check.perform_check(store=False) + p.assert_called_once_with(host='192.168.1.1', **params) diff --git a/openwisp_monitoring/check/tests/test_utils.py b/openwisp_monitoring/check/tests/test_utils.py index c83e0993a..d79312c4b 100644 --- a/openwisp_monitoring/check/tests/test_utils.py +++ b/openwisp_monitoring/check/tests/test_utils.py @@ -4,8 +4,9 @@ from django.test import TransactionTestCase from swapper import load_model +from ...check.tests.utils import MockOpenWRT from ...device.tests import TestDeviceMonitoringMixin -from ..classes import Ping +from ..classes import Ping, Snmp from ..settings import CHECK_CLASSES from ..tasks import perform_check from ..utils import run_checks_async @@ -24,10 +25,12 @@ def _create_check(self): # check is automatically created via django signal @patch.object(Ping, '_command', return_value=_FPING_REACHABLE) + @patch.object(Snmp, 'netengine_instance', MockOpenWRT) def test_run_checks_async_success(self, mocked_method): self._create_check() run_checks_async() + @patch.object(Snmp, 'netengine_instance', MockOpenWRT) @patch.object(Ping, '_command', return_value=_FPING_REACHABLE) def test_management_command(self, mocked_method): self._create_check() diff --git a/openwisp_monitoring/check/tests/utils.py b/openwisp_monitoring/check/tests/utils.py new file mode 100644 index 000000000..38cfe1a4b --- /dev/null +++ b/openwisp_monitoring/check/tests/utils.py @@ -0,0 +1,262 @@ +from collections import OrderedDict + + +class MockOpenWRT: + def __init__(self, *args, **kwargs): + pass + + @classmethod + def to_dict(*args, **kwargs): + res = OrderedDict( + [ + ('type', 'DeviceMonitoring'), + ( + 'general', + { + 'hostname': '1C-3B-F3-10-0A-42', + 'uptime': 3199, + 'local_time': 1628255816, + }, + ), + ( + 'resources', + OrderedDict( + [ + ('load', [0.35, 0.19, 0.18]), + ('cpus', 1), + ( + 'memory', + { + 'total': 61452288, + 'shared': 106496, + 'free': 33239040, + 'cached': 8122368, + 'buffered': 2355200, + }, + ), + ('swap', {'total': 0, 'free': 0}), + ] + ), + ), + ( + 'interfaces', + [ + OrderedDict( + [ + ('name', 'lo'), + ( + 'statistics', + { + 'mac': '', + 'type': 'loopback', + 'up': True, + 'rx_bytes': 17077, + 'tx_bytes': 17077, + 'mtu': 65536, + 'addresses': [ + { + 'family': 'ipv4', + 'address': '127.0.0.1', + 'mask': '255.0.0.0', + } + ], + }, + ), + ] + ), + OrderedDict( + [ + ('name', 'eth0'), + ( + 'statistics', + { + 'mac': '1c:3b:c3:b3:10:0a', + 'type': 'ethernet', + 'up': True, + 'rx_bytes': 35260493, + 'tx_bytes': 1525254905, + 'mtu': 1500, + 'addresses': [], + }, + ), + ] + ), + OrderedDict( + [ + ('name', 'Device 14c3:7662'), + ( + 'statistics', + { + 'mac': '', + 'type': 'wireless', + 'up': False, + 'rx_bytes': 0, + 'tx_bytes': 0, + 'mtu': 1500, + 'addresses': [], + }, + ), + ] + ), + OrderedDict( + [ + ('name', 'br-lan'), + ( + 'statistics', + { + 'mac': '1c:3b:c3:b3:10:0a', + 'type': 'ethernet', + 'up': True, + 'rx_bytes': 30064879, + 'tx_bytes': 1513529004, + 'mtu': 1500, + 'addresses': [ + { + 'family': 'ipv4', + 'address': '192.168.1.1', + 'mask': '255.255.255.0', + } + ], + }, + ), + ] + ), + OrderedDict( + [ + ('name', 'eth0.1'), + ( + 'statistics', + { + 'mac': '1c:3b:c3:b3:10:0a', + 'type': 'ethernet', + 'up': True, + 'rx_bytes': 30064879, + 'tx_bytes': 1518505434, + 'mtu': 1500, + 'addresses': [], + }, + ), + ] + ), + OrderedDict( + [ + ('name', 'eth0.2'), + ( + 'statistics', + { + 'mac': '1c:3b:c3:b3:10:0a', + 'type': 'ethernet', + 'up': True, + 'rx_bytes': 0, + 'tx_bytes': 2169765, + 'mtu': 1500, + 'addresses': [], + }, + ), + ] + ), + OrderedDict( + [ + ('name', 'wlan0'), + ( + 'statistics', + { + 'mac': '1c:3b:c3:b3:10:0a', + 'type': 'wireless', + 'up': True, + 'rx_bytes': 1522781779, + 'tx_bytes': 37676515, + 'mtu': 1500, + 'addresses': [ + { + 'family': 'ipv4', + 'address': '192.168.0.100', + 'mask': '255.255.255.0', + } + ], + }, + ), + ] + ), + ], + ), + ( + 'neighbors', + [ + OrderedDict( + [ + ('mac', '04:0e:3c:ca:55:5f'), + ('state', 'REACHABLE'), + ('interface', 'br-lan'), + ('ip', '192.168.1.140'), + ] + ), + OrderedDict( + [ + ('mac', '04:0e:3c:ca:55:5f'), + ('state', 'STALE'), + ('interface', 'br-lan'), + ('ip', 'fe80::f02:1be3:6bf:967f'), + ] + ), + OrderedDict( + [ + ('mac', '84:d8:1b:62:a3:55'), + ('state', 'DELAY'), + ('interface', 'wlan0'), + ('ip', '192.168.0.1'), + ] + ), + OrderedDict( + [ + ('mac', 'ac:d5:64:1b:15:f7'), + ('state', 'STALE'), + ('interface', 'wlan0'), + ('ip', '192.168.0.105'), + ] + ), + OrderedDict( + [ + ('mac', '52:e6:da:14:67:b0'), + ('state', 'STALE'), + ('interface', 'wlan0'), + ('ip', 'fe80::50e6:daff:fe14:67b0'), + ] + ), + OrderedDict( + [ + ('mac', '0c:f3:46:87:25:17'), + ('state', 'STALE'), + ('interface', 'wlan0'), + ('ip', 'fe80::5137:b030:5cd6:c74c'), + ] + ), + OrderedDict( + [ + ('mac', '70:3a:51:62:fb:9f'), + ('state', 'STALE'), + ('interface', 'wlan0'), + ('ip', 'fe80::a7ff:6d4b:7b4d:edc7'), + ] + ), + OrderedDict( + [ + ('mac', 'aa:d1:28:7c:75:79'), + ('state', 'STALE'), + ('interface', 'wlan0'), + ('ip', 'fe80::a8d1:28ff:fe7c:7579'), + ] + ), + OrderedDict( + [ + ('mac', '70:3a:51:62:fb:9f'), + ('state', 'STALE'), + ('interface', 'wlan0'), + ('ip', 'fe80::cbc1:e171:a82e:a21'), + ] + ), + ], + ), + ] + ) + return res diff --git a/openwisp_monitoring/device/api/views.py b/openwisp_monitoring/device/api/views.py index 6124fdcc1..452ca78fa 100644 --- a/openwisp_monitoring/device/api/views.py +++ b/openwisp_monitoring/device/api/views.py @@ -18,6 +18,7 @@ from pytz.exceptions import UnknownTimeZoneError from rest_framework import serializers, status from rest_framework.generics import GenericAPIView +from rest_framework.permissions import BasePermission from rest_framework.response import Response from swapper import load_model @@ -36,6 +37,7 @@ logger = logging.getLogger(__name__) Chart = load_model('monitoring', 'Chart') +Check = load_model('check', 'Check') Metric = load_model('monitoring', 'Metric') AlertSettings = load_model('monitoring', 'AlertSettings') Device = load_model('config', 'Device') @@ -44,180 +46,28 @@ Location = load_model('geo', 'Location') -class DeviceMetricView(GenericAPIView): - model = DeviceData - queryset = DeviceData.objects.select_related('devicelocation').all() - serializer_class = serializers.Serializer - permission_classes = [DevicePermission] - schema = schema - - def get(self, request, pk): - # ensure valid UUID - try: - pk = str(uuid.UUID(pk)) - except ValueError: - return Response({'detail': 'not found'}, status=404) - self.instance = self.get_object() - ct = ContentType.objects.get_for_model(Device) - charts = Chart.objects.filter( - metric__object_id=pk, metric__content_type=ct - ).select_related('metric') - # determine time range - time = request.query_params.get('time', Chart.DEFAULT_TIME) - if time not in Chart.GROUP_MAP.keys(): - return Response({'detail': 'Time range not supported'}, status=400) - # try to read timezone - timezone = request.query_params.get('timezone', settings.TIME_ZONE) - try: - tz(timezone) - except UnknownTimeZoneError: - return Response('Unkown Time Zone', status=400) - # prepare response data - data = self._get_charts_data(charts, time, timezone) - # csv export has a different response - if request.query_params.get('csv'): - response = HttpResponse(self._get_csv(data), content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename=data.csv' - return response - # add device data if requested - if request.query_params.get('status', False): - data['data'] = self.instance.data - return Response(data) - - def _get_charts_data(self, charts, time, timezone): - chart_map = {} - x_axys = True - data = OrderedDict({'charts': []}) - for chart in charts: - # prepare chart dict - try: - chart_dict = chart.read(time=time, x_axys=x_axys, timezone=timezone) - if not chart_dict['traces']: - continue - chart_dict['description'] = chart.description - chart_dict['title'] = chart.title.format( - metric=chart.metric, **chart.metric.tags - ) - chart_dict['type'] = chart.type - chart_dict['unit'] = chart.unit - chart_dict['summary_labels'] = chart.summary_labels - chart_dict['colors'] = chart.colors - chart_dict['colorscale'] = chart.colorscale - for attr in ['fill', 'xaxis', 'yaxis']: - value = getattr(chart, attr) - if value: - chart_dict[attr] = value - except InvalidChartConfigException: - logger.exception(f'Skipped chart for metric {chart.metric}') - continue - # get x axys (only once) - if x_axys and chart_dict['x'] and chart.type != 'histogram': - data['x'] = chart_dict.pop('x') - x_axys = False - # prepare to sort the items according to - # the order in the chart configuration - key = f'{chart.order} {chart_dict["title"]}' - chart_map[key] = chart_dict - # add sorted chart list to chart data - data['charts'] = list(OrderedDict(sorted(chart_map.items())).values()) - return data - - def _get_csv(self, data): - header = ['time'] - columns = [data.get('x')] - histograms = [] - for chart in data['charts']: - if chart['type'] == 'histogram': - histograms.append(chart) - continue - for trace in chart['traces']: - header.append(self._get_csv_header(chart, trace)) - columns.append(trace[1]) - rows = [header] - for index, element in enumerate(data.get('x', [])): - row = [] - for column in columns: - row.append(column[index]) - rows.append(row) - for chart in histograms: - rows.append([]) - rows.append([chart['title']]) - # Export value as 0 if it is None - for key, value in chart['summary'].items(): - if chart['summary'][key] is None: - chart['summary'][key] = 0 - # Sort Histogram on the basis of value in the descending order - sorted_charts = sorted( - chart['summary'].items(), key=lambda x: x[1], reverse=True - ) - for field, value in sorted_charts: - rows.append([field, value]) - # write CSV to in-memory file object - fileobj = StringIO() - csv.writer(fileobj).writerows(rows) - return fileobj.getvalue() - - def _get_csv_header(self, chart, trace): - header = trace[0] - return f'{header} - {chart["title"]}' - - def post(self, request, pk): - self.instance = self.get_object() - self._init_previous_data() - self.instance.data = request.data - # validate incoming data - try: - self.instance.validate_data() - except ValidationError as e: - logger.info(e.message) - return Response(e.message, status=status.HTTP_400_BAD_REQUEST) - time_obj = request.query_params.get( - 'time', now().utcnow().strftime('%d-%m-%Y_%H:%M:%S.%f') - ) - current = request.query_params.get('current', False) - try: - time = datetime.strptime(time_obj, '%d-%m-%Y_%H:%M:%S.%f').replace( - tzinfo=UTC - ) - except ValueError: - return Response({'detail': _('Incorrect time format')}, status=400) - try: - # write data - self._write(request, self.instance.pk, time=time) - except ValidationError as e: - logger.info(e.message_dict) - return Response(e.message_dict, status=status.HTTP_400_BAD_REQUEST) - device_metrics_received.send( - sender=self.model, - instance=self.instance, - request=request, - time=time, - current=current, - ) - return Response(None) +class DevicePermission(BasePermission): # noqa + def has_object_permission(self, request, view, obj): + return request.query_params.get('key') == obj.key - def _init_previous_data(self): - """ - makes NetJSON interfaces of previous - snapshots more easy to access - """ - data = self.instance.data or {} - if data: - data = deepcopy(data) - data['interfaces_dict'] = {} - for interface in data.get('interfaces', []): - data['interfaces_dict'][interface['name']] = interface - self._previous_data = data - def _write(self, request, pk, time=None, current=False): +class MetricChartsMixin: + def _write(self, pk, time=None, current=False): """ write metrics to database """ - # saves raw device data - self.instance.save_data() - data = self.instance.data + # If object not of type check ie. It is DeviceData + if not hasattr(self, 'check_instance'): + self.instance.save_data() + data = self.instance.data + device_extra_tags = self._get_extra_tags(self.instance) + # Get data attribute from DeviceData object + else: + devicedata_instance = DeviceData.objects.get(pk=pk) + data = getattr(devicedata_instance, 'data', {}) + device_extra_tags = self._get_extra_tags(devicedata_instance) + ct = ContentType.objects.get_for_model(Device) - device_extra_tags = self._get_extra_tags(self.instance) for interface in data.get('interfaces', []): ifname = interface['name'] extra_tags = Metric._sort_dict(device_extra_tags) @@ -420,10 +270,10 @@ def _write_memory( self, memory, primary_key, content_type, current=False, time=None ): extra_values = { - 'total_memory': memory['total'], - 'free_memory': memory['free'], - 'buffered_memory': memory['buffered'], - 'shared_memory': memory['shared'], + 'total_memory': memory.get('total'), + 'free_memory': memory.get('free'), + 'buffered_memory': memory.get('buffered'), + 'shared_memory': memory.get('shared'), } if 'cached' in memory: extra_values['cached_memory'] = memory.get('cached') @@ -516,6 +366,179 @@ def _create_access_tech_chart(self, metric): chart.full_clean() chart.save() + def _init_previous_data(self): + """ + makes NetJSON interfaces of previous + snapshots more easy to access + """ + + # If object not of type check + if not hasattr(self, 'check_instance'): + data = self.instance.data or {} + # Get data attribute from Device object + else: + data = getattr(self.related_object, 'data', {}) + + if data: + data = deepcopy(data) + data['interfaces_dict'] = {} + for interface in data.get('interfaces', []): + data['interfaces_dict'][interface['name']] = interface + self._previous_data = data + + +class DeviceMetricView(GenericAPIView, MetricChartsMixin): + model = DeviceData + queryset = DeviceData.objects.select_related('devicelocation').all() + serializer_class = serializers.Serializer + permission_classes = [DevicePermission] + schema = schema + + def get(self, request, pk): + # ensure valid UUID + try: + pk = str(uuid.UUID(pk)) + except ValueError: + return Response({'detail': 'not found'}, status=404) + self.instance = self.get_object() + ct = ContentType.objects.get_for_model(Device) + charts = Chart.objects.filter( + metric__object_id=pk, metric__content_type=ct + ).select_related('metric') + # determine time range + time = request.query_params.get('time', Chart.DEFAULT_TIME) + if time not in Chart.GROUP_MAP.keys(): + return Response({'detail': 'Time range not supported'}, status=400) + # try to read timezone + timezone = request.query_params.get('timezone', settings.TIME_ZONE) + try: + tz(timezone) + except UnknownTimeZoneError: + return Response('Unkown Time Zone', status=400) + # prepare response data + data = self._get_charts_data(charts, time, timezone) + # csv export has a different response + if request.query_params.get('csv'): + response = HttpResponse(self._get_csv(data), content_type='text/csv') + response['Content-Disposition'] = 'attachment; filename=data.csv' + return response + # add device data if requested + if request.query_params.get('status', False): + data['data'] = self.instance.data + return Response(data) + + def _get_charts_data(self, charts, time, timezone): + chart_map = {} + x_axys = True + data = OrderedDict({'charts': []}) + for chart in charts: + # prepare chart dict + try: + chart_dict = chart.read(time=time, x_axys=x_axys, timezone=timezone) + if not chart_dict['traces']: + continue + chart_dict['description'] = chart.description + chart_dict['title'] = chart.title.format( + metric=chart.metric, **chart.metric.tags + ) + chart_dict['type'] = chart.type + chart_dict['unit'] = chart.unit + chart_dict['summary_labels'] = chart.summary_labels + chart_dict['colors'] = chart.colors + chart_dict['colorscale'] = chart.colorscale + for attr in ['fill', 'xaxis', 'yaxis']: + value = getattr(chart, attr) + if value: + chart_dict[attr] = value + except InvalidChartConfigException: + logger.exception(f'Skipped chart for metric {chart.metric}') + continue + # get x axys (only once) + if x_axys and chart_dict['x'] and chart.type != 'histogram': + data['x'] = chart_dict.pop('x') + x_axys = False + # prepare to sort the items according to + # the order in the chart configuration + key = f'{chart.order} {chart_dict["title"]}' + chart_map[key] = chart_dict + # add sorted chart list to chart data + data['charts'] = list(OrderedDict(sorted(chart_map.items())).values()) + return data + + def _get_csv(self, data): + header = ['time'] + columns = [data.get('x')] + histograms = [] + for chart in data['charts']: + if chart['type'] == 'histogram': + histograms.append(chart) + continue + for trace in chart['traces']: + header.append(self._get_csv_header(chart, trace)) + columns.append(trace[1]) + rows = [header] + for index, element in enumerate(data.get('x', [])): + row = [] + for column in columns: + row.append(column[index]) + rows.append(row) + for chart in histograms: + rows.append([]) + rows.append([chart['title']]) + # Export value as 0 if it is None + for key, value in chart['summary'].items(): + if chart['summary'][key] is None: + chart['summary'][key] = 0 + # Sort Histogram on the basis of value in the descending order + sorted_charts = sorted( + chart['summary'].items(), key=lambda x: x[1], reverse=True + ) + for field, value in sorted_charts: + rows.append([field, value]) + # write CSV to in-memory file object + fileobj = StringIO() + csv.writer(fileobj).writerows(rows) + return fileobj.getvalue() + + def _get_csv_header(self, chart, trace): + header = trace[0] + return f'{header} - {chart["title"]}' + + def post(self, request, pk): + self.instance = self.get_object() + self._init_previous_data() + self.instance.data = request.data + # validate incoming data + try: + self.instance.validate_data() + except ValidationError as e: + logger.info(e.message) + return Response(e.message, status=status.HTTP_400_BAD_REQUEST) + time_obj = request.query_params.get( + 'time', now().utcnow().strftime('%d-%m-%Y_%H:%M:%S.%f') + ) + current = request.query_params.get('current', False) + try: + time = datetime.strptime(time_obj, '%d-%m-%Y_%H:%M:%S.%f').replace( + tzinfo=UTC + ) + except ValueError: + return Response({'detail': _('Incorrect time format')}, status=400) + try: + # write data + self._write(self.instance.pk, time=time) + except ValidationError as e: + logger.info(e.message_dict) + return Response(e.message_dict, status=status.HTTP_400_BAD_REQUEST) + device_metrics_received.send( + sender=self.model, + instance=self.instance, + request=request, + time=time, + current=current, + ) + return Response(None) + device_metric = DeviceMetricView.as_view() diff --git a/openwisp_monitoring/device/schema.py b/openwisp_monitoring/device/schema.py index 1da415fbe..5948ca630 100644 --- a/openwisp_monitoring/device/schema.py +++ b/openwisp_monitoring/device/schema.py @@ -40,7 +40,6 @@ "shared": {"type": "integer"}, "available": {"type": "integer"}, }, - "required": ["total", "free", "buffered", "shared"], }, "disk": { "type": "array", diff --git a/openwisp_monitoring/device/tests/test_transactions.py b/openwisp_monitoring/device/tests/test_transactions.py index 5fd3d9698..1f71b709c 100644 --- a/openwisp_monitoring/device/tests/test_transactions.py +++ b/openwisp_monitoring/device/tests/test_transactions.py @@ -62,11 +62,13 @@ def test_trigger_device_recovery_task_regression( dm = self._create_device_monitoring() dm.device.management_ip = None dm.device.save() + # Delete snmp check to prevent unnecessary response timeout + Check.objects.filter(check_type__endswith='Snmp').delete() trigger_device_checks.delay(dm.device.pk) self.assertTrue(Check.objects.exists()) # we expect update_status() to be called once (by the check) # and not a second time directly by our code - mocked_update_status.assert_called_once() + self.assertEqual(mocked_update_status.call_count, 1) @patch.object(Check, 'perform_check') def test_is_working_false_true(self, perform_check): diff --git a/openwisp_monitoring/tests/test_selenium.py b/openwisp_monitoring/tests/test_selenium.py index e4eebe1b8..1def83f66 100644 --- a/openwisp_monitoring/tests/test_selenium.py +++ b/openwisp_monitoring/tests/test_selenium.py @@ -82,6 +82,8 @@ def test_restoring_deleted_device(self): org = self._get_org() self._create_credentials(auto_add=True, organization=org) device = self._create_config(organization=org).device + # Delete snmp check to prevent unnecessary response timeout + Check.objects.filter(check_type__endswith='Snmp').delete() device_data = DeviceData.objects.get(id=device.id) device_checks = device_data.checks.all() for check in device_checks: diff --git a/requirements.txt b/requirements.txt index b6bd3450e..3d4b081d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -openwisp-controller~=1.0.0 +openwisp-controller @ https://github.com/openwisp/openwisp-controller/tarball/master influxdb~=5.3.1 django-cache-memoize~=0.1.0 django-nested-admin~=3.4.0 netaddr~=0.8 python-dateutil>=2.7,<3.0 openwisp-utils[rest] @ https://github.com/openwisp/openwisp-utils/tarball/master +netengine @ https://github.com/openwisp/netengine/tarball/netjson-compliance diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index 990ae5229..3765227cf 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -194,6 +194,8 @@ OPENWISP_MONITORING_MAC_VENDOR_DETECTION = False OPENWISP_MONITORING_API_URLCONF = 'openwisp_monitoring.urls' OPENWISP_MONITORING_API_BASEURL = 'http://testserver' + # for testing AUTO_SNMP + OPENWISP_MONITORING_AUTO_SNMP = True # Temporarily added to identify slow tests TEST_RUNNER = 'openwisp_utils.tests.TimeLoggingTestRunner'