diff --git a/manifests/nginx_ingress_http.yaml b/manifests/nginx_ingress_http.yaml index ed713bf29ad8228ab3f5b051af24519c2fb9ef09..619d85f7a82af48c71464038bf14a833903d1a58 100644 --- a/manifests/nginx_ingress_http.yaml +++ b/manifests/nginx_ingress_http.yaml @@ -66,6 +66,6 @@ spec: pathType: Prefix backend: service: - name: qkd-appservice + name: nbiservice port: - number: 8005 + number: 8080 diff --git a/manifests/qkd_appservice.yaml b/manifests/qkd_appservice.yaml index 4f89d6c6f8400b509dc595f551e8f181e70b2f51..ba02e2e1da34ac670f68e17c28e9847603401d3a 100644 --- a/manifests/qkd_appservice.yaml +++ b/manifests/qkd_appservice.yaml @@ -28,36 +28,35 @@ spec: spec: terminationGracePeriodSeconds: 5 containers: - - name: server - image: labs.etsi.org:5050/tfs/controller/qkd_app:latest - imagePullPolicy: Always - ports: - - containerPort: 10060 - - containerPort: 9192 - - containerPort: 8005 - env: - - name: LOG_LEVEL - value: "DEBUG" - - name: CRDB_DATABASE_APP - value: "qkd_app" - envFrom: - - secretRef: - name: crdb-data - - secretRef: - name: nats-data - readinessProbe: - exec: - command: ["/bin/grpc_health_probe", "-addr=:10060"] - livenessProbe: - exec: - command: ["/bin/grpc_health_probe", "-addr=:10060"] - resources: - requests: - cpu: 150m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi + - name: server + image: labs.etsi.org:5050/tfs/controller/qkd_app:latest + imagePullPolicy: Always + ports: + - containerPort: 10060 + - containerPort: 9192 + env: + - name: LOG_LEVEL + value: "INFO" + - name: CRDB_DATABASE + value: "qkd_app" + envFrom: + - secretRef: + name: crdb-data + - secretRef: + name: nats-data + readinessProbe: + exec: + command: ["/bin/grpc_health_probe", "-addr=:10060"] + livenessProbe: + exec: + command: ["/bin/grpc_health_probe", "-addr=:10060"] + resources: + requests: + cpu: 150m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi --- apiVersion: v1 kind: Service @@ -70,14 +69,11 @@ spec: selector: app: qkd-appservice ports: - - name: grpc - protocol: TCP - port: 10060 - targetPort: 10060 - - name: metrics - protocol: TCP - port: 9192 - targetPort: 9192 - - name: http - port: 8005 - targetPort: 8005 + - name: grpc + protocol: TCP + port: 10060 + targetPort: 10060 + - name: metrics + protocol: TCP + port: 9192 + targetPort: 9192 diff --git a/proto/qkd_app.proto b/proto/qkd_app.proto index 7b6c47330833849b889e770aac43844ec6e6072c..b22948d6cd3040146677d378a080283786d44f96 100644 --- a/proto/qkd_app.proto +++ b/proto/qkd_app.proto @@ -1,11 +1,26 @@ +// Copyright 2022-2024 ETSI OSG/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. + syntax = "proto3"; package qkd_app; import "context.proto"; -// Optare: Change this if you want to change App's structure or enums. -// Optare: If a message (structure) is changed it must be changed in src/app/service/database +// Define Empty message if you don't want to use google.protobuf.Empty. +message Empty {} +// Enum representing possible states of a QKD application. enum QKDAppStatusEnum { QKDAPPSTATUS_ON = 0; QKDAPPSTATUS_DISCONNECTED = 1; @@ -13,16 +28,26 @@ enum QKDAppStatusEnum { QKDAPPSTATUS_ZOMBIE = 3; } +// Enum representing QKD application types. enum QKDAppTypesEnum { QKDAPPTYPES_INTERNAL = 0; QKDAPPTYPES_CLIENT = 1; } +// Message representing a QKDL (Quantum Key Distribution Link) identifier. message QKDLId { context.Uuid qkdl_uuid = 1; } +// Define QoS parameters for QKD applications +message QoS { + uint32 max_bandwidth = 1; // Maximum bandwidth (in bits per second) + uint32 min_bandwidth = 2; // Minimum bandwidth (optional) + uint32 jitter = 3; // Maximum jitter (in milliseconds) + uint32 ttl = 4; // Time-to-live (in seconds) +} +// Main message representing a QKD application with all required fields. message App { AppId app_id = 1; QKDAppStatusEnum app_status = 2; @@ -32,22 +57,24 @@ message App { repeated QKDLId backing_qkdl_id = 6; context.DeviceId local_device_id = 7; context.DeviceId remote_device_id = 8; + QoS qos = 9; // Include QoS in the App message } - +// Message representing an identifier for an app. message AppId { context.ContextId context_id = 1; context.Uuid app_uuid = 2; } - +// Service definition for AppService, including app registration and listing. service AppService { rpc RegisterApp(App) returns (context.Empty) {} - rpc ListApps (context.ContextId ) returns ( AppList ) {} - } - - + rpc ListApps(context.ContextId) returns (AppList) {} + rpc GetApp(AppId) returns (App) {} + rpc DeleteApp (AppId) returns (Empty) {} // Use locally defined Empty +} - message AppList { +// Message representing a list of apps. +message AppList { repeated App apps = 1; } diff --git a/src/common/Constants.py b/src/common/Constants.py index 8b2e215a0ee669726430d12ea4ebac334f69c1ce..78cf76b0077638251cc95c2f294a1a075ac2a27b 100644 --- a/src/common/Constants.py +++ b/src/common/Constants.py @@ -114,15 +114,12 @@ DEFAULT_SERVICE_GRPC_PORTS = { # Default HTTP/REST-API service ports DEFAULT_SERVICE_HTTP_PORTS = { - ServiceNameEnum.CONTEXT .value : 8080, - ServiceNameEnum.NBI .value : 8080, - ServiceNameEnum.WEBUI .value : 8004, - ServiceNameEnum.QKD_APP .value : 8005, + ServiceNameEnum.NBI .value : 8080, + ServiceNameEnum.WEBUI.value : 8004, } # Default HTTP/REST-API service base URLs DEFAULT_SERVICE_HTTP_BASEURLS = { - ServiceNameEnum.NBI .value : None, - ServiceNameEnum.WEBUI .value : None, - ServiceNameEnum.QKD_APP .value : None, + ServiceNameEnum.NBI .value : None, + ServiceNameEnum.WEBUI.value : None, } diff --git a/src/common/Settings.py b/src/common/Settings.py index 13fcfc76966301599b0f5f39f2b188aea4e4d52a..5845ee5221be69e5d5cf00b60d9376a7fd6fcec3 100644 --- a/src/common/Settings.py +++ b/src/common/Settings.py @@ -15,9 +15,11 @@ import logging, os, re, time from typing import Dict, List from common.Constants import ( - DEFAULT_GRPC_BIND_ADDRESS, DEFAULT_GRPC_GRACE_PERIOD, DEFAULT_GRPC_MAX_WORKERS, DEFAULT_HTTP_BIND_ADDRESS, - DEFAULT_LOG_LEVEL, DEFAULT_METRICS_PORT, DEFAULT_SERVICE_GRPC_PORTS, DEFAULT_SERVICE_HTTP_BASEURLS, - DEFAULT_SERVICE_HTTP_PORTS, ServiceNameEnum + DEFAULT_GRPC_BIND_ADDRESS, DEFAULT_GRPC_GRACE_PERIOD, + DEFAULT_GRPC_MAX_WORKERS, DEFAULT_HTTP_BIND_ADDRESS, + DEFAULT_LOG_LEVEL, DEFAULT_METRICS_PORT, DEFAULT_SERVICE_GRPC_PORTS, + DEFAULT_SERVICE_HTTP_BASEURLS, DEFAULT_SERVICE_HTTP_PORTS, + ServiceNameEnum ) LOGGER = logging.getLogger(__name__) diff --git a/src/nbi/service/__main__.py b/src/nbi/service/__main__.py index 58fbb9625addc43c6b62d06d7a9caa3f648203d5..8f4ef87e03a3954227d777ab06c220d373b70c08 100644 --- a/src/nbi/service/__main__.py +++ b/src/nbi/service/__main__.py @@ -16,9 +16,10 @@ import logging, signal, sys, threading from prometheus_client import start_http_server from common.Constants import ServiceNameEnum from common.Settings import ( - ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, get_env_var_name, get_log_level, get_metrics_port, - wait_for_environment_variables) - + ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, + get_env_var_name, get_log_level, get_metrics_port, + wait_for_environment_variables +) from .NbiService import NbiService from .rest_server.RestServer import RestServer from .rest_server.nbi_plugins.etsi_bwm import register_etsi_bwm_api @@ -28,6 +29,7 @@ from .rest_server.nbi_plugins.ietf_l3vpn import register_ietf_l3vpn from .rest_server.nbi_plugins.ietf_network import register_ietf_network from .rest_server.nbi_plugins.ietf_network_slice import register_ietf_nss from .rest_server.nbi_plugins.ietf_acl import register_ietf_acl +from .rest_server.nbi_plugins.qkd_app import register_qkd_app from .rest_server.nbi_plugins.tfs_api import register_tfs_api terminate = threading.Event() @@ -72,6 +74,7 @@ def main(): register_ietf_network(rest_server) register_ietf_nss(rest_server) # Registering NSS entrypoint register_ietf_acl(rest_server) + register_qkd_app(rest_server) register_tfs_api(rest_server) rest_server.start() diff --git a/src/qkd_app/service/rest_server/qkd_app/Resources.py b/src/nbi/service/rest_server/nbi_plugins/qkd_app/Resources.py similarity index 57% rename from src/qkd_app/service/rest_server/qkd_app/Resources.py rename to src/nbi/service/rest_server/nbi_plugins/qkd_app/Resources.py index 6ba79d3940da91dfebc1a1c666893548caccbe6c..d14fe9575b21319a0fa597a7746510a11e102903 100644 --- a/src/qkd_app/service/rest_server/qkd_app/Resources.py +++ b/src/nbi/service/rest_server/nbi_plugins/qkd_app/Resources.py @@ -12,7 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import uuid, json +import uuid +import json from flask import request from flask_restful import Resource from common.proto.context_pb2 import Empty @@ -21,7 +22,6 @@ from common.Constants import DEFAULT_CONTEXT_NAME from context.client.ContextClient import ContextClient from qkd_app.client.QKDAppClient import QKDAppClient - class _Resource(Resource): def __init__(self) -> None: super().__init__() @@ -32,18 +32,56 @@ class Index(_Resource): def get(self): return {'hello': 'world'} +class ListDevices(_Resource): + def get(self): + """ + List devices and associate the apps with them. + """ + devices = self.context_client.ListDevices(Empty()).devices + for device in devices: + # Fetch apps associated with this device + device.apps = self.get_apps_for_device(device.device_id.device_uuid.uuid) + return {'devices': [self.format_device(device) for device in devices]} + + def get_apps_for_device(self, device_uuid): + """ + Fetch the apps associated with a given device UUID. + """ + try: + # Call the AppService to get the list of apps + apps_list = self.qkd_app_client.ListApps(Empty()) + + # Filter apps for this specific device + device_apps = [] + for app in apps_list.apps: + if app.local_device_id.device_uuid.uuid == device_uuid or \ + app.remote_device_id.device_uuid.uuid == device_uuid: + device_apps.append(app) + return device_apps + + except Exception as e: + print(f"Error fetching apps for device {device_uuid}: {e}") + return [] + + def format_device(self, device): + """ + Formats a device object to include the associated apps in the response. + """ + return { + 'device_uuid': device.device_id.device_uuid.uuid, + 'name': device.name, + 'type': device.device_type, + 'status': device.device_operational_status, + 'apps': [{'app_id': app.app_id.app_uuid.uuid, 'app_status': app.app_status, 'app_type': app.app_type} for app in device.apps] + } + class CreateQKDApp(_Resource): - # Optare: Post request for the QKD Node to call the TeraflowSDN. Example of requests below def post(self): app = request.get_json()['app'] - - devices = self.context_client.ListDevices(Empty()) - devices = devices.devices - + devices = self.context_client.ListDevices(Empty()).devices local_device = None - - # This for-loop won't be necessary if we can garantee Device ID is the same as QKDN Id + # This for-loop won't be necessary if Device ID is guaranteed to be the same as QKDN Id for device in devices: for config_rule in device.device_config.config_rules: if config_rule.custom.resource_key == '__node__': @@ -53,15 +91,6 @@ class CreateQKDApp(_Resource): local_device = device break - # Optare: Todo: Verify that a service is present for this app - ''' - requests.post('http://10.211.36.220/app/create_qkd_app', json={'app': {'server_app_id':'1', 'client_app_id':[], 'app_status':'ON', 'local_qkdn_id':'00000001-0000-0000-0000-000000000000', 'backing_qkdl_id':['00000003-0002-0000-0000-000000000000']}}) - - - requests.post('http://10.211.36.220/app/create_qkd_app', json={'app': {'server_app_id':'1', 'client_app_id':[], 'app_status':'ON', 'local_qkdn_id':'00000003-0000-0000-0000-000000000000', 'backing_qkdl_id':['00000003-0002-0000-0000-000000000000']}}) - ''' - - if local_device is None: return {"status": "fail"} @@ -76,11 +105,7 @@ class CreateQKDApp(_Resource): 'remote_device_id': {'device_uuid': {'uuid': ''}}, } - - # Optare: This will call our internal RegisterApp which supports the creation of both internal and external app. - # Optare the verification for knowing if two parties are requesting the same app is done inside RegisterApp's function self.qkd_app_client.RegisterApp(App(**external_app_src_dst)) - # Optare: Todo: Communicate by SBI with both Nodes of the new App - return {"status": "success"} + diff --git a/src/qkd_app/service/rest_server/qkd_app/__init__.py b/src/nbi/service/rest_server/nbi_plugins/qkd_app/__init__.py similarity index 67% rename from src/qkd_app/service/rest_server/qkd_app/__init__.py rename to src/nbi/service/rest_server/nbi_plugins/qkd_app/__init__.py index 6fc23b371414dcb2bac4afde63524febf71e5337..30982f104e189eb4af5b3f49b1abe2b0e5fb2d85 100644 --- a/src/qkd_app/service/rest_server/qkd_app/__init__.py +++ b/src/nbi/service/rest_server/nbi_plugins/qkd_app/__init__.py @@ -4,7 +4,7 @@ # 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 +# 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, @@ -12,19 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from qkd_app.service.rest_server.RestServer import RestServer -from .Resources import ( - CreateQKDApp, Index) +from nbi.service.rest_server.RestServer import RestServer +from .Resources import CreateQKDApp, Index URL_PREFIX = '/qkd_app' # Use 'path' type since some identifiers might contain char '/' and Flask is unable to recognize them in 'string' type. RESOURCES = [ # (endpoint_name, resource_class, resource_url) - ('api.index', Index, '/'), - ('api.register_qkd_app', CreateQKDApp, '/create_qkd_app'), + ('api.index', Index, '/'), + ('api.register_qkd_app', CreateQKDApp, '/create_qkd_app'), ] -def register_qkd_app(app_server : RestServer): +def register_qkd_app(rest_server : RestServer): for endpoint_name, resource_class, resource_url in RESOURCES: - app_server.add_resource(resource_class, URL_PREFIX + resource_url, endpoint=endpoint_name) + rest_server.add_resource(resource_class, URL_PREFIX + resource_url, endpoint=endpoint_name) diff --git a/src/qkd_app/client/QKDAppClient.py b/src/qkd_app/client/QKDAppClient.py index 1a174df6adc69ab9ce88b0d8878c92b9b9e7820e..a35b18f1d9f380ae4f95f1a643716075642e31d6 100644 --- a/src/qkd_app/client/QKDAppClient.py +++ b/src/qkd_app/client/QKDAppClient.py @@ -12,53 +12,92 @@ # See the License for the specific language governing permissions and # limitations under the License. -import grpc, logging +import grpc +import logging from common.Constants import ServiceNameEnum from common.Settings import get_service_host, get_service_port_grpc -from common.proto.context_pb2 import Empty, ContextId from common.proto.qkd_app_pb2 import App, AppId, AppList from common.proto.qkd_app_pb2_grpc import AppServiceStub from common.tools.client.RetryDecorator import retry, delay_exponential from common.tools.grpc.Tools import grpc_message_to_json_string LOGGER = logging.getLogger(__name__) + +# Define retry mechanism MAX_RETRIES = 15 DELAY_FUNCTION = delay_exponential(initial=0.01, increment=2.0, maximum=5.0) -RETRY_DECORATOR = retry(max_retries=MAX_RETRIES, delay_function=DELAY_FUNCTION, prepare_method_name='connect') class QKDAppClient: def __init__(self, host=None, port=None): - if not host: host = get_service_host(ServiceNameEnum.QKD_APP) - if not port: port = get_service_port_grpc(ServiceNameEnum.QKD_APP) - self.endpoint = '{:s}:{:s}'.format(str(host), str(port)) - LOGGER.debug('Creating channel to {:s}...'.format(self.endpoint)) + self.host = host or get_service_host(ServiceNameEnum.QKD_APP) + self.port = port or get_service_port_grpc(ServiceNameEnum.QKD_APP) + self.endpoint = f'{self.host}:{self.port}' + LOGGER.debug(f'Initializing gRPC client to {self.endpoint}...') self.channel = None self.stub = None self.connect() - LOGGER.debug('Channel created') def connect(self): - self.channel = grpc.insecure_channel(self.endpoint) - self.stub = AppServiceStub(self.channel) + try: + self.channel = grpc.insecure_channel(self.endpoint) + self.stub = AppServiceStub(self.channel) + LOGGER.debug(f'gRPC channel to {self.endpoint} established successfully') + except Exception as e: + LOGGER.error(f"Failed to establish gRPC connection: {e}") + self.stub = None def close(self): - if self.channel is not None: self.channel.close() + if self.channel: + self.channel.close() + LOGGER.debug(f'gRPC channel to {self.endpoint} closed') self.channel = None self.stub = None + def check_connection(self): + if self.stub is None: + LOGGER.error("gRPC connection is not established. Retrying...") + self.connect() + if self.stub is None: + raise ConnectionError("gRPC connection could not be established.") + + @retry(max_retries=MAX_RETRIES, delay_function=DELAY_FUNCTION) + def RegisterApp(self, app_request: App) -> None: + """Register a new QKD app.""" + self.check_connection() + LOGGER.debug(f'RegisterApp request: {grpc_message_to_json_string(app_request)}') + self.stub.RegisterApp(app_request) + LOGGER.debug('App registered successfully') + @retry(max_retries=MAX_RETRIES, delay_function=DELAY_FUNCTION) + def UpdateApp(self, app_request: App) -> None: + """Update an existing QKD app.""" + self.check_connection() + LOGGER.debug(f'UpdateApp request: {grpc_message_to_json_string(app_request)}') + self.stub.UpdateApp(app_request) + LOGGER.debug('App updated successfully') - @RETRY_DECORATOR - def RegisterApp(self, request : App) -> Empty: - LOGGER.debug('RegisterApp request: {:s}'.format(grpc_message_to_json_string(request))) - response = self.stub.RegisterApp(request) - LOGGER.debug('RegisterApp result: {:s}'.format(grpc_message_to_json_string(response))) + @retry(max_retries=MAX_RETRIES, delay_function=DELAY_FUNCTION) + def ListApps(self, context_id) -> AppList: + """List all apps for a given context.""" + self.check_connection() + LOGGER.debug(f'ListApps request for context_id: {grpc_message_to_json_string(context_id)}') + response = self.stub.ListApps(context_id) + LOGGER.debug(f'ListApps result: {grpc_message_to_json_string(response)}') return response - - @RETRY_DECORATOR - def ListApps(self, request: ContextId) -> AppList: - LOGGER.debug('ListApps request: {:s}'.format(grpc_message_to_json_string(request))) - response = self.stub.ListApps(request) - LOGGER.debug('ListApps result: {:s}'.format(grpc_message_to_json_string(response))) + @retry(max_retries=MAX_RETRIES, delay_function=DELAY_FUNCTION) + def GetApp(self, app_id: AppId) -> App: + """Fetch details of a specific app by its ID.""" + self.check_connection() + LOGGER.debug(f'GetApp request for app_id: {grpc_message_to_json_string(app_id)}') + response = self.stub.GetApp(app_id) + LOGGER.debug(f'GetApp result: {grpc_message_to_json_string(response)}') return response + + @retry(max_retries=MAX_RETRIES, delay_function=DELAY_FUNCTION) + def DeleteApp(self, app_id: AppId) -> None: + """Delete an app by its ID.""" + self.check_connection() # Ensures connection is established + LOGGER.debug(f'DeleteApp request for app_id: {grpc_message_to_json_string(app_id)}') + self.stub.DeleteApp(app_id) # Calls the gRPC service + LOGGER.debug('App deleted successfully') diff --git a/src/qkd_app/service/QKDAppService.py b/src/qkd_app/service/QKDAppService.py index a6c93cd811a72594804fe8e8e86a9586533a1317..b9b34ed9b8239ea119c5af1364bbc53914b42c51 100644 --- a/src/qkd_app/service/QKDAppService.py +++ b/src/qkd_app/service/QKDAppService.py @@ -12,7 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import logging, sqlalchemy +import logging +import sqlalchemy from common.Constants import ServiceNameEnum from common.Settings import get_service_port_grpc from common.message_broker.MessageBroker import MessageBroker @@ -20,18 +21,38 @@ from common.proto.qkd_app_pb2_grpc import add_AppServiceServicer_to_server from common.tools.service.GenericGrpcService import GenericGrpcService from qkd_app.service.QKDAppServiceServicerImpl import AppServiceServicerImpl -# Custom gRPC settings -GRPC_MAX_WORKERS = 200 # multiple clients might keep connections alive for Get*Events() RPC methods +# Configure maximum number of workers for gRPC +GRPC_MAX_WORKERS = 200 # Adjusted for high concurrency LOGGER = logging.getLogger(__name__) - class AppService(GenericGrpcService): + """ + gRPC Service for handling QKD App-related operations. + This class initializes the gRPC server and installs the servicers. + """ def __init__( - self, db_engine : sqlalchemy.engine.Engine, messagebroker : MessageBroker, cls_name: str = __name__ + self, db_engine: sqlalchemy.engine.Engine, messagebroker: MessageBroker, cls_name: str = __name__ ) -> None: + """ + Initializes the AppService with the provided database engine and message broker. + Sets up the gRPC server to handle app-related requests. + + Args: + db_engine (sqlalchemy.engine.Engine): Database engine for handling app data. + messagebroker (MessageBroker): Message broker for inter-service communication. + cls_name (str): Class name for logging purposes (default is __name__). + """ + # Get the port for the gRPC AppService port = get_service_port_grpc(ServiceNameEnum.QKD_APP) + # Initialize the base class with port and max worker configuration super().__init__(port, max_workers=GRPC_MAX_WORKERS, cls_name=cls_name) + # Initialize the AppServiceServicer with the database and message broker self.app_servicer = AppServiceServicerImpl(db_engine, messagebroker) def install_servicers(self): + """ + Installs the AppService servicers to the gRPC server. + This allows the server to handle requests for QKD app operations. + """ add_AppServiceServicer_to_server(self.app_servicer, self.server) + LOGGER.debug("AppService servicer installed") diff --git a/src/qkd_app/service/QKDAppServiceServicerImpl.py b/src/qkd_app/service/QKDAppServiceServicerImpl.py index df7a885c47eda9d7a6137c9905388da49c698e7e..3ef13b75e5510a95b91a9a67fa65ed06c32a3526 100644 --- a/src/qkd_app/service/QKDAppServiceServicerImpl.py +++ b/src/qkd_app/service/QKDAppServiceServicerImpl.py @@ -12,62 +12,175 @@ # See the License for the specific language governing permissions and # limitations under the License. -import grpc, logging, sqlalchemy -#from typing import Iterator, Optional +import grpc +import logging +import sqlalchemy +import uuid from common.message_broker.MessageBroker import MessageBroker -import grpc, json, logging #, deepdiff -from common.proto.context_pb2 import ( - Empty, Service, ServiceId, ServiceStatusEnum, ServiceTypeEnum, ContextId) -from common.proto.qkd_app_pb2 import (App, AppId, AppList, QKDAppTypesEnum) +from common.proto.context_pb2 import Empty, ContextId +from common.proto.qkd_app_pb2 import App, AppId, AppList, QKDAppTypesEnum, QoS +from common.method_wrappers.ServiceExceptions import InvalidArgumentException, NotFoundException from common.proto.qkd_app_pb2_grpc import AppServiceServicer +from common.tools.grpc.Tools import grpc_message_to_json_string from common.method_wrappers.Decorator import MetricsPool, safe_and_metered_rpc_method -#from common.tools.context_queries.InterDomain import is_inter_domain #, is_multi_domain -#from common.tools.grpc.ConfigRules import copy_config_rules -#from common.tools.grpc.Constraints import copy_constraints -#from common.tools.grpc.EndPointIds import copy_endpoint_ids -#from common.tools.grpc.ServiceIds import update_service_ids -#from common.tools.grpc.Tools import grpc_message_to_json_string -#from context.client.ContextClient import ContextClient -#from qkd_app.client.QKDAppClient import QKDAppClient -from .database.QKDApp import app_set, app_list_objs, app_get, app_get_by_server -from common.method_wrappers.ServiceExceptions import NotFoundException +from .database.QKDApp import app_set, app_list_objs, app_get, app_get_by_server, app_delete LOGGER = logging.getLogger(__name__) METRICS_POOL = MetricsPool('QkdApp', 'RPC') -# Optare: This file must be edited based on app's logic - class AppServiceServicerImpl(AppServiceServicer): - def __init__(self, db_engine : sqlalchemy.engine.Engine, messagebroker : MessageBroker): - LOGGER.debug('Creating Servicer...') + def __init__(self, db_engine: sqlalchemy.engine.Engine, messagebroker: MessageBroker): + LOGGER.debug('Initializing AppServiceServicer...') self.db_engine = db_engine self.messagebroker = messagebroker - LOGGER.debug('Servicer Created') + LOGGER.debug('AppServiceServicer initialized') @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) - def RegisterApp(self, request : App, context : grpc.ServicerContext) -> Empty: - # Optare: This is the main function required for the project. - # Optare: If it's an internal it will save it directly. If it's an external one it will save it as pending by not providing the remote until the other party requests it too - # Optare: Ideally, the only thing needed to change is the code inside the try block. Currently it just searches by a pending app with the same server_id but you can put more restrictions or different search and raise the NotFoundException + def RegisterApp(self, request: App, context: grpc.ServicerContext) -> Empty: + """ + Registers an app in the system, handling both internal and external applications + with ETSI GS QKD 015 compliance. + """ + LOGGER.debug(f"Received RegisterApp request: {grpc_message_to_json_string(request)}") - if request.app_type == QKDAppTypesEnum.QKDAPPTYPES_INTERNAL: - app_set(self.db_engine, self.messagebroker, request) + try: + # Validate QoS parameters as per ETSI 015 requirements + self._validate_qos(request.qos) - else: - try: - app = app_get_by_server(self.db_engine, request.server_app_id) - except NotFoundException: - app = request - app_set(self.db_engine, self.messagebroker, app) + # Check if an app with the same server_app_id and local_device_id already exists + existing_app = self._check_existing_app(request.server_app_id, request.local_device_id.device_uuid.uuid) + + if existing_app: + if request.app_type == QKDAppTypesEnum.QKDAPPTYPES_CLIENT: + LOGGER.debug(f"Handling external app registration for server_app_id: {request.server_app_id}") + # Handle second-party registration for external apps + if not existing_app.remote_device_id.device_uuid.uuid: + existing_app.remote_device_id.device_uuid.uuid = request.local_device_id.device_uuid.uuid + app_set(self.db_engine, self.messagebroker, existing_app) + LOGGER.debug(f"Updated external app with server_app_id: {request.server_app_id}, remote_device_id: {request.local_device_id.device_uuid.uuid}") + else: + context.set_code(grpc.StatusCode.ALREADY_EXISTS) + context.set_details(f"App with server_app_id {request.server_app_id} already has both parties registered.") + return Empty() + else: + context.set_code(grpc.StatusCode.ALREADY_EXISTS) + context.set_details(f"App with server_app_id {request.server_app_id} already exists.") + return Empty() else: - app.remote_device_id.device_uuid.uuid = request.local_device_id.device_uuid.uuid - app_set(self.db_engine, self.messagebroker, app) - - - return Empty() - + # Assign application IDs as required + self._validate_and_assign_app_ids(request) + + # Register the app + if request.app_type == QKDAppTypesEnum.QKDAPPTYPES_INTERNAL: + LOGGER.debug(f"Registering internal app with app_uuid: {request.app_id.app_uuid.uuid}") + app_set(self.db_engine, self.messagebroker, request) + else: + self._register_external_app(request) + + LOGGER.debug(f"RegisterApp completed successfully for app: {request.server_app_id}") + return Empty() + + except InvalidArgumentException as e: + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(str(e)) + raise e + except Exception as e: + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details("An internal error occurred during app registration.") + raise e + + def _validate_qos(self, qos: QoS) -> None: + """ + Validates the QoS parameters for the application, ensuring ETSI 015 compliance. + """ + if qos.max_bandwidth and qos.min_bandwidth and qos.max_bandwidth < qos.min_bandwidth: + raise InvalidArgumentException("QoS max_bandwidth cannot be less than min_bandwidth.") + + if qos.ttl and qos.ttl <= 0: + raise InvalidArgumentException("QoS TTL must be a positive value.") + + LOGGER.debug(f"QoS validated: {qos}") + + def _check_existing_app(self, server_app_id: str, local_device_id: str): + try: + return app_get_by_server(self.db_engine, server_app_id) + except NotFoundException: + return None + + def _validate_and_assign_app_ids(self, request: App) -> None: + """ + Validates and assigns app IDs (app_uuid, server_app_id, client_app_id) if not provided. + """ + if not request.app_id.app_uuid.uuid: + request.app_id.app_uuid.uuid = str(uuid.uuid4()) + LOGGER.debug(f"Assigned new app_uuid: {request.app_id.app_uuid.uuid}") + + if not request.server_app_id: + request.server_app_id = str(uuid.uuid4()) + LOGGER.debug(f"Assigned new server_app_id: {request.server_app_id}") + + del request.client_app_id[:] # Clear the repeated field for clients + + def _register_external_app(self, request: App) -> None: + try: + existing_app = app_get_by_server(self.db_engine, request.server_app_id) + + if not existing_app.remote_device_id.device_uuid.uuid: + existing_app.remote_device_id.device_uuid.uuid = request.local_device_id.device_uuid.uuid + app_set(self.db_engine, self.messagebroker, existing_app) + else: + LOGGER.debug(f"App with server_app_id: {request.server_app_id} already has both parties registered.") + except NotFoundException: + app_set(self.db_engine, self.messagebroker, request) + + @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) + def ListApps(self, request: ContextId, context: grpc.ServicerContext) -> AppList: + """ + Lists all apps in the system, including their statistics and QoS attributes. + """ + LOGGER.debug(f"Received ListApps request: {grpc_message_to_json_string(request)}") + + try: + apps = app_list_objs(self.db_engine, request.context_uuid.uuid) + for app in apps.apps: + LOGGER.debug(f"App retrieved: {grpc_message_to_json_string(app)}") + + LOGGER.debug(f"ListApps returned {len(apps.apps)} apps for context_id: {request.context_uuid.uuid}") + return apps + except Exception as e: + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details("An internal error occurred while listing apps.") + raise e + + @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) + def GetApp(self, request: AppId, context: grpc.ServicerContext) -> App: + """ + Fetches details of a specific app based on its AppId, including QoS and performance stats. + """ + LOGGER.debug(f"Received GetApp request: {grpc_message_to_json_string(request)}") + try: + app = app_get(self.db_engine, request) + LOGGER.debug(f"GetApp found app with app_uuid: {request.app_uuid.uuid}") + return app + except NotFoundException as e: + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(f"App not found: {e}") + raise e @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) - def ListApps(self, request: ContextId, context : grpc.ServicerContext) -> AppList: - return app_list_objs(self.db_engine) + def DeleteApp(self, request: AppId, context: grpc.ServicerContext) -> Empty: + """ + Deletes an app from the system by its AppId, following ETSI compliance. + """ + LOGGER.debug(f"Received DeleteApp request for app_uuid: {request.app_uuid.uuid}") + try: + app_delete(self.db_engine, request.app_uuid.uuid) + LOGGER.debug(f"App with UUID {request.app_uuid.uuid} deleted successfully.") + return Empty() + except NotFoundException as e: + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(f"App not found: {e}") + raise e + + diff --git a/src/qkd_app/service/__main__.py b/src/qkd_app/service/__main__.py index ed7e554728eb2de6240dd4facb7f084337a026a4..17f3ac240c33b28454e2df43862dd66a50eb5c45 100644 --- a/src/qkd_app/service/__main__.py +++ b/src/qkd_app/service/__main__.py @@ -4,7 +4,7 @@ # 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 +# 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, @@ -12,82 +12,82 @@ # See the License for the specific language governing permissions and # limitations under the License. -import logging, signal, sys, threading +import logging +import signal +import sys +import threading from prometheus_client import start_http_server -#from common.Constants import ServiceNameEnum -from common.Settings import ( - #ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, get_env_var_name, - get_log_level, get_metrics_port, wait_for_environment_variables) +from common.Settings import get_log_level, get_metrics_port from qkd_app.service.QKDAppService import AppService -from qkd_app.service.rest_server.RestServer import RestServer -from qkd_app.service.rest_server.qkd_app import register_qkd_app -#from common.message_broker.Factory import get_messagebroker_backend -#from common.message_broker.MessageBroker import MessageBroker from qkd_app.service.database.Engine import Engine from qkd_app.service.database.models._Base import rebuild_database +# Set up logging +LOG_LEVEL = get_log_level() +logging.basicConfig(level=LOG_LEVEL, format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s") +LOGGER = logging.getLogger(__name__) + +#LOGGER.addHandler(logging.StreamHandler(stream=sys.stderr)) +#LOGGER.setLevel(logging.WARNING) + +# Event for terminating the service gracefully terminate = threading.Event() -LOGGER : logging.Logger = None -def signal_handler(signal, frame): # pylint: disable=redefined-outer-name - LOGGER.warning('Terminate signal received') +def signal_handler(signum, frame): + """ + Handle termination signals like SIGINT and SIGTERM to ensure graceful shutdown. + """ + LOGGER.warning('Termination signal received') terminate.set() def main(): - global LOGGER # pylint: disable=global-statement - - log_level = get_log_level() - logging.basicConfig(level=log_level, format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s") - LOGGER = logging.getLogger(__name__) - - wait_for_environment_variables([ - #get_env_var_name(ServiceNameEnum.CONTEXT, ENVVAR_SUFIX_SERVICE_HOST ), - #get_env_var_name(ServiceNameEnum.CONTEXT, ENVVAR_SUFIX_SERVICE_PORT_GRPC), - ]) - + LOGGER.info('Starting...') + # Register signal handlers for graceful shutdown signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - LOGGER.info('Starting...') - - # Start metrics server + # Start Prometheus metrics server metrics_port = get_metrics_port() start_http_server(metrics_port) + LOGGER.info(f'Metrics server started on port {metrics_port}') - # Get Database Engine instance and initialize database, if needed + # Initialize the SQLAlchemy database engine LOGGER.info('Getting SQLAlchemy DB Engine...') db_engine = Engine.get_engine() if db_engine is None: - LOGGER.error('Unable to get SQLAlchemy DB Engine...') + LOGGER.error('Unable to get SQLAlchemy DB Engine. Exiting...') return -1 + # Try creating the database or log any issues try: Engine.create_database(db_engine) - except: # pylint: disable=bare-except # pragma: no cover - LOGGER.exception('Failed to check/create the database: {:s}'.format(str(db_engine.url))) + except Exception as e: # More specific exception handling + LOGGER.exception(f'Failed to check/create the database: {db_engine.url}. Error: {str(e)}') + return -1 + # Rebuild the database schema if necessary rebuild_database(db_engine) - # Get message broker instance - messagebroker = None #MessageBroker(get_messagebroker_backend()) + # Initialize the message broker (if needed) + messagebroker = None # Disabled until further notice, can be re-enabled when necessary + # messagebroker = MessageBroker(get_messagebroker_backend()) - # Starting context service + # Start the gRPC App Service grpc_service = AppService(db_engine, messagebroker) grpc_service.start() - rest_server = RestServer() - register_qkd_app(rest_server) - rest_server.start() + LOGGER.info('Services started. Waiting for termination signal...') # Wait for Ctrl+C or termination signal - while not terminate.wait(timeout=1.0): pass + # Keep the process running until a termination signal is received + while not terminate.wait(timeout=1.0): + pass - LOGGER.info('Terminating...') + # Shutdown services gracefully on termination + LOGGER.info('Terminating services...') grpc_service.stop() - rest_server.shutdown() - rest_server.join() - LOGGER.info('Bye') + LOGGER.info('Shutdown complete. Exiting...') return 0 if __name__ == '__main__': diff --git a/src/qkd_app/service/database/Engine.py b/src/qkd_app/service/database/Engine.py index 8f528f9a1b3cacca2ea260901ab808461dd3183d..ec86618218cc396dea31404b88d33e46d320ed58 100644 --- a/src/qkd_app/service/database/Engine.py +++ b/src/qkd_app/service/database/Engine.py @@ -28,7 +28,7 @@ class Engine: if crdb_uri is None: CRDB_NAMESPACE = get_setting('CRDB_NAMESPACE') CRDB_SQL_PORT = get_setting('CRDB_SQL_PORT') - CRDB_DATABASE = get_setting('CRDB_DATABASE_APP') + CRDB_DATABASE = get_setting('CRDB_DATABASE') CRDB_USERNAME = get_setting('CRDB_USERNAME') CRDB_PASSWORD = get_setting('CRDB_PASSWORD') CRDB_SSLMODE = get_setting('CRDB_SSLMODE') diff --git a/src/qkd_app/service/database/QKDApp.py b/src/qkd_app/service/database/QKDApp.py index b1fb90d4efcd0770bcc4c48c1f00deb0e95687ad..539555a4dac055d56158298ed7db6dceec39f446 100644 --- a/src/qkd_app/service/database/QKDApp.py +++ b/src/qkd_app/service/database/QKDApp.py @@ -12,174 +12,162 @@ # See the License for the specific language governing permissions and # limitations under the License. -import datetime, logging, uuid +import datetime +import logging +from typing import Dict, List, Optional + from sqlalchemy.dialects.postgresql import insert from sqlalchemy.engine import Engine -from sqlalchemy.orm import Session, selectinload, sessionmaker +from sqlalchemy.orm import Session, sessionmaker from sqlalchemy_cockroachdb import run_transaction -from typing import Dict, List, Optional, Set, Tuple -from common.method_wrappers.ServiceExceptions import InvalidArgumentException, NotFoundException + +from common.method_wrappers.ServiceExceptions import NotFoundException from common.message_broker.MessageBroker import MessageBroker -from common.proto.context_pb2 import Empty -from common.proto.qkd_app_pb2 import ( - AppList, App, AppId) -from common.tools.grpc.Tools import grpc_message_to_json_string +from common.proto.qkd_app_pb2 import AppList, App, AppId +from qkd_app.service.database.uuids._Builder import get_uuid_from_string, get_uuid_random +from common.method_wrappers.ServiceExceptions import InvalidArgumentsException +from common.tools.object_factory.QKDApp import json_app_id +from common.tools.object_factory.Context import json_context_id + from .models.QKDAppModel import AppModel -from .models.enums.QKDAppStatus import grpc_to_enum__qkd_app_status -from .models.enums.QKDAppTypes import grpc_to_enum__qkd_app_types from .uuids.QKDApp import app_get_uuid -from common.tools.object_factory.Context import json_context_id -from common.tools.object_factory.QKDApp import json_app_id from context.service.database.uuids.Context import context_get_uuid +from .models.enums.QKDAppStatus import grpc_to_enum__qkd_app_status +from .models.enums.QKDAppTypes import grpc_to_enum__qkd_app_types +LOGGER = logging.getLogger(__name__) -#from .Events import notify_event_context, notify_event_device, notify_event_topology +def app_list_objs(db_engine: Engine, context_uuid: str = None) -> AppList: + """ + Fetches a list of all QKD applications from the database. Optionally filters by context UUID. -LOGGER = logging.getLogger(__name__) + :param db_engine: SQLAlchemy Engine for DB connection + :param context_uuid: UUID of the context to filter by (optional) + :return: AppList containing all apps + """ + def callback(session: Session) -> List[Dict]: + query = session.query(AppModel) + + if context_uuid: + query = query.filter_by(context_uuid=context_uuid) + return [obj.dump() for obj in query.all()] -def app_list_objs(db_engine : Engine) -> AppList: - def callback(session : Session) -> List[Dict]: - obj_list : List[AppModel] = session.query(AppModel)\ - .all() - return [obj.dump() for obj in obj_list] apps = run_transaction(sessionmaker(bind=db_engine), callback) return AppList(apps=apps) -def app_get(db_engine : Engine, request : AppId) -> App: + +def app_get(db_engine: Engine, request: AppId) -> App: + """ + Fetches a specific app by its UUID. + + :param db_engine: SQLAlchemy Engine for DB connection + :param request: AppId protobuf containing app ID and context ID + :return: App protobuf object + :raises NotFoundException: If the app is not found in the database + """ app_uuid = app_get_uuid(request, allow_random=False) - def callback(session : Session) -> Optional[Dict]: - obj : Optional[AppModel] = session.query(AppModel)\ - .filter_by(app_uuid=app_uuid).one_or_none() - return None if obj is None else obj.dump() + + def callback(session: Session) -> Optional[Dict]: + obj = session.query(AppModel).filter_by(app_uuid=app_uuid).one_or_none() + return obj.dump() if obj else None + obj = run_transaction(sessionmaker(bind=db_engine), callback) - if obj is None: - raw_app_uuid = request.app_uuid.uuid - raise NotFoundException('App', raw_app_uuid, extra_details=[ - 'app_uuid generated was: {:s}'.format(app_uuid) + + if not obj: + raise NotFoundException('App', request.app_uuid.uuid, extra_details=[ + f'app_uuid generated was: {app_uuid}' ]) + return App(**obj) -def app_set(db_engine : Engine, messagebroker : MessageBroker, request : App) -> AppId: - context_uuid = context_get_uuid(request.app_id.context_id, allow_random=False) - raw_app_uuid = request.app_id.app_uuid.uuid - app_uuid = app_get_uuid(request.app_id, allow_random=True) - app_type = request.app_type - app_status = grpc_to_enum__qkd_app_status(request.app_status) - app_type = grpc_to_enum__qkd_app_types(request.app_type) +def app_set(db_engine: Engine, messagebroker: MessageBroker, request: App) -> AppId: + """ + Creates or updates an app in the database. If the app already exists, updates the app. + Otherwise, inserts a new entry. - now = datetime.datetime.utcnow() + :param db_engine: SQLAlchemy Engine for DB connection + :param messagebroker: MessageBroker instance for notifications + :param request: App protobuf object containing app data + :return: AppId protobuf object representing the newly created or updated app + """ + context_uuid = context_get_uuid(request.app_id.context_id, allow_random=False) + app_uuid = app_get_uuid(request.app_id, allow_random=True) - - app_data = [{ - 'context_uuid' : context_uuid, - 'app_uuid' : app_uuid, - 'app_status' : app_status, - 'app_type' : app_type, - 'server_app_id' : request.server_app_id, - 'client_app_id' : request.client_app_id, - 'backing_qkdl_uuid' : [qkdl_id.qkdl_uuid.uuid for qkdl_id in request.backing_qkdl_id], - 'local_device_uuid' : request.local_device_id.device_uuid.uuid, - 'remote_device_uuid' : request.remote_device_id.device_uuid.uuid or None, - 'created_at' : now, - 'updated_at' : now, - }] - - - def callback(session : Session) -> Tuple[bool, List[Dict]]: + # Prepare app data for insertion/update + app_data = { + 'context_uuid': context_uuid, + 'app_uuid': app_uuid, + 'app_status': grpc_to_enum__qkd_app_status(request.app_status), + 'app_type': grpc_to_enum__qkd_app_types(request.app_type), + 'server_app_id': request.server_app_id, + 'client_app_id': request.client_app_id, + 'backing_qkdl_uuid': [qkdl.qkdl_uuid.uuid for qkdl in request.backing_qkdl_id], + 'local_device_uuid': request.local_device_id.device_uuid.uuid, + 'remote_device_uuid': request.remote_device_id.device_uuid.uuid if request.remote_device_id.device_uuid.uuid else None, + 'created_at': datetime.datetime.utcnow(), + 'updated_at': datetime.datetime.utcnow(), + } + + def callback(session: Session) -> bool: + # Create the insert statement stmt = insert(AppModel).values(app_data) + + # Apply the conflict resolution stmt = stmt.on_conflict_do_update( index_elements=[AppModel.app_uuid], set_=dict( - app_status = stmt.excluded.app_status, - app_type = stmt.excluded.app_type, - server_app_id = stmt.excluded.server_app_id, - client_app_id = stmt.excluded.client_app_id, - backing_qkdl_uuid = stmt.excluded.backing_qkdl_uuid, - local_device_uuid = stmt.excluded.local_device_uuid, - remote_device_uuid = stmt.excluded.remote_device_uuid, - updated_at = stmt.excluded.updated_at, + app_status=stmt.excluded.app_status, + app_type=stmt.excluded.app_type, + server_app_id=stmt.excluded.server_app_id, + client_app_id=stmt.excluded.client_app_id, + backing_qkdl_uuid=stmt.excluded.backing_qkdl_uuid, + local_device_uuid=stmt.excluded.local_device_uuid, + remote_device_uuid=stmt.excluded.remote_device_uuid, + updated_at=stmt.excluded.updated_at ) ) - stmt = stmt.returning(AppModel.created_at, AppModel.updated_at) - created_at,updated_at = session.execute(stmt).fetchone() - updated = updated_at > created_at - - return updated - - updated = run_transaction(sessionmaker(bind=db_engine), callback) - context_id = json_context_id(context_uuid) - app_id = json_app_id(app_uuid, context_id=context_id) - #event_type = EventTypeEnum.EVENTTYPE_UPDATE if updated else EventTypeEnum.EVENTTYPE_CREATE - #notify_event_app(messagebroker, event_type, app_id) - #notify_event_context(messagebroker, EventTypeEnum.EVENTTYPE_UPDATE, context_id) + session.execute(stmt) + return True + + run_transaction(sessionmaker(bind=db_engine), callback) + app_id = json_app_id(app_uuid, context_id=json_context_id(context_uuid)) + return AppId(**app_id) +def app_get_by_server(db_engine: Engine, server_app_id: str) -> App: + """ + Fetches an app by its server_app_id. + """ + def callback(session: Session) -> Optional[Dict]: + obj = session.query(AppModel).filter_by(server_app_id=server_app_id).one_or_none() + return obj.dump() if obj else None -def app_get_by_server(db_engine : Engine, request : str) -> App: - def callback(session : Session) -> Optional[Dict]: - obj : Optional[AppModel] = session.query(AppModel)\ - .filter_by(server_app_id=request).one_or_none() - return None if obj is None else obj.dump() obj = run_transaction(sessionmaker(bind=db_engine), callback) - if obj is None: - raise NotFoundException('No app match found for', request) + + if not obj: + raise NotFoundException('App', server_app_id) + return App(**obj) +def app_delete(db_engine: Engine, app_uuid: str) -> None: + """ + Deletes an app by its UUID from the database. + + :param db_engine: SQLAlchemy Engine for DB connection + :param app_uuid: The UUID of the app to be deleted + """ + def callback(session: Session) -> bool: + app_obj = session.query(AppModel).filter_by(app_uuid=app_uuid).one_or_none() + + if app_obj is None: + raise NotFoundException('App', app_uuid) + + session.delete(app_obj) + return True -""" -def device_delete(db_engine : Engine, messagebroker : MessageBroker, request : DeviceId) -> Empty: - device_uuid = device_get_uuid(request, allow_random=False) - def callback(session : Session) -> Tuple[bool, List[Dict]]: - query = session.query(TopologyDeviceModel) - query = query.filter_by(device_uuid=device_uuid) - topology_device_list : List[TopologyDeviceModel] = query.all() - topology_ids = [obj.topology.dump_id() for obj in topology_device_list] - num_deleted = session.query(DeviceModel).filter_by(device_uuid=device_uuid).delete() - return num_deleted > 0, topology_ids - deleted, updated_topology_ids = run_transaction(sessionmaker(bind=db_engine), callback) - device_id = json_device_id(device_uuid) - if deleted: - notify_event_device(messagebroker, EventTypeEnum.EVENTTYPE_REMOVE, device_id) - - context_ids : Dict[str, Dict] = dict() - topology_ids : Dict[str, Dict] = dict() - for topology_id in updated_topology_ids: - topology_uuid = topology_id['topology_uuid']['uuid'] - topology_ids[topology_uuid] = topology_id - context_id = topology_id['context_id'] - context_uuid = context_id['context_uuid']['uuid'] - context_ids[context_uuid] = context_id - - for topology_id in topology_ids.values(): - notify_event_topology(messagebroker, EventTypeEnum.EVENTTYPE_UPDATE, topology_id) - - for context_id in context_ids.values(): - notify_event_context(messagebroker, EventTypeEnum.EVENTTYPE_UPDATE, context_id) - - return Empty() - -def device_select(db_engine : Engine, request : DeviceFilter) -> DeviceList: - device_uuids = [ - device_get_uuid(device_id, allow_random=False) - for device_id in request.device_ids.device_ids - ] - dump_params = dict( - include_endpoints =request.include_endpoints, - include_config_rules=request.include_config_rules, - include_components =request.include_components, - ) - def callback(session : Session) -> List[Dict]: - query = session.query(DeviceModel) - if request.include_endpoints : query = query.options(selectinload(DeviceModel.endpoints)) - if request.include_config_rules: query = query.options(selectinload(DeviceModel.config_rules)) - #if request.include_components : query = query.options(selectinload(DeviceModel.components)) - obj_list : List[DeviceModel] = query.filter(DeviceModel.device_uuid.in_(device_uuids)).all() - return [obj.dump(**dump_params) for obj in obj_list] - devices = run_transaction(sessionmaker(bind=db_engine), callback) - return DeviceList(devices=devices) -""" + run_transaction(sessionmaker(bind=db_engine), callback) diff --git a/src/qkd_app/service/database/models/QKDAppModel.py b/src/qkd_app/service/database/models/QKDAppModel.py index c32b4e28c95105d8659cb52790f51b330764c2cf..d9797d73c9c168efee5810014a70e0425f67e051 100644 --- a/src/qkd_app/service/database/models/QKDAppModel.py +++ b/src/qkd_app/service/database/models/QKDAppModel.py @@ -12,52 +12,70 @@ # See the License for the specific language governing permissions and # limitations under the License. -import operator -from sqlalchemy import CheckConstraint, Column, DateTime, Float, Enum, ForeignKey, Integer, String -from sqlalchemy.dialects.postgresql import UUID, ARRAY -from sqlalchemy.orm import relationship +from sqlalchemy import Column, DateTime, String, Enum, ARRAY +from sqlalchemy.dialects.postgresql import UUID from typing import Dict from ._Base import _Base from .enums.QKDAppStatus import ORM_QKDAppStatusEnum from .enums.QKDAppTypes import ORM_QKDAppTypesEnum + class AppModel(_Base): + """ + ORM model representing a QKD (Quantum Key Distribution) Application. + This model stores information about the QKD app status, type, and related device and + backing QKD links. It is stored in the 'qkd_app' table. + """ __tablename__ = 'qkd_app' - app_uuid = Column(UUID(as_uuid=False), primary_key=True) - context_uuid = Column(UUID(as_uuid=False), nullable=False) # Supposed to be Foreign Key - app_status = Column(Enum(ORM_QKDAppStatusEnum), nullable=False) - app_type = Column(Enum(ORM_QKDAppTypesEnum), nullable=False) - server_app_id = Column(String, nullable=False) - client_app_id = Column(ARRAY(String), nullable=False) - backing_qkdl_uuid = Column(ARRAY(UUID(as_uuid=False)), nullable=False) - local_device_uuid = Column(UUID(as_uuid=False), nullable=False) - remote_device_uuid = Column(UUID(as_uuid=False), nullable=True) + # Primary Key + app_uuid = Column(UUID(as_uuid=False), primary_key=True, nullable=False, doc="Unique identifier for the QKD app.") + + # Foreign Key-like field (context) + context_uuid = Column(UUID(as_uuid=False), nullable=False, doc="Foreign key linking to the application's context.") + + # Status and type + app_status = Column(Enum(ORM_QKDAppStatusEnum), nullable=False, doc="Current status of the QKD app.") + app_type = Column(Enum(ORM_QKDAppTypesEnum), nullable=False, doc="Type of the QKD app (internal or client).") - # Optare: Created_at and Updated_at are only used to know if an app was updated later on the code. Don't change it + # Application IDs + server_app_id = Column(String, nullable=False, doc="ID of the server-side QKD application.") + client_app_id = Column(ARRAY(String), nullable=False, doc="List of client-side QKD application IDs.") - created_at = Column(DateTime, nullable=False) - updated_at = Column(DateTime, nullable=False) + # Backing QKD links and devices + backing_qkdl_uuid = Column(ARRAY(UUID(as_uuid=False)), nullable=False, doc="List of UUIDs of the backing QKD links.") + local_device_uuid = Column(UUID(as_uuid=False), nullable=False, doc="UUID of the local QKD device.") + remote_device_uuid = Column(UUID(as_uuid=False), nullable=True, doc="UUID of the remote QKD device (nullable).") - #__table_args__ = ( - # CheckConstraint(... >= 0, name='name_value_...'), - #) + # Timestamps + created_at = Column(DateTime, nullable=False, doc="Timestamp when the QKD app record was created.") + updated_at = Column(DateTime, nullable=False, doc="Timestamp when the QKD app record was last updated.") def dump_id(self) -> Dict: + """ + Serializes the primary key fields (context and app UUID) into a dictionary. + + :return: A dictionary with 'context_id' and 'app_uuid' keys. + """ return { 'context_id': {'context_uuid': {'uuid': self.context_uuid}}, 'app_uuid': {'uuid': self.app_uuid} } def dump(self) -> Dict: - result = { - 'app_id' : self.dump_id(), - 'app_status' : self.app_status.value, - 'app_type' : self.app_type.value, - 'server_app_id' : self.server_app_id, - 'client_app_id' : self.client_app_id, - 'backing_qkdl_id' : [{'qkdl_uuid': {'uuid': qkdl_id}} for qkdl_id in self.backing_qkdl_uuid], - 'local_device_id' : {'device_uuid': {'uuid': self.local_device_uuid}}, - 'remote_device_id' : {'device_uuid': {'uuid': self.remote_device_uuid}}, + """ + Serializes the entire QKD app model into a dictionary, including app status, type, IDs, + device info, and backing QKD links. + + :return: A dictionary representation of the QKD app. + """ + return { + 'app_id': self.dump_id(), + 'app_status': self.app_status.value, + 'app_type': self.app_type.value, + 'server_app_id': self.server_app_id, + 'client_app_id': self.client_app_id, + 'backing_qkdl_id': [{'qkdl_uuid': {'uuid': qkdl_id}} for qkdl_id in self.backing_qkdl_uuid], + 'local_device_id': {'device_uuid': {'uuid': self.local_device_uuid}}, + 'remote_device_id': {'device_uuid': {'uuid': self.remote_device_uuid}} if self.remote_device_uuid else None, } - return result diff --git a/src/qkd_app/service/database/models/_Base.py b/src/qkd_app/service/database/models/_Base.py index 51863e1d5c06a875c298eab726cfdc3b7fcb75ca..f17fb9a56dcd120ad1dc95ceee720aa942f6ae6c 100644 --- a/src/qkd_app/service/database/models/_Base.py +++ b/src/qkd_app/service/database/models/_Base.py @@ -13,32 +13,17 @@ # limitations under the License. import sqlalchemy -from typing import Any, List -from sqlalchemy.orm import Session, sessionmaker, declarative_base -from sqlalchemy.sql import text -from sqlalchemy_cockroachdb import run_transaction +from sqlalchemy.orm import declarative_base _Base = declarative_base() -''' -def create_performance_enhancers(db_engine : sqlalchemy.engine.Engine) -> None: - def index_storing( - index_name : str, table_name : str, index_fields : List[str], storing_fields : List[str] - ) -> Any: - str_index_fields = ','.join(['"{:s}"'.format(index_field) for index_field in index_fields]) - str_storing_fields = ','.join(['"{:s}"'.format(storing_field) for storing_field in storing_fields]) - INDEX_STORING = 'CREATE INDEX IF NOT EXISTS {:s} ON "{:s}" ({:s}) STORING ({:s});' - return text(INDEX_STORING.format(index_name, table_name, str_index_fields, str_storing_fields)) - - statements = [ - # In case of relations - ] - def callback(session : Session) -> bool: - for stmt in statements: session.execute(stmt) - run_transaction(sessionmaker(bind=db_engine), callback) -''' - -def rebuild_database(db_engine : sqlalchemy.engine.Engine, drop_if_exists : bool = False): - if drop_if_exists: _Base.metadata.drop_all(db_engine) +def rebuild_database(db_engine: sqlalchemy.engine.Engine, drop_if_exists: bool = False): + """ + Rebuild the database schema for the QKD application. Optionally drop the existing schema if specified. + + :param db_engine: SQLAlchemy engine instance. + :param drop_if_exists: Boolean indicating if the schema should be dropped before rebuilding. + """ + if drop_if_exists: + _Base.metadata.drop_all(db_engine) _Base.metadata.create_all(db_engine) - #create_performance_enhancers(db_engine) diff --git a/src/qkd_app/service/database/models/enums/QKDAppStatus.py b/src/qkd_app/service/database/models/enums/QKDAppStatus.py index d3063ef56704ce1bdd48d15ea8c6486ed7c8cfae..980a8b14adca8c69eaf567037dc09872f33622f9 100644 --- a/src/qkd_app/service/database/models/enums/QKDAppStatus.py +++ b/src/qkd_app/service/database/models/enums/QKDAppStatus.py @@ -12,16 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -import enum, functools +import enum +import functools from common.proto.qkd_app_pb2 import QKDAppStatusEnum from ._GrpcToEnum import grpc_to_enum +# Enum mapping for ORM-based app statuses. class ORM_QKDAppStatusEnum(enum.Enum): - ON = QKDAppStatusEnum.QKDAPPSTATUS_ON + ON = QKDAppStatusEnum.QKDAPPSTATUS_ON DISCONNECTED = QKDAppStatusEnum.QKDAPPSTATUS_DISCONNECTED - OUT_OF_TIME = QKDAppStatusEnum.QKDAPPSTATUS_OUT_OF_TIME - ZOMBIE = QKDAppStatusEnum.QKDAPPSTATUS_ZOMBIE - + OUT_OF_TIME = QKDAppStatusEnum.QKDAPPSTATUS_OUT_OF_TIME + ZOMBIE = QKDAppStatusEnum.QKDAPPSTATUS_ZOMBIE +# Function to map between gRPC and ORM enums. grpc_to_enum__qkd_app_status = functools.partial( - grpc_to_enum, QKDAppStatusEnum, ORM_QKDAppStatusEnum) + grpc_to_enum, QKDAppStatusEnum, ORM_QKDAppStatusEnum +) diff --git a/src/qkd_app/service/database/models/enums/QKDAppTypes.py b/src/qkd_app/service/database/models/enums/QKDAppTypes.py index f50b8982d80c0af97c2cbd96d336f450afc50f9b..fdd318cec49b7b7ec8e1a0347c8b0f8c1aeb3a5c 100644 --- a/src/qkd_app/service/database/models/enums/QKDAppTypes.py +++ b/src/qkd_app/service/database/models/enums/QKDAppTypes.py @@ -12,14 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -import enum, functools +import enum +import functools from common.proto.qkd_app_pb2 import QKDAppTypesEnum from ._GrpcToEnum import grpc_to_enum +# Enum mapping for ORM-based app types. class ORM_QKDAppTypesEnum(enum.Enum): INTERNAL = QKDAppTypesEnum.QKDAPPTYPES_INTERNAL - CLIENT = QKDAppTypesEnum.QKDAPPTYPES_CLIENT - + CLIENT = QKDAppTypesEnum.QKDAPPTYPES_CLIENT +# Function to map between gRPC and ORM enums. grpc_to_enum__qkd_app_types = functools.partial( - grpc_to_enum, QKDAppTypesEnum, ORM_QKDAppTypesEnum) + grpc_to_enum, QKDAppTypesEnum, ORM_QKDAppTypesEnum +) diff --git a/src/qkd_app/service/database/uuids/QKDApp.py b/src/qkd_app/service/database/uuids/QKDApp.py index 175f1d5f3cf4ceda12a022b4afadb376e11ae5a5..f6c0e1e12d591e747adf9497c77177e0fdd4dc51 100644 --- a/src/qkd_app/service/database/uuids/QKDApp.py +++ b/src/qkd_app/service/database/uuids/QKDApp.py @@ -16,15 +16,22 @@ from common.proto.qkd_app_pb2 import AppId from common.method_wrappers.ServiceExceptions import InvalidArgumentsException from ._Builder import get_uuid_from_string, get_uuid_random -def app_get_uuid( - app_id : AppId, allow_random : bool = False -) -> str: +def app_get_uuid(app_id: AppId, allow_random: bool = False) -> str: + """ + Retrieves or generates the UUID for an app. + + :param app_id: AppId object that contains the app UUID + :param allow_random: If True, generates a random UUID if app_uuid is not set + :return: App UUID as a string + """ app_uuid = app_id.app_uuid.uuid - if len(app_uuid) > 0: + if app_uuid: return get_uuid_from_string(app_uuid) - if allow_random: return get_uuid_random() + + if allow_random: + return get_uuid_random() raise InvalidArgumentsException([ ('app_id.app_uuid.uuid', app_uuid), - ], extra_details=['At least one is required to produce a App UUID']) + ], extra_details=['At least one UUID is required to identify the app.']) diff --git a/src/qkd_app/service/database/uuids/_Builder.py b/src/qkd_app/service/database/uuids/_Builder.py index 39c98de69d577ce2722693e57c4ee678124f9e30..c5996b0f9d2cced27fd05e3966c3a60fa9bae24d 100644 --- a/src/qkd_app/service/database/uuids/_Builder.py +++ b/src/qkd_app/service/database/uuids/_Builder.py @@ -15,30 +15,37 @@ from typing import Optional, Union from uuid import UUID, uuid4, uuid5 -# Generate a UUIDv5-like from the SHA-1 of "TFS" and no namespace to be used as the NAMESPACE for all -# the context UUIDs generated. For efficiency purposes, the UUID is hardcoded; however, it is produced -# using the following code: -# from hashlib import sha1 -# from uuid import UUID -# hash = sha1(bytes('TFS', 'utf-8')).digest() -# NAMESPACE_TFS = UUID(bytes=hash[:16], version=5) +# Hardcoded namespace for generating UUIDs. NAMESPACE_TFS = UUID('200e3a1f-2223-534f-a100-758e29c37f40') -def get_uuid_from_string(str_uuid_or_name : Union[str, UUID], prefix_for_name : Optional[str] = None) -> str: - # if UUID given, assume it is already a valid UUID - if isinstance(str_uuid_or_name, UUID): return str_uuid_or_name +def get_uuid_from_string(str_uuid_or_name: Union[str, UUID], prefix_for_name: Optional[str] = None) -> str: + """ + Convert a string or UUID object into a UUID string. If input is a name, generate a UUID using the TFS namespace. + + :param str_uuid_or_name: Input string or UUID to be converted into UUID format + :param prefix_for_name: Optional prefix to add before the name when generating a name-based UUID + :return: A valid UUID string + :raises ValueError: If the input is invalid and cannot be converted to a UUID + """ + if isinstance(str_uuid_or_name, UUID): + return str(str_uuid_or_name) # Ensure returning a string representation + if not isinstance(str_uuid_or_name, str): - MSG = 'Parameter({:s}) cannot be used to produce a UUID' - raise Exception(MSG.format(str(repr(str_uuid_or_name)))) + raise ValueError(f"Invalid parameter ({repr(str_uuid_or_name)}). Expected a string or UUID to produce a valid UUID.") + try: - # try to parse as UUID + # Try to interpret the input as a UUID return str(UUID(str_uuid_or_name)) - except: # pylint: disable=bare-except - # produce a UUID within TFS namespace from parameter - if prefix_for_name is not None: - str_uuid_or_name = '{:s}/{:s}'.format(prefix_for_name, str_uuid_or_name) + except ValueError: + # If the input isn't a valid UUID, generate one using the name-based approach + if prefix_for_name: + str_uuid_or_name = f"{prefix_for_name}/{str_uuid_or_name}" return str(uuid5(NAMESPACE_TFS, str_uuid_or_name)) def get_uuid_random() -> str: - # Generate random UUID. No need to use namespace since "namespace + random = random". + """ + Generate and return a new random UUID as a string. + + :return: A randomly generated UUID string + """ return str(uuid4()) diff --git a/src/qkd_app/service/rest_server/RestServer.py b/src/qkd_app/service/rest_server/RestServer.py deleted file mode 100644 index e21531c5bcf0e1cf15a8f08952d6325a8349f398..0000000000000000000000000000000000000000 --- a/src/qkd_app/service/rest_server/RestServer.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright 2022-2024 ETSI OSG/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 common.Constants import ServiceNameEnum -from common.Settings import get_service_baseurl_http, get_service_port_http -from common.tools.service.GenericRestServer import GenericRestServer - -class RestServer(GenericRestServer): - def __init__(self, cls_name: str = __name__) -> None: - bind_port = get_service_port_http(ServiceNameEnum.QKD_APP) - base_url = get_service_baseurl_http(ServiceNameEnum.QKD_APP) - super().__init__(bind_port, base_url, cls_name=cls_name) diff --git a/src/qkd_app/service/rest_server/__init__.py b/src/qkd_app/service/rest_server/__init__.py deleted file mode 100644 index 07d08814021ef82220611ee21c01ba01806682e9..0000000000000000000000000000000000000000 --- a/src/qkd_app/service/rest_server/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2022-2024 ETSI OSG/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. diff --git a/src/service/service/service_handlers/qkd/qkd_service_handler.py b/src/service/service/service_handlers/qkd/qkd_service_handler.py index 0977388005ef72fe036de93de2dc73438f0c6163..2bfbcb59dd045d10b9267dc15119e1d17e1929d4 100644 --- a/src/service/service/service_handlers/qkd/qkd_service_handler.py +++ b/src/service/service/service_handlers/qkd/qkd_service_handler.py @@ -17,7 +17,8 @@ import json, logging, uuid from typing import Any, Dict, List, Optional, Tuple, Union from common.method_wrappers.Decorator import MetricsPool, metered_subclass_method from common.proto.context_pb2 import ConfigRule, DeviceId, Service -from common.proto.qkd_app_pb2 import App, QKDAppStatusEnum, QKDAppTypesEnum +from common.proto.qkd_app_pb2 import App, AppId, QKDAppStatusEnum, QKDAppTypesEnum +from common.proto.context_pb2 import ContextId, Uuid from common.tools.object_factory.ConfigRule import json_config_rule_delete, json_config_rule_set from common.tools.object_factory.Device import json_device_id from common.type_checkers.Checkers import chk_type @@ -41,6 +42,7 @@ class QKDServiceHandler(_ServiceHandler): self.__service = service self.__task_executor = task_executor self.__settings_handler = SettingsHandler(service.service_config, **settings) + self.qkd_app_client = task_executor._qkd_app_client # Initialize qkd_app_client # Optare: This function is where the service is created @@ -88,14 +90,9 @@ class QKDServiceHandler(_ServiceHandler): interfaces.append([0,0]) links.append([]) - - - - endpoint_left = get_endpoint_name_by_uuid(device, endpoint_left_uuid) if idx > 0 else None endpoint_right = get_endpoint_name_by_uuid(device, endpoint_right_uuid) if 2 * idx + 2 < len(endpoints) else None - for config_rule in device.device_config.config_rules: resource_key = config_rule.custom.resource_key @@ -271,7 +268,7 @@ class QKDServiceHandler(_ServiceHandler): 'remote_device_id': dst_device.device_id, } - self.__task_executor.register_app(App(**internal_app_src_dst)) + self.__task_executor.register_qkd_app(App(**internal_app_src_dst)) # Register App @@ -286,7 +283,7 @@ class QKDServiceHandler(_ServiceHandler): 'remote_device_id': src_device.device_id, } - self.__task_executor.register_app(App(**internal_app_dst_src)) + self.__task_executor.register_qkd_app(App(**internal_app_dst_src)) results.append(True) except Exception as e: # pylint: disable=broad-except @@ -297,31 +294,127 @@ class QKDServiceHandler(_ServiceHandler): # Optare: This will be to delete a service def DeleteEndpoint( - self, endpoints : List[Tuple[str, str, Optional[str]]], - connection_uuid : Optional[str] = None + self, endpoints: List[Tuple[str, str, Optional[str]]], connection_uuid: Optional[str] = None ) -> List[Union[bool, Exception]]: - """ Delete service endpoints form a list. - Parameters: - endpoints: List[Tuple[str, str, Optional[str]]] - List of tuples, each containing a device_uuid, - endpoint_uuid, and the topology_uuid of the endpoint - to be removed. - connection_uuid : Optional[str] - If specified, is the UUID of the connection this endpoint is associated to. - Returns: - results: List[Union[bool, Exception]] - List of results for endpoint deletions requested. - Return values must be in the same order as the requested - endpoints. If an endpoint is properly deleted, True must be - returned; otherwise, the Exception that is raised during - the processing must be returned. - """ - raise NotImplementedError() + chk_type('endpoints', endpoints, list) + if len(endpoints) == 0: + return [] + + LOGGER.info(f'Deleting Endpoints: {endpoints}') + LOGGER.info(f'Connection UUID: {connection_uuid}') + + service_uuid = self.__service.service_id.service_uuid.uuid + context_uuid = self.__service.service_id.context_id.context_uuid.uuid + LOGGER.info(f'Service UUID: {service_uuid}, Context UUID: {context_uuid}') + + results = [] + apps = list() # Initialize apps as an empty list, in case fetching fails + try: + # Initialize device lists and QKDN IDs + devices = [] + qkdn_ids = [] + interfaces = [] + links = [] + + # Populate devices and QKDN ids from endpoints + for idx, endpoint in enumerate(endpoints[::2]): + device_uuid, endpoint_left_uuid = get_device_endpoint_uuids(endpoint) + _, endpoint_right_uuid = get_device_endpoint_uuids(endpoints[2 * idx + 1]) + + device = self.__task_executor.get_device(DeviceId(**json_device_id(device_uuid))) + LOGGER.info(f'Device: {device}, Endpoint Left: {endpoint_left_uuid}, Endpoint Right: {endpoint_right_uuid}') + + devices.append(device) + interfaces.append([0, 0]) + links.append([]) + + for config_rule in device.device_config.config_rules: + resource_key = config_rule.custom.resource_key + + if resource_key == '__node__': + value = json.loads(config_rule.custom.resource_value) + qkdn_ids.append(value['qkdn_id']) + + elif resource_key.startswith('/interface'): + value = json.loads(config_rule.custom.resource_value) + try: + endpoint_str = value['qkdi_att_point']['uuid'] + if endpoint_str == endpoint_left_uuid: + interfaces[idx][0] = value['qkdi_id'] + elif endpoint_str == endpoint_right_uuid: + interfaces[idx][1] = value['qkdi_id'] + except KeyError: + pass + + elif resource_key.startswith('/link'): + value = json.loads(config_rule.custom.resource_value) + links[idx].append(( + value['uuid'], + (value['src_qkdn_id'], value['src_interface_id']), + (value['dst_qkdn_id'], value['dst_interface_id']) + )) + + LOGGER.info(f'Interfaces: {interfaces}, Links: {links}, QKDN IDs: {qkdn_ids}') + + # Fetch the related apps for the service using the same pattern as in routes.py + try: + context_id = ContextId(context_uuid=Uuid(uuid=context_uuid)) + apps_response = self.__task_executor._qkd_app_client.ListApps(context_id) + apps = apps_response.apps # Assign the apps to the list, if successful + LOGGER.info(f"Apps retrieved: {apps}") + except grpc.RpcError as e: + LOGGER.error(f"gRPC error while fetching apps: {e.details()}") + if e.code() != grpc.StatusCode.NOT_FOUND: + raise + apps = list() # If an error occurs, ensure `apps` is still an empty list + + # Filter related internal apps + related_apps = [ + app for app in apps + if app.server_app_id == service_uuid and app.app_type == QKDAppTypesEnum.QKDAPPTYPES_INTERNAL + ] + + # Log each app's details + for app in related_apps: + LOGGER.info(f"App ID: {app.app_id.app_uuid.uuid}, Status: {app.app_status}") + + # Update each app status to DISCONNECTED before deletion + for app in related_apps: + self.__task_executor.update_qkd_app_status(app, QKDAppStatusEnum.QKDAPPSTATUS_DISCONNECTED) + + results.append(True) + + except Exception as e: # pylint: disable=broad-except + LOGGER.error(f"Failed to delete QKD service: {str(e)}") + results.append(e) + + return results + + def fetch_related_internal_apps(self, context_uuid: str, service_uuid: str) -> List[App]: + try: + context_id = ContextId(context_uuid=Uuid(uuid=context_uuid)) + apps_response = self.qkd_app_client.ListApps(context_id) + + # Log the apps retrieved to ensure they exist and have a status + LOGGER.info(f"Apps retrieved: {apps_response.apps}") + + internal_apps = [ + app for app in apps_response.apps + if app.app_type == QKDAppTypesEnum.QKDAPPTYPES_INTERNAL + and app.server_app_id == service_uuid + and app.app_status == QKDAppStatusEnum.ACTIVE # Ensure you are checking status + ] + + LOGGER.info(f"Filtered internal apps: {internal_apps}") + return internal_apps + + except Exception as e: + LOGGER.error(f"Error fetching related internal apps: {e}") + return [] # Optare: Can be ingored. It's in case if a service is later updated. Not required to proper functioning - def SetConstraint(self, constraints: List[Tuple[str, Any]]) \ - -> List[Union[bool, Exception]]: + def SetConstraint(self, constraints: List[Tuple[str, Any]]) -> List[Union[bool, Exception]]: """ Create/Update service constraints. Parameters: constraints: List[Tuple[str, Any]] @@ -335,10 +428,30 @@ class QKDServiceHandler(_ServiceHandler): returned; otherwise, the Exception that is raised during the processing must be returned. """ - raise NotImplementedError() + results = [] + try: + for constraint_type, constraint_value in constraints: + LOGGER.info(f"Setting constraint: {constraint_type} with value: {constraint_value}") + + # Assuming you store constraints as part of service config rules + constraint_key = f"/constraints/{constraint_type}" + json_config_rule = json_config_rule_set(constraint_key, constraint_value) - def DeleteConstraint(self, constraints: List[Tuple[str, Any]]) \ - -> List[Union[bool, Exception]]: + # Apply the configuration rule to the service + self.__service.service_config.config_rules.append(ConfigRule(**json_config_rule)) + + # Reconfigure the service with new constraints + self.__task_executor.configure_service(self.__service) + + results.append(True) + + except Exception as e: + LOGGER.error(f"Failed to set constraints: {str(e)}") + results.append(e) + + return results + + def DeleteConstraint(self, constraints: List[Tuple[str, Any]]) -> List[Union[bool, Exception]]: """ Delete service constraints. Parameters: constraints: List[Tuple[str, Any]] @@ -354,16 +467,37 @@ class QKDServiceHandler(_ServiceHandler): be returned; otherwise, the Exception that is raised during the processing must be returned. """ - raise NotImplementedError() + results = [] + try: + for constraint_type, _ in constraints: + LOGGER.info(f"Deleting constraint: {constraint_type}") + + # Remove the constraint from the service config rules + constraint_key = f"/constraints/{constraint_type}" + json_config_rule = json_config_rule_delete(constraint_key) + + for rule in self.__service.service_config.config_rules: + if rule.custom.resource_key == constraint_key: + self.__service.service_config.config_rules.remove(rule) + + # Reconfigure the service after removing constraints + self.__task_executor.configure_service(self.__service) + + results.append(True) + + except Exception as e: + LOGGER.error(f"Failed to delete constraints: {str(e)}") + results.append(e) - def SetConfig(self, resources: List[Tuple[str, Any]]) \ - -> List[Union[bool, Exception]]: + return results + + def SetConfig(self, resources: List[Tuple[str, Any]]) -> List[Union[bool, Exception]]: """ Create/Update configuration for a list of service resources. Parameters: resources: List[Tuple[str, Any]] List of tuples, each containing a resource_key pointing to - the resource to be modified, and a resource_value - containing the new value to be set. + the resource to be modified, and a resource_value containing + the new value to be set. Returns: results: List[Union[bool, Exception]] List of results for resource key changes requested. @@ -372,10 +506,28 @@ class QKDServiceHandler(_ServiceHandler): returned; otherwise, the Exception that is raised during the processing must be returned. """ - raise NotImplementedError() + results = [] + try: + for resource_key, resource_value in resources: + LOGGER.info(f"Setting config: {resource_key} with value: {resource_value}") + + json_config_rule = json_config_rule_set(resource_key, resource_value) + + # Apply the configuration rule to the service + self.__service.service_config.config_rules.append(ConfigRule(**json_config_rule)) + + # Reconfigure the service with new configurations + self.__task_executor.configure_service(self.__service) + + results.append(True) + + except Exception as e: + LOGGER.error(f"Failed to set config: {str(e)}") + results.append(e) + + return results - def DeleteConfig(self, resources: List[Tuple[str, Any]]) \ - -> List[Union[bool, Exception]]: + def DeleteConfig(self, resources: List[Tuple[str, Any]]) -> List[Union[bool, Exception]]: """ Delete configuration for a list of service resources. Parameters: resources: List[Tuple[str, Any]] @@ -391,4 +543,25 @@ class QKDServiceHandler(_ServiceHandler): be returned; otherwise, the Exception that is raised during the processing must be returned. """ - raise NotImplementedError() + results = [] + try: + for resource_key, _ in resources: + LOGGER.info(f"Deleting config: {resource_key}") + + json_config_rule = json_config_rule_delete(resource_key) + + # Remove the matching configuration rule + for rule in self.__service.service_config.config_rules: + if rule.custom.resource_key == resource_key: + self.__service.service_config.config_rules.remove(rule) + + # Reconfigure the service after deleting configurations + self.__task_executor.configure_service(self.__service) + + results.append(True) + + except Exception as e: + LOGGER.error(f"Failed to delete config: {str(e)}") + results.append(e) + + return results diff --git a/src/service/service/task_scheduler/TaskExecutor.py b/src/service/service/task_scheduler/TaskExecutor.py index cb27993702963b4aac88ec04eca2a1c796d0c364..67f6a516c9bc3031b5cdd2aed80cd6fdd7e1c9c2 100644 --- a/src/service/service/task_scheduler/TaskExecutor.py +++ b/src/service/service/task_scheduler/TaskExecutor.py @@ -16,11 +16,14 @@ import json, logging from enum import Enum from typing import TYPE_CHECKING, Any, Dict, Optional, Union from common.method_wrappers.ServiceExceptions import NotFoundException +from typing import List +from common.proto.qkd_app_pb2 import QKDAppStatusEnum from common.proto.context_pb2 import ( Connection, ConnectionId, Device, DeviceDriverEnum, DeviceId, Service, ServiceId, OpticalConfig, OpticalConfigId ) -from common.proto.qkd_app_pb2 import App +from common.proto.qkd_app_pb2 import App, AppId +from common.proto.context_pb2 import ContextId from common.tools.context_queries.Connection import get_connection_by_id from common.tools.context_queries.Device import get_device from common.tools.context_queries.Service import get_service_by_id @@ -33,7 +36,7 @@ from service.service.service_handler_api.Exceptions import ( UnsatisfiedFilterException, UnsupportedFilterFieldException, UnsupportedFilterFieldValueException ) from service.service.service_handler_api.ServiceHandlerFactory import ServiceHandlerFactory, get_service_handler_class -from service.service.tools.ObjectKeys import get_connection_key, get_device_key, get_service_key, get_app_key +from service.service.tools.ObjectKeys import get_connection_key, get_device_key, get_service_key, get_qkd_app_key if TYPE_CHECKING: from service.service.service_handler_api._ServiceHandler import _ServiceHandler @@ -229,8 +232,56 @@ class TaskExecutor: # ----- QkdApp-related methods ------------------------------------------------------------------------------------- - def register_app(self, app: App) -> None: - app_key = get_app_key(app.app_id) + def register_qkd_app(self, app: App) -> None: + """ + Registers a QKD App and stores it in the cache. + """ + qkd_app_key = get_qkd_app_key(app.app_id) self._qkd_app_client.RegisterApp(app) - LOGGER.info("reg registered") - self._store_grpc_object(CacheableObjectType.QKD_APP, app_key, app) + LOGGER.info("QKD app registered with key: %s", qkd_app_key) + self._store_grpc_object(CacheableObjectType.QKD_APP, qkd_app_key, app) + + def update_qkd_app_status(self, app: App, new_status: QKDAppStatusEnum) -> None: + """ + Updates the status of a QKD app and persists it to the database. + """ + try: + app.app_status = new_status + LOGGER.info(f"Attempting to update app {app.app_id.app_uuid.uuid} to status {new_status}") + self._qkd_app_client.UpdateApp(app) + LOGGER.info(f"Successfully updated app {app.app_id.app_uuid.uuid} to status {new_status}") + except Exception as e: + LOGGER.error(f"Failed to update QKD app {app.app_id.app_uuid.uuid}: {str(e)}") + raise e + + def list_qkd_apps(self, context_id: ContextId) -> List[App]: + """ + Retrieves a list of QKD apps from the QKD App service. + """ + try: + apps_response = self._qkd_app_client.ListApps(context_id) + LOGGER.info(f"ListApps retrieved: {len(apps_response.apps)} apps with status") + + # Ensure that the status is logged and used + for app in apps_response.apps: + LOGGER.info(f"App ID: {app.app_id.app_uuid.uuid}, Status: {app.app_status}") + + return apps_response.apps + except Exception as e: + LOGGER.error(f"Failed to list QKD apps: {str(e)}") + return [] + + def delete_qkd_app(self, app_id: AppId) -> None: + """ + Deletes a QKD App by its AppId and removes it from the cache. + """ + qkd_app_key = get_qkd_app_key(app_id) + try: + LOGGER.info(f"Attempting to delete QKD app with AppId: {app_id}") + self._qkd_app_client.DeleteApp(app_id) + LOGGER.info(f"QKD app deleted with key: {qkd_app_key}") + self._delete_grpc_object(CacheableObjectType.QKD_APP, qkd_app_key) + except Exception as e: + LOGGER.error(f"Failed to delete QKD app with AppId {app_id}: {str(e)}") + raise e + diff --git a/src/service/service/tools/ObjectKeys.py b/src/service/service/tools/ObjectKeys.py index cfc719bba736a4ea0789b028a97ca267b2d04089..f67cb02e143b78127484d6644a7fdd8c9c71e29c 100644 --- a/src/service/service/tools/ObjectKeys.py +++ b/src/service/service/tools/ObjectKeys.py @@ -26,6 +26,6 @@ def get_service_key(service_id : ServiceId) -> str: service_uuid = service_id.service_uuid.uuid return '{:s}/{:s}'.format(context_uuid, service_uuid) -def get_app_key(app_id : AppId) -> str: +def get_qkd_app_key(app_id: AppId) -> str: return app_id.app_uuid.uuid diff --git a/src/tests/tools/mock_qkd_nodes/start.sh b/src/tests/tools/mock_qkd_nodes/start.sh index faf2f84baf61f16565b497b53bf5f41f45007c00..89797b9c9496cdf58061c406ad2886be0d9c47f6 100755 --- a/src/tests/tools/mock_qkd_nodes/start.sh +++ b/src/tests/tools/mock_qkd_nodes/start.sh @@ -13,18 +13,30 @@ # See the License for the specific language governing permissions and # limitations under the License. +#!/bin/bash cd "$(dirname "$0")" +# Function to kill all background processes killbg() { - for p in "${pids[@]}" ; do - kill "$p"; - done + for p in "${pids[@]}" ; do + kill "$p"; + done } trap killbg EXIT pids=() + +# Set FLASK_APP and run the Flask instances on different ports +export FLASK_APP=wsgi flask run --host 0.0.0.0 --port 11111 & pids+=($!) + flask run --host 0.0.0.0 --port 22222 & pids+=($!) -flask run --host 0.0.0.0 --port 33333 + +flask run --host 0.0.0.0 --port 33333 & +pids+=($!) + +# Wait for all background processes to finish +wait + diff --git a/src/webui/service/qkd_app/routes.py b/src/webui/service/qkd_app/routes.py index 71243fb75e552ec5568eedacdcadabbc39516b4e..200d6ebdd17918dbb3d026980c4286212f519b82 100644 --- a/src/webui/service/qkd_app/routes.py +++ b/src/webui/service/qkd_app/routes.py @@ -12,21 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -import grpc, json, logging - +import grpc +import logging from flask import current_app, render_template, Blueprint, flash, session, redirect, url_for -from common.proto.context_pb2 import Empty, Link, LinkId, LinkList -from common.proto.qkd_app_pb2 import App, QKDAppStatusEnum, QKDAppTypesEnum +from common.proto.qkd_app_pb2 import App, AppId, QKDAppStatusEnum, QKDAppTypesEnum +from common.proto.context_pb2 import Uuid, ContextId from common.tools.context_queries.Context import get_context from common.tools.context_queries.Device import get_device -from common.tools.context_queries.Topology import get_topology from context.client.ContextClient import ContextClient from qkd_app.client.QKDAppClient import QKDAppClient - +# Set up logging LOGGER = logging.getLogger(__name__) + +# Blueprint for QKDApp routes qkd_app = Blueprint('qkd_app', __name__, url_prefix='/qkd_app') +# Initialize clients qkd_app_client = QKDAppClient() context_client = ContextClient() @@ -35,79 +37,131 @@ 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'] + # Connect to context client context_client.connect() device_names = dict() - 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') - apps = list() - else: - try: - apps = qkd_app_client.ListApps(context_obj.context_id) - apps = apps.apps - except grpc.RpcError as e: - if e.code() != grpc.StatusCode.NOT_FOUND: raise - if e.details() != 'Context({:s}) not found'.format(context_uuid): raise + try: + + # Fetch context object + 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') apps = list() else: - # Too many requests to context_client if it has too many apps (update in the future) - for app in apps: - if app.local_device_id.device_uuid.uuid not in device_names: - device = get_device(context_client, app.local_device_id.device_uuid.uuid) - if device is not None: - device_names[app.local_device_id.device_uuid.uuid] = device.name - - if app.remote_device_id.device_uuid.uuid and app.remote_device_id.device_uuid.uuid not in device_names: - device = get_device(context_client, app.remote_device_id.device_uuid.uuid) - if device is not None: - device_names[app.remote_device_id.device_uuid.uuid] = device.name - - context_client.close() - return render_template( - 'qkd_app/home.html', apps=apps, device_names=device_names, ate=QKDAppTypesEnum, ase=QKDAppStatusEnum) + try: + # Call ListApps using the context_id + apps_response = qkd_app_client.ListApps(context_obj.context_id) + apps = apps_response.apps + except grpc.RpcError as e: + LOGGER.error(f"gRPC error while fetching apps: {e.details()}") + if e.code() != grpc.StatusCode.NOT_FOUND: raise + if e.details() != 'Context({:s}) not found'.format(context_uuid): raise + apps = list() + else: + # Map local and remote device names + for app in apps: + if app.local_device_id.device_uuid.uuid not in device_names: + device = get_device(context_client, app.local_device_id.device_uuid.uuid) + if device: + device_names[app.local_device_id.device_uuid.uuid] = device.name + if app.remote_device_id.device_uuid.uuid and app.remote_device_id.device_uuid.uuid not in device_names: + device = get_device(context_client, app.remote_device_id.device_uuid.uuid) + if device: + device_names[app.remote_device_id.device_uuid.uuid] = device.name + finally: + context_client.close() + + # Render the template with app list and device names + return render_template( + 'qkd_app/home.html', + apps=apps, + device_names=device_names, + ate=QKDAppTypesEnum, + ase=QKDAppStatusEnum + ) -@qkd_app.route('detail/', methods=('GET', 'POST')) +@qkd_app.route('detail/', methods=['GET', 'POST']) def detail(app_uuid: str): - ''' - context_client.connect() - link_obj = get_link(context_client, link_uuid, rw_copy=False) - if link_obj is None: - flash('Link({:s}) not found'.format(str(link_uuid)), 'danger') - link_obj = Link() - device_names, endpoints_data = dict(), dict() - else: - device_names, endpoints_data = get_endpoint_names(context_client, link_obj.link_endpoint_ids) - context_client.close() - return render_template('link/detail.html',link=link_obj, device_names=device_names, endpoints_data=endpoints_data) - ''' - pass + """ + Displays details for a specific QKD app identified by its UUID. + """ + try: + qkd_app_client.connect() + + # Wrap the app_uuid in a Uuid object and fetch details + uuid_message = Uuid(uuid=app_uuid) + app_id = AppId(app_uuid=uuid_message) + app_detail = qkd_app_client.GetApp(app_id) + + if not app_detail: + flash(f"App with UUID {app_uuid} not found", "danger") + return redirect(url_for("qkd_app.home")) + + # Fetch device details + context_client.connect() + device_names = {} + + try: + if app_detail.local_device_id.device_uuid.uuid: + local_device = get_device(context_client, app_detail.local_device_id.device_uuid.uuid) + if local_device: + device_names[app_detail.local_device_id.device_uuid.uuid] = local_device.name + + if app_detail.remote_device_id.device_uuid.uuid: + remote_device = get_device(context_client, app_detail.remote_device_id.device_uuid.uuid) + if remote_device: + device_names[app_detail.remote_device_id.device_uuid.uuid] = remote_device.name + + except grpc.RpcError as e: + LOGGER.error(f"Failed to retrieve device details for app {app_uuid}: {e}") + flash(f"Error retrieving device details: {e.details()}", "danger") + return redirect(url_for("qkd_app.home")) + + finally: + context_client.close() + + return render_template( + 'qkd_app/detail.html', + app=app_detail, + ase=QKDAppStatusEnum, + ate=QKDAppTypesEnum, + device_names=device_names + ) + + except grpc.RpcError as e: + LOGGER.error(f"Failed to retrieve app details for {app_uuid}: {e}") + flash(f"Error retrieving app details: {e.details()}", "danger") + return redirect(url_for("qkd_app.home")) + + finally: + qkd_app_client.close() @qkd_app.get('/delete') -def delete(app_uuid): - ''' +def delete(app_uuid: str): + """ + Deletes a specific QKD app identified by its UUID. + """ try: + request = AppId(app_uuid=Uuid(uuid=app_uuid)) - # first, check if link exists! - # request: LinkId = LinkId() - # request.link_uuid.uuid = link_uuid - # response: Link = client.GetLink(request) - # TODO: finalize implementation + qkd_app_client.connect() + qkd_app_client.DeleteApp(request) # Call the DeleteApp method + qkd_app_client.close() - request = LinkId() - request.link_uuid.uuid = link_uuid # pylint: disable=no-member - context_client.connect() - context_client.RemoveLink(request) - context_client.close() + flash(f'App "{app_uuid}" deleted successfully!', 'success') + + except grpc.RpcError as e: + LOGGER.error(f"Problem deleting app {app_uuid}: {e}") + flash(f"Problem deleting app {app_uuid}: {e.details()}", 'danger') + + except Exception as e: + LOGGER.exception(f"Unexpected error while deleting app {app_uuid}: {e}") + flash(f"Unexpected error: {str(e)}", 'danger') - flash(f'Link "{link_uuid}" deleted successfully!', 'success') - except Exception as e: # pylint: disable=broad-except - flash(f'Problem deleting link "{link_uuid}": {e.details()}', 'danger') - current_app.logger.exception(e) - return redirect(url_for('link.home')) - ''' - pass + return redirect(url_for('qkd_app.home')) diff --git a/src/webui/service/templates/qkd_app/detail.html b/src/webui/service/templates/qkd_app/detail.html new file mode 100644 index 0000000000000000000000000000000000000000..078895cec81b4680715f9d027beac3158afdc4a4 --- /dev/null +++ b/src/webui/service/templates/qkd_app/detail.html @@ -0,0 +1,126 @@ +{% extends 'base.html' %} + +{% block content %} +

