diff --git a/src/tests/ofc22/descriptors_emulated_xr.json b/src/tests/ofc22/descriptors_emulated_xr.json index 4e247bb30d4df25fa75d30a3baa94f1348c0a6d9..b873d31143406a5f6cedbf19c1b357f2223d42d9 100644 --- a/src/tests/ofc22/descriptors_emulated_xr.json +++ b/src/tests/ofc22/descriptors_emulated_xr.json @@ -96,6 +96,18 @@ "device_operational_status": 1, "device_drivers": [6], "device_endpoints": [] + }, + { + "device_id": {"device_uuid": {"uuid": "X2-XR-CONSTELLATION"}}, + "device_type": "xr-constellation", + "device_config": {"config_rules": [ + {"action": 1, "custom": {"resource_key": "_connect/address", "resource_value": "172.19.219.44"}}, + {"action": 1, "custom": {"resource_key": "_connect/port", "resource_value": "443"}}, + {"action": 1, "custom": {"resource_key": "_connect/settings", "resource_value": "{\"username\": \"xr-user-1\", \"password\": \"xr-user-1\", \"hub_module_name\": \"XR HUB 2\", \"consistency-mode\": \"lifecycle\"}"}} + ]}, + "device_operational_status": 1, + "device_drivers": [6], + "device_endpoints": [] } ], "links": [ diff --git a/src/webui/service/service/routes.py b/src/webui/service/service/routes.py index defbe2cb003cc97830d6ec24db01bf8734a7f530..70a5b5bad41df6520cb2facdad94cfee04f726cd 100644 --- a/src/webui/service/service/routes.py +++ b/src/webui/service/service/routes.py @@ -12,27 +12,56 @@ # See the License for the specific language governing permissions and # limitations under the License. +from contextlib import contextmanager +import json import grpc -from flask import current_app, redirect, render_template, Blueprint, flash, session, url_for +from collections import defaultdict +from flask import current_app, redirect, render_template, Blueprint, flash, session, url_for, request from common.proto.context_pb2 import ( - IsolationLevelEnum, Service, ServiceId, ServiceTypeEnum, ServiceStatusEnum, Connection) + IsolationLevelEnum, Service, ServiceId, ServiceTypeEnum, ServiceStatusEnum, Connection, Empty, DeviceDriverEnum, ConfigActionEnum, Device, DeviceList) from common.tools.context_queries.Context import get_context +from common.tools.context_queries.Topology import get_topology from common.tools.context_queries.EndPoint import get_endpoint_names from common.tools.context_queries.Service import get_service from context.client.ContextClient import ContextClient from service.client.ServiceClient import ServiceClient +from typing import Optional, Set +from common.tools.object_factory.Topology import json_topology_id +from common.tools.object_factory.ConfigRule import json_config_rule_set +from common.tools.object_factory.Context import json_context_id service = Blueprint('service', __name__, url_prefix='/service') context_client = ContextClient() service_client = ServiceClient() +@contextmanager +def connected_client(c): + try: + c.connect() + yield c + finally: + c.close() + +# Context client must be in connected state when calling this function +def get_device_drivers_in_use(topology_uuid: str, context_uuid: str) -> Set[str]: + active_drivers = set() + grpc_topology = get_topology(context_client, topology_uuid, context_uuid=context_uuid, rw_copy=False) + topo_device_uuids = {device_id.device_uuid.uuid for device_id in grpc_topology.device_ids} + grpc_devices: DeviceList = context_client.ListDevices(Empty()) + for device in grpc_devices.devices: + if device.device_id.device_uuid.uuid in topo_device_uuids: + for driver in device.device_drivers: + active_drivers.add(DeviceDriverEnum.Name(driver)) + return active_drivers + @service.get('/') def home(): if 'context_uuid' not in session or 'topology_uuid' not in session: flash("Please select a context!", "warning") return redirect(url_for("main.home")) context_uuid = session['context_uuid'] + topology_uuid = session['topology_uuid'] context_client.connect() @@ -44,10 +73,12 @@ def home(): try: services = context_client.ListServices(context_obj.context_id) services = services.services + active_drivers = get_device_drivers_in_use(topology_uuid, context_uuid) except grpc.RpcError as e: if e.code() != grpc.StatusCode.NOT_FOUND: raise if e.details() != 'Context({:s}) not found'.format(context_uuid): raise services, device_names, endpoints_data = list(), dict(), dict() + active_drivers = set() else: endpoint_ids = list() for service_ in services: @@ -57,7 +88,7 @@ def home(): context_client.close() return render_template( 'service/home.html', services=services, device_names=device_names, endpoints_data=endpoints_data, - ste=ServiceTypeEnum, sse=ServiceStatusEnum) + ste=ServiceTypeEnum, sse=ServiceStatusEnum, active_drivers=active_drivers) @service.route('add', methods=['GET', 'POST']) @@ -67,6 +98,151 @@ def add(): #return render_template('service/home.html') +def get_hub_module_name(dev: Device) -> Optional[str]: + for cr in dev.device_config.config_rules: + if cr.action == ConfigActionEnum.CONFIGACTION_SET and cr.custom and cr.custom.resource_key == "_connect/settings": + try: + cr_dict = json.loads(cr.custom.resource_value) + if "hub_module_name" in cr_dict: + return cr_dict["hub_module_name"] + except json.JSONDecodeError: + pass + return None + +@service.route('add-xr', methods=['GET', 'POST']) +def add_xr(): + ### FIXME: copypaste + if 'context_uuid' not in session or 'topology_uuid' not in session: + flash("Please select a context!", "warning") + return redirect(url_for("main.home")) + + context_uuid = session['context_uuid'] + topology_uuid = session['topology_uuid'] + + context_client.connect() + grpc_topology = get_topology(context_client, topology_uuid, context_uuid=context_uuid, rw_copy=False) + if grpc_topology is None: + flash('Context({:s})/Topology({:s}) not found'.format(str(context_uuid), str(topology_uuid)), 'danger') + return redirect(url_for("main.home")) + else: + topo_device_uuids = {device_id.device_uuid.uuid for device_id in grpc_topology.device_ids} + grpc_devices= context_client.ListDevices(Empty()) + devices = [ + device for device in grpc_devices.devices + if device.device_id.device_uuid.uuid in topo_device_uuids and DeviceDriverEnum.DEVICEDRIVER_XR in device.device_drivers + ] + devices.sort(key=lambda dev: dev.name) + + hub_interfaces_by_device = defaultdict(list) + leaf_interfaces_by_device = defaultdict(list) + constellation_name_to_uuid = {} + dev_ep_to_uuid = {} + ep_uuid_to_name = {} + for d in devices: + constellation_name_to_uuid[d.name] = d.device_id.device_uuid.uuid + hm_name = get_hub_module_name(d) + if hm_name is not None: + hm_if_prefix= hm_name + "|" + for ep in d.device_endpoints: + dev_ep_to_uuid[(d.name, ep.name)] = ep.endpoint_id.endpoint_uuid.uuid + if ep.name.startswith(hm_if_prefix): + hub_interfaces_by_device[d.name].append(ep.name) + else: + leaf_interfaces_by_device[d.name].append(ep.name) + ep_uuid_to_name[ep.endpoint_id.endpoint_uuid.uuid] = (d.name, ep.name) + hub_interfaces_by_device[d.name].sort() + leaf_interfaces_by_device[d.name].sort() + + # Find out what endpoints are already used so that they can be disabled + # in the create screen + context_obj = get_context(context_client, context_uuid, rw_copy=False) + if context_obj is None: + flash('Context({:s}) not found'.format(str(context_uuid)), 'danger') + return redirect(request.url) + + services = context_client.ListServices(context_obj.context_id) + ep_used_by={} + for service in services.services: + if service.service_type == ServiceTypeEnum.SERVICETYPE_TAPI_CONNECTIVITY_SERVICE: + for ep in service.service_endpoint_ids: + ep_uuid = ep.endpoint_uuid.uuid + if ep_uuid in ep_uuid_to_name: + dev_name, ep_name = ep_uuid_to_name[ep_uuid] + ep_used_by[f"{ep_name}@{dev_name}"] = service.name + + context_client.close() + + if request.method != 'POST': + return render_template('service/add-xr.html', devices=devices, hub_if=hub_interfaces_by_device, leaf_if=leaf_interfaces_by_device, ep_used_by=ep_used_by) + else: + service_name = request.form["service_name"] + if service_name == "": + flash(f"Service name must be specified", 'danger') + + constellation = request.form["constellation"] + constellation_uuid = constellation_name_to_uuid.get(constellation, None) + if constellation_uuid is None: + flash(f"Invalid constellation \"{constellation}\"", 'danger') + + hub_if = request.form["hubif"] + hub_if_uuid = dev_ep_to_uuid.get((constellation, hub_if), None) + if hub_if_uuid is None: + flash(f"Invalid hub interface \"{hub_if}\"", 'danger') + + leaf_if = request.form["leafif"] + leaf_if_uuid = dev_ep_to_uuid.get((constellation, leaf_if), None) + if leaf_if_uuid is None: + flash(f"Invalid leaf interface \"{leaf_if}\"", 'danger') + + if service_name == "" or constellation_uuid is None or hub_if_uuid is None or leaf_if_uuid is None: + return redirect(request.url) + + + json_context_uuid=json_context_id(context_uuid) + sr = { + "name": service_name, + "service_id": { + "context_id": {"context_uuid": {"uuid": context_uuid}}, + "service_uuid": {"uuid": service_name} + }, + 'service_type' : ServiceTypeEnum.SERVICETYPE_TAPI_CONNECTIVITY_SERVICE, + "service_endpoint_ids": [ + {'device_id': {'device_uuid': {'uuid': constellation_uuid}}, 'endpoint_uuid': {'uuid': hub_if_uuid}, 'topology_id': json_topology_id("admin", context_id=json_context_uuid)}, + {'device_id': {'device_uuid': {'uuid': constellation_uuid}}, 'endpoint_uuid': {'uuid': leaf_if_uuid}, 'topology_id': json_topology_id("admin", context_id=json_context_uuid)} + ], + 'service_status' : {'service_status': ServiceStatusEnum.SERVICESTATUS_PLANNED}, + 'service_constraints' : [], + } + + json_tapi_settings = { + 'capacity_value' : 50.0, + 'capacity_unit' : 'GHz', + 'layer_proto_name': 'PHOTONIC_MEDIA', + 'layer_proto_qual': 'tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_NMC', + 'direction' : 'UNIDIRECTIONAL', + } + config_rule = json_config_rule_set('/settings', json_tapi_settings) + + with connected_client(service_client) as sc: + endpoints, sr['service_endpoint_ids'] = sr['service_endpoint_ids'], [] + try: + create_response = sc.CreateService(Service(**sr)) + except Exception as e: + flash(f'Failure to update service name {service_name} with endpoints and configuration, exception {str(e)}', 'danger') + return redirect(request.url) + + sr['service_endpoint_ids'] = endpoints + sr['service_config'] = {'config_rules': [config_rule]} + + try: + update_response = sc.UpdateService(Service(**sr)) + flash(f'Created service {update_response.service_uuid.uuid}', 'success') + except Exception as e: + flash(f'Failure to update service {create_response.service_uuid.uuid} with endpoints and configuration, exception {str(e)}', 'danger') + return redirect(request.url) + + return redirect(url_for('service.home')) + @service.get('<path:service_uuid>/detail') def detail(service_uuid: str): if 'context_uuid' not in session or 'topology_uuid' not in session: diff --git a/src/webui/service/templates/service/add-xr.html b/src/webui/service/templates/service/add-xr.html new file mode 100644 index 0000000000000000000000000000000000000000..36fe132caa7df1e3c72fa09ff2c39e2a92a7a357 --- /dev/null +++ b/src/webui/service/templates/service/add-xr.html @@ -0,0 +1,105 @@ +<!-- + Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (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. +--> + +{% extends 'base.html' %} + +{% block content %} + <script> + var js_hub_if = JSON.parse('{{hub_if | tojson | safe}}'); + var js_leaf_if = JSON.parse('{{leaf_if | tojson | safe}}'); + var js_ep_used_by = JSON.parse('{{ep_used_by | tojson | safe}}'); + + function clear_select_except_first(s) { + while (s.options.length > 1) { + s.remove(1); + } + } + + function add_ep_to_select(sel, dev_name, ep_name) { + used_by = js_ep_used_by[ep_name + "@" + dev_name]; + var o; + if (used_by === undefined) { + o = new Option(ep_name, ep_name) + } else { + o = new Option(ep_name + " (used by " + used_by + ")", ep_name) + o.disabled=true + } + sel.add(o); + } + + function constellationSelected() { + const constellation_select = document.getElementById('constellation'); + const hubif_select = document.getElementById('hubif'); + const leafif_select = document.getElementById('leafif'); + + clear_select_except_first(hubif_select) + clear_select_except_first(leafif_select) + if (constellation_select.value) { + const hub_ifs=js_hub_if[constellation_select.value] + for (const hi of hub_ifs) { + add_ep_to_select(hubif_select, constellation_select.value, hi); + } + + const leaf_ifs=js_leaf_if[constellation_select.value] + for (const li of leaf_ifs) { + add_ep_to_select(leafif_select, constellation_select.value, li); + } + } + } + </script> + + <h1>Add XR Service</h1> + <form action="#" method="post"> + <fieldset class="form-group row mb-3"> + <label for="service_name" class="col-sm-3 col-form-label">Service name:</label> + <div class="col-sm-9"> + <input type="text" id="service_name" name="service_name" class="form-control"> + </div> + </fieldset> + + <fieldset class="form-group row mb-3"> + <label for="constellation" class="col-sm-3 col-form-label">Constellation:</label> + <div class="col-sm-9"> + <select name="constellation" id="constellation" onchange="constellationSelected()" class="form-select"> + <option value="">(choose constellation)</option> + {% for dev in devices %} + <option value="{{dev.name}}">{{dev.name}}</option> + {% endfor %} + </select> + </div> + </fieldset> + + <fieldset class="form-group row mb-3"> + <label for="hubif" class="col-sm-3 col-form-label">Hub Endpoint:</label> + <div class="col-sm-9"> + <select name="hubif" id="hubif" class="col-sm-8 form-select"> + <option value="">(choose hub endpoint)</option> + </select> + </div> + </fieldset> + + <fieldset class="form-group row mb-3"> + <label for="leafif" class="col-sm-3 col-form-label">Leaf Endpoint:</label> + <div class="col-sm-9"> + <select name="leafif" id="leafif" class="col-sm-8 form-select"> + <option value="">(choose leaf endpoint)</option> + </select> + </div> + </fieldset> + + <input type="submit" class="btn btn-primary" value="Create"> + </form> +{% endblock %} diff --git a/src/webui/service/templates/service/home.html b/src/webui/service/templates/service/home.html index 79b55c962dcdd0af4a380928c180f6c9def75ba7..00feaff59128dd026ab2bdb369229a9d0aaae805 100644 --- a/src/webui/service/templates/service/home.html +++ b/src/webui/service/templates/service/home.html @@ -26,6 +26,18 @@ Add New Service </a> </div> --> + + <!-- Only display XR service addition button if there are XR constellations. Otherwise it might confuse + user, as other service types do not have GUI to add service yet. --> + {% if "DEVICEDRIVER_XR" in active_drivers %} + <div class="col"> + <a href="{{ url_for('service.add_xr') }}" class="btn btn-primary" style="margin-bottom: 10px;"> + <i class="bi bi-plus"></i> + Add New XR Service + </a> + </div> + {% endif %} + <div class="col"> {{ services | length }} services found in context <i>{{ session['context_uuid'] }}</i> </div>