diff --git a/src/common/Checkers.py b/src/common/Checkers.py index 4ac047a1c6d9078b66d70e6a97755cd3ac6349cf..b9e0f3067357912ef8e7404768b234f3c58d40e5 100644 --- a/src/common/Checkers.py +++ b/src/common/Checkers.py @@ -1,4 +1,4 @@ -from typing import Any, Set, Union +from typing import Any, List, Set, Sized, Tuple, Union def chk_none(name : str, value : Any) -> Any: if value is not None: @@ -18,13 +18,60 @@ def chk_type(name : str, value : Any, type_or_types : Union[type, Set[type]] = s raise AttributeError(msg.format(str(name), str(value), type(value).__name__, str(type_or_types))) return value -def chk_string(name, value, allow_empty=False) -> str: - chk_not_none(name, value) +def chk_length( + name : str, value : Sized, allow_empty : bool = False, + min_length : Union[int, None] = None, max_length : Union[int, None] = None, + allowed_lengths : Union[None, int, Set[int], List[int], Tuple[int]] = None) -> Any: + + length = len(chk_type(name, value, Sized)) + + allow_empty = chk_type('allow_empty for {}'.format(name), allow_empty, bool) + if not allow_empty and length == 0: + msg = '{}({}) is out of range: allow_empty({}) min_length({}) max_length({}) allowed_lengths({}).' + raise AttributeError(msg.format( + str(name), str(value), str(allow_empty), str(min_length), str(max_length), str(allowed_lengths))) + + + if min_length is not None: + min_length = chk_type('min_length for {}'.format(name), min_length, int) + if length < min_length: + msg = '{}({}) is out of range: allow_empty({}) min_length({}) max_length({}) allowed_lengths({}).' + raise AttributeError(msg.format( + str(name), str(value), str(allow_empty), str(min_length), str(max_length), str(allowed_lengths))) + + if max_length is not None: + max_length = chk_type('max_length for {}'.format(name), max_length, int) + if length > max_length: + msg = '{}({}) is out of range: allow_empty({}) min_value({}) max_value({}) allowed_lengths({}).' + raise AttributeError(msg.format( + str(name), str(value), str(allow_empty), str(max_length), str(max_length), str(allowed_lengths))) + + if allowed_lengths is not None: + chk_type('allowed_lengths for {}'.format(name), allowed_lengths, (int, set, list, tuple)) + if isinstance(allowed_lengths, int): + fixed_length = allowed_lengths + if length != fixed_length: + msg = '{}({}) is out of range: allow_empty({}) min_value({}) max_value({}) allowed_lengths({}).' + raise AttributeError(msg.format( + str(name), str(value), str(allow_empty), str(max_length), str(max_length), str(allowed_lengths))) + else: + for i in allowed_lengths: chk_type('allowed_lengths[#{}] for {}'.format(i, name), i, int) + if length not in allowed_lengths: + msg = '{}({}) is out of range: allow_empty({}) min_value({}) max_value({}) allowed_lengths({}).' + raise AttributeError(msg.format( + str(name), str(value), str(allow_empty), str(max_length), str(max_length), str(allowed_lengths))) + + return value + +def chk_string( + name : str, value : Any, allow_empty : bool = False, + min_length : Union[int, None] = None, max_length : Union[int, None] = None, + allowed_lengths : Union[None, int, Set[int], List[int], Tuple[int]] = None) -> str: + chk_type(name, value, str) - if allow_empty: return value - if len(value) == 0: - msg = '{}({}) string is empty.' - raise AttributeError(msg.format(str(name), str(value))) + chk_length( + name, value, allow_empty=allow_empty, min_length=min_length, max_length=max_length, + allowed_lengths=allowed_lengths) return value def chk_float(name, value, type_or_types=(int, float), min_value=None, max_value=None) -> float: diff --git a/src/device/requirements.in b/src/device/requirements.in index 25abdad1b5767117956a88b816399635348884c7..bb900440cb67dfa7ce158cee1002d64c63b50f43 100644 --- a/src/device/requirements.in +++ b/src/device/requirements.in @@ -1,6 +1,7 @@ +anytree +fastcache grpcio-health-checking grpcio prometheus-client pytest pytest-benchmark -redis diff --git a/src/device/service/driver_api/_Driver.py b/src/device/service/driver_api/_Driver.py index 129628a5d44aff7b1b51fcb39bb52eb81d92cb81..331613928bf7a7ea207c29a6da75465afa69cc81 100644 --- a/src/device/service/driver_api/_Driver.py +++ b/src/device/service/driver_api/_Driver.py @@ -29,13 +29,13 @@ class _Driver: """ raise NotImplementedError() - def GetConfig(self, resource : List[str]) -> List[Union[str, None]]: + def GetConfig(self, resources : List[str]) -> List[Union[Any, None]]: """ Retrieve running configuration of entire device, or selected resources. Parameters: resources : List[str] List of keys pointing to the resources to be retrieved. Returns: - values : List[Union[str, None]] + values : List[Union[Any, None]] List of values for resource keys requested. Values should be in the same order than resource keys requested. If a resource is not found, None should be specified in the List for that resource. """ @@ -54,7 +54,7 @@ class _Driver: """ raise NotImplementedError() - def DeleteConfig(self, resource : List[str]) -> List[bool]: + def DeleteConfig(self, resources : List[str]) -> List[bool]: """ Delete configuration for a list of resources. Parameters: resources : List[str] diff --git a/src/device/service/drivers/emulated/DiscreteEventSimulator.py b/src/device/service/drivers/emulated/DiscreteEventSimulator.py new file mode 100644 index 0000000000000000000000000000000000000000..283166eb25b0d3b7f1d2deb33e816b91802cc5e7 --- /dev/null +++ b/src/device/service/drivers/emulated/DiscreteEventSimulator.py @@ -0,0 +1,72 @@ +import asyncio, logging +from typing import Dict + +LOGGER = logging.getLogger(__name__) + +class EventSequence: + # all values expressed as float in seconds + + def __init__(self, name, event_rate, start_delay=0, duration=0, num_repeats=0) -> None: + self.name = name # name of this event sequence + self.start_delay = start_delay # seconds to wait before first event; 0 means trigger first event immediately + self.duration = duration # seconds the event sequence should elapse, includes wait times, 0 means infinity + self.num_repeats = num_repeats # number of times the event should be triggered, 0 means infinity + self.event_rate = event_rate # wait time between events, must be positive float + + # Internal variables: + self.__next_delay = self.start_delay # delay until next event + self.__termination_time = None # termination time, if duration > 0, else None and runs infinity + self.__terminate = asyncio.Event() # do not execute more iterations when terminate is set + + def schedule(self): + loop = asyncio.get_event_loop() + current_time = loop.time() + + if (self.num_repeats == 0) and (self.duration > 0): + self.__termination_time = current_time + self.duration + + if self.__termination_time and (current_time > self.__termination_time): return + + LOGGER.info('Scheduling {} for #{} time to run after {} seconds...'.format( + self.name, self.num_repeats, self.__next_delay)) + loop.call_later(self.__next_delay, self.run) + self.__next_delay = self.event_rate + + def terminate(self): self.__terminate.set() + def terminate_is_set(self): return self.__terminate.is_set + + def run(self): + if self.terminate_is_set(): return + LOGGER.info('Running {} for #{} time...'.format(self.name, self.num_repeats)) + self.schedule() + self.num_repeats += 1 + +class DiscreteEventSimulator: + def __init__(self) -> None: + self.__eventsequences : Dict[str, EventSequence] = {} + + def add_event_sequence(self, event_sequence : EventSequence): + if event_sequence.name in self.__eventsequences: return + self.__eventsequences[event_sequence.name] = event_sequence + event_sequence.schedule() + return event_sequence + + def remove_event_sequence(self, event_sequence : EventSequence): + if event_sequence.name not in self.__eventsequences: return + event_sequence = self.__eventsequences.pop(event_sequence.name) + event_sequence.terminate() + +async def terminate(des : DiscreteEventSimulator, es: EventSequence): + des.remove_event_sequence(es) + +async def async_main(): + des = DiscreteEventSimulator() + es1 = des.add_event_sequence(EventSequence('ES1-10s', 1.0, start_delay=3.0, duration=10.0)) + es2 = des.add_event_sequence(EventSequence('ES2-inf', 1.5, start_delay=3.0)) + es3 = des.add_event_sequence(EventSequence('ES3-10r', 2.0, num_repeats=5)) + loop = asyncio.get_event_loop() + loop.call_later(60.0, terminate, des, es3) + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(async_main()) diff --git a/src/device/service/drivers/emulated/EmulatedDriver.py b/src/device/service/drivers/emulated/EmulatedDriver.py new file mode 100644 index 0000000000000000000000000000000000000000..b1d36e85974b0e76723cae5e5bce14fa05c7508a --- /dev/null +++ b/src/device/service/drivers/emulated/EmulatedDriver.py @@ -0,0 +1,118 @@ +import anytree, logging, threading +from typing import Any, Iterator, List, Tuple, Union +from common.Checkers import chk_float, chk_length, chk_string, chk_type +from device.service.driver_api._Driver import _Driver +from device.service.drivers.emulated.tools.AnyTreeTools import TreeNode, dump_subtree, get_subnode, set_subnode_value + +LOGGER = logging.getLogger(__name__) + +class EmulatedDriver(_Driver): + def __init__(self, address : str, port : int, **kwargs) -> None: + self.__lock = threading.Lock() + self.__root = TreeNode('.') + self.__subscriptions = {} # resource_key => (duration, sampling_rate, start_timestamp, end_timestamp) + + def Connect(self) -> bool: + return True + + def Disconnect(self) -> bool: + return True + + def GetConfig(self, resource_keys : List[str] = []) -> List[Union[Any, None, Exception]]: + chk_type('resources', resource_keys, list) + with self.__lock: + if len(resource_keys) == 0: return dump_subtree(self.__root) + results = [] + resolver = anytree.Resolver(pathattr='name') + for i,resource_key in enumerate(resource_keys): + str_resource_name = 'resource_key[#{:d}]'.format(i) + try: + chk_string(str_resource_name, resource_key, allow_empty=False) + resource_path = resource_key.split('/') + except Exception as e: + LOGGER.exception('Exception validating {}: {}'.format(str_resource_name, str(resource_key))) + results.append(e) # if validation fails, store the exception + continue + resource_node = get_subnode(resolver, self.__root, resource_path, default=None) + # if not found, resource_node is None + results.append(None if resource_node is None else dump_subtree(resource_node)) + return results + + def SetConfig(self, resources : List[Tuple[str, Any]]) -> List[Union[bool, Exception]]: + chk_type('resources', resources, list) + if len(resources) == 0: return [] + results = [] + resolver = anytree.Resolver(pathattr='name') + with self.__lock: + for i,resource in enumerate(resources): + str_resource_name = 'resources[#{:d}]'.format(i) + try: + chk_type(str_resource_name, resource, (list, tuple)) + chk_length(str_resource_name, resource, allowed_lengths=2) + resource_key,resource_value = resource + resource_path = resource_key.split('/') + except Exception as e: + LOGGER.exception('Exception validating {}: {}'.format(str_resource_name, str(resource_key))) + results.append(e) # if validation fails, store the exception + continue + set_subnode_value(resolver, self.__root, resource_path, resource_value) + results.append(True) + return results + + def DeleteConfig(self, resource_keys : List[str]) -> List[Union[bool, Exception]]: + chk_type('resources', resource_keys, list) + if len(resource_keys) == 0: return [] + results = [] + resolver = anytree.Resolver(pathattr='name') + with self.__lock: + for i,resource_key in enumerate(resource_keys): + str_resource_name = 'resource_key[#{:d}]'.format(i) + try: + chk_string(str_resource_name, resource_key, allow_empty=False) + resource_path = resource_key.split('/') + except Exception as e: + LOGGER.exception('Exception validating {}: {}'.format(str_resource_name, str(resource_key))) + results.append(e) # if validation fails, store the exception + continue + resource_node = get_subnode(resolver, self.__root, resource_path, default=None) + # if not found, resource_node is None + if resource_node is None: + results.append(False) + else: + parent = resource_node.parent + children = list(parent.children) + children.remove(resource_node) + parent.children = tuple(children) + results.append(True) + return results + + def SubscribeState(self, resources : List[Tuple[str, float, float]]) -> List[Union[bool, Exception]]: + chk_type('resources', resources, list) + if len(resources) == 0: return [] + results = [] + resolver = anytree.Resolver(pathattr='name') + with self.__lock: + for i,resource in enumerate(resources): + str_resource_name = 'resources[#{:d}]'.format(i) + try: + chk_type(str_resource_name, resource, (list, tuple)) + chk_length(str_resource_name, resource, allowed_lengths=3) + resource_key,sampling_duration,sampling_rate = resource + chk_string(str_resource_name + '.resource_key', resource_key, allow_empty=False) + resource_path = resource_key.split('/') + chk_float(str_resource_name + '.sampling_duration', sampling_duration, min_value=0) + chk_float(str_resource_name + '.sampling_rate', sampling_rate, min_value=0) + except Exception as e: + LOGGER.exception('Exception validating {}: {}'.format(str_resource_name, str(resource_key))) + results.append(e) # if validation fails, store the exception + continue + set_subnode_value(resolver, self.__root, resource_path, resource_value) + results.append(True) + return results + + + def UnsubscribeState(self, resources : List[str]) -> List[bool]: + raise NotImplementedError() + + def GetState(self) -> Iterator[Tuple[str, Any]]: + raise NotImplementedError() diff --git a/src/device/service/drivers/emulated/tools/AnyTreeTools.py b/src/device/service/drivers/emulated/tools/AnyTreeTools.py new file mode 100644 index 0000000000000000000000000000000000000000..6e39bbee46b8f409efee110d5b5f26a05d566996 --- /dev/null +++ b/src/device/service/drivers/emulated/tools/AnyTreeTools.py @@ -0,0 +1,57 @@ +import anytree + +class TreeNode(anytree.node.Node): + def __init__(self, name, parent=None, children=None, **kwargs) -> None: + super().__init__(name, parent=parent, children=children, **kwargs) + + def get_full_path(self): + return self.separator.join([''] + [str(node.name) for node in self.path]) + +class RawStyle(anytree.render.AbstractStyle): + def __init__(self): + """ + Raw style. + + >>> from anytree import Node, RenderTree + >>> root = Node("root") + >>> s0 = Node("sub0", parent=root) + >>> s0b = Node("sub0B", parent=s0) + >>> s0a = Node("sub0A", parent=s0) + >>> s1 = Node("sub1", parent=root) + >>> print(RenderTree(root, style=RawStyle())) + Node('/root') + Node('/root/sub0') + Node('/root/sub0/sub0B') + Node('/root/sub0/sub0A') + Node('/root/sub1') + """ + super(RawStyle, self).__init__(u'', u'', u'') + +def get_subnode(resolver : anytree.Resolver, root, path, default=None): + node = root + for path_item in path: + try: + node = resolver.get(node, path_item) + except anytree.ChildResolverError: + return default + return node + +def set_subnode_value(resolver : anytree.Resolver, root, path, value): + node = root + for path_item in path: + try: + node = resolver.get(node, path_item) + except anytree.ChildResolverError: + node = TreeNode(path_item, parent=node) + setattr(node, 'value', value) + +def dump_subtree(root : TreeNode): + if not isinstance(root, TreeNode): raise Exception('root must be a TreeNode') + results = [] + for row in anytree.RenderTree(root, style=RawStyle()): + path = row.node.get_full_path()[2:] # get full path except the heading root placeholder "/." + if len(path) == 0: continue + value = getattr(row.node, 'value', None) + if value is None: continue + results.append((path, value)) + return results diff --git a/src/device/service/drivers/emulated/tools/__init__.py b/src/device/service/drivers/emulated/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/device/tests/test_unitary_driverapi.py b/src/device/tests/test_unitary_driverapi.py new file mode 100644 index 0000000000000000000000000000000000000000..ee9c8272258b3a6f2a867b4d604f207d20aa6d78 --- /dev/null +++ b/src/device/tests/test_unitary_driverapi.py @@ -0,0 +1,75 @@ +import copy, logging, pytest +from device.service.drivers.emulated.EmulatedDriver import EmulatedDriver + +LOGGER = logging.getLogger(__name__) +LOGGER.setLevel(logging.DEBUG) + +PATH_IF = '/interfaces/interface[name="{}"]' +PATH_SUBIF = PATH_IF + '/subinterfaces/subinterface[index="{}"]' +PATH_ADDRIPV4 = PATH_SUBIF + '/ipv4/address[ip="{}"]' + +DEVICE_CONFIG_IF1 = [] +DEVICE_CONFIG_IF1.append((PATH_IF .format('IF1' ) + '/config/name', "IF1" )) +DEVICE_CONFIG_IF1.append((PATH_IF .format('IF1' ) + '/config/enabled', True )) +DEVICE_CONFIG_IF1.append((PATH_SUBIF .format('IF1', 0 ) + '/config/index', 0 )) +DEVICE_CONFIG_IF1.append((PATH_ADDRIPV4.format('IF1', 0, '10.1.0.1') + '/config/ip', "10.1.0.1")) +DEVICE_CONFIG_IF1.append((PATH_ADDRIPV4.format('IF1', 0, '10.1.0.1') + '/config/prefix_length', 24 )) + +DEVICE_CONFIG_IF2 = [] +DEVICE_CONFIG_IF2.append((PATH_IF .format('IF2' ) + '/config/name', "IF2" )) +DEVICE_CONFIG_IF2.append((PATH_IF .format('IF2' ) + '/config/enabled', True )) +DEVICE_CONFIG_IF2.append((PATH_SUBIF .format('IF2', 0 ) + '/config/index', 0 )) +DEVICE_CONFIG_IF2.append((PATH_ADDRIPV4.format('IF2', 0, '10.2.0.1') + '/config/ip', "10.2.0.1")) +DEVICE_CONFIG_IF2.append((PATH_ADDRIPV4.format('IF2', 0, '10.2.0.1') + '/config/prefix_length', 24 )) + +@pytest.fixture(scope='session') +def device_driverapi_emulated(): + _driver = EmulatedDriver('127.0.0.1', 0) + _driver.Connect() + yield _driver + _driver.Disconnect() + +def test_device_driverapi_emulated_setconfig(device_driverapi_emulated : EmulatedDriver): + # should work + results = device_driverapi_emulated.SetConfig(DEVICE_CONFIG_IF1) + LOGGER.info('results:\n{}'.format('\n'.join(map(str, results)))) + assert len(results) == len(DEVICE_CONFIG_IF1) + for result in results: assert isinstance(result, bool) and result + + results = device_driverapi_emulated.SetConfig(DEVICE_CONFIG_IF2) + LOGGER.info('results:\n{}'.format('\n'.join(map(str, results)))) + assert len(results) == len(DEVICE_CONFIG_IF2) + for result in results: assert isinstance(result, bool) and result + +def test_device_driverapi_emulated_getconfig(device_driverapi_emulated : EmulatedDriver): + stored_config = device_driverapi_emulated.GetConfig() + LOGGER.info('stored_config:\n{}'.format('\n'.join(map(str, stored_config)))) + assert len(stored_config) == len(DEVICE_CONFIG_IF1) + len(DEVICE_CONFIG_IF2) + for config_row in stored_config: assert (config_row in DEVICE_CONFIG_IF1) or (config_row in DEVICE_CONFIG_IF2) + for config_row in DEVICE_CONFIG_IF1: assert config_row in stored_config + for config_row in DEVICE_CONFIG_IF2: assert config_row in stored_config + + # should work + stored_config = device_driverapi_emulated.GetConfig([PATH_IF.format('IF2')]) + LOGGER.info('stored_config:\n{}'.format('\n'.join(map(str, stored_config)))) + assert len(stored_config) == 1 + stored_config = stored_config[0] + LOGGER.info('stored_config[0]:\n{}'.format('\n'.join(map(str, stored_config)))) + assert len(stored_config) == len(DEVICE_CONFIG_IF2) + for config_row in stored_config: assert config_row in DEVICE_CONFIG_IF2 + for config_row in DEVICE_CONFIG_IF2: assert config_row in stored_config + +def test_device_driverapi_emulated_deleteconfig(device_driverapi_emulated : EmulatedDriver): + # should work + results = device_driverapi_emulated.DeleteConfig([PATH_ADDRIPV4.format('IF2', 0, '10.2.0.1')]) + LOGGER.info('results:\n{}'.format('\n'.join(map(str, results)))) + assert (len(results) == 1) and isinstance(results[0], bool) and results[0] + + stored_config = device_driverapi_emulated.GetConfig() + LOGGER.info('stored_config:\n{}'.format('\n'.join(map(str, stored_config)))) + + device_config_if2 = list(filter(lambda row: '10.2.0.1' not in row[0], copy.deepcopy(DEVICE_CONFIG_IF2))) + assert len(stored_config) == len(DEVICE_CONFIG_IF1) + len(device_config_if2) + for config_row in stored_config: assert (config_row in DEVICE_CONFIG_IF1) or (config_row in device_config_if2) + for config_row in DEVICE_CONFIG_IF1: assert config_row in stored_config + for config_row in device_config_if2: assert config_row in stored_config diff --git a/src/device/tests/test_unitary.py b/src/device/tests/test_unitary_service.py similarity index 100% rename from src/device/tests/test_unitary.py rename to src/device/tests/test_unitary_service.py