App {{ app.app_id.app_uuid.uuid }}

+ + +
+
+ +
+
+ +
+
+ + +
+
+ UUID: {{ app.app_id.app_uuid.uuid }}
+ Status: {{ ase.Name(app.app_status).replace('QKDAPPSTATUS_', '') }}
+ Type: {{ ate.Name(app.app_type).replace('QKDAPPTYPES_', '').replace('CLIENT', 'EXTERNAL') }}
+
+ + +
+
Associated Devices
+ + + + + + + + + + + + + {% if app.remote_device_id.device_uuid.uuid %} + + + + + {% endif %} + +
DeviceEndpoint Type
+ + {{ device_names.get(app.local_device_id.device_uuid.uuid, app.local_device_id.device_uuid.uuid) }} + + + + + + + Local Device
+ + {{ device_names.get(app.remote_device_id.device_uuid.uuid, app.remote_device_id.device_uuid.uuid) }} + + + + + + + Remote Device
+
+
+ + +
+
+
App QoS
+ + + + + + + + + + + + + + + + + + + + + + + + + +
QoS ParameterValue
Max Bandwidth{{ app.qos.max_bandwidth }} bps
Min Bandwidth{{ app.qos.min_bandwidth or 'N/A' }} bps
Jitter{{ app.qos.jitter or 'N/A' }} ms
TTL{{ app.qos.ttl or 'N/A' }} seconds
+
+
+ + + + +{% endblock %} diff --git a/src/webui/service/templates/qkd_app/home.html b/src/webui/service/templates/qkd_app/home.html index 9573013f41410a5d8560e71c174ce6a85237089f..39b43ecc4b9b71e03d27bf080ae6c7be2bf1a90d 100644 --- a/src/webui/service/templates/qkd_app/home.html +++ b/src/webui/service/templates/qkd_app/home.html @@ -74,14 +74,12 @@ {% endif %} - + + + + + + {% endfor %}