From ab0fb71f1da77be483fb50dfc65dc68df99f9cb9 Mon Sep 17 00:00:00 2001
From: Erik Reid <erik.reid@geant.org>
Date: Thu, 27 May 2021 16:07:18 +0200
Subject: [PATCH] get inventory interfaces, save state

---
 brian_polling_manager/cli.py       | 125 ++++++++++++++++++++++-------
 brian_polling_manager/inventory.py | 122 ++++++++++++++++++++++++++++
 2 files changed, 217 insertions(+), 30 deletions(-)
 create mode 100644 brian_polling_manager/inventory.py

diff --git a/brian_polling_manager/cli.py b/brian_polling_manager/cli.py
index 0d2e1a7..0865ea7 100644
--- a/brian_polling_manager/cli.py
+++ b/brian_polling_manager/cli.py
@@ -1,11 +1,12 @@
 import json
 import logging
+import os
 import re
 
 import click
 import jsonschema
 
-from brian_polling_manager import sensu
+from brian_polling_manager import sensu, inventory
 
 logger = logging.getLogger(__name__)
 
@@ -21,7 +22,8 @@ _DEFAULT_CONFIG = {
             'https://test-poller-sensu-agent03.geant.org:8080'
         ],
         'token': '696a815c-607e-4090-81de-58988c83033e'
-    }
+    },
+    'statedir': '/tmp/'
 }
 
 CONFIG_SCHEMA = {
@@ -46,13 +48,76 @@ CONFIG_SCHEMA = {
             'items': {'type': 'string'},
             'minItems': 1
         },
-        'sensu': {'$ref': '#/definitions/sensu'}
+        'sensu': {'$ref': '#/definitions/sensu'},
+        'statedir': {'type': 'string'}
     },
-    'required': ['inventory', 'sensu'],
+    'required': ['inventory', 'sensu', 'statedir'],
     'additionalProperties': False
 }
 
 
