diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..8d6fe3ccb931c4867538b49a6bea8be51087f6c6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.log
+*config.sample.json
+__pycache__
diff --git a/ims-data-example.py b/ims-data-example.py
new file mode 100644
index 0000000000000000000000000000000000000000..445cb6414d5e47cbaf5ba505eb6a72d359b5987c
--- /dev/null
+++ b/ims-data-example.py
@@ -0,0 +1,20 @@
+import json
+import os
+
+from inventory_provider.ims import IMS
+from inventory_provider import config
+from inventory_provider import ims_data
+
+CONFIG_FILENAME = os.path.abspath(os.path.join(
+    os.path.dirname(__file__), 'config.sample.json'))
+
+with open(CONFIG_FILENAME) as f:
+    params = config.load(f)
+    ims_params = params['ims']
+
+ds = IMS(ims_params['api'], ims_params['username'], ims_params['password'])
+
+services = ims_data.lookup_lg_routers(ds=ds)
+services = ims_data.get_port_id_services(ds=ds)
+services = list(services)
+print(json.dumps(services, indent=2))
diff --git a/inventory_provider/config.py b/inventory_provider/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..f332f5bb2bdf60b66c99c231addf5f0c23fcc94e
--- /dev/null
+++ b/inventory_provider/config.py
@@ -0,0 +1,265 @@
+import json
+import jsonschema
+
+CONFIG_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-07/schema#',
+
+    'definitions': {
+        'timeout': {
+            'type': 'number',
+            'maximum': 60,  # sanity
+            'exclusiveMinimum': 0
+        },
+        'database-credentials': {
+            'type': 'object',
+            'properties': {
+                'hostname': {'type': 'string'},
+                'dbname': {'type': 'string'},
+                'username': {'type': 'string'},
+                'password': {'type': 'string'}
+            },
+            'required': ['hostname', 'dbname', 'username', 'password'],
+            'additionalProperties': False
+        },
+        'ssh-credentials': {
+            'type': 'object',
+            'properties': {
+                'username': {'type': 'string'},
+                'private-key': {'type': 'string'},
+                'known-hosts': {'type': 'string'}
+            },
+            'required': ['private-key', 'known-hosts'],
+            'additionalProperties': False
+        },
+        'ims': {
+            'type': 'object',
+            'properties': {
+                'api': {'type': 'string'},
+                'username': {'type': 'string'},
+                'password': {'type': 'string'}
+            },
+            'required': ['api', 'username', 'password'],
+            'additionalProperties': False
+        },
+        'otrs-export': {
+            'type': 'object',
+            'properties': {
+                'username': {'type': 'string'},
+                'private-key': {'type': 'string'},
+                'known-hosts': {'type': 'string'},
+                'destination': {'type': 'string'}
+            },
+            'required': [
+                'username',
+                'private-key',
+                'known-hosts',
+                'destination'
+            ],
+            'additionalProperties': False
+        },
+        'redis-credentials': {
+            'type': 'object',
+            'properties': {
+                'hostname': {'type': 'string'},
+                'port': {'type': 'integer'},
+                'celery-db-index': {'type': 'integer'},
+                'socket_timeout': {'$ref': '#/definitions/timeout'}
+            },
+            'required': ['hostname', 'port'],
+            'additionalProperties': False
+        },
+        'redis-sentinel-config': {
+            'type': 'object',
+            'properties': {
+                'hostname': {'type': 'string'},
+                'port': {'type': 'integer'},
+                'celery-db-index': {'type': 'integer'},
+                'name': {'type': 'string'},
+                'redis_socket_timeout': {'$ref': '#/definitions/timeout'},
+                'sentinel_socket_timeout': {'$ref': '#/definitions/timeout'}
+            },
+            'required': ['hostname', 'port', 'name'],
+            'additionalProperties': False
+        },
+        'interface-address': {
+            'type': 'object',
+            'properties': {
+                'address': {'type': 'string'},
+                'network': {'type': 'string'},
+                'interface': {'type': 'string'},
+                'router': {'type': 'string'}
+            },
+            'required': ['address', 'network', 'interface', 'router'],
+            'additionalProperties': False
+        },
+        'oid': {
+            'type': 'string',
+            'pattern': r'^(\d+\.)*\d+$'
+        },
+        'gws-direct-counters': {
+            'type': 'object',
+            'properties': {
+                'discards_in': {'$ref': '#/definitions/oid'},
+                'discards_out': {'$ref': '#/definitions/oid'},
+                'errors_in': {'$ref': '#/definitions/oid'},
+                'errors_out': {'$ref': '#/definitions/oid'},
+                'traffic_in': {'$ref': '#/definitions/oid'},
+                'traffic_out': {'$ref': '#/definitions/oid'},
+            },
+            'additionalProperties': False
+        },
+        'gws-direct-interface': {
+            'type': 'object',
+            'properties': {
+                'info': {'type': 'string'},
+                'tag': {'type': 'string'},
+                'counters': {'$ref': '#/definitions/gws-direct-counters'}
+            },
+            'required': ['tag', 'counters'],
+            'additionalProperties': False
+        },
+        'gws-direct-host-v2': {
+            'type': 'object',
+            'properties': {
+                'hostname': {'type': 'string'},
+                'community': {'type': 'string'},
+                'interfaces': {
+                    'type': 'array',
+                    'items': {'$ref': '#/definitions/gws-direct-interface'},
+                    'minItems': 1
+                }
+            },
+            'required': ['hostname', 'community', 'interfaces'],
+            'additionalProperties': False
+        },
+        'snmp-v3-cred': {
+            'type': 'object',
+            'properties': {
+                'protocol': {'enum': ['MD5', 'DES']},
+                'password': {'type': 'string'}
+            },
+            'required': ['protocol', 'password'],
+            'additionalProperties': False
+        },
+        'gws-direct-host-v3': {
+            'type': 'object',
+            'properties': {
+                'hostname': {'type': 'string'},
+                'sec-name': {'type': 'string'},
+                'auth': {'$ref': '#/definitions/snmp-v3-cred'},
+                'priv': {'$ref': '#/definitions/snmp-v3-cred'},
+                'interfaces': {
+                    'type': 'array',
+                    'items': {'$ref': '#/definitions/gws-direct-interface'},
+                    'minItems': 1
+                }
+            },
+            'required': ['hostname', 'sec-name', 'interfaces'],
+            'additionalProperties': False
+        },
+        'gws-direct-nren-isp': {
+            'type': 'object',
+            'properties': {
+                'nren': {'type': 'string'},
+                'isp': {
+                    'type': 'string',
+                    'enum': ['Cogent', 'Telia', 'CenturyLink']
+                },
+                'hosts': {
+                    'type': 'array',
+                    'items': {
+                        'oneOf': [
+                            {'$ref': '#/definitions/gws-direct-host-v2'},
+                            {'$ref': '#/definitions/gws-direct-host-v3'},
+                        ]
+                    },
+                    'minItems': 1
+                }
+            },
+            'required': ['nren', 'isp', 'hosts'],
+            'additionalProperties': False
+        },
+        'gws-direct': {
+            'type': 'array',
+            'items': {'$ref': '#/definitions/gws-direct-nren-isp'}
+        },
+        'nren-asn-map': {
+            'type': 'array',
+            'items': {
+                'type': 'object',
+                'properties': {
+                  'nren': {'type': 'string'},
+                  'asn': {'type': 'integer'}
+                },
+                'required': ['nren', 'asn'],
+                'additionalProperties': False
+            },
+        }
+    },
+
+    'type': 'object',
+    'properties': {
+        'ops-db': {'$ref': '#/definitions/database-credentials'},
+        'ssh': {'$ref': '#/definitions/ssh-credentials'},
+        'redis': {'$ref': '#/definitions/redis-credentials'},
+        'sentinel': {'$ref': '#/definitions/redis-sentinel-config'},
+        'ims': {'$ref': '#/definitions/ims'},
+        'otrs-export': {'$ref': '#/definitions/otrs-export'},
+        'redis-databases': {
+            'type': 'array',
+            'minItems': 1,
+            'items': {'type': 'integer'}
+        },
+        'managed-routers': {'type': 'string'},
+        'lab-routers': {
+            'type': 'array',
+            'items': {'type': 'string'}
+        },
+        'unmanaged-interfaces': {
+            'type': 'array',
+            'items': {'$ref': '#/definitions/interface-address'}
+        },
+        'gws-direct': {'$ref': '#/definitions/gws-direct'},
+        'nren-asn-map': {'$ref': '#/definitions/nren-asn-map'}
+
+    },
+    'oneOf': [
+        {
+            'required': [
+                'ssh',
+                'redis',
+                'redis-databases',
+                'ims',
+                'managed-routers',
+                'gws-direct',
+                'nren-asn-map']
+        },
+        {
+            'required': [
+                'ssh',
+                'sentinel',
+                'redis-databases',
+                'ims',
+                'managed-routers',
+                'gws-direct',
+                'nren-asn-map']
+        }
+    ],
+    'additionalProperties': False
+}
+
+
+def load(f):
+    """
+    Loads, validates and returns configuration parameters.
+
+    Input is validated against this jsonschema:
+
+    .. asjson:: inventory_provider.config.CONFIG_SCHEMA
+
+    :param f: file-like object that produces the config file
+    :return: a dict containing the parsed configuration parameters
+    """
+    config = json.loads(f.read())
+    jsonschema.validate(config, CONFIG_SCHEMA)
+    return config
diff --git a/inventory_provider/environment.py b/inventory_provider/environment.py
new file mode 100644
index 0000000000000000000000000000000000000000..989c0a1355ebb7b0e44f110842b25f045868a47d
--- /dev/null
+++ b/inventory_provider/environment.py
@@ -0,0 +1,24 @@
+import json
+import logging.config
+import os
+
+
+def setup_logging():
+    """
+    set up logging using the configured filename
+
+    if LOGGING_CONFIG is defined in the environment, use this for
+    the filename, otherwise use logging_default_config.json
+    """
+    default_filename = os.path.join(
+        os.path.dirname(__file__),
+        'logging_default_config.json')
+    filename = os.getenv('LOGGING_CONFIG', default_filename)
+    with open(filename) as f:
+        # TODO: this mac workaround should be removed ...
+        d = json.loads(f.read())
+        import platform
+        if platform.system() == 'Darwin':
+            d['handlers']['syslog_handler']['address'] = '/var/run/syslog'
+        logging.config.dictConfig(d)
+        # logging.config.dictConfig(json.loads(f.read()))
diff --git a/inventory_provider/ims.py b/inventory_provider/ims.py
new file mode 100644
index 0000000000000000000000000000000000000000..d7b4eebd7297576c0936b5b2461bfca3117702f8
--- /dev/null
+++ b/inventory_provider/ims.py
@@ -0,0 +1,388 @@
+import logging
+
+import requests
+import time
+
+from enum import Enum
+
+
+# Navigation Properties
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/86d07a57-fa45-835e-d4a2-a789c4acbc96.htm  # noqa
+from requests import HTTPError
+
+logger = logging.getLogger(__name__)
+
+# http://149.210.162.190:81/ImsVersions/21.9/html/50e6a1b1-3910-2091-63d5-e13777b2194e.htm  # noqa
+CIRCUIT_CUSTOMER_RELATION = {
+    "Circuit": 2,
+    "Customer": 4
+}
+# http://149.210.162.190:81/ImsVersions/20.1/html/86d07a57-fa45-835e-d4a2-a789c4acbc96.htm  # noqa
+CIRCUIT_PROPERTIES = {
+    'Site': 8,
+    'Speed': 16,
+    'Customer': 32,
+    'Product': 128,
+    'CalculatedNode': 256,
+    'Ports': 512,
+    'InternalPorts': 1024,
+    'CarrierCircuits': 65536,
+    'SubCircuits': 131072,
+    'PortsFullDetails': 262144,
+    'InternalPortsFullDetails': 524288,
+    'PortA': 34359738368,
+    'PortB': 68719476736
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/347cb410-8c05-47bd-ceb0-d1dd05bf98a4.htm  # noqa
+CITY_PROPERTIES = {
+    'Country': 8
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/dbc969d0-e735-132e-6281-f724c6d7da64.htm  # noqa
+CONTACT_PROPERTIES = {
+    'SiteRelatedContacts': 8,
+    'CustomerRelatedContacts': 16,
+    'GroupRelatedContacts': 32,
+    'VendorRelatedContacts': 64
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/5a40472e-48ee-c120-0a36-52a85d52127c.htm  # noqa
+CUSTOMER_PROPERTIES = {
+    'CustomerRelatedContacts': 32768,
+    'CustomerType': 262144
+}
+# http://149.210.162.190:81/ImsVersions/20.1/html/4f3e1d5e-53c3-1beb-cb16-65b5308dcb81.htm
+CUSTOMER_RELATED_CONTACT_PROPERTIES = {
+    'Customer': 8,
+    'Contact': 16
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/a8dc6266-d934-8162-4a55-9e1648187f2c.htm  # noqa
+EQUIP_DEF_PROPERTIES = {
+    'Nodes': 4096
+}
+# http://149.210.162.190:81/ImsVersions/20.1/html/6fd3a968-26e2-e40f-e3cd-c99afa34c3e6.htm
+EXTRA_FIELD_VALUE_PROPERTIES = {
+    'ExtraFieldValueObjectInfo': 64
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/f18222e3-e353-0abe-b89c-820db87940ac.htm  # noqa
+INTERNAL_PORT_PROPERTIES = {
+    'Node': 8,
+    'Shelf': 64,
+    'Circuit': 256,
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/2d27d509-77cb-537d-3ffa-796de7e82af8.htm  # noqa
+NODE_PROPERTIES = {
+    'EquipmentDefinition': 16,
+    'ManagementSystem': 128,
+    'Site': 256,
+    'Shelves': 1024,
+    'Ports': 4096,
+    'InternalPorts': 8192,
+    'NodeCity': 32768,
+    'Order': 67108864,
+    'NodeCountry': 1073741824
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/199f32b5-5104-fec7-8787-0d730113e902.htm  # noqa
+PORT_PROPERTIES = {
+    'Node': 16,
+    'Shelf': 32,
+    'Circuit': 512,
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/9c8d50f0-842c-5959-0fa6-14e1720669ec.htm  # noqa
+SITE_PROPERTIES = {
+    'City': 2,
+    'SiteAliases': 64,
+    'Country': 256,
+    'Nodes': 32768
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/8ce06cb7-7707-46c4-f02f-86083310d81b.htm  # noqa
+VENDOR_PROPERTIES = {
+    'VendorRelatedContacts': 64,
+    'VendorType': 1024
+}
+# http://149.210.162.190:81/ImsVersions/4.19.9/html/62e7aa63-aff0-6992-7697-377ace239c4f.htm  # noqa
+VENDOR_RELATED_CONTACT_PROPERTIES = {
+    'Vendor': 8,
+    'Contact': 16
+}
+
+VM_PORT_RELATE_PROPERTIES = {
+    'Circuit': 32
+}
+
+VM_INTERNAL_PORT_RELATE_PROPERTIES = {
+    'Circuit': 32
+}
+
+NO_FILTERED_RESULTS_MESSAGE = 'no records found for entity:'
+
+# this will be obsolete as soon as Inventory Provider update is done, but is
+# here for between the time of the roll out and the Inventory Update
+IMS_SERVICE_NAMES = {
+    'EUMETSAT GRE',
+    'EUMETSAT INTERNATIONAL',
+    'EUMETSAT TERRESTRIAL',
+    'EXPRESS ROUTE',
+    'GEANT - GBS',
+    'GEANT CLOUD PEERING',
+    'GEANT IP',
+    'GEANT LAMBDA',
+    'GEANT OPEN CROSS CONNECT',
+    'GEANT OPEN PORT',
+    'GEANT PEERING',
+    'GEANT PLUS',
+    'GTS',
+    'GWS - BROKERED',
+    'GWS - DIRECT',
+    'GWS - INDIRECT',
+    'GWS - UPSTREAM',
+    'IP PEERING - NON R&E (PRIVATE)',
+    'IP PEERING - NON R&E (PUBLIC)',
+    'IP PEERING - R&E',
+    'IP TRUNK',
+    'L2SERVICES',
+    'L3-VPN',
+    'MD-VPN (INTERNAL)',
+    'MD-VPN (NATIVE)',
+    'MD-VPN (PROXY)',
+    'POP LAN LINK',
+    'SERVER LINK'
+}
+
+
+class CustomerType(Enum):
+    COMMERCIAL_PEER = 1
+    CONNECTIVITY_SUPPLIER = 2
+    EU_NREN = 3
+    HOUSING_SUPPLIER = 4
+    IX_SUPPLIER = 5
+    OTHER = 6
+    R_AND_E_PEER = 7
+    UNKNOWN = 'UNKNOWN'
+
+
+class InventoryStatus(Enum):
+    PLANNED = 1
+    READY_FOR_SERVICE = 2
+    IN_SERVICE = 3
+    MIGRATION = 4
+    OUT_OF_SERVICE = 6
+    READY_FOR_CEASURE = 7
+
+
+class RelateType(Enum):
+    VENDOR = 1
+    CONTACT = 2
+    GROUP = 3
+    CONTRACT = 4
+    CUSTOMER = 5
+    SITE = 6
+
+
+class IMSError(Exception):
+    pass
+
+
+class IMS(object):
+
+    TIMEOUT_THRESHOLD = 1200
+    PERMITTED_RECONNECT_ATTEMPTS = 3
+    LOGIN_PATH = '/login'
+    IMS_PATH = '/ims'
+
+    cache = {}
+    base_url = None
+    bearer_token = None
+    bearer_token_init_time = 0
+    reconnect_attempts = 0
+
+    def __init__(self, base_url, username, password, bearer_token=None):
+        IMS.base_url = base_url
+        self.username = username
+        self.password = password
+        IMS.bearer_token = bearer_token
+
+    @classmethod
+    def _init_bearer_token(cls, username, password):
+        re_init_time = time.time()
+        if not cls.bearer_token or \
+                re_init_time - cls.bearer_token_init_time \
+                > cls.TIMEOUT_THRESHOLD:
+            cls.reconnect_attempts = 0
+        else:
+            cls.reconnect_attempts += 1
+            if cls.reconnect_attempts > cls.PERMITTED_RECONNECT_ATTEMPTS:
+                raise IMSError('Too many reconnection attempts made')
+
+        logger.debug(f'Logging in - Username: {username}'
+                     f' - URL: {cls.base_url + cls.LOGIN_PATH}')
+        response = requests.post(
+            cls.base_url + cls.LOGIN_PATH,
+            auth=(username, password))
+        response.raise_for_status()
+        cls.bearer_token_init_time = re_init_time
+        cls.bearer_token = response.text
+
+    def clear_dynamic_context_cache(self):
+        if not IMS.bearer_token:
+            IMS._init_bearer_token(self.username, self.password)
+        while True:
+            logger.info('Clearing Dynamic Context Cache')
+            response = requests.put(
+                f'{self.base_url + IMS.IMS_PATH}/ClearDynamicContextCache',
+                headers={'Authorization': f'Bearer {self.bearer_token}'})
+            if response.status_code == 401:
+                IMS._init_bearer_token(self.username, self.password)
+                continue
+            response.raise_for_status()
+            break
+
+    def _get_entity(
+            self,
+            url,
+            params=None,
+            navigation_properties=None,
+            use_cache=False):
+        url = f'{self.base_url + IMS.IMS_PATH}/{url}'
+        cache_key = url
+        if navigation_properties:
+            params = params if params else {}
+            params['navigationproperty'] = navigation_properties if isinstance(
+                navigation_properties, int) else sum(navigation_properties)
+
+        if use_cache:
+            if params:
+                s = '-'.join(
+                    [f'{t}::{params[t]}'
+                     for t in sorted(params)])
+                cache_key = f'{cache_key}::{s}'
+            entity = IMS.cache.get(cache_key, None)
+            if entity:
+                return entity
+
+        if not IMS.bearer_token:
+            IMS._init_bearer_token(self.username, self.password)
+
+        def _is_invalid_login_state(response_):
+            if response_.status_code == requests.codes.unauthorized:
+                return True
+
+            if response_.status_code in (requests.codes.ok,
+                                         requests.codes.not_found):
+
+                if NO_FILTERED_RESULTS_MESSAGE in response_.text.lower():
+                    return False
+                try:
+                    r = _convert_keys(response_.json())
+                except Exception as e:
+                    t = response_.text
+                    if len(t) > 100:
+                        message_text = f"{t[:50]} ... {t[-50:]}"
+                    else:
+                        message_text = t
+                    logger.debug(f"unexpected response: {message_text}\n{e}"
+                                 "\nre-raising")
+                    raise e
+                if r and 'haserrors' in r and r['haserrors']:
+                    for e in r['Errors']:
+                        if e['errormessage'] and \
+                                'guid expired' in e['errormessage'].lower():
+                            return True
+
+            return False
+
+        def _convert_keys(source):
+            if isinstance(source, list):
+                return [_convert_keys(x) for x in source]
+            elif isinstance(source, dict):
+                new = {}
+                for k, v in source.items():
+                    if isinstance(v, (dict, list)):
+                        v = _convert_keys(v)
+                    new[k.lower()] = v
+                return new
+            return source
+
+        while True:
+            response = requests.get(
+                url,
+                headers={'Authorization': f'Bearer {self.bearer_token}'},
+                params=params)
+            if _is_invalid_login_state(response):
+                IMS._init_bearer_token(self.username, self.password)
+            else:
+                response.raise_for_status()
+                orig = response.json()
+                return_value = _convert_keys(orig)
+
+                if use_cache:
+                    IMS.cache[cache_key] = return_value
+                return return_value
+
+    def get_entity_by_id(
+            self,
+            entity_type,
+            entity_id,
+            navigation_properties=None,
+            use_cache=False):
+
+        url = f'{entity_type}/{entity_id}'
+        return \
+            self._get_entity(url, None, navigation_properties, use_cache)
+
+    def get_entity_by_name(
+            self,
+            entity,
+            name,
+            navigation_properties=None,
+            use_cache=False):
+        url = f'{entity}/byname/"{name}"'
+        return self._get_entity(url, None, navigation_properties, use_cache)
+
+    def get_filtered_entities(
+            self,
+            entity,
+            filter_string,
+            navigation_properties=None,
+            use_cache=False,
+            step_count=50):
+        more_to_come = True
+        start_entity = 0
+        while more_to_come:
+            params = {
+                'paginatorStartElement': start_entity,
+                'paginatorNumberOfElements': step_count
+            }
+            url = f'{entity}/filtered/{filter_string}'
+            try:
+                more_to_come = False
+                entities = self._get_entity(
+                    url,
+                    params,
+                    navigation_properties,
+                    use_cache)
+            except HTTPError as e:
+                r = e.response
+                if r.status_code == requests.codes.not_found \
+                        and NO_FILTERED_RESULTS_MESSAGE in r.text.lower():
+                    entities = None
+                else:
+                    raise e
+            if entities:
+                more_to_come = \
+                    len(entities) >= step_count
+                start_entity += step_count
+                yield from entities
+
+    def get_all_entities(
+            self,
+            entity,
+            navigation_properties=None,
+            use_cache=False,
+            step_count=50
+    ):
+        yield from self.get_filtered_entities(
+            entity,
+            'Id <> 0',
+            navigation_properties,
+            use_cache,
+            step_count
+        )
diff --git a/inventory_provider/ims_data.py b/inventory_provider/ims_data.py
new file mode 100644
index 0000000000000000000000000000000000000000..28392fb60c27d80f2c7a2d142970d7b0ae492aa4
--- /dev/null
+++ b/inventory_provider/ims_data.py
@@ -0,0 +1,570 @@
+import logging
+import re
+from collections import defaultdict
+from copy import copy
+from itertools import chain
+
+from inventory_provider import environment
+from inventory_provider import ims
+from inventory_provider.ims import InventoryStatus, IMS, \
+    CUSTOMER_RELATED_CONTACT_PROPERTIES, EXTRA_FIELD_VALUE_PROPERTIES
+
+environment.setup_logging()
+logger = logging.getLogger(__name__)
+
+# Dashboard V3
+
+IMS_OPSDB_STATUS_MAP = {
+    InventoryStatus.PLANNED: 'planned',
+    InventoryStatus.READY_FOR_SERVICE: 'installed',
+    InventoryStatus.IN_SERVICE: 'operational',
+    InventoryStatus.MIGRATION: 'planned',
+    InventoryStatus.OUT_OF_SERVICE: 'terminated',
+    InventoryStatus.READY_FOR_CEASURE: 'terminated'
+}
+STATUSES_TO_IGNORE = \
+    [InventoryStatus.OUT_OF_SERVICE.value]
+
+_POP_LOCATION_SCHEMA_STRUCT = {
+    'type': 'object',
+    'properties': {
+        'name': {'type': 'string'},
+        'city': {'type': 'string'},
+        'country': {'type': 'string'},
+        'abbreviation': {'type': 'string'},
+        'longitude': {'type': 'number'},
+        'latitude': {'type': 'number'}
+    },
+    'required': [
+        'name',
+        'city',
+        'country',
+        'abbreviation',
+        'longitude',
+        'latitude'],
+    'additionalProperties': False
+}
+
+POP_LOCATION_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-07/schema#',
+    **_POP_LOCATION_SCHEMA_STRUCT
+}
+
+NODE_LOCATION_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-07/schema#',
+    'definitions': {
+        'pop-location': _POP_LOCATION_SCHEMA_STRUCT
+    },
+
+    'type': 'object',
+    'properties': {
+        'equipment-name': {'type': 'string'},
+        'status': {'type': 'string'},
+        'pop':  {'$ref': '#/definitions/pop-location'}
+    },
+    'required': ['equipment-name', 'status', 'pop'],
+    'additionalProperties': False
+}
+
+
+def get_flexils_by_circuitid(ds: IMS):
+    by_circuit = defaultdict(list)
+    found_keys = set()
+    for entity in ds.get_all_entities('FLEXILS_SCHF_SUBINTERFACES'):
+        k = f'{entity["nodename"]}:{entity["port_ref"]}'
+        if k in found_keys:
+            continue
+        found_keys.add(k)
+        by_circuit[entity['circuitid']].append({
+            'node_name': entity['nodename'],
+            'full_port_name': entity['port_ref'],
+            'key': k
+        })
+    return dict(by_circuit)
+
+
+def get_non_monitored_circuit_ids(ds: IMS):
+    # note the id for the relevant field is hard-coded. I didn't want to use
+    # the name of the field as this can be changed by users
+    for d in ds.get_filtered_entities(
+            'ExtraFieldValue',
+            'extrafield.id == 2898 | value == 0',
+            EXTRA_FIELD_VALUE_PROPERTIES['ExtraFieldValueObjectInfo']
+    ):
+        yield d['extrafieldvalueobjectinfo']['objectid']
+
+
+def get_monitored_circuit_ids(ds: IMS):
+    # note the id for the relevant field is hard-coded. I didn't want to use
+    # the name of the field as this can be changed by users
+    for d in ds.get_filtered_entities(
+            'ExtraFieldValue',
+            'extrafield.id == 2898 | value == 1',
+            EXTRA_FIELD_VALUE_PROPERTIES['ExtraFieldValueObjectInfo'],
+            step_count=10000
+    ):
+        yield d['extrafieldvalueobjectinfo']['objectid']
+
+
+def get_ids_and_sids(ds: IMS):
+    for sid_circuit in ds.get_filtered_entities(
+        'ExtraFieldValue',
+        'extrafieldid == 3209 | value <> ""',
+        step_count=10000
+    ):
+        yield sid_circuit['objectid'], sid_circuit['value']
+
+
+def get_service_types(ds: IMS):
+    for d in ds.get_filtered_entities(
+            'ComboBoxData',
+            'name == "PW_INFORM_PRODUCTCODE"'):
+        yield d['selection']
+
+
+def get_customer_tts_contacts(ds: IMS):
+
+    customer_contacts = defaultdict(set)
+
+    for x in ds.get_filtered_entities(
+        'customerrelatedcontact',
+        "contact.troubleticketMail != ''",
+        CUSTOMER_RELATED_CONTACT_PROPERTIES['Contact']
+    ):
+        customer_contacts[x['customerid']].add(
+            x['contact']['troubleticketmail'])
+    for k, v in customer_contacts.items():
+        yield k, sorted(list(v))
+
+
+def get_customer_planned_work_contacts(ds: IMS):
+
+    customer_contacts = defaultdict(set)
+
+    for x in ds.get_filtered_entities(
+        'customerrelatedcontact',
+        "contact.PlannedworkMail  != ''",
+        CUSTOMER_RELATED_CONTACT_PROPERTIES['Contact']
+    ):
+        customer_contacts[x['customerid']].add(
+            x['contact']['plannedworkmail'])
+    for k, v in customer_contacts.items():
+        yield k, sorted(list(v))
+
+
+def get_circuit_related_customers(ds: IMS):
+
+    return_value = defaultdict(list)
+    for ccr in ds.get_filtered_entities(
+        'CircuitCustomerRelation',
+        'circuit.inventoryStatusId== 3',
+            ims.CIRCUIT_CUSTOMER_RELATION['Customer']):
+        return_value[ccr['circuitid']].append(
+            {
+                'id': ccr['customer']['id'],
+                'name': ccr['customer']['name'],
+                'type': ccr['customer']['customertypeid']
+            }
+        )
+    return return_value
+
+
+def get_port_id_services(ds: IMS):
+    circuit_nav_props = [
+        ims.CIRCUIT_PROPERTIES['Ports'],
+        ims.CIRCUIT_PROPERTIES['InternalPorts'],
+    ]
+    ims_service_names = list(get_service_types(ds))
+    customers = {c['id']: c['name'] for c in ds.get_all_entities('customer')}
+    products = {p['id']: p['name'] for p in ds.get_all_entities('product')}
+
+    def _get_circuit_type(c):
+        if products[c['productid']] in ims_service_names:
+            return 'service'
+        else:
+            return 'circuit'
+
+    def _get_circuits():
+
+        _ignore_status_str = ' | '.join([
+            f'inventoryStatusId != {s}' for s in STATUSES_TO_IGNORE
+        ])
+        for c in ds.get_filtered_entities(
+                'Circuit',
+                _ignore_status_str,
+                circuit_nav_props,
+                step_count=2000):
+            c['circuit_type'] = _get_circuit_type(c)
+            yield c
+
+    circuits = _get_circuits()
+
+    # order of preference
+    # internalports (first and last sequencenumbers)
+    # ports (first and last sequencenumbers)
+    # internal port a / internal port b
+    # port a / port b
+
+    # if there are more than two ports we'll yield a circuit of the ends of the
+    # sequence and the reverse; and then single ended circuits for all ports
+    # between
+    # e.g. four ports are reported [a,b,c,d] the 4 circs would have endpoints
+    # a and d
+    # d and a
+    # b only
+    # c only
+    def _populate_end_info(_circuit, _port_ids):
+        port_ids = [p for p in _port_ids if p]
+        if not port_ids:
+            return []
+        port_a_id = port_ids[0]
+        port_b_id = port_ids[-1]
+        _circuit['port_a_id'] = port_a_id
+        if port_a_id != port_b_id:
+            _circuit['port_b_id'] = port_b_id
+            yield copy(_circuit)
+            _circuit['port_a_id'], _circuit['port_b_id'] = \
+                _circuit['port_b_id'], _circuit['port_a_id']
+        yield copy(_circuit)
+        if len(port_ids) > 2:
+            _circuit.pop('port_b_id', None)
+            for p in port_ids[1:-1]:
+                _circuit['port_a_id'] = p
+                yield copy(_circuit)
+
+    for circuit in circuits:
+        cd = {
+            'id': circuit['id'],
+            'name': circuit['name'],
+            'status': IMS_OPSDB_STATUS_MAP.get(
+                InventoryStatus(circuit['inventorystatusid']), 'unknown'),
+            'circuit_type': circuit['circuit_type'],
+            'service_type': products[circuit['productid']],
+            'project': customers[circuit['customerid']],
+            'customer': customers[circuit['customerid']],
+            'customerid': circuit['customerid']
+        }
+        ports = []
+        cd['port_type'] = 'unknowm'
+        if circuit['internalports']:
+            ports = sorted(
+                circuit['internalports'], key=lambda x: x['sequencenumber'])
+            ports = [p['id'] for p in ports]
+            cd['port_type'] = 'internal'
+        elif circuit['ports']:
+            ports = sorted(
+                circuit['ports'], key=lambda x: x['sequencenumber'])
+            ports = [p['id'] for p in ports]
+            cd['port_type'] = 'ports'
+        elif circuit['portaid'] or circuit['portbid']:
+            ports = [circuit['portaid'], circuit['portbid']]
+            cd['port_type'] = 'ab'
+        yield from _populate_end_info(cd, ports)
+
+    ignore_status_str = ''.join([
+        f'circuit.inventoryStatusId != {s} | ' for s in STATUSES_TO_IGNORE
+    ])
+    for portrelate in chain(
+            ds.get_filtered_entities(
+                'vmportrelate',
+                ignore_status_str +
+                'circuitId != 0',
+                ims.VM_PORT_RELATE_PROPERTIES['Circuit'],
+                step_count=2000
+            ),
+            ds.get_filtered_entities(
+                'vminternalportrelate',
+                ignore_status_str +
+                'circuitId != 0',
+                ims.VM_INTERNAL_PORT_RELATE_PROPERTIES['Circuit'],
+                step_count=2000
+            )
+    ):
+        circuit = portrelate['circuit']
+        if circuit:
+            yield {
+                'id': circuit['id'],
+                'name': circuit['name'],
+                'status': IMS_OPSDB_STATUS_MAP.get(
+                    InventoryStatus(circuit['inventorystatusid']),
+                    'unknown'),
+                'circuit_type': _get_circuit_type(circuit),
+                'service_type': products[circuit['productid']],
+                'project': customers[circuit['customerid']],
+                'customer': customers[circuit['customerid']],
+                'customerid': circuit['customerid'],
+                'port_a_id': portrelate.get(
+                    'portid',
+                    portrelate.get('internalportid', '')),
+                'port_type': 'port relate'
+            }
+
+
+def get_port_sids(ds: IMS):
+    """
+    This function fetches SIDs for external ports that have them defined,
+
+    :param ds: IMS datasource object
+    :returns: Dict mapping external port IDs to the SID assigned to the port
+    """
+    return {
+        p['objectid']: p['value'] for p in ds.get_filtered_entities(
+            'ExtraFieldValue', 'extrafieldid == 3249 | value <> ""',
+            step_count=10000)}
+
+
+def get_internal_port_sids(ds: IMS):
+    """
+    This function fetches SIDs for external ports that have them defined,
+
+    :param ds: IMS datasource object
+    :returns: Dict mapping internal port IDs to the SID assigned to the port
+    """
+    return {
+        p['objectid']: p['value'] for p in ds.get_filtered_entities(
+            'ExtraFieldValue', 'extrafieldid == 3250 | value <> ""',
+            step_count=10000)}
+
+
+def get_port_details(ds: IMS):
+    port_nav_props = [
+        ims.PORT_PROPERTIES['Node'],
+        ims.PORT_PROPERTIES['Shelf']
+    ]
+    internal_port_nav_props = {
+        ims.INTERNAL_PORT_PROPERTIES['Node'],
+        ims.PORT_PROPERTIES['Shelf']
+    }
+
+    # this is here instead of chaining to make debugging easier
+    def _process_ports(ports, p_type):
+        _port_sids = {}
+
+        if p_type == 'external':
+            _port_sids = get_port_sids(ds)
+        else:
+            _port_sids = get_internal_port_sids(ds)
+
+        for p in ports:
+            vendor = None
+            interface_name = None
+            try:
+                vendor = \
+                    p['node']['equipmentdefinition']['vendor']['name'].lower()
+            except (TypeError, KeyError):
+                pass
+            # if there become more exceptions we will need to abstract this
+            if vendor == 'infinera' and p.get('shelf', None):
+                try:
+                    interface_name = \
+                        f"{p['shelf']['sequencenumber']}-{p['name']}"
+                except KeyError:
+                    pass
+
+            if not interface_name:
+                interface_name = p['name']
+
+            data = {
+                'port_id': p['id'],
+                'equipment_name': p['node']['name'],
+                'interface_name': interface_name
+            }
+
+            if p['id'] in _port_sids:
+                data['sid'] = _port_sids[p['id']]
+
+            yield data
+
+    yield from _process_ports(ds.get_all_entities(
+        'port', port_nav_props, step_count=2000), 'external')
+    yield from _process_ports(ds.get_all_entities(
+        'internalport', internal_port_nav_props, step_count=2000), 'internal')
+
+
+def get_circuit_hierarchy(ds: IMS):
+    circuit_nav_props = [
+        ims.CIRCUIT_PROPERTIES['Customer'],
+        ims.CIRCUIT_PROPERTIES['Product'],
+        ims.CIRCUIT_PROPERTIES['Speed'],
+        ims.CIRCUIT_PROPERTIES['SubCircuits'],
+        ims.CIRCUIT_PROPERTIES['CarrierCircuits']
+    ]
+
+    ignore_status_str = ' | '.join([
+        f'inventoryStatusId != {s}' for s in STATUSES_TO_IGNORE
+    ])
+    circuits = ds.get_filtered_entities(
+        'Circuit',
+        ignore_status_str,
+        circuit_nav_props,
+        step_count=1000)
+    # circuits = ds.get_all_entities(
+    #     'Circuit', circuit_nav_props, step_count=1000)
+    service_types = list(get_service_types(ds))
+    for circuit in circuits:
+        if circuit['product']['name'] in service_types:
+            circuit_type = 'service'
+        else:
+            circuit_type = 'circuit'
+
+        sub_circuits = [c['subcircuitid'] for c in circuit['subcircuits']]
+        carrier_circuits = \
+            [c['carriercircuitid'] for c in circuit['carriercircuits']]
+        yield {
+            'id': circuit['id'],
+            'name': circuit['name'],
+            'status': IMS_OPSDB_STATUS_MAP.get(
+                InventoryStatus(circuit['inventorystatusid']), 'unknown'),
+            'product': circuit['product']['name'],
+            'speed': circuit['speed']['name'],
+            'project': circuit['customer']['name'],
+            'circuit-type': circuit_type,
+            'sub-circuits': sub_circuits,
+            'carrier-circuits': carrier_circuits,
+            'customerid': circuit['customerid']
+        }
+
+
+def get_node_locations(ds: IMS):
+    """
+    return location info for all Site nodes
+
+    yields dictionaries formatted as:
+
+    .. as_json::
+        inventory_provider.db.ims_data.NODE_LOCATION_SCHEMA
+
+    :param ds:
+    :return: yields dicts as above
+    """
+    site_nav_props = [
+        ims.SITE_PROPERTIES['City'],
+        ims.SITE_PROPERTIES['SiteAliases'],
+        ims.SITE_PROPERTIES['Country'],
+        ims.SITE_PROPERTIES['Nodes']
+    ]
+    sites = ds.get_all_entities('Site', site_nav_props, step_count=500)
+    for site in sites:
+        city = site['city']
+        abbreviation = ''
+        try:
+            abbreviation = site['sitealiases'][0]['aliasname']
+        except IndexError:
+            pass  # no alias - ignore silently
+
+        for node in site['nodes']:
+            if node['inventorystatusid'] in STATUSES_TO_IGNORE:
+                continue
+
+            yield (node['name'], {
+                'equipment-name': node['name'],
+                'status': IMS_OPSDB_STATUS_MAP.get(
+                    InventoryStatus(node['inventorystatusid']), 'unknown'),
+                'pop': {
+                    'name': site['name'],
+                    'city': city['name'],
+                    'country': city['country']['name'],
+                    'abbreviation': abbreviation,
+                    'longitude': site['longitude'],
+                    'latitude': site['latitude'],
+                }
+            })
+
+    yield ('UNKNOWN_LOC', {
+        'equipment-name': 'UNKNOWN',
+        'status': 'unknown',
+        'pop': {
+            'name': 'UNKNOWN',
+            'city': 'UNKNOWN',
+            'country': 'UNKNOWN',
+            'abbreviation': 'UNKNOWN',
+            'longitude': 0,
+            'latitude': 0,
+        }
+    })
+
+
+# End of Dashboard V3 stuff
+
+INTERNAL_POP_NAMES = {
+    'Cambridge OC',
+    'DANTE Lab',
+    'GÉANT LAB',
+    'GEANT LAB',
+    'Amsterdam GEANT Office',
+    'Amsterdam GÉANT Office',
+    'CAMBRIDGE CITY HOUSE',
+    'AMSTERDAM GEANT OFFICE',
+}
+
+
+def lookup_lg_routers(ds: IMS):
+    pattern = re.compile("vpn-proxy|vrr|taas", re.IGNORECASE)
+
+    def _matching_node(node_):
+        # [LG-46]
+        if InventoryStatus(node_['inventorystatusid']) \
+                != InventoryStatus.IN_SERVICE:
+            return False
+
+        if pattern.match(node_['name']):
+            return False
+
+        return True
+
+    site_nav_props = [
+        ims.SITE_PROPERTIES['SiteAliases'],
+        ims.SITE_PROPERTIES['City'],
+        ims.SITE_PROPERTIES['Country']
+    ]
+
+    eq_definitions = ds.get_filtered_entities(
+        'EquipmentDefinition',
+        'Name like MX',
+        ims.EQUIP_DEF_PROPERTIES['Nodes'])
+
+    for eq_def in eq_definitions:
+        nodes = eq_def['nodes']
+
+        for node in nodes:
+            if not _matching_node(node):
+                continue
+            # [LG - 46]
+            if node['inventorystatusid'] != InventoryStatus.IN_SERVICE.value:
+                continue
+
+            site = ds.get_entity_by_id('Site', node['siteid'], site_nav_props,
+                                       True)
+            city = site['city']
+
+            abbreviation = ''
+            try:
+                abbreviation = site['sitealiases'][0]['aliasname']
+            except IndexError:
+                pass  # no alias - ignore silently
+
+            eq = {
+                'equipment name': node['name'],
+                'type':
+                    'INTERNAL'
+                    if site['name'] in INTERNAL_POP_NAMES
+                    else 'CORE',
+                    'pop': {
+                        'name': site['name'],
+                        'city': city['name'],
+                        'country': city['country']['name'],
+                        'country code': city['country']['abbreviation'],
+                        'abbreviation': abbreviation,
+                        'longitude': site['longitude'],
+                        'latitude': site['latitude'],
+                }
+            }
+            yield eq
+
+
+def lookup_geant_nodes(ds: IMS):
+
+    return (n["name"]for n in ds.get_filtered_entities(
+        'Node',
+        'customer.Name == "GEANT"',
+        ims.EQUIP_DEF_PROPERTIES['Nodes']))
diff --git a/inventory_provider/logging_default_config.json b/inventory_provider/logging_default_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..592693384664dc5882f683caacd49977986dcfb8
--- /dev/null
+++ b/inventory_provider/logging_default_config.json
@@ -0,0 +1,62 @@
+{
+    "version": 1,
+    "disable_existing_loggers": false,
+    "formatters": {
+        "simple": {
+            "format": "%(asctime)s - %(name)s (%(lineno)d) - %(levelname)s - %(message)s"
+        }
+    },
+
+    "handlers": {
+        "console": {
+            "class": "logging.StreamHandler",
+            "level": "DEBUG",
+            "formatter": "simple",
+            "stream": "ext://sys.stdout"
+        },
+
+        "syslog_handler": {
+            "class": "logging.handlers.SysLogHandler",
+            "level": "DEBUG",
+            "address": "/dev/log",
+            "facility": "user",
+            "formatter": "simple"
+        },
+
+        "info_file_handler": {
+            "class": "logging.handlers.RotatingFileHandler",
+            "level": "INFO",
+            "formatter": "simple",
+            "filename": "info.log",
+            "maxBytes": 10485760,
+            "backupCount": 20,
+            "encoding": "utf8"
+        },
+
+        "error_file_handler": {
+            "class": "logging.handlers.RotatingFileHandler",
+            "level": "ERROR",
+            "formatter": "simple",
+            "filename": "errors.log",
+            "maxBytes": 10485760,
+            "backupCount": 20,
+            "encoding": "utf8"
+        }
+    },
+
+    "loggers": {
+        "inventory_provider": {
+            "level": "DEBUG",
+            "handlers": ["console", "syslog_handler"],
+            "propagate": false
+        },
+        "inventory_provider.tasks": {
+            "level": "DEBUG"
+        }
+    },
+
+    "root": {
+        "level": "DEBUG",
+        "handlers": ["console", "syslog_handler"]
+    }
+}