diff --git a/connection_transition.png b/connection_transition.png index 219a547..8281c91 100644 Binary files a/connection_transition.png and b/connection_transition.png differ diff --git a/pyproject.toml b/pyproject.toml index 4b5acb3..6f54300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "networkx", "transitions", "fastapi", + "pytz", "importlib-resources; python_version < '3.9'", ] diff --git a/src/sdx_datamodel/connection_sm.py b/src/sdx_datamodel/connection_sm.py index 42168e0..86f91b0 100644 --- a/src/sdx_datamodel/connection_sm.py +++ b/src/sdx_datamodel/connection_sm.py @@ -52,6 +52,7 @@ class State(Enum): FAILED = auto() RECOVERING = auto() DELETED = auto() + MAINTENANCE = auto() def __str__(self): return self.name @@ -71,6 +72,8 @@ class Trigger(Enum): RECOVER_SUCCESS = auto() RECOVER_FAIL = auto() DELETE = auto() + MAINTENANCE_DOWN = auto() + MAINTENANCE_UP = auto() def __str__(self): return self.name @@ -141,6 +144,16 @@ def __str__(self): "source": str(State.FAILED), "dest": str(State.DELETED), }, + { + "trigger": str(Trigger.MAINTENANCE_DOWN), + "source": str(State.PROVISIONED), + "dest": str(State.MAINTENANCE), + }, + { + "trigger": str(Trigger.MAINTENANCE_UP), + "source": str(State.MAINTENANCE), + "dest": str(State.PROVISIONED), + }, ] def __init__(self): @@ -193,6 +206,10 @@ def set_state(self, state): self.state = state +class ControllerStateMachine(ConnectionStateMachine): + name = "SDX Controller State Machine" + + def draw_transition(model, output): machine = GraphMachine( model=model, diff --git a/src/sdx_datamodel/data/requests/test-l2vpn-p2p-v2.json b/src/sdx_datamodel/data/requests/test-l2vpn-p2p-v2.json index cb28fdf..22ff65e 100644 --- a/src/sdx_datamodel/data/requests/test-l2vpn-p2p-v2.json +++ b/src/sdx_datamodel/data/requests/test-l2vpn-p2p-v2.json @@ -13,8 +13,8 @@ ], "description": "a test circuit", "scheduling": { - "start_time": "2024-06-24T01:00:00.000Z", - "end_time": "2024-06-26T01:00:00.000Z" + "start_time": "2030-06-24T01:00:00.000", + "end_time": "2030-06-26T01:00:00.000" }, "qos_metrics": { "min_bw": { diff --git a/src/sdx_datamodel/data/requests/test-request-amlight_sax-p2p-v2.json b/src/sdx_datamodel/data/requests/test-request-amlight_sax-p2p-v2.json index 34e03cf..8940c2c 100644 --- a/src/sdx_datamodel/data/requests/test-request-amlight_sax-p2p-v2.json +++ b/src/sdx_datamodel/data/requests/test-request-amlight_sax-p2p-v2.json @@ -13,8 +13,8 @@ ], "description": "a test circuit", "scheduling": { - "start_time": "2024-06-24T01:00:00.000Z", - "end_time": "2024-06-26T01:00:00.000Z" + "start_time": "2030-06-24T01:00:00.000", + "end_time": "2030-06-26T01:00:00.000" }, "qos_metrics": { "min_bw": { diff --git a/src/sdx_datamodel/data/requests/test_request-amlight_zaoxi-p2p-v2.json b/src/sdx_datamodel/data/requests/test_request-amlight_zaoxi-p2p-v2.json index 0875c37..8503167 100644 --- a/src/sdx_datamodel/data/requests/test_request-amlight_zaoxi-p2p-v2.json +++ b/src/sdx_datamodel/data/requests/test_request-amlight_zaoxi-p2p-v2.json @@ -13,8 +13,8 @@ ], "description": "a test circuit", "scheduling": { - "start_time": "2024-06-24T01:00:00.000Z", - "end_time": "2024-06-26T01:00:00.000Z" + "start_time": "2030-06-24T01:00:00.000", + "end_time": "2030-06-26T01:00:00.000" }, "qos_metrics": { "min_bw": { diff --git a/src/sdx_datamodel/data/requests/test_request.json b/src/sdx_datamodel/data/requests/test_request.json index 14d1225..d4df0a5 100644 --- a/src/sdx_datamodel/data/requests/test_request.json +++ b/src/sdx_datamodel/data/requests/test_request.json @@ -1,8 +1,8 @@ { "id": "285eea4b-1e86-4d54-bd75-f14b8cb4a63a", "name": "Test connection request", - "start_time": "2000-01-23T04:56:07.000Z", - "end_time": "2000-01-23T04:56:07.000Z", + "start_time": "2030-01-23T04:56:07.000", + "end_time": "2030-01-23T04:56:07.000", "bandwidth_required": 10, "latency_required": 300, "egress_port": { diff --git a/src/sdx_datamodel/data/requests/test_request_no_node.json b/src/sdx_datamodel/data/requests/test_request_no_node.json index a6f1805..c05cc84 100644 --- a/src/sdx_datamodel/data/requests/test_request_no_node.json +++ b/src/sdx_datamodel/data/requests/test_request_no_node.json @@ -1,8 +1,8 @@ { "id": "id", "name": "AMLight", - "start_time": "2000-01-23T04:56:07.000Z", - "end_time": "2000-01-23T04:56:07.000Z", + "start_time": "2030-01-23T04:56:07.000", + "end_time": "2030-01-23T04:56:07.000", "bandwidth_required": 100, "latency_required": 20, "egress_port": { diff --git a/src/sdx_datamodel/data/requests/test_request_p2p.json b/src/sdx_datamodel/data/requests/test_request_p2p.json index f70d637..d5e1cb7 100644 --- a/src/sdx_datamodel/data/requests/test_request_p2p.json +++ b/src/sdx_datamodel/data/requests/test_request_p2p.json @@ -1,8 +1,8 @@ { "id": "id", "name": "AMLight", - "start_time": "2000-01-23T04:56:07.000Z", - "end_time": "2000-01-23T04:56:07.000Z", + "start_time": "2030-01-23T04:56:07.000", + "end_time": "2030-01-23T04:56:07.000", "bandwidth_required": 100, "latency_required": 20, "egress_port": { diff --git a/src/sdx_datamodel/models/connection.py b/src/sdx_datamodel/models/connection.py index 1a5a7ab..e213c19 100644 --- a/src/sdx_datamodel/models/connection.py +++ b/src/sdx_datamodel/models/connection.py @@ -39,6 +39,7 @@ def __init__( packetloss_measured=None, availability_required=None, availability_measured=None, + max_number_oxps=None, paths=None, status=None, complete=False, @@ -113,6 +114,7 @@ def __init__( "packetloss_measured": float, "availability_required": float, "availability_measured": float, + "max_number_oxps": int, "paths": List[str], "status": str, "complete": bool, @@ -139,6 +141,7 @@ def __init__( "packetloss_measured": "packetloss_measured", "availability_required": "availability_required", "availability_measured": "availability_measured", + "max_number_oxps": "max_number_oxps", "paths": "paths", "status": "status", "complete": "complete", @@ -163,6 +166,7 @@ def __init__( self._packetloss_measured = packetloss_measured self._availability_required = availability_required self._availability_measured = availability_measured + self._max_number_oxps = max_number_oxps self._paths = paths self._status = status self._complete = complete @@ -665,6 +669,27 @@ def availability_measured(self, availability_measured): self._availability_measured = availability_measured + @property + def max_number_oxps(self): + """Gets the max_number_oxps of this Connection. + + + :return: The max_number_oxps of this Connection. + :rtype: int + """ + return self._max_number_oxps + + @max_number_oxps.setter + def max_number_oxps(self, max_number_oxps): + """Sets the max_number_oxps of this Connection. + + + :param max_number_oxps: The max_number_oxps of this Connection. + :type max_number_oxps: int + """ + + self._max_number_oxps = max_number_oxps + @property def paths(self): """Gets the paths of this Connection. diff --git a/src/sdx_datamodel/parsing/connectionhandler.py b/src/sdx_datamodel/parsing/connectionhandler.py index 1c33dae..e2f8a1a 100644 --- a/src/sdx_datamodel/parsing/connectionhandler.py +++ b/src/sdx_datamodel/parsing/connectionhandler.py @@ -30,6 +30,7 @@ def import_connection_data(self, data: dict) -> Connection: name = data["name"] bandwidth_required = None latency_required = None + max_number_oxps = None if data.get("endpoints") is not None: # spec version 2.0.0 endpoints = data.get("endpoints") if len(endpoints) != 2: @@ -41,9 +42,13 @@ def import_connection_data(self, data: dict) -> Connection: bandwidth_required_obj = qos_metrics.get("min_bw") if bandwidth_required_obj is not None: bandwidth_required = bandwidth_required_obj.get("value") - latency_required_obj = qos_metrics.get("max_latency") + latency_required_obj = qos_metrics.get("max_delay") if latency_required_obj is not None: latency_required = latency_required_obj.get("value") + if qos_metrics.get("max_number_oxps") is not None: + max_number_oxps = qos_metrics.get("max_number_oxps").get( + "value" + ) scheduling = data.get("scheduling", {}) start_time = scheduling.get("start_time") @@ -70,6 +75,7 @@ def import_connection_data(self, data: dict) -> Connection: end_time=end_time, bandwidth_required=bandwidth_required, latency_required=latency_required, + max_number_oxps=max_number_oxps, ingress_port=ingress_port, egress_port=egress_port, ) diff --git a/src/sdx_datamodel/validation/connectionvalidator.py b/src/sdx_datamodel/validation/connectionvalidator.py index 243688d..351726a 100644 --- a/src/sdx_datamodel/validation/connectionvalidator.py +++ b/src/sdx_datamodel/validation/connectionvalidator.py @@ -3,8 +3,11 @@ """ import logging +from datetime import datetime from re import match +import pytz + from sdx_datamodel.models.connection import Connection from sdx_datamodel.models.port import Port @@ -55,13 +58,53 @@ def _validate_connection(self, conn: Connection): :return: A list of any issues in the data. """ + errors = [] errors += self._validate_object_defaults(conn) errors += self._validate_port(conn.ingress_port, conn) errors += self._validate_port(conn.egress_port, conn) - # errors += self._validate_time(conn.start_time, conn) - # errors += self._validate_time(conn.end_time, conn) + if conn.start_time or conn.end_time: + errors += self._validate_time(conn.start_time, conn.end_time, conn) + + if conn.latency_required: + errors += self._validate_qos_metrics_value( + "max_delay", conn.latency_required, 1000 + ) + + if conn.bandwidth_required: + errors += self._validate_qos_metrics_value( + "min_bw", conn.bandwidth_required, 100 + ) + + if conn.max_number_oxps: + errors += self._validate_qos_metrics_value( + "max_number_oxps", conn.bandwidth_required, 100 + ) + return errors + + def _validate_qos_metrics_value(self, metric, value, max_value): + """ + Validate that the QoS Metrics provided meets the XSD standards. + + A connection must have the following: + + - It must meet object default standards. + + - The max_delay must be a number + + - The max_number_oxps must be a number + + :param qos_metrics: The QoS Metrics being evaluated. + + :return: A list of any issues in the data. + """ + errors = [] + + if not isinstance(value, int): + errors.append(f"{value} {metric} must be a number") + if not (0 <= value <= max_value): + errors.append(f"{value} {metric} must be between 0 and 1000") return errors @@ -94,26 +137,47 @@ def _validate_port(self, port: Port, conn: Connection): """ return errors - def _validate_time(self, time: str, conn: Connection): + def _validate_time(self, start_time: str, end_time: str, conn: Connection): """ Validate that the time provided meets the XSD standards. - A port must have the following: - - - It must meet object default standards. - - - A link can only connect to 2 nodes - - - The nodes that a link is connected to must be in the - parent Topology's nodes list - - :param time: time being validated + :param start_time, end_time: time being validated :return: A list of any issues in the data. """ + utc = pytz.UTC errors = [] - if not match(ISO_TIME_FORMAT, time): - errors.append(f"{time} time needs to be in full ISO format") + # if not match(ISO_TIME_FORMAT, time): + # errors.append(f"{time} time needs to be in full ISO format") + if not start_time: + start_time = str(datetime.now()) + try: + start_time_obj = datetime.fromisoformat(start_time) + start_time = start_time_obj.replace(tzinfo=utc) + if start_time < datetime.now().replace(tzinfo=utc): + errors.append( + f"{start_time} start_time cannot be before the current time" + ) + except ValueError: + errors.append( + f"{start_time} start_time is not in a valid ISO format" + ) + if end_time: + try: + end_time_obj = datetime.fromisoformat(end_time) + end_time = end_time_obj.replace(tzinfo=utc) + if ( + end_time < datetime.now().replace(tzinfo=utc) + or end_time < start_time + ): + errors.append( + f"{end_time} end_time cannot be before the current or start time" + ) + except ValueError: + errors.append( + f"{end_time} end_time is not in a valid ISO format" + ) + return errors def _validate_object_defaults(self, sdx_object): diff --git a/tests/test_connection_v2.py b/tests/test_connection_v2.py index 5bc5773..9fbf9c6 100644 --- a/tests/test_connection_v2.py +++ b/tests/test_connection_v2.py @@ -6,9 +6,20 @@ from sdx_datamodel.models.connection_v2 import Connection from sdx_datamodel.models.link import Link from sdx_datamodel.models.port import Port +from sdx_datamodel.parsing.connectionhandler import ConnectionHandler +from sdx_datamodel.validation.connectionvalidator import ConnectionValidator class TestConnection(unittest.TestCase): + + def _get_validator(self, data): + """ + Return a validator for the given file. + """ + handler = ConnectionHandler() + connection = handler.import_connection_data(data) + return ConnectionValidator(connection) + def test_connection(self): # Create test data endpoints = [Port(id="port1"), Port(id="port2")] @@ -44,6 +55,41 @@ def test_connection(self): self.assertEqual(connection.paths, paths) self.assertEqual(connection.exclusive_links, exclusive_links) + """ + Validate a JSON document descibing a connection. + """ + + def test_connection_invalid_qos_metrics(self): + connection_request = { + "name": "VLAN between AMPATH/2010 and TENET/2010", + "id": "urn:sdx:connection:ampath.net:Ampath01:3-zaoxi.net:zaoxi02:1", + "endpoints": [ + { + "port_id": "urn:sdx:port:ampath.net:Ampath3:50", + "vlan": "2010", + }, + { + "port_id": "urn:sdx:port:tenet.ac.za:Tenet03:50", + "vlan": "2010", + }, + ], + "scheduling": {"end_time": "2023-12-30"}, + "qos_metrics": { + "min_bw": {"value": 101}, + "max_delay": {"value": 1001}, + "max_number_oxps": {"value": 101}, + }, + } + + validator = self._get_validator(connection_request) + + with self.assertRaises(ValueError) as ex: + validator.is_valid() + + errors = ex.exception.args[0].splitlines() + print(f"{errors}") + self.assertEqual(len(errors), 5) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_connection_validator.py b/tests/test_connection_validator.py index 9a0e875..1f99528 100644 --- a/tests/test_connection_validator.py +++ b/tests/test_connection_validator.py @@ -126,8 +126,12 @@ def test_connection_object(self): ingress_port=ingress_port, egress_port=egress_port, quantity=0, - start_time=datetime.datetime.fromtimestamp(0), - end_time=datetime.datetime.fromtimestamp(0), + start_time=str( + datetime.datetime.now() + datetime.timedelta(hours=1) + ), + end_time=str( + datetime.datetime.now() + datetime.timedelta(hours=2) + ), status="fail", complete=False, )