+class State(object):
+
+    INTERFACES = 'interfaces.json'
+    STATE = 'state.json'
+
+    STATE_SCHEMA = {
+        '$schema': 'http://json-schema.org/draft-07/schema#',
+        'type': 'object',
+        'properties': {
+            'last': {'type': 'number'}
+        },
+        'required': ['last'],
+        'additionalProperties': False
+    }
+
+    def __init__(self, state_dir: str):
+        assert os.path.isdir(state_dir)
+        self.filenames = {
+            'state': os.path.join(state_dir, State.STATE),
+            'cache': os.path.join(state_dir, State.INTERFACES)
+        }
+
+    @staticmethod
+    def _load_json(filename, schema):
+        try:
+            with open(filename) as f:
+                state = json.loads(f.read())
+                jsonschema.validate(state, schema)
+                return state
+        except (json.JSONDecodeError, jsonschema.ValidationError, OSError):
+            logger.exception(
+                f'unable to open state file {filename}')
+            return None
+
+    @property
+    def last(self) -> int:
+        state = State._load_json(self.filenames['state'], State.STATE_SCHEMA)
+        return state['last'] if state else -1
+
+    @last.setter
+    def last(self, new_last: float):
+        state = {'last': new_last}
+        with open(self.filenames['state'], 'w') as f:
+            f.write(json.dumps(state))
+
+    @property
+    def interfaces(self) -> list:
+        return State._load_json(self.filenames['cache'], inventory.INVENTORY_INTERFACES_SCHEMA)
+
+    @interfaces.setter
+    def interfaces(self, new_interfaces):
+        try:
+            jsonschema.validate(new_interfaces, inventory.INVENTORY_INTERFACES_SCHEMA)
+        except jsonschema.ValidationError:
+            logger.exception('invalid interface state data')
+            return
+
+        with open(self.filenames['cache'], 'w') as f:
+            f.write(json.dumps(new_interfaces))
+
+
+
 def _validate_config(ctx, param, value):
     """
     loads, validates and returns configuration parameters
@@ -79,6 +144,14 @@ def _validate_config(ctx, param, value):
     return config
 
 
+def load_ifc_checks(sensu_params):
+    def _is_ifc_check(check):
+        name = check['metadata']['name']
+        return re.match(r'^check-([^-]+\.geant\.net)-(.+)$', name)
+    ifc_checks = filter(_is_ifc_check, sensu.load_all_checks(sensu_params))
+    return {c['metadata']['name']:c for c in ifc_checks}
+
+
 @click.command()
 @click.option(
     '--config',
@@ -86,35 +159,27 @@ def _validate_config(ctx, param, value):
     type=click.STRING,
     callback=_validate_config,
     help='configuration filename')
-def main(config):
+@click.option(
+    '--force/--no-force',
+    default=False,
+    help="update even if inventory hasn't been updated")
+def main(config, force):
+    state = State(config['statedir'])
+    last = inventory.last_update_timestamp(config['inventory'])
+    if force or last != state.last:
+        interfaces = inventory.load_interfaces(config['inventory'])
+        state.last = last
+        state.interfaces = interfaces
+
+    assert interfaces
 
-    def _categorize_ifc_check(check):
-        name = check['metadata']['name']
-        m = re.match(r'^check-([^-]+\.geant\.net)-(.+)$', name)
-        if not m:
-            return None
-        return {
-            'hostname': m.group(1),
-            'interface': m.group(2),
-            'check': check
-        }
 
-    ifc_checks = {}
-    for check in filter(None, map(_categorize_ifc_check, sensu.load_all_checks(config['sensu']))):
-        host_checks = ifc_checks.setdefault(check['hostname'], {})
-        if check['interface'] in host_checks:
-            other_check = host_checks[check['interface']]
-            other_name = other_check['metadata']['name']
-            this_name = check['metadata']['name']
-            logger.warning(f'found both {this_name} and {other_name}')
-        host_checks[check['interface']] = check['check']
+    ifc_checks = load_ifc_checks(config['sensu'])
+    print(ifc_checks.keys())
 
-    for host, interfaces in ifc_checks.items():
-        print(host)
-        for ifc, check in interfaces.items():
-            name = check['metadata']['name']
-            print(f'\t{ifc}: {name}')
 
+    print('here')
 
 if __name__ == '__main__':
-    main()
\ No newline at end of file
+    logging.basicConfig(level=logging.DEBUG)
+    main()
diff --git a/brian_polling_manager/inventory.py b/brian_polling_manager/inventory.py
new file mode 100644
index 0000000..b521104
--- /dev/null
+++ b/brian_polling_manager/inventory.py
@@ -0,0 +1,122 @@
+import logging
+import random
+
+import jsonschema
+import requests
+
+logger = logging.getLogger(__name__)
+
+# minimal inventory response schema
+INVENTORY_VERSION_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-07/schema#',
+
+    'definitions': {
+        'latch': {
+            'type': 'object',
+            'properties': {
+                'timestamp': {'type': 'number'}
+            },
+            'required': ['timestamp'],
+            'additionalProperties': True
+        }
+    },
+
+    'type': 'object',
+    'properties': {
+        'latch': {'$ref': '#/definitions/latch'}
+    },
+    'required': ['latch'],
+    'additionalProperties': True
+}
+
+INVENTORY_INTERFACES_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-07/schema#',
+
+    'definitions': {
+        'service': {
+            'type': 'object',
+            'properties': {
+                'id': {'type': 'integer'},
+                'name': {'type': 'string'},
+                'type': {'type': 'string'},
+                'status': {'type': 'string'},
+            },
+            'required': ['id', 'name', 'type', 'status'],
+            'additionalProperties': False
+        },
+        'interface': {
+            'type': 'object',
+            'properties': {
+                'router': {'type': 'string'},
+                'name': {'type': 'string'},
+                'description': {'type': 'string'},
+                'snmp-index': {
+                    'type': 'integer',
+                    'minimum': 1
+                },
+                'bundle': {
+                    'type': 'array',
+                    'items': {'type': 'string'}
+                },
+                'bundle-parents': {
+                    'type': 'array',
+                    'items': {'type': 'string'}
+                },
+                'circuits': {
+                    'type': 'array',
+                    'items': {'$ref': '#/definitions/service'}
+                }
+            },
+            'required': [
+                'router', 'name', 'description',
+                'snmp-index', 'bundle', 'bundle-parents',
+                'circuits'],
+            'additionalProperties': False
+        },
+    },
+
+    'type': 'array',
+    'items': {'$ref': '#/definitions/interface'}
+}
+
+
+def _pick_one(things):
+    if not isinstance(things, (list, tuple, set)):
+        things = [things]
+    return random.choice(things)
+
+
+def load_interfaces(base_urls):
+    """
+    Load /poller/interfaces from inventory provider
+    and return a slightly reformatted dict.
+
+    :param base_urls: inventory provider base api url, or a list of them
+    :return: a dict like [<router>][<interface>] = inventory leaf data
+    """
+    url = _pick_one(base_urls)
+    logger.debug(f'using inventory base api url: {url}')
+
+    rsp = requests.get(
+        f'{url}/poller/interfaces',
+        headers={'Accept': 'application/json'})
+    rsp.raise_for_status()
+
+    result = rsp.json()
+    jsonschema.validate(result, INVENTORY_INTERFACES_SCHEMA)
+    return result
+
+
+def last_update_timestamp(base_urls) -> float:
+    try:
+        r = requests.get(
+            f'{_pick_one(base_urls)}/version',
+            headers={'Accept': 'application/json'})
+        r.raise_for_status()
+
+        result = r.json()
+        jsonschema.validate(result, INVENTORY_VERSION_SCHEMA)
+        return result['latch']['timestamp']
+    except (IOError, jsonschema.ValidationError, ValueError):
+        logger.exception('connection error')
+        return None
-- 
GitLab