Commit 2b82f786 authored by Lluis Gifre Renom's avatar Lluis Gifre Renom
Browse files

Tests - Tools - Mock NCE-T Controller

- Added tode to inject NCE-T topology to SIMAP
parent 4c83196e
Loading
Loading
Loading
Loading
+42 −0
Original line number Diff line number Diff line
@@ -20,6 +20,8 @@ from .Dispatch import RestConfDispatch
from .HostMeta import HostMeta
from .YangHandler import YangHandler
from .YangModelDiscoverer import YangModuleDiscoverer
from .simap_client.RestConfClient import RestConfClient
from .simap_client.SimapClient import SimapClient


logging.basicConfig(
@@ -27,6 +29,7 @@ logging.basicConfig(
    format="[Worker-%(process)d][%(asctime)s] %(levelname)s:%(name)s:%(message)s",
)
LOGGER = logging.getLogger(__name__)
logging.getLogger('RestConfClient').setLevel(logging.WARN)


RESTCONF_PREFIX = '/restconf'
@@ -43,6 +46,45 @@ with open(STARTUP_FILE, mode='r', encoding='UTF-8') as fp:
    YANG_STARTUP_DATA = json.loads(fp.read())


restconf_client = RestConfClient(
    'simap-client', port=8080,
    logger=logging.getLogger('RestConfClient')
)
simap_client = SimapClient(restconf_client)

te_topo = simap_client.network('admin')
te_topo.update()

networks = YANG_STARTUP_DATA.get('ietf-network:networks', dict())
networks = networks.get('network', list())
assert len(networks) == 1
network = networks[0]
assert network['network-id'] == 'admin'

nodes = network.get('node', list())
for node in nodes:
    node_id = node['node-id']
    tp_ids = [
        tp['tp-id']
        for tp in node['ietf-network-topology:termination-point']
    ]
    te_topo.node(node_id).create(termination_point_ids=tp_ids)

links = network.get('ietf-network-topology:link', list())
for link in links:
    link_id   = link['link-id']
    link_src  = link['source']
    link_dst  = link['destination']
    link_src_node_id = link_src['source-node']
    link_src_tp_id   = link_src['source-tp']
    link_dst_node_id = link_dst['dest-node']
    link_dst_tp_id   = link_dst['dest-tp']

    te_topo.link(link_id).create(
        link_src_node_id, link_src_tp_id, link_dst_node_id, link_dst_tp_id
    )


yang_handler = YangHandler(
    YANG_SEARCH_PATH, YANG_MODULE_NAMES, YANG_STARTUP_DATA
)
+191 −0
Original line number Diff line number Diff line
# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import enum, logging, requests
from requests.auth import HTTPBasicAuth
from typing import Any, Dict, Optional, Set

class RestRequestMethod(enum.Enum):
    GET    = 'get'
    POST   = 'post'
    PUT    = 'put'
    PATCH  = 'patch'
    DELETE = 'delete'

EXPECTED_STATUS_CODES : Set[int] = {
    requests.codes['OK'        ],   # 200 - OK
    requests.codes['CREATED'   ],   # 201 - Created
    requests.codes['ACCEPTED'  ],   # 202 - Accepted
    requests.codes['NO_CONTENT'],   # 204 - No Content
}

def compose_basic_auth(
    username : Optional[str] = None, password : Optional[str] = None
) -> Optional[HTTPBasicAuth]:
    if username is None or password is None: return None
    return HTTPBasicAuth(username, password)

class SchemeEnum(enum.Enum):
    HTTP  = 'http'
    HTTPS = 'https'

def check_scheme(scheme : str) -> str:
    str_scheme = str(scheme).lower()
    enm_scheme = SchemeEnum._value2member_map_[str_scheme]
    return enm_scheme.value

HOST_META_URL = '{:s}://{:s}:{:d}/.well-known/host-meta'
RESTCONF_URL  = '{:s}://{:s}:{:d}/{:s}'

class RestConfClient:
    def __init__(
        self, address : str, port : int = 8080, scheme : str = 'http',
        username : Optional[str] = None, password : Optional[str] = None,
        timeout : int = 10, verify_certs : bool = True, allow_redirects : bool = True,
        logger : Optional[logging.Logger] = None
    ) -> None:
        self._address         = address
        self._port            = int(port)
        self._scheme          = check_scheme(scheme)
        self._auth            = compose_basic_auth(username=username, password=password)
        self._base_url        = ''
        self._timeout         = int(timeout)
        self._verify_certs    = verify_certs
        self._allow_redirects = allow_redirects
        self._logger          = logger

        self._discover_base_url()

    def _discover_base_url(self) -> None:
        host_meta_url = HOST_META_URL.format(self._scheme, self._address, self._port)
        host_meta : Dict = self.get(host_meta_url, expected_status_codes={requests.codes['OK']})

        links = host_meta.get('links')
        if links is None: raise AttributeError('Missing attribute "links" in host-meta reply')
        if not isinstance(links, list): raise AttributeError('Attribute "links" must be a list')
        if len(links) != 1: raise AttributeError('Attribute "links" is expected to have exactly 1 item')

        link = links[0]
        if not isinstance(link, dict): raise AttributeError('Attribute "links[0]" must be a dict')

        rel = link.get('rel')
        if rel is None: raise AttributeError('Missing attribute "links[0].rel" in host-meta reply')
        if not isinstance(rel, str): raise AttributeError('Attribute "links[0].rel" must be a str')
        if rel != 'restconf': raise AttributeError('Attribute "links[0].rel" != "restconf"')

        href = link.get('href')
        if href is None: raise AttributeError('Missing attribute "links[0]" in host-meta reply')
        if not isinstance(href, str): raise AttributeError('Attribute "links[0].href" must be a str')

        self._base_url = str(href + '/data').replace('//', '/')

    def _log_msg_request(
        self, method : RestRequestMethod, request_url : str, body : Optional[Any],
        log_level : int = logging.INFO
    ) -> str:
        msg = 'Request: {:s} {:s}'.format(str(method.value).upper(), str(request_url))
        if body is not None: msg += ' body={:s}'.format(str(body))
        if self._logger is not None: self._logger.log(log_level, msg)
        return msg

    def _log_msg_check_reply(
        self, method : RestRequestMethod, request_url : str, body : Optional[Any],
        reply : requests.Response, expected_status_codes : Set[int],
        log_level : int = logging.INFO
    ) -> str:
        msg = 'Reply: {:s}'.format(str(reply.text))
        if self._logger is not None: self._logger.log(log_level, msg)
        http_status_code = reply.status_code
        if http_status_code in expected_status_codes: return msg
        MSG = 'Request failed. method={:s} url={:s} body={:s} status_code={:s} reply={:s}'
        msg = MSG.format(
            str(method.value).upper(), str(request_url), str(body),
            str(http_status_code), str(reply.text)
        )
        self._logger.error(msg)
        raise Exception(msg)

    def _do_rest_request(
        self, method : RestRequestMethod, endpoint : str, body : Optional[Any] = None,
        expected_status_codes : Set[int] = EXPECTED_STATUS_CODES
    ) -> Optional[Any]:
        candidate_schemes = tuple(['{:s}://'.format(m).lower() for m in SchemeEnum.__members__.keys()])
        if endpoint.lower().startswith(candidate_schemes):
            request_url = endpoint.lstrip('/')
        else:
            endpoint = str(self._base_url + '/' + endpoint).replace('//', '/').lstrip('/')
            request_url = '{:s}://{:s}:{:d}/{:s}'.format(
                self._scheme, self._address, self._port, endpoint.lstrip('/')
            )
        self._log_msg_request(method, request_url, body)
        try:
            headers = {'accept': 'application/json'}
            reply = requests.request(
                method.value, request_url, headers=headers, json=body,
                auth=self._auth, verify=self._verify_certs, timeout=self._timeout,
                allow_redirects=self._allow_redirects
            )
        except Exception as e:
            MSG = 'Request failed. method={:s} url={:s} body={:s}'
            msg = MSG.format(str(method.value).upper(), request_url, str(body))
            self._logger.exception(msg)
            raise Exception(msg) from e
        self._log_msg_check_reply(method, request_url, body, reply, expected_status_codes)
        if reply.content and len(reply.content) > 0: return reply.json()
        return None

    def get(
        self, endpoint : str,
        expected_status_codes : Set[int] = {requests.codes['OK']}
    ) -> Optional[Any]:
        return self._do_rest_request(
            RestRequestMethod.GET, endpoint,
            expected_status_codes=expected_status_codes
        )

    def post(
        self, endpoint : str, body : Optional[Any] = None,
        expected_status_codes : Set[int] = {requests.codes['CREATED']}
    ) -> Optional[Any]:
        return self._do_rest_request(
            RestRequestMethod.POST, endpoint, body=body,
            expected_status_codes=expected_status_codes
        )

    def put(
        self, endpoint : str, body : Optional[Any] = None,
        expected_status_codes : Set[int] = {requests.codes['CREATED'], requests.codes['NO_CONTENT']}
    ) -> Optional[Any]:
        return self._do_rest_request(
            RestRequestMethod.PUT, endpoint, body=body,
            expected_status_codes=expected_status_codes
        )

    def patch(
        self, endpoint : str, body : Optional[Any] = None,
        expected_status_codes : Set[int] = {requests.codes['NO_CONTENT']}
    ) -> Optional[Any]:
        return self._do_rest_request(
            RestRequestMethod.PATCH, endpoint, body=body,
            expected_status_codes=expected_status_codes
        )

    def delete(
        self, endpoint : str, body : Optional[Any] = None,
        expected_status_codes : Set[int] = {requests.codes['NO_CONTENT']}
    ) -> Optional[Any]:
        return self._do_rest_request(
            RestRequestMethod.DELETE, endpoint, body=body,
            expected_status_codes=expected_status_codes
        )
+242 −0
Original line number Diff line number Diff line
# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


from typing import Dict, List, Tuple
from .RestConfClient import RestConfClient


class TerminationPoint:
    ENDPOINT_NO_ID = '/ietf-network:networks/network[network-id="{:s}"]/node[node-id="{:s}"]'
    ENDPOINT_ID    = ENDPOINT_NO_ID + '/ietf-network-topology:termination-point[tp-id="{:s}"]'

    def __init__(self, restconf_client : RestConfClient, network_id : str, node_id : str, tp_id : str):
        self._restconf_client = restconf_client
        self._network_id = network_id
        self._node_id = node_id
        self._tp_id = tp_id

    def create(self, supporting_termination_point_ids : List[Tuple[str, str, str]] = []) -> None:
        endpoint = TerminationPoint.ENDPOINT_ID.format(self._network_id, self._node_id, self._tp_id)
        tp = {'tp-id': self._tp_id}
        stps = [
            {'network-ref': snet_id, 'node-ref': snode_id, 'tp-ref': stp_id}
            for snet_id,snode_id,stp_id in supporting_termination_point_ids
        ]
        if len(stps) > 0: tp['supporting-termination-point'] = stps
        node = {'node-id': self._node_id, 'ietf-network-topology:termination-point': [tp]}
        network = {'network-id': self._network_id, 'node': [node]}
        payload  = {'ietf-network:networks': {'network': [network]}}
        self._restconf_client.post(endpoint, payload)

    def get(self) -> Dict:
        endpoint = TerminationPoint.ENDPOINT_ID.format(self._network_id, self._node_id, self._tp_id)
        node : Dict = self._restconf_client.get(endpoint)
        return node['ietf-network-topology:termination-point'][0]

    def update(self, supporting_termination_point_ids : List[Tuple[str, str, str]] = []) -> None:
        endpoint = TerminationPoint.ENDPOINT_ID.format(self._network_id, self._node_id, self._tp_id)
        tp = {'tp-id': self._tp_id}
        stps = [
            {'network-ref': snet_id, 'node-ref': snode_id, 'tp-ref': stp_id}
            for snet_id,snode_id,stp_id in supporting_termination_point_ids
        ]
        if len(stps) > 0: tp['supporting-termination-point'] = stps
        node = {'node-id': self._node_id, 'ietf-network-topology:termination-point': [tp]}
        network = {'network-id': self._network_id, 'node': [node]}
        payload  = {'ietf-network:networks': {'network': [network]}}
        self._restconf_client.patch(endpoint, payload)

    def delete(self) -> None:
        endpoint = TerminationPoint.ENDPOINT_ID.format(self._network_id, self._node_id, self._tp_id)
        self._restconf_client.delete(endpoint)

class Node:
    ENDPOINT_NO_ID = '/ietf-network:networks/network[network-id="{:s}"]'
    ENDPOINT_ID    = ENDPOINT_NO_ID + '/node[node-id="{:s}"]'

    def __init__(self, restconf_client : RestConfClient, network_id : str, node_id : str):
        self._restconf_client = restconf_client
        self._network_id = network_id
        self._node_id = node_id
        self._tps : Dict[str, TerminationPoint] = dict()

    def termination_points(self) -> List[Dict]:
        tps : Dict = self._restconf_client.get(TerminationPoint.ENDPOINT_NO_ID)
        return tps['ietf-network-topology:termination-point'].get('termination-point', list())

    def termination_point(self, tp_id : str) -> TerminationPoint:
        _tp = self._tps.get(tp_id)
        if _tp is not None: return _tp
        _tp = TerminationPoint(self._restconf_client, self._network_id, self._node_id, tp_id)
        return self._tps.setdefault(tp_id, _tp)

    def create(
        self, termination_point_ids : List[str] = [],
        supporting_node_ids : List[Tuple[str, str]] = []
    ) -> None:
        endpoint = Node.ENDPOINT_ID.format(self._network_id, self._node_id)
        node = {'node-id': self._node_id}
        tps = [{'tp-id': tp_id} for tp_id in termination_point_ids]
        if len(tps) > 0: node['ietf-network-topology:termination-point'] = tps
        sns = [{'network-ref': snet_id, 'node-ref': snode_id} for snet_id,snode_id in supporting_node_ids]
        if len(sns) > 0: node['supporting-node'] = sns
        network = {'network-id': self._network_id, 'node': [node]}
        payload  = {'ietf-network:networks': {'network': [network]}}
        self._restconf_client.post(endpoint, payload)

    def get(self) -> Dict:
        endpoint = Node.ENDPOINT_ID.format(self._network_id, self._node_id)
        node : Dict = self._restconf_client.get(endpoint)
        return node['ietf-network:node'][0]

    def update(
        self, termination_point_ids : List[str] = [],
        supporting_node_ids : List[Tuple[str, str]] = []
    ) -> None:
        endpoint = Node.ENDPOINT_ID.format(self._network_id, self._node_id)
        node = {'node-id': self._node_id}
        tps = [{'tp-id': tp_id} for tp_id in termination_point_ids]
        if len(tps) > 0: node['ietf-network-topology:termination-point'] = tps
        sns = [{'network-ref': snet_id, 'node-ref': snode_id} for snet_id,snode_id in supporting_node_ids]
        if len(sns) > 0: node['supporting-node'] = sns
        network = {'network-id': self._network_id, 'node': [node]}
        payload  = {'ietf-network:networks': {'network': [network]}}
        self._restconf_client.patch(endpoint, payload)

    def delete(self) -> None:
        endpoint = Node.ENDPOINT_ID.format(self._network_id, self._node_id)
        self._restconf_client.delete(endpoint)

class Link:
    ENDPOINT_NO_ID = '/ietf-network:networks/network[network-id="{:s}"]'
    ENDPOINT_ID    = ENDPOINT_NO_ID + '/ietf-network-topology:link[link-id="{:s}"]'

    def __init__(self, restconf_client : RestConfClient, network_id : str, link_id : str):
        self._restconf_client = restconf_client
        self._network_id = network_id
        self._link_id = link_id

    def create(
        self, src_node_id : str, src_tp_id : str, dst_node_id : str, dst_tp_id : str,
        supporting_link_ids : List[Tuple[str, str]] = []
    ) -> None:
        endpoint = Link.ENDPOINT_ID.format(self._network_id, self._link_id)
        link = {
            'link-id'    : self._link_id,
            'source'     : {'source-node': src_node_id, 'source-tp': src_tp_id},
            'destination': {'dest-node'  : dst_node_id, 'dest-tp'  : dst_tp_id},
        }
        sls = [{'network-ref': snet_id, 'link-ref': slink_id} for snet_id,slink_id in supporting_link_ids]
        if len(sls) > 0: link['supporting-link'] = sls
        network = {'network-id': self._network_id, 'ietf-network-topology:link': [link]}
        payload  = {'ietf-network:networks': {'network': [network]}}
        self._restconf_client.post(endpoint, payload)

    def get(self) -> Dict:
        endpoint = Link.ENDPOINT_ID.format(self._network_id, self._link_id)
        link : Dict = self._restconf_client.get(endpoint)
        return link['ietf-network-topology:link'][0]

    def update(
        self, src_node_id : str, src_tp_id : str, dst_node_id : str, dst_tp_id : str,
        supporting_link_ids : List[Tuple[str, str]] = []
    ) -> None:
        endpoint = Link.ENDPOINT_ID.format(self._network_id, self._link_id)
        link = {
            'link-id'    : self._link_id,
            'source'     : {'source-node': src_node_id, 'source-tp': src_tp_id},
            'destination': {'dest-node'  : dst_node_id, 'dest-tp'  : dst_tp_id},
        }
        sls = [{'network-ref': snet_id, 'link-ref': slink_id} for snet_id,slink_id in supporting_link_ids]
        if len(sls) > 0: link['supporting-link'] = sls
        network = {'network-id': self._network_id, 'ietf-network-topology:link': [link]}
        payload  = {'ietf-network:networks': {'network': [network]}}
        self._restconf_client.patch(endpoint, payload)

    def delete(self) -> None:
        endpoint = Link.ENDPOINT_ID.format(self._network_id, self._link_id)
        self._restconf_client.delete(endpoint)


class Network:
    ENDPOINT_NO_ID = '/ietf-network:networks'
    ENDPOINT_ID    = ENDPOINT_NO_ID + '/network[network-id="{:s}"]'

    def __init__(self, restconf_client : RestConfClient, network_id : str):
        self._restconf_client = restconf_client
        self._network_id = network_id
        self._nodes : Dict[str, Node] = dict()
        self._links : Dict[str, Link] = dict()

    def nodes(self) -> List[Dict]:
        reply : Dict = self._restconf_client.get(Node.ENDPOINT_NO_ID.format(self._network_id))
        return reply['ietf-network:network'][0].get('node', list())

    def links(self) -> List[Dict]:
        reply : Dict = self._restconf_client.get(Link.ENDPOINT_NO_ID.format(self._network_id))
        return reply['ietf-network:network'][0].get('ietf-network-topology:link', list())

    def node(self, node_id : str) -> Node:
        _node = self._nodes.get(node_id)
        if _node is not None: return _node
        _node = Node(self._restconf_client, self._network_id, node_id)
        return self._nodes.setdefault(node_id, _node)

    def link(self, link_id : str) -> Link:
        _link = self._links.get(link_id)
        if _link is not None: return _link
        _link = Link(self._restconf_client, self._network_id, link_id)
        return self._links.setdefault(link_id, _link)

    def create(self, supporting_network_ids : List[str] = []) -> None:
        endpoint = Network.ENDPOINT_ID.format(self._network_id)
        network = {'network-id': self._network_id}
        sns = [{'network-ref': sn_id} for sn_id in supporting_network_ids]
        if len(sns) > 0: network['supporting-network'] = sns
        payload  = {'ietf-network:networks': {'network': [network]}}
        self._restconf_client.post(endpoint, payload)

    def get(self) -> Dict:
        endpoint = Network.ENDPOINT_ID.format(self._network_id)
        networks : Dict = self._restconf_client.get(endpoint)
        return networks['ietf-network:network'][0]

    def update(self, supporting_network_ids : List[str] = []) -> None:
        endpoint = Network.ENDPOINT_ID.format(self._network_id)
        network = {'network-id': self._network_id}
        sns = [{'network-ref': sn_id} for sn_id in supporting_network_ids]
        if len(sns) > 0: network['supporting-network'] = sns
        payload  = {'ietf-network:networks': {'network': [network]}}
        self._restconf_client.patch(endpoint, payload)

    def delete(self) -> None:
        endpoint = Network.ENDPOINT_ID.format(self._network_id)
        self._restconf_client.delete(endpoint)


class SimapClient:
    def __init__(self, restconf_client : RestConfClient) -> None:
        self._restconf_client = restconf_client
        self._networks : Dict[str, Network] = dict()

    def networks(self) -> List[Dict]:
        reply : Dict = self._restconf_client.get(Network.ENDPOINT_NO_ID)
        return reply['ietf-network:networks'].get('network', list())

    def network(self, network_id : str) -> Network:
        _network = self._networks.get(network_id)
        if _network is not None: return _network
        _network = Network(self._restconf_client, network_id)
        return self._networks.setdefault(network_id, _network)
+14 −0
Original line number Diff line number Diff line
# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.