From 35bf5beb44402d0744a6c423027487b463cca6e3 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Fri, 17 Oct 2025 11:41:27 +0300 Subject: [PATCH 1/9] Update aerOS adapter framework. Adapt aerOS-CAMARA responses. Implement edge-zones GSMA TFs. --- .../adapters/aeros/camara2aeros_converter.py | 124 +++ .../edgecloud/adapters/aeros/client.py | 926 ++++++++++++------ .../adapters/aeros/continuum_client.py | 10 +- .../adapters/aeros/continuum_models.py | 269 +++++ .../edgecloud/adapters/aeros/errors.py | 30 + .../aeros/storageManagement/__init__.py | 5 + .../storageManagement/appStorageManager.py | 69 ++ .../storageManagement/inMemoryStorage.py | 123 +++ .../aeros/storageManagement/sqlite_storage.py | 229 +++++ .../edgecloud/adapters/aeros/utils.py | 68 +- .../edgecloud/adapters/i2edge/client.py | 2 +- 11 files changed, 1509 insertions(+), 346 deletions(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py new file mode 100644 index 0000000..da9d83d --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py @@ -0,0 +1,124 @@ +''' +Module: converter.py +This module provides functions to convert application manifests into TOSCA models. +It includes the `generate_tosca` function that constructs a TOSCA model based on +the application manifest and associated app zones. +''' +from typing import List, Dict, Any +import yaml +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.logger import setup_logger +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppManifest, VisibilityType +from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( + TOSCA, NodeTemplate, CustomRequirement, HostRequirement, HostCapability, + Property as HostProperty, DomainIdOperator, NodeFilter, NetworkRequirement, + NetworkProperties, ExposedPort, PortProperties, ArtifactModel) + +logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) + + +def generate_tosca(app_manifest: AppManifest, + app_zones: List[Dict[str, Any]]) -> str: + ''' + Generate a TOSCA model from the application manifest and app zones. + Args: + app_manifest (AppManifest): The application manifest containing details about the app. + app_zones (List[Dict[str, Any]]): List of app zones where the app will be deployed. + Returns: + TOSCA yaml as string which can be used in a POST request with applcation type yaml + ''' + component = app_manifest.componentSpec[0] + image_path = app_manifest.appRepo.imagePath.root + image_file = image_path.split("/")[-1] + repository_url = "/".join( + image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" + + zone_id = app_zones[0].get("EdgeCloudZone", + {}).get("edgeCloudZoneId", "default-zone") + logger.info("DEBUG : %s", app_manifest.requiredResources.root) + # Extract minNodeMemory (fallback = 1024 MB) + + res = app_manifest.requiredResources.root + if hasattr(res, "applicationResources") and hasattr( + res.applicationResources.cpuPool.topology, "minNodeMemory"): + min_node_memory = res.applicationResources.cpuPool.topology.minNodeMemory + else: + min_node_memory = 1024 + + # Build exposed network ports + ports = { + iface.interfaceId: + ExposedPort(properties=PortProperties( + protocol=[iface.protocol.value.lower()], source=iface.port)) + for iface in component.networkInterfaces + } + + expose_ports = any( + iface.visibilityType == VisibilityType.VISIBILITY_EXTERNAL + for iface in component.networkInterfaces) + + # Define host property constraints + host_props = HostProperty( + cpu_arch={"equal": "x64"}, + realtime={"equal": False}, + cpu_usage={"less_or_equal": "0.4"}, + mem_size={"greater_or_equal": str(min_node_memory)}, + energy_efficiency={"greater_or_equal": "0"}, + green={"greater_or_equal": "0"}, + domain_id=DomainIdOperator(equal=zone_id), + ) + + # Create Node compute and network requirements + requirements = [ + CustomRequirement(network=NetworkRequirement( + properties=NetworkProperties(ports=ports, + exposePorts=expose_ports))), + CustomRequirement(host=HostRequirement(node_filter=NodeFilter( + capabilities=[{ + "host": HostCapability(properties=host_props) + }], + properties=None))) + ] + # Define the NodeTemplate + node_template = NodeTemplate( + type="tosca.nodes.Container.Application", + isJob=False, + requirements=requirements, + artifacts={ + "application_image": + ArtifactModel( + file=image_file, + type="tosca.artifacts.Deployment.Image.Container.Docker", + repository=repository_url, + is_private=app_manifest.appRepo.type == "PRIVATEREPO", + username=app_manifest.appRepo.userName, + password=app_manifest.appRepo.credentials) + }, + interfaces={ + "Standard": { + "create": { + "implementation": "application_image", + "inputs": { + "cliArgs": [], + "envVars": [] + } + } + } + }) + + # Assemble full TOSCA object + tosca = TOSCA(tosca_definitions_version="tosca_simple_yaml_1_3", + description=f"TOSCA for {app_manifest.name}", + serviceOverlay=False, + node_templates={component.componentName: node_template}) + + tosca_dict = tosca.model_dump(by_alias=True, exclude_none=True) + + for template in tosca_dict.get("node_templates", {}).values(): + template["requirements"] = [{ + k: v + for k, v in req.items() if v is not None + } for req in template.get("requirements", [])] + + yaml_str = yaml.dump(tosca_dict, sort_keys=False) + return yaml_str diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 99dece8..9ca815b 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -6,32 +6,45 @@ # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## import uuid +import json from typing import Any, Dict, List, Optional - -import yaml +from collections import defaultdict +from pydantic import ValidationError from requests import Response from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage +from sunrise6g_opensdk.edgecloud.adapters.aeros import camara2aeros_converter +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( + AppStorageManager, ) from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( - EdgeCloudManagementInterface, -) + EdgeCloudManagementInterface, ) +from sunrise6g_opensdk.edgecloud.core.utils import build_custom_http_response +from sunrise6g_opensdk.edgecloud.core import camara_schemas, gsma_schemas from sunrise6g_opensdk.logger import setup_logger class EdgeApplicationManager(EdgeCloudManagementInterface): """ - aerOS Continuum Client - FIXME: Handle None responses from continuum client + aerOS Edge Application Manager Adapter implementing CAMARA and GSMA APIs. """ - def __init__(self, base_url: str, **kwargs): + def __init__(self, + base_url: str, + storage: Optional[AppStorageManager] = None, + **kwargs): + ''' + storage can + ''' self.base_url = base_url - self.logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) - self._app_store: Dict[str, Dict] = {} - self._deployed_services: Dict[str, List[str]] = {} - self._stopped_services: Dict[str, List[str]] = {} + self.logger = setup_logger(__name__, + is_debug=True, + file_name=config.LOG_FILE) + self.content_type_gsma = "application/json" + self.encoding_gsma = "utf-8" + self.storage = storage or inMemoryStorage.InMemoryAppStorage() # Overwrite config values if provided via kwargs if "aerOS_API_URL" in kwargs: @@ -48,259 +61,69 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if not config.aerOS_HLO_TOKEN: raise ValueError("Missing 'aerOS_HLO_TOKEN'") - def onboard_app(self, app_manifest: Dict) -> Dict: - app_id = app_manifest.get("appId") - if not app_id: - raise EdgeCloudPlatformError("Missing 'appId' in app manifest") - - if app_id in self._app_store: - raise EdgeCloudPlatformError(f"Application with id '{app_id}' already exists") - - self._app_store[app_id] = app_manifest - self.logger.debug("Onboarded application with id: %s", app_id) - return {"appId": app_id} - - def get_all_onboarded_apps(self) -> List[Dict]: - self.logger.debug("Onboarded applications: %s", list(self._app_store.keys())) - return list(self._app_store.values()) - - def get_onboarded_app(self, app_id: str) -> Dict: - if app_id not in self._app_store: - raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") - self.logger.debug("Retrieved application with id: %s", app_id) - return self._app_store[app_id] - - def delete_onboarded_app(self, app_id: str) -> None: - if app_id not in self._app_store: - raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") - service_instances = self._stopped_services.get(app_id, []) - self.logger.debug( - "Deleting application with id: %s and instances: %s", - app_id, - service_instances, - ) - for service_instance in service_instances: - self._purge_deployed_app_from_continuum(service_instance) - self.logger.debug("successfully purged service instance: %s", service_instance) - del self._stopped_services[app_id] # Clean up stopped services - del self._app_store[app_id] # Remove from onboarded apps - - def _generate_service_id(self, app_id: str) -> str: - return f"urn:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" - - def _generate_tosca_yaml_dict(self, app_manifest: Dict, app_zones: List[Dict]) -> Dict: - component = app_manifest.get("componentSpec", [{}])[0] - component_name = component.get("componentName", "application") - - image_path = app_manifest.get("appRepo", {}).get("imagePath", "") - image_file = image_path.split("/")[-1] - repository_url = "/".join(image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" - zone_id = app_zones[0].get("EdgeCloudZone", {}).get("edgeCloudZoneId", "default-zone") - - # Extract minNodeMemory - min_node_memory = ( - app_manifest.get("requiredResources", {}) - .get("applicationResources", {}) - .get("cpuPool", {}) - .get("topology", {}) - .get("minNodeMemory", 1024) - ) - - ports = {} - for iface in component.get("networkInterfaces", []): - interface_id = iface.get("interfaceId", "default") - protocol = iface.get("protocol", "TCP").lower() - port = iface.get("port", 8080) - ports[interface_id] = {"properties": {"protocol": [protocol], "source": port}} - - expose_ports = any( - iface.get("visibilityType") == "VISIBILITY_EXTERNAL" - for iface in component.get("networkInterfaces", []) - ) - - yaml_dict = { - "tosca_definitions_version": "tosca_simple_yaml_1_3", - "description": f"TOSCA for {app_manifest.get('name', 'application')}", - "serviceOverlay": False, - "node_templates": { - component_name: { - "type": "tosca.nodes.Container.Application", - "isJob": False, - "requirements": [ - { - "network": { - "properties": { - "ports": ports, - "exposePorts": expose_ports, - } - } - }, - { - "host": { - "node_filter": { - "capabilities": [ - { - "host": { - "properties": { - "cpu_arch": {"equal": "x64"}, - "realtime": {"equal": False}, - "cpu_usage": {"less_or_equal": "0.1"}, - "mem_size": { - "greater_or_equal": str(min_node_memory) - }, - "domain_id": {"equal": zone_id}, - } - } - } - ], - "properties": None, - } - } - }, - ], - "artifacts": { - "application_image": { - "file": image_file, - "type": "tosca.artifacts.Deployment.Image.Container.Docker", - "is_private": False, - "repository": repository_url, - } - }, - "interfaces": { - "Standard": { - "create": { - "implementation": "application_image", - "inputs": {"cliArgs": [], "envVars": []}, - } - } - }, - } - }, - } + # ######################################################################## + # CAMARA EDGE CLOUD MANAGEMENT API + # ######################################################################## - return yaml_dict + # ------------------------------------------------------------------------ + # Edge Cloud Zone Management (CAMARA) + # ------------------------------------------------------------------------ - def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: - # 1. Get app CAMARA manifest - app_manifest = self._app_store.get(app_id) - if not app_manifest: - raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") - - # 2. Generate unique service ID - service_id = self._generate_service_id(app_id) - - # 3. Convert dict to YAML string - yaml_dict = self._generate_tosca_yaml_dict(app_manifest, app_zones) - tosca_yaml = yaml.dump(yaml_dict, sort_keys=False) - self.logger.info("Generated TOSCA YAML:") - self.logger.info(tosca_yaml) - - # 4. Instantiate client and call continuum to deploy service - aeros_client = ContinuumClient(self.base_url) - response = aeros_client.onboard_and_deploy_service(service_id, tosca_yaml) - - if "serviceId" not in response: - raise EdgeCloudPlatformError( - "Invalid response from onboard_service: missing 'serviceId'" - ) - - # 5. Track deployment - if app_id not in self._deployed_services: - self._deployed_services[app_id] = [] - self._deployed_services[app_id].append(service_id) - - # 6. Return expected format - return {"appInstanceId": response["serviceId"]} - - def get_all_deployed_apps( - self, - app_id: Optional[str] = None, - app_instance_id: Optional[str] = None, - region: Optional[str] = None, - ) -> List[Dict]: - deployed = [] - for stored_app_id, instance_ids in self._deployed_services.items(): - for instance_id in instance_ids: - deployed.append({"appId": stored_app_id, "appInstanceId": instance_id}) - return deployed - - def get_deployed_app( - self, app_instance_id: str, app_id: Optional[str] = None, region: Optional[str] = None - ) -> Response: + # Zones methods + def get_edge_cloud_zones(self, + region: Optional[str] = None, + status: Optional[str] = None) -> Response: """ - Placeholder implementation for CAMARA compliance. - Retrieves information of a specific application instance. + Retrieves a list of available Edge Cloud Zones. - :param app_instance_id: Unique identifier of the application instance - :param app_id: Optional filter by application ID - :param region: Optional filter by Edge Cloud region - :return: Response with application instance details + :param region: Filter by geographical region. + :param status: Filter by status (active, inactive, unknown). + :return: Response with list of Edge Cloud Zones in CAMARA format. """ - # TODO: Implement actual aeros-specific logic for retrieving a specific deployed app - raise NotImplementedError("get_deployed_app is not yet implemented for aeros adapter") - - def _purge_deployed_app_from_continuum(self, app_id: str) -> None: - aeros_client = ContinuumClient(self.base_url) - response = aeros_client.purge_service(app_id) - if response: - self.logger.debug("Purged deployed application with id: %s", app_id) - else: - raise EdgeCloudPlatformError( - f"Failed to purg service with id from the continuum '{app_id}'" - ) - - def undeploy_app(self, app_instance_id: str) -> None: - # 1. Locate app_id corresponding to this instance - found_app_id = None - for app_id, instances in self._deployed_services.items(): - if app_instance_id in instances: - found_app_id = app_id - break - - if not found_app_id: - raise EdgeCloudPlatformError( - f"No deployed app instance with ID '{app_instance_id}' found" - ) - - # 2. Call the external undeploy_service - aeros_client = ContinuumClient(self.base_url) try: - aeros_client.undeploy_service(app_instance_id) - except Exception as e: - raise EdgeCloudPlatformError( - f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" - ) from e - - # We could do it here with a little wait but better all instances in the same app are purged at once - # 3. Purge the deployed app from continuum - # self._purge_deployed_app_from_continuum(app_instance_id) - - # 4. Clean up internal tracking - self._deployed_services[found_app_id].remove(app_instance_id) - # Add instance to _stopped_services to purge it later - if found_app_id not in self._stopped_services: - self._stopped_services[found_app_id] = [] - self._stopped_services[found_app_id].append(app_instance_id) - # If app has no instances left, remove it from deployed services - if not self._deployed_services[found_app_id]: - del self._deployed_services[found_app_id] - - def get_edge_cloud_zones( - self, region: Optional[str] = None, status: Optional[str] = None - ) -> List[Dict]: - aeros_client = ContinuumClient(self.base_url) - ngsild_params = "type=Domain&format=simplified" - aeros_domains = aeros_client.query_entities(ngsild_params) - return [ - { - "zoneId": domain["id"], - "status": domain["domainStatus"].split(":")[-1].lower(), - "geographyDetails": "NOT_USED", - } - for domain in aeros_domains - ] - - def get_edge_cloud_zones_details(self, zone_id: str, flavour_id: Optional[str] = None) -> Dict: + aeros_client = ContinuumClient(self.base_url) + ngsild_params = "type=Domain&format=simplified" + camara_response = aeros_client.query_entities(ngsild_params) + aeros_domains = camara_response.json() + zone_list = [{ + "zoneId": + domain["id"], + "status": + domain["domainStatus"].split(":")[-1].lower(), + "geographyDetails": + "NOT_USED", + } for domain in aeros_domains] + if status: + zone_list = [ + z for z in zone_list + if z["domainStatus"] == status.lower() + ] # FIXME: Check CAMARA status map to aerOS status literals + # if region: + # zone_list = [ + # z for z in zone_list if z.get("region") == region + # ] # No region for aerOS domains + + return build_custom_http_response( + status_code=camara_response.status_code, + content=zone_list, + headers={"Content-Type": "application/json"}, + encoding=camara_response.encoding, + url=camara_response.url, + request=camara_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise + + def get_edge_cloud_zones_details(self, + zone_id: str, + flavour_id: Optional[str] = None) -> Dict: """ Get details of a specific edge cloud zone. :param zone_id: The ID of the edge cloud zone @@ -348,20 +171,37 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zone_id, ngsild_params, ) - # Query the infrastructure elements for the specified zonese - aeros_domain_ies = aeros_client.query_entities(ngsild_params) - # Transform the infrastructure elements into the required format - # and return the details of the edge cloud zone - response = self.transform_infrastructure_elements( - domain_ies=aeros_domain_ies, domain=zone_id - ) - self.logger.debug("Transformed response: %s", response) - # Return the transformed response - return response - - def transform_infrastructure_elements( - self, domain_ies: List[Dict[str, Any]], domain: str - ) -> Dict[str, Any]: + try: + # Query the infrastructure elements for the specified zonese + aeros_response = aeros_client.query_entities(ngsild_params) + aeros_domain_ies = aeros_response.json() + # Transform the infrastructure elements into the required format + # and return the details of the edge cloud zone + camara_response = self.transform_infrastructure_elements( + domain_ies=aeros_domain_ies, domain=zone_id) + self.logger.debug("Transformed response: %s", camara_response) + # Return the transformed response + return build_custom_http_response( + status_code=aeros_response.status_code, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding=aeros_response.encoding, + url=aeros_response.url, + request=aeros_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise + + def transform_infrastructure_elements(self, domain_ies: List[Dict[str, + Any]], + domain: str) -> Dict[str, Any]: """ Transform the infrastructure elements into a format suitable for the edge cloud zone details. @@ -386,75 +226,482 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Create a flavour per machine flavour = { - "flavourId": f"{element.get('hostname')}-{element.get('containerTechnology')}", - "cpuArchType": f"{element.get('cpuArchitecture')}", - "supportedOSTypes": [ - { - "architecture": f"{element.get('cpuArchitecture')}", - "distribution": f"{element.get('operatingSystem')}", # assume - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE", - } - ], - "numCPU": element.get("cpuCores", 0), - "memorySize": element.get("ramCapacity", 0), - "storageSize": element.get("diskCapacity", 0), + "flavourId": + f"{element.get('hostname')}-{element.get('containerTechnology')}", + "cpuArchType": + f"{element.get('cpuArchitecture')}", + "supportedOSTypes": [{ + "architecture": f"{element.get('cpuArchitecture')}", + "distribution": + f"{element.get('operatingSystem')}", # assume + "version": "OS_VERSION_UBUNTU_2204_LTS", + "license": "OS_LICENSE_TYPE_FREE", + }], + "numCPU": + element.get("cpuCores", 0), + "memorySize": + element.get("ramCapacity", 0), + "storageSize": + element.get("diskCapacity", 0), } flavours_supported.append(flavour) result = { - "zoneId": domain, - "reservedComputeResources": [ - { - "cpuArchType": "ISA_X86_64", - "numCPU": str(total_cpu), - "memory": total_ram, - } - ], - "computeResourceQuotaLimits": [ - { - "cpuArchType": "ISA_X86_64", - "numCPU": str(total_cpu * 2), # Assume quota is 2x total? - "memory": total_ram * 2, - } - ], - "flavoursSupported": flavours_supported, + "zoneId": + domain, + "reservedComputeResources": [{ + "cpuArchType": "ISA_X86_64", + "numCPU": str(total_cpu), + "memory": total_ram, + }], + "computeResourceQuotaLimits": [{ + "cpuArchType": "ISA_X86_64", + "numCPU": str(total_cpu * 2), # Assume quota is 2x total? + "memory": total_ram * 2, + }], + "flavoursSupported": + flavours_supported, } return result - # --- GSMA-specific methods --- + # ------------------------------------------------------------------------ + # Application Management (CAMARA-Compliant) + # ------------------------------------------------------------------------ + + # Onboarding methods + def onboard_app(self, app_manifest: Dict) -> Response: + # Validate CAMARA input + camara_schemas.AppManifest(**app_manifest) - # FederationManagement + app_id = app_manifest.get("appId") + if not app_id: + raise EdgeCloudPlatformError("Missing 'appId' in app manifest") - def get_edge_cloud_zones_list_gsma(self) -> List: + if self.storage.get_app(app_id=app_id): + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' already exists") + + self.storage.store_app(app_id, app_manifest) + self.logger.debug("Onboarded application with id: %s", app_id) + submitted_app = camara_schemas.SubmittedApp( + appId=camara_schemas.AppId(app_id)) + return build_custom_http_response( + status_code=201, + content=submitted_app.model_dump(mode="json"), + headers={"Content-Type": "application/json"}, + encoding="utf-8") + + def get_all_onboarded_apps(self) -> Response: + apps = self.storage.list_apps() + self.logger.debug("Onboarded applications: %s", apps) + return build_custom_http_response( + status_code=200, + content=apps, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + ) + + def get_onboarded_app(self, app_id: str) -> Response: + app_data = self.storage.get_app(app_id) + if not app_data: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' does not exist") + self.logger.debug("Retrieved application with id: %s", app_id) + + #Do we need to do this ?? + # app_manifest_response = { + # "appManifest": { + # "appId": app_data.get("app_id", app_id), + # "name": app_data.get("name", ""), + # "version": app_data.get("version", ""), + # "appProvider": app_data.get("appProvider", ""), + # # Add other required fields with defaults if not available + # "packageType": "CONTAINER", # Default value + # "appRepo": { + # "type": "PUBLICREPO", + # "imagePath": "not-available" + # }, + # "requiredResources": { + # "infraKind": "kubernetes", + # "applicationResources": {}, + # "isStandalone": False, + # }, + # "componentSpec": [], + # } + # } + # or this ? + # rr = app_data.get("requiredResources", + # {}) # shortcut for requiredResources + # app_repo = app_data.get("appRepo", {}) # shortcut for appRepo + + # app_manifest_response = { + # "appManifest": { + # "appId": app_data.get("appId", app_id), + # "name": app_data.get("name", ""), + # "version": app_data.get("version", ""), + # "appProvider": app_data.get("appProvider", ""), + # # Add other required fields with defaults if not available + # "packageType": app_data.get("packageType", "CONTAINER"), + # "appRepo": { + # "type": app_repo.get("type", "PUBLICREPO"), + # # Take it from the stored object: + # "imagePath": app_repo.get("imagePath", ""), + # }, + # "requiredResources": { + # "infraKind": rr.get("infraKind", "kubernetes"), + # # Copy the whole block as-is (cpuPool etc.) + # "applicationResources": rr.get("applicationResources", {}), + # # From stored object, default False if absent + # "isStandalone": rr.get("isStandalone", False), + # # (optional, since your stored object includes it) + # "version": rr.get("version", ""), + # }, + # # Pass through the list from the stored object + # "componentSpec": app_data.get("componentSpec", []), + # } + # } + # Build CAMARA-compliant response using schema + # Note: This is a partial AppManifest for get operation + return build_custom_http_response( + status_code=200, + content=app_data, # We already keep the app manifest when onboarding + headers={"Content-Type": "application/json"}, + encoding="utf-8", + ) + + def delete_onboarded_app(self, app_id: str) -> Response: + app = self.storage.get_app(app_id) + if not app: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' does not exist") + + service_instances = self.storage.get_stopped_instances(app_id=app_id) + if not service_instances: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' cannot be deleted — please stop it first" + ) + self.logger.debug( + "Deleting application with id: %s and instances: %s", + app_id, + service_instances, + ) + for service_instance in service_instances: + self._purge_deployed_app_from_continuum(service_instance) + self.logger.debug("successfully purged service instance: %s", + service_instance) + + self.storage.remove_stopped_instances(app_id) + self.storage.delete_app(app_id) + + return build_custom_http_response( + status_code=204, + content=b"", # absolutely no body for 204 + headers={"Content-Type": "application/json"}, + encoding="utf-8", + # url=None, + # request=None, + ) + + def _generate_service_id(self, app_id: str) -> str: + return f"urn:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" + + # Instantiation methods + def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Response: + # 1. Get app CAMARA manifest + app_manifest = self.storage.get_app(app_id) + if not app_manifest: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' does not exist") + app_manifest = camara_schemas.AppManifest.model_validate(app_manifest) + + # 2. Generate unique service ID + # (aerOS) service id <=> CAMARA appInstanceId + service_id = self._generate_service_id(app_id) + + # 3. Convert dict to YAML string + tosca_str = camara2aeros_converter.generate_tosca( + app_manifest=app_manifest, app_zones=app_zones) + self.logger.info("Generated TOSCA YAML:") + self.logger.info(tosca_str) + + # 4. Instantiate client and call continuum to deploy service + try: + aeros_client = ContinuumClient(self.base_url) + aeros_response = aeros_client.onboard_and_deploy_service( + service_id, tosca_str) + + if "serviceId" not in aeros_response: + raise EdgeCloudPlatformError( + "Invalid response from onboard_service: missing 'serviceId'" + ) + + # Build CAMARA-compliant info + app_provider_id = app_manifest.get("appProvider", + "unknown-provider") + zone_id = app_zones[0].get("EdgeCloudZone", + {}).get("edgeCloudZoneId", + "default-zone") + app_instance_info = camara_schemas.AppInstanceInfo( + name=camara_schemas.AppInstanceName(service_id), + appId=camara_schemas.AppId(app_id), + appInstanceId=camara_schemas.AppInstanceId(service_id), + appProvider=camara_schemas.AppProvider(app_provider_id), + status=camara_schemas.Status.instantiating, + edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(zone_id), + ) + + # 5. Track deployment + self.storage.store_deployment(app_instance=app_instance_info) + + # 6. Return expected format + self.logger.info("App deployment request submitted successfully") + + # CAMARA spec requires appInstances array wrapper + camara_response = app_instance_info.model_dump(mode="json") + # Add mandatory Location header + location_url = f"/appinstances/{service_id}" + camara_headers = { + "Content-Type": "application/json", + "Location": location_url + } + + return build_custom_http_response( + status_code=aeros_response.status_code, + content=camara_response, + headers=camara_headers, + encoding="utf-8", + url=aeros_response.url, + request=aeros_response.request) + except EdgeCloudPlatformError as ex: + # Catch all platform-specific errors. + # All custom exception types (InvalidArgumentError, UnauthenticatedError, etc.) + # inherit from EdgeCloudPlatformError, so a single handler here will capture + # any of them. We can further elaborate per eachone of needed. + self.logger.error("Failed to deploy app '%s': %s", app_id, str(ex)) + raise + + def get_all_deployed_apps( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> Response: + + instances = self.storage.find_deployments(app_id, app_instance_id, + region) + + # CAMARA spec format for multiple instances response + camara_response = {"appInstances": instances} + + self.logger.info("All app instances retrieved successfully") + return build_custom_http_response( + status_code=200, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + # url=response.url, + # request=response.request, + ) + + def get_deployed_app(self, + app_instance_id: str, + app_id: Optional[str] = None, + region: Optional[str] = None) -> Response: """ - Retrieves list of all Zones + Placeholder implementation for CAMARA compliance. + Retrieves information of a specific application instance. - :return: List. + :param app_instance_id: Unique identifier of the application instance + :param app_id: Optional filter by application ID + :param region: Optional filter by Edge Cloud region + :return: Response with application instance details """ - pass + # TODO: Implement actual aeros-specific logic for retrieving a specific deployed app + raise NotImplementedError( + "get_deployed_app is not yet implemented for aeros adapter") + + def _purge_deployed_app_from_continuum(self, app_id: str) -> None: + aeros_client = ContinuumClient(self.base_url) + response = aeros_client.purge_service(app_id) + if response: + self.logger.debug("Purged deployed application with id: %s", + app_id) + else: + raise EdgeCloudPlatformError( + f"Failed to purg service with id from the continuum '{app_id}'" + ) + + def undeploy_app(self, app_instance_id: str) -> Response: + # 1. Locate app_id corresponding to this instance + app_id = self.storage.remove_deployment(app_instance_id) + if not app_id: + raise EdgeCloudPlatformError( + f"No deployed app instance with ID '{app_instance_id}' found") + + # 2. Call the external undeploy_service + aeros_client = ContinuumClient(self.base_url) + try: + aeros_response = aeros_client.undeploy_service(app_instance_id) + except Exception as e: + raise EdgeCloudPlatformError( + f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" + ) from e + + # We could do it here with a little wait but better all instances in the same app are purged at once + # 3. Purge the deployed app from continuum + # self._purge_deployed_app_from_continuum(app_instance_id) + + # 4. Clean up internal tracking + self.storage.store_stopped_instance(app_id, app_instance_id) + return build_custom_http_response( + status_code=204, + content="", + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=aeros_response.url, + request=aeros_response.request, + ) + + # ######################################################################## + # GSMA EDGE COMPUTING API (EWBI OPG) - FEDERATION + # ######################################################################## + + # ------------------------------------------------------------------------ + # Zone Management (GSMA) + # ------------------------------------------------------------------------ + + def get_edge_cloud_zones_list_gsma(self) -> Response: + """ + Retrieves details of all Zones for GSMA federation. + + :return: Response with zone details in GSMA format. + """ + try: + aeros_client = ContinuumClient(self.base_url) + ngsild_params = "type=Domain&format=simplified" + aeros_response = aeros_client.query_entities(ngsild_params) + aeros_domains = aeros_response.json() + zone_list = [{ + "zoneId": + domain["id"], + "status": + domain["domainStatus"].split(":")[-1].lower(), + "geographyDetails": + "NOT_USED", + } for domain in aeros_domains] + return build_custom_http_response( + status_code=aeros_domains.status_code, + content=zone_list, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=aeros_response.url, + request=aeros_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise # AvailabilityZoneInfoSynchronization - def get_edge_cloud_zones_gsma(self) -> List: + def get_edge_cloud_zones_gsma(self) -> Response: """ - Retrieves details of all Zones + Retrieves details of all Zones with compute resources and flavours for GSMA federation. - :return: List. + :return: Response with zones and detailed resource information. """ - pass + aeros_client = ContinuumClient(self.base_url) + ngsild_params = 'format=simplified&type=InfrastructureElement' - def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: + try: + # Query the infrastructure elements whithin the whole continuum + aeros_response = aeros_client.query_entities(ngsild_params) + aeros_ies = aeros_response.json() # IEs as list of dicts + + # Create a dict that groups by "domain" + grouped_by_domain = defaultdict(list) + for item in aeros_ies: + domain = item["domain"] + grouped_by_domain[domain].append(item) + + # Transform the IEs to required format + # per domain and append to response list + camara_response = [] + for domain, ies in grouped_by_domain.items(): + result = self.transform_infrastructure_elements(domain_ies=ies, + domain=domain) + camara_response.append(result) + self.logger.debug("Transformed response: %s", camara_response) + # Return the transformed response + return build_custom_http_response( + status_code=aeros_response.status_code, + content=camara_response, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=aeros_response.url, + request=aeros_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise + + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Response: """ Retrieves details of a specific Edge Cloud Zone reserved - for the specified zone by the partner OP. + for the specified zone by the partner OP using GSMA federation. :param zone_id: Unique identifier of the Edge Cloud Zone. - :return: Dictionary with Edge Cloud Zone details. + :return: Response with Edge Cloud Zone details. """ - pass - - # ArtefactManagement + aeros_client = ContinuumClient(self.base_url) + ngsild_params = f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' + self.logger.debug( + "Querying infrastructure elements for zone %s with params: %s", + zone_id, + ngsild_params, + ) + try: + # Query the infrastructure elements for the specified zonese + aeros_response = aeros_client.query_entities(ngsild_params) + aeros_domain_ies = aeros_response.json() + # Transform the infrastructure elements into the required format + # and return the details of the edge cloud zone + camara_response = self.transform_infrastructure_elements( + domain_ies=aeros_domain_ies, domain=zone_id) + self.logger.debug("Transformed response: %s", camara_response) + # Return the transformed response + return build_custom_http_response( + status_code=aeros_response.status_code, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding=aeros_response.encoding, + url=aeros_response.url, + request=aeros_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise + + # ------------------------------------------------------------------------ + # Artefact Management (GSMA) + # ------------------------------------------------------------------------ def create_artefact_gsma(self, request_body: dict): """ @@ -485,7 +732,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - # ApplicationOnboardingManagement + # ------------------------------------------------------------------------ + # Application Onboarding Management (GSMA) + # ------------------------------------------------------------------------ def onboard_app_gsma(self, request_body: dict): """ @@ -494,9 +743,33 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): resource validation and other pre-deployment operations. :param request_body: Payload with onboarding info. - :return: + :return: Response with onboarding confirmation. """ - pass + try: + # Validate input against GSMA schema + gsma_validated_body = gsma_schemas.AppOnboardManifestGSMA.model_validate( + request_body) + data = gsma_validated_body.model_dump() + except ValidationError as e: + self.logger.error("Invalid GSMA input schema: %s", e) + raise + try: + data["app_id"] = data.pop("appId") + data.pop("edgeAppFQDN", None) + # FIXME: payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) + url = f"{self.base_url}/application/onboarding" + # FIXME: response = i2edge_post(url, payload, expected_status=201) + return build_custom_http_response( + status_code=200, + content={"response": "Application onboarded successfully"}, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + # FIXME: url=response.url, + # FIXME: request=response.request, + ) + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ @@ -527,7 +800,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - # ApplicationDeploymentManagement + # ------------------------------------------------------------------------ + # Application Deployment Management (GSMA) + # ------------------------------------------------------------------------ def deploy_app_gsma(self, request_body: dict) -> Dict: """ @@ -538,7 +813,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, + zone_id: str) -> Dict: """ Retrieves an application instance details from partner OP. @@ -549,7 +825,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_all_deployed_apps_gsma(self) -> Response: + def get_all_deployed_apps_gsma(self, app_id: str, + app_provider: str) -> List: """ Retrieves all instances for a given application of partner OP @@ -559,7 +836,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, + zone_id: str): """ Terminate an application instance on a partner OP zone. diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index 67ed942..aa4a85a 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -73,7 +73,7 @@ class ContinuumClient: return response.json() @catch_requests_exceptions - def query_entities(self, ngsild_params): + def query_entities(self, ngsild_params) -> requests.Response: """ Query entities with ngsi-ld params :input @@ -90,7 +90,7 @@ class ContinuumClient: # self.logger.debug("Query entities URL: %s", entities_url) # self.logger.debug("Query entities response: %s %s", # response.status_code, response.text) - return response.json() + return response @catch_requests_exceptions def deploy_service(self, service_id: str) -> dict: @@ -116,7 +116,7 @@ class ContinuumClient: return response.json() @catch_requests_exceptions - def undeploy_service(self, service_id: str) -> dict: + def undeploy_service(self, service_id: str) -> requests.Response: """ Undeploy service :input @@ -136,10 +136,10 @@ class ContinuumClient: response.status_code, response.text, ) - return response.json() + return response @catch_requests_exceptions - def onboard_and_deploy_service(self, service_id: str, tosca_str: str) -> dict: + def onboard_and_deploy_service(self, service_id: str, tosca_str: str) -> requests.Response: """ Onboard (& deploy) service on aerOS continuum :input diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py new file mode 100644 index 0000000..a0a7fe1 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py @@ -0,0 +1,269 @@ +''' + aerOS continuum models +''' +from enum import Enum +from typing import List, Dict, Any, Union, Optional +from pydantic import BaseModel, Field + + +class ServiceNotFound(BaseModel): + ''' + Docstring + ''' + detail: str = "Service not found" + + +class CPUComparisonOperator(BaseModel): + """ + CPU requirment for now is that usage should be less than + """ + less_or_equal: Union[float, None] = None + + +class CPUArchComparisonOperator(BaseModel): + """ + CPU arch requirment, equal to str + """ + equal: Union[str, None] = None + + +class MEMComparisonOperator(BaseModel): + """ + RAM requirment for now is that available RAM should be more than + """ + greater_or_equal: Union[str, None] = None + + +class EnergyEfficienyComparisonOperator(BaseModel): + """ + Energy Efficiency requirment for now is that IE should have energy efficiency more than a % + """ + greater_or_equal: Union[str, None] = None + + +class GreenComparisonOperator(BaseModel): + """ + IE Green requirment for now is that IE should have green energy mix which us more than a % + """ + greater_or_equal: Union[str, None] = None + + +class RTComparisonOperator(BaseModel): + """ + Real Time requirment T/F + """ + equal: Union[bool, None] = None + + +class CpuArch(str, Enum): + ''' + Enumeration with possible cpu types + ''' + x86_64 = "x86_64" + arm64 = "arm64" + arm32 = "arm32" + + +class Coordinates(BaseModel): + ''' + IE coordinate requirements + ''' + coordinates: List[List[float]] + + +class DomainIdOperator(BaseModel): + """ + CPU arch requirment, equal to str + """ + equal: Union[str, None] = None + + +class Property(BaseModel): + ''' + IE capabilities + ''' + cpu_usage: CPUComparisonOperator = Field( + default_factory=CPUComparisonOperator) + cpu_arch: CPUArchComparisonOperator = Field( + default_factory=CPUArchComparisonOperator) + mem_size: MEMComparisonOperator = Field( + default_factory=MEMComparisonOperator) + realtime: RTComparisonOperator = Field( + default_factory=RTComparisonOperator) + area: Coordinates = None + energy_efficiency: EnergyEfficienyComparisonOperator = Field( + default_factory=EnergyEfficienyComparisonOperator) + green: GreenComparisonOperator = Field( + default_factory=GreenComparisonOperator) + domain_id: DomainIdOperator = Field(default_factory=DomainIdOperator) + + # @field_validator('mem_size') + # def validate_mem_size(cls, v): + # if not v or "MB" not in v: + # raise ValueError("mem_size must be in MB and specified") + # mem_size_value = int(v.split(" ")[0]) + # if mem_size_value < 2000: + # raise ValueError("mem_size must be greater or equal to 2000 MB") + # return v + + +class HostCapability(BaseModel): + ''' + Host properties + ''' + properties: Property + + +class NodeFilter(BaseModel): + ''' + Node filter, + How to filter continuum IE and select canditate list + ''' + properties: Optional[Dict[str, List[str]]] = None + capabilities: Optional[List[Dict[str, HostCapability]]] = None + + +class HostRequirement(BaseModel): + ''' + capabilities of node + ''' + # node_filter: Dict[str, List[Dict[str, HostCapability]]] + node_filter: NodeFilter + + +class PortProperties(BaseModel): + ''' + Workload port description + ''' + protocol: List[str] = Field(...) + source: int = Field(...) + + +class ExposedPort(BaseModel): + ''' + Workload exposed network ports + ''' + properties: PortProperties = Field(...) + + +class NetworkProperties(BaseModel): + ''' + Dict of network requirments, name of port and protperty = [protocol, port] mapping + ''' + ports: Dict[str, ExposedPort] = Field(...) + exposePorts: Optional[bool] + + +class NetworkRequirement(BaseModel): + ''' + Top level key of network requirments + ''' + properties: NetworkProperties + + +class CustomRequirement(BaseModel): + ''' + Define a custom requirement type that can be either a host or a network requirement + ''' + host: HostRequirement = None + network: NetworkRequirement = None + + +class ArtifactModel(BaseModel): + ''' + Artifact has a useer defined id and then a dict with the following keys: + ''' + file: str + type: str + repository: str + is_private: Optional[bool] = False + username: Optional[str] = None + password: Optional[str] = None + + +class NodeTemplate(BaseModel): + ''' + Node template "tosca.nodes.Container.Application" + ''' + type: str + requirements: List[CustomRequirement] + artifacts: Dict[str, ArtifactModel] + interfaces: Dict[str, Any] + isJob: Optional[bool] = False + + +class TOSCA(BaseModel): + ''' + The TOSCA structure + ''' + tosca_definitions_version: str + description: str + serviceOverlay: Optional[bool] = False + node_templates: Dict[str, NodeTemplate] + + +TOSCA_YAML_EXAMPLE = """ +tosca_definitions_version: tosca_simple_yaml_1_3 +description: A test service for testing TOSCA generation +serviceOverlay: false + +node_templates: + auto-component: + type: tosca.nodes.Container.Application + isJob: False + artifacts: + application_image: + file: aeros-public/common-deployments/nginx:latest + repository: registry.gitlab.aeros-project.eu + type: tosca.artifacts.Deployment.Image.Container.Docker + interfaces: + Standard: + create: + implementation: application_image + inputs: + cliArgs: + - -a: aa + envVars: + - URL: bb + requirements: + - network: + properties: + ports: + port1: + properties: + protocol: + - tcp + source: 80 + port2: + properties: + protocol: + - tcp + source: 443 + exposePorts: True + - host: + node_filter: + capabilities: + - host: + properties: + cpu_arch: + equal: x64 + realtime: + equal: false + cpu_usage: + less_or_equal: '0.4' + mem_size: + greater_or_equal: '1' + domain_id: + equal: urn:ngsi-ld:Domain:NCSRD + energy_efficiency: + greater_or_equal: '0.5' + green: + greater_or_equal: '0.5' + domain_id: + equal: urn:ngsi-ld:Domain:ncsrd01 + properties: null + + + + +""" diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py new file mode 100644 index 0000000..2ca44f4 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py @@ -0,0 +1,30 @@ +''' +Custom aerOS adapter exceptions on top of EdgeCloudPlatformError +''' + +from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError + + +class InvalidArgumentError(EdgeCloudPlatformError): + """400 Bad Request""" + pass + + +class UnauthenticatedError(EdgeCloudPlatformError): + """401 Unauthorized""" + pass + + +class PermissionDeniedError(EdgeCloudPlatformError): + """403 Forbidden""" + pass + + +class ResourceNotFoundError(EdgeCloudPlatformError): + """404 Not Found""" + pass + + +class ServiceUnavailableError(EdgeCloudPlatformError): + """503 Service Unavailable""" + pass diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py new file mode 100644 index 0000000..612b363 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py @@ -0,0 +1,5 @@ +''' +This module contains the storage management implementations for aerOS. +''' +from .inMemoryStorage import InMemoryAppStorage +from .sqlite_storage import SQLiteAppStorage diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py new file mode 100644 index 0000000..16cff05 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py @@ -0,0 +1,69 @@ +''' +# Class: AppStorageManager +# Abstract base class for application storage backends. +# This module defines the interface for managing application storage, +# ''' +from abc import ABC, abstractmethod +from typing import Dict, List, Optional +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo + + +class AppStorageManager(ABC): + """Abstract base class for application storage backends.""" + + @abstractmethod + def store_app(self, app_id: str, manifest: Dict) -> None: + pass + + @abstractmethod + def get_app(self, app_id: str) -> Optional[Dict]: + pass + + @abstractmethod + def app_exists(self, app_id: str) -> bool: + pass + + @abstractmethod + def list_apps(self) -> List[Dict]: + pass + + @abstractmethod + def delete_app(self, app_id: str) -> None: + pass + + @abstractmethod + def store_deployment(self, app_instance: AppInstanceInfo) -> None: + pass + + @abstractmethod + def get_deployments(self, + app_id: Optional[str] = None) -> Dict[str, List[str]]: + pass + + @abstractmethod + def find_deployments( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None) -> List[AppInstanceInfo]: + pass + + @abstractmethod + def remove_deployment(self, app_instance_id: str) -> Optional[str]: + """Removes the given instance ID and returns the corresponding app_id, if found.""" + pass + + @abstractmethod + def store_stopped_instance(self, app_id: str, + app_instance_id: str) -> None: + pass + + @abstractmethod + def get_stopped_instances( + self, + app_id: Optional[str] = None) -> List[str] | Dict[str, List[str]]: + pass + + @abstractmethod + def remove_stopped_instances(self, app_id: str) -> None: + pass diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py new file mode 100644 index 0000000..972421d --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py @@ -0,0 +1,123 @@ +''' +Class: InMemoryAppStorage +''' +from threading import Lock +from typing import Dict, List, Optional +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager \ + import AppStorageManager +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.logger import setup_logger + + +class SingletonMeta(type): + """Thread-safe Singleton metaclass.""" + _instances: Dict[type, object] = {} + _lock = Lock() + + def __call__(cls, *args, **kwargs): + # Double-checked locking pattern + if cls not in cls._instances: + with cls._lock: + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): + ''' + In-memory implementation of the AppStorageManager interface. + ''' + + def __init__(self): + + # Make __init__ idempotent so repeated calls don't reset state + if getattr(self, "_initialized", False): + return + + if config.DEBUG: + self.logger = setup_logger() + self.logger.info("Using InMemoryStorage") + self._apps: Dict[str, Dict] = {} + self._deployed: Dict[str, List[AppInstanceInfo]] = {} + self._stopped: Dict[str, List[str]] = {} + + self._initialized = True + + def reset(self) -> None: + ''' + Helpful for unit tests to clear global state + ''' + self._apps.clear() + self._deployed.clear() + self._stopped.clear() + + def store_app(self, app_id: str, manifest: Dict) -> None: + self._apps[app_id] = manifest + + def get_app(self, app_id: str) -> Optional[Dict]: + return self._apps.get(app_id) + + def app_exists(self, app_id: str) -> bool: + return app_id in self._apps + + def list_apps(self) -> List[Dict]: + return list(self._apps.values()) + + def delete_app(self, app_id: str) -> None: + self._apps.pop(app_id, None) + + def store_deployment(self, app_instance: AppInstanceInfo) -> None: + app_id = str(app_instance.appId) + if app_id not in self._deployed: + self._deployed[app_id] = [] + self._deployed[app_id].append(app_instance) + + def get_deployments(self, + app_id: Optional[str] = None) -> List[AppInstanceInfo]: + if app_id: + return self._deployed.get(app_id, []) + + all_instances = [] + for instances in self._deployed.values(): + all_instances.extend(instances) + return all_instances + + def find_deployments(self, app_id=None, app_instance_id=None, region=None): + result = [] + for instances in self._deployed.values(): # iterate lists of instances + for instance in instances: # iterate individual AppInstanceInfo objects + if app_id and str(instance.appId) != app_id: + continue + if app_instance_id and str( + instance.appInstanceId) != app_instance_id: + continue + # Region filtering can go here if needed + result.append(instance) + return result + + def remove_deployment(self, app_instance_id: str) -> Optional[str]: + for app_id, instances in self._deployed.items(): + for instance in instances: + if str(instance.appInstanceId) == app_instance_id: + instances.remove(instance) + if not instances: + del self._deployed[app_id] + return app_id # return the app_id that had this instance + return None + + def store_stopped_instance(self, app_id: str, + app_instance_id: str) -> None: + if app_id not in self._stopped: + self._stopped[app_id] = [] + self._stopped[app_id].append(app_instance_id) + + def get_stopped_instances( + self, + app_id: Optional[str] = None) -> List[str] | Dict[str, List[str]]: + if app_id: + return self._stopped.get(app_id, []) + return self._stopped + + def remove_stopped_instances(self, app_id: str) -> None: + self._stopped.pop(app_id, None) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py new file mode 100644 index 0000000..9fcb360 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py @@ -0,0 +1,229 @@ +''' +SQLite storage implementation +''' +import sqlite3 +import json +from functools import wraps +from typing import Dict, List, Optional, Union +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo, AppInstanceName,\ + AppProvider, AppId, AppInstanceId, EdgeCloudZoneId, Status +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager\ + import AppStorageManager +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.logger import setup_logger + +decorator_logger = setup_logger() + + +def debug_log(msg: str): + """ + Decorator that logs the given message if config.DEBUG is True. + """ + + def decorator(func): + + @wraps(func) + def wrapper(*args, **kwargs): + if config.DEBUG: + decorator_logger.debug("[DEBUG] %s", msg) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +class SQLiteAppStorage(AppStorageManager): + ''' + SQLite storage implementation + ''' + + @debug_log("Initializing SQLITE storage manager") + def __init__(self, db_path: str = "app_storage.db"): + if config.DEBUG: + self.logger = setup_logger() + self.logger.info("DB Path: %s", db_path) + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self._init_schema() + + def _init_schema(self): + if config.DEBUG: + self.logger.info("Initializing db schema") + cursor = self.conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS apps ( + app_id TEXT PRIMARY KEY, + manifest TEXT + ); + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS deployments ( + app_instance_id TEXT PRIMARY KEY, + app_id TEXT, + name TEXT, + app_provider TEXT, + status TEXT, + component_endpoint_info TEXT, + kubernetes_cluster_ref TEXT, + edge_cloud_zone_id TEXT + ); + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS stopped ( + app_id TEXT, + app_instance_id TEXT + ); + """) + self.conn.commit() + + @debug_log("In SQLITE store_app method ") + def store_app(self, app_id: str, manifest: Dict) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO apps (app_id, manifest) VALUES (?, ?);", + (app_id, json.dumps(manifest))) + self.conn.commit() + + @debug_log("In SQLITE get_app method ") + def get_app(self, app_id: str) -> Optional[Dict]: + row = self.conn.execute("SELECT manifest FROM apps WHERE app_id = ?;", + (app_id, )).fetchone() + return json.loads(row[0]) if row else None + + @debug_log("In SQLITE app_exists method ") + def app_exists(self, app_id: str) -> bool: + row = self.conn.execute("SELECT 1 FROM apps WHERE app_id = ?;", + (app_id, )).fetchone() + return row is not None + + @debug_log("In SQLITE list_apps method ") + def list_apps(self) -> List[Dict]: + rows = self.conn.execute("SELECT manifest FROM apps;").fetchall() + return [json.loads(row[0]) for row in rows] + + @debug_log("In SQLITE delete_app method ") + def delete_app(self, app_id: str) -> None: + self.conn.execute("DELETE FROM apps WHERE app_id = ?;", (app_id, )) + self.conn.commit() + + @debug_log("In SQLITE store_deployment method ") + def store_deployment(self, app_instance: AppInstanceInfo) -> None: + resolved_status = (str(app_instance.status.value) if hasattr( + app_instance.status, "value") else str(app_instance.status) + if app_instance.status else "unknown") + self.logger.info("Resolved status for DB insert: %s", resolved_status) + + self.conn.execute( + """ + INSERT OR REPLACE INTO deployments ( + app_instance_id, app_id, name, app_provider, status, + component_endpoint_info, kubernetes_cluster_ref, edge_cloud_zone_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?); + """, + ( + str(app_instance.appInstanceId), + str(app_instance.appId), + str(app_instance.name.root), + str(app_instance.appProvider.root), + str(app_instance.status.value) if hasattr( + app_instance.status, "value") else + str(app_instance.status) if app_instance.status else "unknown", + json.dumps(app_instance.componentEndpointInfo) + if app_instance.componentEndpointInfo else None, + app_instance.kubernetesClusterRef, + str(app_instance.edgeCloudZoneId.root), + ), + ) + + self.conn.commit() + + @debug_log("In SQLITE get_deployments method ") + def get_deployments(self, + app_id: Optional[str] = None) -> Dict[str, List[str]]: + if app_id: + rows = self.conn.execute( + "SELECT app_id, app_instance_id FROM deployments WHERE app_id = ?;", + (app_id, )).fetchall() + else: + rows = self.conn.execute( + "SELECT app_id, app_instance_id FROM deployments;").fetchall() + + result: Dict[str, List[str]] = {} + for app_id_val, instance_id in rows: + result.setdefault(app_id_val, []).append(instance_id) + return result + + @debug_log("In SQLITE find_deployments method ") + def find_deployments( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> List[AppInstanceInfo]: + query = "SELECT * FROM deployments WHERE 1=1" + params = [] + if app_id: + query += " AND app_id = ?" + params.append(app_id) + if app_instance_id: + query += " AND app_instance_id = ?" + params.append(app_instance_id) + + rows = self.conn.execute(query, params).fetchall() + + result = [] + for row in rows: + result.append( + AppInstanceInfo( + appInstanceId=AppInstanceId(row[0]), + appId=AppId(row[1]), + name=AppInstanceName(row[2]), + appProvider=AppProvider(row[3]), + status=Status(row[4]) if row[4] else Status.unknown, + componentEndpointInfo=json.loads(row[5]) + if row[5] else None, + kubernetesClusterRef=row[6], + edgeCloudZoneId=EdgeCloudZoneId(row[7]), + )) + + return result + + @debug_log("In SQLITE remove_deployments method ") + def remove_deployment(self, app_instance_id: str) -> Optional[str]: + row = self.conn.execute( + "SELECT app_id FROM deployments WHERE app_instance_id = ?;", + (app_instance_id, )).fetchone() + self.conn.execute("DELETE FROM deployments WHERE app_instance_id = ?;", + (app_instance_id, )) + self.conn.commit() + return row[0] if row else None + + @debug_log("In SQLITE store_stopped_instance method ") + def store_stopped_instance(self, app_id: str, + app_instance_id: str) -> None: + self.conn.execute( + "INSERT INTO stopped (app_id, app_instance_id) VALUES (?, ?);", + (app_id, app_instance_id)) + self.conn.commit() + + @debug_log("In SQLITE get_Stopped_instances method ") + def get_stopped_instances( + self, + app_id: Optional[str] = None + ) -> Union[List[str], Dict[str, List[str]]]: + if app_id: + rows = self.conn.execute( + "SELECT app_instance_id FROM stopped WHERE app_id = ?;", + (app_id, )).fetchall() + return [r[0] for r in rows] + else: + rows = self.conn.execute( + "SELECT app_id, app_instance_id FROM stopped;").fetchall() + result: Dict[str, List[str]] = {} + for aid, iid in rows: + result.setdefault(aid, []).append(iid) + return result + + @debug_log("In SQLITE remove_stopped_instances method ") + def remove_stopped_instances(self, app_id: str) -> None: + self.conn.execute("DELETE FROM stopped WHERE app_id = ?;", (app_id, )) + self.conn.commit() diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py index d2424e3..2d8e7ed 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py @@ -6,38 +6,74 @@ # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## """ -Docstring +aerOS help methods """ from requests.exceptions import HTTPError, RequestException, Timeout import sunrise6g_opensdk.edgecloud.adapters.aeros.config as config +import sunrise6g_opensdk.edgecloud.adapters.aeros.errors as errors from sunrise6g_opensdk.logger import setup_logger def catch_requests_exceptions(func): """ - Docstring + Decorator to catch and translate requests exceptions into custom app errors. """ logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) def wrapper(*args, **kwargs): try: - result = func(*args, **kwargs) - return result + return func(*args, **kwargs) + except HTTPError as e: - logger.info("4xx or 5xx: %s \n", {e}) - return None # raise our custom exception or log, etc. - except ConnectionError as e: - logger.info( - "Raised for connection-related issues (e.g., DNS resolution failure, network issues): %s \n", - {e}, - ) - return None # raise our custom exception or log, etc. + response = getattr(e, "response", None) + status_code = getattr(response, "status_code", None) + logger.error("HTTPError occurred: %s", e) + + if status_code == 401: + raise errors.UnauthenticatedError("Unauthorized access") from e + elif status_code == 403: + raise errors.PermissionDeniedError("Forbidden access") from e + elif status_code == 404: + raise errors.ResourceNotFoundError("Resource not found") from e + elif status_code == 400: + raise errors.InvalidArgumentError("Bad request") from e + elif status_code == 503: + raise errors.ServiceUnavailableError( + "Service unavailable") from e + + raise errors.EdgeCloudPlatformError( + f"Unhandled HTTP error: {status_code}") from e + except Timeout as e: - logger.info("Timeout occured: %s \n", {e}) - return None # raise our custom exception or log, etc. + logger.warning("Timeout occurred: %s", e) + raise errors.ServiceUnavailableError("Request timed out") from e + + except ConnectionError as e: + logger.warning("Connection error (e.g., DNS): %s", e) + raise errors.ServiceUnavailableError("Connection issue") from e + except RequestException as e: - logger.info("Request failed: %s \n", {e}) - return None # raise our custom exception or log, etc. + # Catch other unclassified request exceptions (non-HTTP) + logger.error("Request failed: %s", str(e)) + + if e.response is not None: + logger.error("Status Code: %s", e.response.status_code) + logger.error("Response Body (raw): %s", e.response.text) + + try: + json_data = e.response.json() + logger.debug("Parsed JSON response: %s", json_data) + except ValueError: + logger.warning("Response body is not valid JSON.") + + if e.request is not None: + logger.error("Request URL: %s", e.request.url) + logger.error("Request Method: %s", e.request.method) + logger.error("Request Headers: %s", e.request.headers) + logger.error("Request Body: %s", e.request.body) + + raise errors.EdgeCloudPlatformError( + "Unhandled request error") from e return wrapper diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index cce2e73..20e4d18 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -801,7 +801,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: validated_data = gsma_schemas.ZoneRegisteredData.model_validate(mapped) except ValidationError as e: - raise ValueError(f"Invalid schema: {e}") + raise ValueError(f"Invalid schema: {e}") from e return build_custom_http_response( status_code=200, content=validated_data.model_dump(), -- GitLab From cf64e19eb8cba1f475d348b3e70d7c43caec92d6 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Fri, 17 Oct 2025 12:34:29 +0300 Subject: [PATCH 2/9] Fix (thread safety) inMemory. --- .../storageManagement/inMemoryStorage.py | 166 +++++++++++------- 1 file changed, 102 insertions(+), 64 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py index 972421d..0be3a40 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py @@ -1,22 +1,21 @@ ''' Class: InMemoryAppStorage ''' -from threading import Lock -from typing import Dict, List, Optional -from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager \ - import AppStorageManager -from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo +from threading import RLock +from typing import Dict, List, Optional, Union +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import AppStorageManager +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.logger import setup_logger +# import copy # optional if you want deep copies class SingletonMeta(type): """Thread-safe Singleton metaclass.""" _instances: Dict[type, object] = {} - _lock = Lock() + _lock = RLock() def __call__(cls, *args, **kwargs): - # Double-checked locking pattern if cls not in cls._instances: with cls._lock: if cls not in cls._instances: @@ -25,99 +24,138 @@ class SingletonMeta(type): class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): - ''' - In-memory implementation of the AppStorageManager interface. - ''' + """ + In-memory implementation of the AppStorageManager interface (process-wide singleton). + """ def __init__(self): - - # Make __init__ idempotent so repeated calls don't reset state if getattr(self, "_initialized", False): return + # Initialize logger always; emit debug message conditionally + self.logger = setup_logger() if config.DEBUG: - self.logger = setup_logger() self.logger.info("Using InMemoryStorage") + + self._lock = RLock() self._apps: Dict[str, Dict] = {} self._deployed: Dict[str, List[AppInstanceInfo]] = {} self._stopped: Dict[str, List[str]] = {} - self._initialized = True def reset(self) -> None: ''' Helpful for unit tests to clear global state ''' - self._apps.clear() - self._deployed.clear() - self._stopped.clear() + with self._lock: + self._apps.clear() + self._deployed.clear() + self._stopped.clear() + # --- Apps --- def store_app(self, app_id: str, manifest: Dict) -> None: - self._apps[app_id] = manifest + with self._lock: + self._apps[app_id] = manifest def get_app(self, app_id: str) -> Optional[Dict]: - return self._apps.get(app_id) + with self._lock: + return self._apps.get(app_id) def app_exists(self, app_id: str) -> bool: - return app_id in self._apps + with self._lock: + return app_id in self._apps def list_apps(self) -> List[Dict]: - return list(self._apps.values()) + with self._lock: + # If you want full isolation, use deepcopy: + # return [copy.deepcopy(m) for m in self._apps.values()] + return [dict(m) for m in self._apps.values()] def delete_app(self, app_id: str) -> None: - self._apps.pop(app_id, None) + with self._lock: + self._apps.pop(app_id, None) + # --- Deployments --- def store_deployment(self, app_instance: AppInstanceInfo) -> None: - app_id = str(app_instance.appId) - if app_id not in self._deployed: - self._deployed[app_id] = [] - self._deployed[app_id].append(app_instance) + with self._lock: + aid = str(app_instance.appId) + self._deployed.setdefault(aid, []).append(app_instance) + # Conform to interface -> Dict[str, List[str]] def get_deployments(self, - app_id: Optional[str] = None) -> List[AppInstanceInfo]: - if app_id: - return self._deployed.get(app_id, []) - - all_instances = [] - for instances in self._deployed.values(): - all_instances.extend(instances) - return all_instances - - def find_deployments(self, app_id=None, app_instance_id=None, region=None): - result = [] - for instances in self._deployed.values(): # iterate lists of instances - for instance in instances: # iterate individual AppInstanceInfo objects - if app_id and str(instance.appId) != app_id: + app_id: Optional[str] = None) -> Dict[str, List[str]]: + with self._lock: + if app_id: + ids = [ + str(i.appInstanceId) + for i in self._deployed.get(app_id, []) + ] + return {app_id: ids} + return { + aid: [str(i.appInstanceId) for i in insts] + for aid, insts in self._deployed.items() + } + + def find_deployments( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> List[AppInstanceInfo]: + with self._lock: + # Fast path by instance id + if app_instance_id: + for insts in self._deployed.values(): + for inst in insts: + if str(inst.appInstanceId) == app_instance_id: + if app_id and str(inst.appId) != app_id: + return [] + if region is not None and getattr( + inst, "region", None) != region: + return [] + return [inst] + return [] + + results: List[AppInstanceInfo] = [] + for aid, insts in self._deployed.items(): + if app_id and aid != app_id: continue - if app_instance_id and str( - instance.appInstanceId) != app_instance_id: - continue - # Region filtering can go here if needed - result.append(instance) - return result + for inst in insts: + if region is not None and getattr(inst, "region", + None) != region: + continue + results.append(inst) + return results def remove_deployment(self, app_instance_id: str) -> Optional[str]: - for app_id, instances in self._deployed.items(): - for instance in instances: - if str(instance.appInstanceId) == app_instance_id: - instances.remove(instance) - if not instances: - del self._deployed[app_id] - return app_id # return the app_id that had this instance - return None - + with self._lock: + for aid, insts in list( + self._deployed.items()): # iterate over a copy of items + for idx, inst in enumerate(insts): + if str(inst.appInstanceId) == app_instance_id: + insts.pop(idx) + if not insts: + self._deployed.pop(aid, None) + return aid + return None + + # --- Stopped --- def store_stopped_instance(self, app_id: str, app_instance_id: str) -> None: - if app_id not in self._stopped: - self._stopped[app_id] = [] - self._stopped[app_id].append(app_instance_id) + with self._lock: + lst = self._stopped.setdefault(app_id, []) + if app_instance_id not in lst: # de-duplicate + lst.append(app_instance_id) def get_stopped_instances( - self, - app_id: Optional[str] = None) -> List[str] | Dict[str, List[str]]: - if app_id: - return self._stopped.get(app_id, []) - return self._stopped + self, + app_id: Optional[str] = None + ) -> Union[List[str], Dict[str, List[str]]]: + with self._lock: + if app_id: + return list(self._stopped.get(app_id, [])) + return {aid: list(ids) for aid, ids in self._stopped.items()} def remove_stopped_instances(self, app_id: str) -> None: - self._stopped.pop(app_id, None) + with self._lock: + self._stopped.pop(app_id, None) -- GitLab From 60b84da2f5b09f1b9382cc70ef93245477bd1269 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Sun, 19 Oct 2025 10:05:19 +0300 Subject: [PATCH 3/9] CAMARA updates valiadted, all tests pass --- .../edgecloud/adapters/aeros/client.py | 688 +++++++++++++----- .../edgecloud/adapters/aeros/config.py | 2 +- .../adapters/aeros/continuum_client.py | 4 +- .../adapters/aeros/converters/__init__.py | 0 .../camara2aeros_converter.py | 6 +- .../aeros/converters/gsma2aeros_converter.py | 155 ++++ .../storageManagement/appStorageManager.py | 105 ++- .../storageManagement/inMemoryStorage.py | 276 ++++++- .../edgecloud/adapters/aeros/utils.py | 76 ++ 9 files changed, 1114 insertions(+), 198 deletions(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/__init__.py rename src/sunrise6g_opensdk/edgecloud/adapters/aeros/{ => converters}/camara2aeros_converter.py (95%) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 9ca815b..3c8def8 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -5,20 +5,29 @@ # - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## +from time import sleep import uuid import json +import re from typing import Any, Dict, List, Optional from collections import defaultdict from pydantic import ValidationError from requests import Response from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.edgecloud.adapters.aeros.utils import ( + urn_to_uuid, encode_app_instance_name) from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient +from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import gsma2aeros_converter from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage -from sunrise6g_opensdk.edgecloud.adapters.aeros import camara2aeros_converter +from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import camara2aeros_converter from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( AppStorageManager, ) from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError +from sunrise6g_opensdk.edgecloud.adapters.aeros.errors import ( + ResourceNotFoundError, + InvalidArgumentError, +) from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) from sunrise6g_opensdk.edgecloud.core.utils import build_custom_http_response @@ -85,24 +94,42 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ngsild_params = "type=Domain&format=simplified" camara_response = aeros_client.query_entities(ngsild_params) aeros_domains = camara_response.json() - zone_list = [{ - "zoneId": - domain["id"], - "status": - domain["domainStatus"].split(":")[-1].lower(), - "geographyDetails": - "NOT_USED", - } for domain in aeros_domains] - if status: - zone_list = [ - z for z in zone_list - if z["domainStatus"] == status.lower() - ] # FIXME: Check CAMARA status map to aerOS status literals - # if region: - # zone_list = [ - # z for z in zone_list if z.get("region") == region - # ] # No region for aerOS domains - + if config.DEBUG: + self.logger.debug("aerOS edge cloud zones: %s", aeros_domains) + + zone_list = [] + for domain in aeros_domains: + domain_id = domain.get("id") + if not domain_id: + continue + + # Normalize status + raw_status = domain.get("domainStatus", "") + status_token = raw_status.split(":")[-1].strip().lower() + status = "Active" if status_token == "functional" else "Unknown" + + zone = { + "edgeCloudZoneId": + str(urn_to_uuid(domain_id)), + "edgeCloudZoneName": + domain_id, # or domain_id.split(":")[-1] if you prefer short name + "edgeCloudProvider": + (domain.get("owner", ["unknown"])[0] if isinstance( + domain.get("owner"), list) else domain.get( + "owner", "unknown")), + "status": + status, + "geographyDetails": + "NOT_USED", + } + zone_list.append(zone) + + # Store zones keyed by the aerOS domain id + self.storage.store_zones( + {d["edgeCloudZoneName"]: d + for d in zone_list}) + if config.DEBUG: + self.logger.debug("aerOS Local domains store: %s", zone_list) return build_custom_http_response( status_code=camara_response.status_code, content=zone_list, @@ -130,40 +157,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param flavour_id: Optional flavour ID to filter the results :return: Details of the edge cloud zone """ - # Minimal mocked response based on required fields of 'ZoneRegisteredData' in GSMA OPG E/WBI API - # return { - # "zoneId": - # zone_id, - # "reservedComputeResources": [{ - # "cpuArchType": "ISA_X86_64", - # "numCPU": "4", - # "memory": 8192, - # }], - # "computeResourceQuotaLimits": [{ - # "cpuArchType": "ISA_X86_64", - # "numCPU": "8", - # "memory": 16384, - # }], - # "flavoursSupported": [{ - # "flavourId": - # "medium-x86", - # "cpuArchType": - # "ISA_X86_64", - # "supportedOSTypes": [{ - # "architecture": "x86_64", - # "distribution": "UBUNTU", - # "version": "OS_VERSION_UBUNTU_2204_LTS", - # "license": "OS_LICENSE_TYPE_FREE", - # }], - # "numCPU": - # 4, - # "memorySize": - # 8192, - # "storageSize": - # 100, - # }], - # # - # } aeros_client = ContinuumClient(self.base_url) ngsild_params = f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' self.logger.debug( @@ -308,63 +301,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): f"Application with id '{app_id}' does not exist") self.logger.debug("Retrieved application with id: %s", app_id) - #Do we need to do this ?? - # app_manifest_response = { - # "appManifest": { - # "appId": app_data.get("app_id", app_id), - # "name": app_data.get("name", ""), - # "version": app_data.get("version", ""), - # "appProvider": app_data.get("appProvider", ""), - # # Add other required fields with defaults if not available - # "packageType": "CONTAINER", # Default value - # "appRepo": { - # "type": "PUBLICREPO", - # "imagePath": "not-available" - # }, - # "requiredResources": { - # "infraKind": "kubernetes", - # "applicationResources": {}, - # "isStandalone": False, - # }, - # "componentSpec": [], - # } - # } - # or this ? - # rr = app_data.get("requiredResources", - # {}) # shortcut for requiredResources - # app_repo = app_data.get("appRepo", {}) # shortcut for appRepo - - # app_manifest_response = { - # "appManifest": { - # "appId": app_data.get("appId", app_id), - # "name": app_data.get("name", ""), - # "version": app_data.get("version", ""), - # "appProvider": app_data.get("appProvider", ""), - # # Add other required fields with defaults if not available - # "packageType": app_data.get("packageType", "CONTAINER"), - # "appRepo": { - # "type": app_repo.get("type", "PUBLICREPO"), - # # Take it from the stored object: - # "imagePath": app_repo.get("imagePath", ""), - # }, - # "requiredResources": { - # "infraKind": rr.get("infraKind", "kubernetes"), - # # Copy the whole block as-is (cpuPool etc.) - # "applicationResources": rr.get("applicationResources", {}), - # # From stored object, default False if absent - # "isStandalone": rr.get("isStandalone", False), - # # (optional, since your stored object includes it) - # "version": rr.get("version", ""), - # }, - # # Pass through the list from the stored object - # "componentSpec": app_data.get("componentSpec", []), - # } - # } - # Build CAMARA-compliant response using schema - # Note: This is a partial AppManifest for get operation + app_manifest_response = { + "appManifest": app_data + } # We already keep the app manifest when onboarding + return build_custom_http_response( status_code=200, - content=app_data, # We already keep the app manifest when onboarding + content=app_manifest_response, headers={"Content-Type": "application/json"}, encoding="utf-8", ) @@ -403,7 +346,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) def _generate_service_id(self, app_id: str) -> str: - return f"urn:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" + ''' + Generate a unique service ID for aerOS continuum. + The service ID is in the format of a NGSI-LD URN with a random suffix. + :param app_id: The application ID + :return: The generated service ID + ''' + return f"{app_id}-{uuid.uuid4().hex[:4]}" + + def _generate_aeros_service_id(self, camara_app_instance_id: str) -> str: + ''' + Convert CAMARA appInstanceId to aerOS service ID. + :param camara_app_instance_id: The CAMARA appInstanceId + :return: The corresponding aerOS service ID + ''' + return f"urn:ngsi-ld:Service:{camara_app_instance_id}" # Instantiation methods def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Response: @@ -419,8 +376,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): service_id = self._generate_service_id(app_id) # 3. Convert dict to YAML string + # 3a. Get aerOS domain IDs from zones uuids + aeros_domain_ids = [ + self.storage.resolve_domain_id_by_zone_uuid( + z["EdgeCloudZone"]["edgeCloudZoneId"]) for z in app_zones + if z.get("EdgeCloudZone", {}).get("edgeCloudZoneId") + ] tosca_str = camara2aeros_converter.generate_tosca( - app_manifest=app_manifest, app_zones=app_zones) + app_manifest=app_manifest, app_zones=aeros_domain_ids) self.logger.info("Generated TOSCA YAML:") self.logger.info(tosca_str) @@ -428,21 +391,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: aeros_client = ContinuumClient(self.base_url) aeros_response = aeros_client.onboard_and_deploy_service( - service_id, tosca_str) + self._generate_aeros_service_id(service_id), tosca_str) - if "serviceId" not in aeros_response: + if "serviceId" not in aeros_response.json(): raise EdgeCloudPlatformError( "Invalid response from onboard_service: missing 'serviceId'" ) # Build CAMARA-compliant info - app_provider_id = app_manifest.get("appProvider", - "unknown-provider") + app_provider_id = app_manifest.appProvider.root zone_id = app_zones[0].get("EdgeCloudZone", {}).get("edgeCloudZoneId", "default-zone") app_instance_info = camara_schemas.AppInstanceInfo( - name=camara_schemas.AppInstanceName(service_id), + name=camara_schemas.AppInstanceName( + encode_app_instance_name(service_id)), appId=camara_schemas.AppId(app_id), appInstanceId=camara_schemas.AppInstanceId(service_id), appProvider=camara_schemas.AppProvider(app_provider_id), @@ -491,9 +454,16 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): region) # CAMARA spec format for multiple instances response - camara_response = {"appInstances": instances} + camara_response = { + "appInstances": [ + inst.model_dump( + mode="json") if hasattr(inst, "model_dump") else inst + for inst in instances + ] + } self.logger.info("All app instances retrieved successfully") + self.logger.debug("Onboarded applications: %s", camara_response) return build_custom_http_response( status_code=200, content=camara_response, @@ -516,24 +486,80 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param region: Optional filter by Edge Cloud region :return: Response with application instance details """ - # TODO: Implement actual aeros-specific logic for retrieving a specific deployed app - raise NotImplementedError( - "get_deployed_app is not yet implemented for aeros adapter") + try: + if not app_instance_id: + raise InvalidArgumentError("app_instance_id is required") + + # Look up the instance in CAMARA storage (returns List[AppInstanceInfo]) + self.logger.debug( + "@@@@@@ Retrieving deployed app instance '%s' (app_id=%s, region=%s) @@@@@@", + app_instance_id, app_id, region) + matches = self.storage.find_deployments( + app_id=app_id, + app_instance_id=app_instance_id, + region=region, + ) + self.logger.debug("@@@ Deployed app instance matches: %s @@@", + matches) + if not matches: + # Be explicit in the error so callers know what was used to filter + scope = [] + scope.append(f"instance_id={app_instance_id}") + if app_id: + scope.append(f"app_id={app_id}") + if region: + scope.append(f"region={region}") + raise ResourceNotFoundError( + f"Deployed app not found ({', '.join(scope)})") + + # If multiple matched (shouldn't normally happen after filtering by instance id), + # return the first deterministically. + inst = matches[0] + + # Serialize to JSON-safe dict + content = {"appInstance": inst.model_dump(mode="json")} + + return build_custom_http_response( + status_code=200, + content=content, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + ) + + except (InvalidArgumentError, ResourceNotFoundError): + # Let well-typed domain errors propagate + raise + except EdgeCloudPlatformError: + raise + except Exception as e: + # Defensive catch-all with context + self.logger.exception( + "Unhandled error retrieving deployed app instance '%s' (app_id=%s, region=%s): %s", + app_instance_id, app_id, region, e) + raise EdgeCloudPlatformError(str(e)) def _purge_deployed_app_from_continuum(self, app_id: str) -> None: + ''' + Purge the deployed application from aerOS continuum. + :param app_id: The application ID to purge + All instances of this app should be stopped + ''' aeros_client = ContinuumClient(self.base_url) - response = aeros_client.purge_service(app_id) + response = aeros_client.purge_service( + self._generate_aeros_service_id(app_id)) if response: self.logger.debug("Purged deployed application with id: %s", - app_id) + self._generate_aeros_service_id(app_id)) else: raise EdgeCloudPlatformError( - f"Failed to purg service with id from the continuum '{app_id}'" + f"Failed to purge service with id from the continuum '{app_id}'" ) def undeploy_app(self, app_instance_id: str) -> Response: - # 1. Locate app_id corresponding to this instance - app_id = self.storage.remove_deployment(app_instance_id) + # 1. Locate app_id corresponding to this instance and + # remove from deployed instances for this appId + app_id = self.storage.remove_deployment( + app_instance_id=app_instance_id) if not app_id: raise EdgeCloudPlatformError( f"No deployed app instance with ID '{app_instance_id}' found") @@ -541,7 +567,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # 2. Call the external undeploy_service aeros_client = ContinuumClient(self.base_url) try: - aeros_response = aeros_client.undeploy_service(app_instance_id) + aeros_response = aeros_client.undeploy_service( + self._generate_aeros_service_id(app_instance_id)) except Exception as e: raise EdgeCloudPlatformError( f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" @@ -552,6 +579,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # self._purge_deployed_app_from_continuum(app_instance_id) # 4. Clean up internal tracking + self.storage.remove_deployment(app_instance_id) self.storage.store_stopped_instance(app_id, app_instance_id) return build_custom_http_response( status_code=204, @@ -636,7 +664,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): result = self.transform_infrastructure_elements(domain_ies=ies, domain=domain) camara_response.append(result) - self.logger.debug("Transformed response: %s", camara_response) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, @@ -666,11 +693,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ aeros_client = ContinuumClient(self.base_url) ngsild_params = f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' - self.logger.debug( - "Querying infrastructure elements for zone %s with params: %s", - zone_id, - ngsild_params, - ) + if config.DEBUG: + self.logger.debug( + "Querying infrastructure elements for zone %s with params: %s", + zone_id, + ngsild_params, + ) try: # Query the infrastructure elements for the specified zonese aeros_response = aeros_client.query_entities(ngsild_params) @@ -679,7 +707,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # and return the details of the edge cloud zone camara_response = self.transform_infrastructure_elements( domain_ies=aeros_domain_ies, domain=zone_id) - self.logger.debug("Transformed response: %s", camara_response) + if config.DEBUG: + self.logger.debug("Transformed response: %s", camara_response) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, @@ -703,7 +732,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Artefact Management (GSMA) # ------------------------------------------------------------------------ - def create_artefact_gsma(self, request_body: dict): + def create_artefact_gsma(self, request_body: dict) -> Response: """ Uploads application artefact on partner OP. Artefact is a zip file containing scripts and/or packaging files like Terraform or Helm @@ -712,30 +741,88 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param request_body: Payload with artefact information. :return: """ - pass + try: + artefact = gsma_schemas.Artefact.model_validate(request_body) + self.storage.store_artefact_gsma(artefact) + return build_custom_http_response( + status_code=201, + content=artefact.model_dump(mode="json"), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except ValidationError as e: + self.logger.error("Invalid GSMA artefact schema: %s", e) + raise InvalidArgumentError(str(e)) - def get_artefact_gsma(self, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Response: """ Retrieves details about an artefact :param artefact_id: Unique identifier of the artefact. :return: Dictionary with artefact details. """ - pass + art = self.storage.get_artefact_gsma(artefact_id) + if not art: + raise ResourceNotFoundError( + f"GSMA artefact '{artefact_id}' not found") + return build_custom_http_response( + status_code=200, + content=art.model_dump(mode="json"), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + + def list_artefacts_gsma(self): + """List all GSMA Artefacts.""" + arts = [ + a.model_dump(mode="json") + for a in self.storage.list_artefacts_gsma() + ] + return build_custom_http_response( + status_code=200, + content=arts, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) - def delete_artefact_gsma(self, artefact_id: str): + def delete_artefact_gsma(self, artefact_id: str) -> Response: """ Removes an artefact from partners OP. :param artefact_id: Unique identifier of the artefact. :return: """ - pass + if not self.storage.get_artefact_gsma(artefact_id): + raise ResourceNotFoundError( + f"GSMA artefact '{artefact_id}' not found") + self.storage.delete_artefact_gsma(artefact_id) + return build_custom_http_response(status_code=204, + content=b"", + headers={}, + encoding=None) # ------------------------------------------------------------------------ # Application Onboarding Management (GSMA) # ------------------------------------------------------------------------ + def _to_application_model( + self, entry: gsma_schemas.AppOnboardManifestGSMA + ) -> gsma_schemas.ApplicationModel: + """Internal helper to convert GSMA onboarding entry into canonical ApplicationModel.""" + zones = [ + gsma_schemas.AppDeploymentZone(countryCode="XX", zoneInfo=z) + for z in entry.appDeploymentZones + ] + return gsma_schemas.ApplicationModel( + appId=entry.appId, + appProviderId=entry.appProviderId, + appDeploymentZones=zones, + appMetaData=entry.appMetaData, + appQoSProfile=entry.appQoSProfile, + appComponentSpecs=entry.appComponentSpecs, + onboardStatusInfo="ONBOARDED", + ) + def onboard_app_gsma(self, request_body: dict): """ Submits an application details to a partner OP. @@ -747,29 +834,38 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ try: # Validate input against GSMA schema - gsma_validated_body = gsma_schemas.AppOnboardManifestGSMA.model_validate( + entry = gsma_schemas.AppOnboardManifestGSMA.model_validate( request_body) - data = gsma_validated_body.model_dump() except ValidationError as e: self.logger.error("Invalid GSMA input schema: %s", e) - raise + raise InvalidArgumentError(str(e)) + try: - data["app_id"] = data.pop("appId") - data.pop("edgeAppFQDN", None) - # FIXME: payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) - url = f"{self.base_url}/application/onboarding" - # FIXME: response = i2edge_post(url, payload, expected_status=201) + # Convert to ApplicationModel (canonical onboarded representation) + app_model = self._to_application_model(entry) + + # Ensure uniqueness + if self.storage.get_app_gsma(app_model.appId): + raise InvalidArgumentError( + f"GSMA app '{app_model.appId}' already exists") + + # Store in GSMA apps storage + self.storage.store_app_gsma(app_model.appId, app_model) + + # Build and return confirmation response return build_custom_http_response( - status_code=200, - content={"response": "Application onboarded successfully"}, + status_code=201, + content=app_model.model_dump(mode="json"), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, - # FIXME: url=response.url, - # FIXME: request=response.request, ) except EdgeCloudPlatformError as e: - self.logger.error("Error retrieving edge cloud zones: %s", e) + self.logger.error("Error during GSMA app onboarding: %s", e) raise + except Exception as e: + self.logger.exception("Unhandled error during GSMA onboarding: %s", + e) + raise EdgeCloudPlatformError(str(e)) def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ @@ -778,27 +874,118 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param app_id: Identifier of the application onboarded. :return: Dictionary with application details. """ - pass + try: + app = self.storage.get_app_gsma(app_id) + if not app: + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + return build_custom_http_response( + status_code=200, + content=app.model_dump(mode="json"), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving GSMA app '%s': %s", app_id, e) + raise + except Exception as e: + self.logger.exception( + "Unhandled error retrieving GSMA app '%s': %s", app_id, e) + raise EdgeCloudPlatformError(str(e)) def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): """ Updates partner OP about changes in application compute resource requirements, - QOS Profile, associated descriptor or change in associated components + QOS Profile, associated descriptor or change in associated components. :param app_id: Identifier of the application onboarded. :param request_body: Payload with updated onboarding info. - :return: + :return: Response with updated application details. """ - pass + try: + patch = gsma_schemas.PatchOnboardedAppGSMA.model_validate( + request_body) + except ValidationError as e: + self.logger.error("Invalid GSMA patch schema: %s", e) + raise InvalidArgumentError(str(e)) + + try: + app = self.storage.get_app_gsma(app_id) + if not app: + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + upd = patch.appUpdQoSProfile + + # Update QoS profile fields + if upd.latencyConstraints is not None: + app.appQoSProfile.latencyConstraints = upd.latencyConstraints + if upd.bandwidthRequired is not None: + app.appQoSProfile.bandwidthRequired = upd.bandwidthRequired + if upd.multiUserClients is not None: + app.appQoSProfile.multiUserClients = upd.multiUserClients + if upd.noOfUsersPerAppInst is not None: + app.appQoSProfile.noOfUsersPerAppInst = upd.noOfUsersPerAppInst + if upd.appProvisioning is not None: + app.appQoSProfile.appProvisioning = upd.appProvisioning + + # mobilitySupport lives under AppMetaData + if upd.mobilitySupport is not None: + app.appMetaData.mobilitySupport = upd.mobilitySupport + + # Replace component specs if provided + if patch.appComponentSpecs: + app.appComponentSpecs = [ + gsma_schemas.AppComponentSpec( + serviceNameNB=p.serviceNameNB, + serviceNameEW=p.serviceNameEW, + componentName=p.componentName, + artefactId=p.artefactId, + ) for p in patch.appComponentSpecs + ] + + # Persist updated model + self.storage.store_app_gsma(app_id, app) + + return build_custom_http_response( + status_code=200, + content=app.model_dump(mode="json"), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError as e: + self.logger.error("Error updating GSMA app '%s': %s", app_id, e) + raise + except Exception as e: + self.logger.exception("Unhandled error patching GSMA app '%s': %s", + app_id, e) + raise EdgeCloudPlatformError(str(e)) def delete_onboarded_app_gsma(self, app_id: str): """ - Deboards an application from specific partner OP zones + Deboards an application from specific partner OP zones. :param app_id: Identifier of the application onboarded. - :return: + :return: 204 No Content on success. """ - pass + try: + if not self.storage.get_app_gsma(app_id): + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + self.storage.delete_app_gsma(app_id) + + return build_custom_http_response( + status_code=204, + content=b"", + headers={}, + encoding=None, + ) + except EdgeCloudPlatformError as e: + self.logger.error("Error deleting GSMA app '%s': %s", app_id, e) + raise + except Exception as e: + self.logger.exception("Unhandled error deleting GSMA app '%s': %s", + app_id, e) + raise EdgeCloudPlatformError(str(e)) # ------------------------------------------------------------------------ # Application Deployment Management (GSMA) @@ -811,7 +998,84 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param request_body: Payload with deployment info. :return: Dictionary with deployment details. """ - pass + try: + payload = gsma_schemas.AppDeployPayloadGSMA.model_validate( + request_body) + except ValidationError as e: + self.logger.error("Invalid GSMA deploy schema: %s", e) + raise InvalidArgumentError(str(e)) + + try: + # Ensure app exists + onboarded_app = self.storage.get_app_gsma(payload.appId) + if not onboarded_app: + raise ResourceNotFoundError( + f"GSMA app '{payload.appId}' not found") + + # 2. Generate unique service ID + # (aerOS) service id <=> CAMARA appInstanceId + service_id = self._generate_service_id(onboarded_app.appId) + + # 3. Create TOSCA (yaml str) from GSMA onboarded_app + connected artefacts + # GSMA app corresponds to aerOS Service + # Each GSMA AppComponentSpec references an artefact which is mapped to aerOS Service Component + tosca_yaml = gsma2aeros_converter.generate_tosca_from_gsma_with_artefacts( + app_model=onboarded_app, + zone_id=payload.zoneInfo.zoneId, + artefact_resolver=self.storage.get_artefact_gsma, # cleaner + ) + self.logger.info("Generated TOSCA YAML:") + self.logger.info(tosca_yaml) + + # 4. Instantiate client and call continuum to deploy servic + aeros_client = ContinuumClient(self.base_url) + aeros_response = aeros_client.onboard_and_deploy_service( + service_id, tosca_str=tosca_yaml) + + if "serviceId" not in aeros_response: + raise EdgeCloudPlatformError( + "Invalid response from onboard_service: missing 'serviceId'" + ) + + # 5. Track deployment (Store in GSMA deployment store) + # Build AppInstance and optional status (if you want to persist status later) + inst = gsma_schemas.AppInstance( + zoneId=payload.zoneInfo.zoneId, + appInstIdentifier=service_id, + ) + status = gsma_schemas.AppInstanceStatus( + appInstanceState= + "DEPLOYED", # or "PENDING" if you simulate async + accesspointInfo=[], + ) + + self.storage.store_deployment_gsma(onboarded_app.appId, + inst, + status=status) + + # 6. Return expected format (deployment details) + body = { + "appId": payload.appId, + "appVersion": payload.appVersion, + "appProviderId": payload.appProviderId, + "zoneId": payload.zoneInfo.zoneId, + "appInstance": inst.model_dump(mode="json"), + "status": status.model_dump(mode="json"), + } + + return build_custom_http_response( + status_code=201, + content=body, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError as ex: + self.logger.error("Failed to deploy app '%s': %s", + onboarded_app.appId, str(ex)) + raise + except Exception as e: + self.logger.exception("Unhandled error during GSMA deploy: %s", e) + raise EdgeCloudPlatformError(str(e)) def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: @@ -823,7 +1087,37 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param zone_id: Identifier of the zone :return: Dictionary with application instance details """ - pass + try: + # Ensure app exists + if not self.storage.get_app_gsma(app_id): + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + matches = self.storage.find_deployments_gsma( + app_id=app_id, + app_instance_id=app_instance_id, + zone_id=zone_id, + ) + if not matches: + raise ResourceNotFoundError( + f"Deployment not found (app_id={app_id}, instance={app_instance_id}, zone={zone_id})" + ) + + inst = matches[0] + body = inst.model_dump(mode="json") + + return build_custom_http_response( + status_code=200, + content=body, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError: + raise + except Exception as e: + self.logger.exception( + "Unhandled error retrieving GSMA deployment '%s' (%s/%s): %s", + app_instance_id, app_id, zone_id, e) + raise EdgeCloudPlatformError(str(e)) def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: @@ -834,7 +1128,33 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param app_provider: App provider :return: List with application instances details """ - pass + try: + app = self.storage.get_app_gsma(app_id) + if not app: + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + # Optional provider check (keep if you want extra validation) + if app_provider and app.appProviderId != app_provider: + raise ResourceNotFoundError( + f"GSMA app '{app_id}' not found for provider '{app_provider}'" + ) + + insts = self.storage.find_deployments_gsma(app_id=app_id) + body = [i.model_dump(mode="json") for i in insts] + + return build_custom_http_response( + status_code=200, + content=body, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError: + raise + except Exception as e: + self.logger.exception( + "Unhandled error listing GSMA deployments for app '%s': %s", + app_id, e) + raise EdgeCloudPlatformError(str(e)) def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): @@ -846,4 +1166,48 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param zone_id: Identifier of the zone :return: """ - pass + try: + # Ensure app exists + if not self.storage.get_app_gsma(app_id): + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + # Ensure the (app_id, instance, zone) exists + matches = self.storage.find_deployments_gsma( + app_id=app_id, + app_instance_id=app_instance_id, + zone_id=zone_id) + if not matches: + raise ResourceNotFoundError( + f"Deployment not found (app_id={app_id}, instance={app_instance_id}, zone={zone_id})" + ) + + # Placeholder: call aerOS undeploy here (GSMA → aerOS conversion) + # aeros_client.undeploy(instance_id=app_instance_id, zone_id=zone_id) + + # Remove from deployed and mark as stopped so it can be purged later + removed_app_id = self.storage.remove_deployment_gsma( + app_instance_id) + if removed_app_id: + self.storage.store_stopped_instance_gsma( + removed_app_id, app_instance_id) + + # Async-friendly: 202 Accepted (termination in progress) + body = { + "appId": app_id, + "appInstIdentifier": app_instance_id, + "zoneId": zone_id, + "state": "TERMINATING", + } + return build_custom_http_response( + status_code=202, + content=body, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError: + raise + except Exception as e: + self.logger.exception( + "Unhandled error undeploying GSMA app instance '%s' (app=%s zone=%s): %s", + app_instance_id, app_id, zone_id, e) + raise EdgeCloudPlatformError(str(e)) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py index 81d7b8c..ded19cb 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py @@ -23,5 +23,5 @@ if not aerOS_ACCESS_TOKEN: aerOS_HLO_TOKEN = "harcoded_hlo_token" if not aerOS_HLO_TOKEN: raise ValueError("Environment variable 'aerOS_HLO_TOKEN' is not set.") -DEBUG = False +DEBUG = True LOG_FILE = ".log/aeros_client.log" diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index aa4a85a..ac439ab 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -127,8 +127,10 @@ class ContinuumClient: undeploy_url = f"{self.api_url}/hlo_fe/services/{service_id}" response = requests.delete(undeploy_url, headers=self.hlo_headers, timeout=15) if response is None: + self.logger.debug("In NONE Undeploy service URL: %s", undeploy_url) return None else: + self.logger.debug("In OK Undeploy and text: %s", response.text) if config.DEBUG: self.logger.debug("Re-allocate service URL: %s", undeploy_url) self.logger.debug( @@ -165,7 +167,7 @@ class ContinuumClient: response.status_code, response.text, ) - return response.json() + return response @catch_requests_exceptions def purge_service(self, service_id: str) -> bool: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py similarity index 95% rename from src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py rename to src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py index da9d83d..318d575 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py @@ -17,8 +17,7 @@ from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) -def generate_tosca(app_manifest: AppManifest, - app_zones: List[Dict[str, Any]]) -> str: +def generate_tosca(app_manifest: AppManifest, app_zones: List[str]) -> str: ''' Generate a TOSCA model from the application manifest and app zones. Args: @@ -33,8 +32,7 @@ def generate_tosca(app_manifest: AppManifest, repository_url = "/".join( image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" - zone_id = app_zones[0].get("EdgeCloudZone", - {}).get("edgeCloudZoneId", "default-zone") + zone_id = app_zones[0] logger.info("DEBUG : %s", app_manifest.requiredResources.root) # Extract minNodeMemory (fallback = 1024 MB) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py new file mode 100644 index 0000000..a6cd1cd --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py @@ -0,0 +1,155 @@ +""" +Module: gsm2aeros_converter.py +Initial GSMA -> TOSCA generator. + +Notes: +- GSMA ApplicationModel does not include container image or ports directly. + (Those usually come from Artefacts, which we're ignoring for now.) +- We provide an `image_map` hook to resolve artefactId -> image string. +- Defaults to a public nginx image if nothing is provided. +- Network ports are omitted for now (exposePorts = False). +""" + +from typing import Optional, Callable +import yaml +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.edgecloud.adapters.aeros.errors import ( + ResourceNotFoundError, InvalidArgumentError) +from sunrise6g_opensdk.logger import setup_logger +from sunrise6g_opensdk.edgecloud.core import gsma_schemas +from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( + TOSCA, NodeTemplate, CustomRequirement, HostRequirement, HostCapability, + Property as HostProperty, DomainIdOperator, NodeFilter, NetworkRequirement, + NetworkProperties, ExposedPort, PortProperties, ArtifactModel) + +logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) + + +def generate_tosca_from_gsma_with_artefacts( + app_model: gsma_schemas.ApplicationModel, + zone_id: str, + artefact_resolver: Callable[[str], Optional[gsma_schemas.Artefact]], +) -> str: + """ + Build TOSCA from GSMA ApplicationModel by resolving each component's artefactId. + + - One Node (under NodeTemplates) per AppComponentSpec (connects to Artefacts). + - Image pulled from Artefact.componentSpec[i].images[0] (first image). + - Optional ports read from Artefact.componentSpec[i].exposedInterfaces (if present). + Expected dict fields (best-effort): {"protocol": "TCP", "port": 8080} + """ + node_templates = {} + + for comp in app_model.appComponentSpecs: + artefact = artefact_resolver(comp.artefactId) + if not artefact: + raise ResourceNotFoundError( + f"GSMA artefact '{comp.artefactId}' not found") + + # We pick the FIRST componentSpec entry that matches componentName if present, + # else fall back to the first componentSpec entry. + comp_spec = None + if artefact.componentSpec: + # try exact match by name + for c in artefact.componentSpec: + if c.componentName == comp.componentName: + comp_spec = c + break + if not comp_spec: + comp_spec = artefact.componentSpec[0] + else: + raise InvalidArgumentError( + f"Artefact '{artefact.artefactId}' has no componentSpec") + + # Resolve image (first image in the list) + image = comp_spec.images[ + 0] if comp_spec.images else "docker.io/library/nginx:stable" + if "/" in image: + repository_url = "/".join(image.split("/")[:-1]) + image_file = image.split("/")[-1] + else: + repository_url, image_file = "docker_hub", image + + # Build ports (best-effort read from exposedInterfaces) + ports = {} + expose_ports = False + if comp_spec.exposedInterfaces: + for idx, iface in enumerate(comp_spec.exposedInterfaces): + protocol = str(iface.get("protocol", "TCP")).lower() + port = iface.get("port") + if isinstance(port, int): + ports_id = f"if{idx}" + ports[ports_id] = ExposedPort(properties=PortProperties( + protocol=[protocol], source=port)) + expose_ports = True + + host_props = HostProperty( + cpu_arch={"equal": "x64"}, + realtime={"equal": False}, + cpu_usage={"less_or_equal": "0.4"}, + mem_size={"greater_or_equal": "1024"}, + energy_efficiency={"greater_or_equal": "0"}, + green={"greater_or_equal": "0"}, + domain_id=DomainIdOperator(equal=zone_id), + ) + + requirements = [ + CustomRequirement(network=NetworkRequirement( + properties=NetworkProperties(ports=ports, + exposePorts=expose_ports))), + CustomRequirement(host=HostRequirement(node_filter=NodeFilter( + capabilities=[{ + "host": HostCapability(properties=host_props) + }], + properties=None, + ))), + ] + + node_templates[comp.componentName] = NodeTemplate( + type="tosca.nodes.Container.Application", + isJob=False, + requirements=requirements, + artifacts={ + "application_image": + ArtifactModel( + file=image_file, + type="tosca.artifacts.Deployment.Image.Container.Docker", + repository=repository_url, + is_private=(artefact.repoType == "PRIVATEREPO"), + username=(artefact.artefactRepoLocation.userName + if artefact.artefactRepoLocation else None), + password=(artefact.artefactRepoLocation.password + if artefact.artefactRepoLocation else None), + ) + }, + interfaces={ + "Standard": { + "create": { + "implementation": "application_image", + "inputs": { + "cliArgs": + [], # could map comp_spec.commandLineParams later + "envVars": + [], # could map comp_spec.compEnvParams later + } + } + } + }, + ) + + tosca = TOSCA( + tosca_definitions_version="tosca_simple_yaml_1_3", + description= + f"GSMA->TOSCA for {app_model.appMetaData.appName} ({app_model.appId})", + serviceOverlay=False, + node_templates=node_templates, + ) + + tosca_dict = tosca.model_dump(by_alias=True, exclude_none=True) + for template in tosca_dict.get("node_templates", {}).values(): + template["requirements"] = [{ + k: v + for k, v in req.items() if v is not None + } for req in template.get("requirements", [])] + + return yaml.dump(tosca_dict, sort_keys=False) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py index 16cff05..9bff25a 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py @@ -4,13 +4,36 @@ # This module defines the interface for managing application storage, # ''' from abc import ABC, abstractmethod -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo +from sunrise6g_opensdk.edgecloud.core.gsma_schemas import (ApplicationModel, + AppInstance, + AppInstanceStatus, + Artefact) class AppStorageManager(ABC): """Abstract base class for application storage backends.""" + # ------------------------------------------------------------------------ + # aerOS Domain → Zone mapping + # ------------------------------------------------------------------------ + @abstractmethod + def store_zones(self, zones: Dict[str, Dict]) -> None: + """Store or update the aerOS domain → zone info mapping.""" + + @abstractmethod + def list_zones(self) -> List[Dict]: + """Return a list of all stored zone records (values).""" + + @abstractmethod + def resolve_domain_id_by_zone_uuid(self, zone_uuid: str) -> Optional[str]: + """Return the aerOS domain id (key) for a given edgeCloudZoneId (UUID).""" + + # ------------------------------------------------------------------------ + # CAMARA + # ------------------------------------------------------------------------ + @abstractmethod def store_app(self, app_id: str, manifest: Dict) -> None: pass @@ -67,3 +90,83 @@ class AppStorageManager(ABC): @abstractmethod def remove_stopped_instances(self, app_id: str) -> None: pass + + # ------------------------------------------------------------------------ + # GSMA + # ------------------------------------------------------------------------ + @abstractmethod + def store_app_gsma(self, app_id: str, model: ApplicationModel) -> None: + ... + + @abstractmethod + def get_app_gsma(self, app_id: str) -> Optional[ApplicationModel]: + ... + + @abstractmethod + def list_apps_gsma(self) -> List[ApplicationModel]: + ... + + @abstractmethod + def delete_app_gsma(self, app_id: str) -> None: + ... + + @abstractmethod + def store_deployment_gsma( + self, + app_id: str, + inst: AppInstance, + status: Optional[AppInstanceStatus] = None, # optional future use + ) -> None: + ... + + @abstractmethod + def get_deployments_gsma(self, + app_id: Optional[str] = None + ) -> Dict[str, List[str]]: + ... + + @abstractmethod + def find_deployments_gsma( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + zone_id: Optional[str] = None, + ) -> List[AppInstance]: + ... + + @abstractmethod + def remove_deployment_gsma(self, app_instance_id: str) -> Optional[str]: + ... + + @abstractmethod + def store_stopped_instance_gsma(self, app_id: str, + app_instance_id: str) -> None: + ... + + @abstractmethod + def get_stopped_instances_gsma( + self, + app_id: Optional[str] = None + ) -> Union[List[str], Dict[str, List[str]]]: + ... + + @abstractmethod + def remove_stopped_instances_gsma(self, app_id: str) -> None: + ... + + # --- GSMA Artefacts --- + @abstractmethod + def store_artefact_gsma(self, artefact: Artefact) -> None: + ... + + @abstractmethod + def get_artefact_gsma(self, artefact_id: str) -> Optional[Artefact]: + ... + + @abstractmethod + def list_artefacts_gsma(self) -> List[Artefact]: + ... + + @abstractmethod + def delete_artefact_gsma(self, artefact_id: str) -> None: + ... diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py index 0be3a40..79e20b0 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py @@ -1,21 +1,31 @@ -''' +""" Class: InMemoryAppStorage -''' -from threading import RLock +Process-wide singleton, thread-safe with a single RLock. +Keeps CAMARA and GSMA stores separate to avoid schema confusion. +""" + +from abc import ABCMeta +from threading import RLock from typing import Dict, List, Optional, Union -from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import AppStorageManager + +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( + AppStorageManager) from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo +from sunrise6g_opensdk.edgecloud.core.gsma_schemas import (ApplicationModel, + AppInstance, + AppInstanceStatus, + Artefact) from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.logger import setup_logger -# import copy # optional if you want deep copies -class SingletonMeta(type): - """Thread-safe Singleton metaclass.""" +class SingletonMeta(ABCMeta): + """Thread-safe Singleton metaclass (process-wide).""" _instances: Dict[type, object] = {} _lock = RLock() def __call__(cls, *args, **kwargs): + # Double-checked locking if cls not in cls._instances: with cls._lock: if cls not in cls._instances: @@ -25,34 +35,101 @@ class SingletonMeta(type): class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): """ - In-memory implementation of the AppStorageManager interface (process-wide singleton). + In-memory implementation of the AppStorageManager interface. + CAMARA and GSMA data are stored in separate namespaces. """ def __init__(self): if getattr(self, "_initialized", False): return - # Initialize logger always; emit debug message conditionally + # Always have a logger; gate noisy messages by DEBUG self.logger = setup_logger() if config.DEBUG: - self.logger.info("Using InMemoryStorage") + self.logger.info("Using InMemoryStorage (singleton)") self._lock = RLock() - self._apps: Dict[str, Dict] = {} - self._deployed: Dict[str, List[AppInstanceInfo]] = {} - self._stopped: Dict[str, List[str]] = {} + + # aerOS Domain → Zone mapping + self._zones: Dict[str, + Dict] = {} # {aeros_domain_id: camara_zone_dict} + + # CAMARA stores + self._apps: Dict[str, Dict] = {} # app_id -> manifest (CAMARA dict) + self._deployed: Dict[str, List[AppInstanceInfo]] = { + } # app_id -> [AppInstanceInfo] + self._stopped: Dict[str, + List[str]] = {} # app_id -> [stopped instance ids] + + # GSMA stores + self._apps_gsma: Dict[str, ApplicationModel] = { + } # app_id -> ApplicationModel + self._deployed_gsma: Dict[str, List[AppInstance]] = { + } # app_id -> [AppInstance] + self._stopped_gsma: Dict[str, List[str]] = { + } # app_id -> [stopped instance ids] + + self._artefacts_gsma: Dict[str, + Artefact] = {} # artefact_id -> Artefact + self._initialized = True + # ------------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------------ def reset(self) -> None: - ''' - Helpful for unit tests to clear global state - ''' + """Helper for tests to clear global state.""" with self._lock: + # CAMARA self._apps.clear() self._deployed.clear() self._stopped.clear() + # GSMA + self._apps_gsma.clear() + self._deployed_gsma.clear() + self._stopped_gsma.clear() + + # ------------------------------------------------------------------------ + # aerOS Domain → Zone mapping + # ------------------------------------------------------------------------ - # --- Apps --- + def store_zones(self, zones: Dict[str, Dict]) -> None: + """ + Directly store a mapping of aerOS domain_id -> zone_info dict. + Example: + { + "urn:ngsi-ld:Domain:Athens": { + "edgeCloudZoneId": "550e8400-e29b-41d4-a716-446655440000", + "edgeCloudZoneName": "Athens", + "edgeCloudProvider": "aeros_dev", + "status": "active", + "geographyDetails": "NOT_USED", + }, + ... + } + """ + with self._lock: + self._zones.update(zones) + + def list_zones(self) -> List[Dict]: + """Return all zone records as a list of dicts.""" + with self._lock: + return [dict(v) for v in self._zones.values()] + + def resolve_domain_id_by_zone_uuid(self, zone_uuid: str) -> Optional[str]: + """ + Given the edgeCloudZoneId (UUID string), return the original aerOS domain id. + Performs a simple scan — fine for small to medium sets. + """ + with self._lock: + for domain_id, zone in self._zones.items(): + if zone.get("edgeCloudZoneId") == zone_uuid: + return domain_id + return None + + # ------------------------------------------------------------------------ + # CAMARA + # ------------------------------------------------------------------------ def store_app(self, app_id: str, manifest: Dict) -> None: with self._lock: self._apps[app_id] = manifest @@ -67,21 +144,19 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): def list_apps(self) -> List[Dict]: with self._lock: - # If you want full isolation, use deepcopy: - # return [copy.deepcopy(m) for m in self._apps.values()] + # shallow copies to avoid external mutation of nested dicts return [dict(m) for m in self._apps.values()] def delete_app(self, app_id: str) -> None: with self._lock: self._apps.pop(app_id, None) - # --- Deployments --- def store_deployment(self, app_instance: AppInstanceInfo) -> None: with self._lock: - aid = str(app_instance.appId) + # Ensure the key is a plain string + aid = getattr(app_instance.appId, "root", str(app_instance.appId)) self._deployed.setdefault(aid, []).append(app_instance) - # Conform to interface -> Dict[str, List[str]] def get_deployments(self, app_id: Optional[str] = None) -> Dict[str, List[str]]: with self._lock: @@ -107,13 +182,26 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): if app_instance_id: for insts in self._deployed.values(): for inst in insts: - if str(inst.appInstanceId) == app_instance_id: + self.logger.debug( + "186: Checking deployed instance id='%s' against '%s'", + str(inst.appInstanceId), app_instance_id) + if str(inst.appInstanceId.root) == app_instance_id: if app_id and str(inst.appId) != app_id: + self.logger.debug( + "189: app_id mismatch: '%s' != '%s'", + str(inst.appId), app_id) return [] if region is not None and getattr( inst, "region", None) != region: + self.logger.debug( + "193: region mismatch: '%s' != '%s'", + getattr(inst, "region", None), region) return [] + self.logger.debug( + "197: Found matching instance: %s", inst) return [inst] + self.logger.debug("200: No matching instance found for id='%s'", + app_instance_id) return [] results: List[AppInstanceInfo] = [] @@ -129,22 +217,25 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): def remove_deployment(self, app_instance_id: str) -> Optional[str]: with self._lock: - for aid, insts in list( - self._deployed.items()): # iterate over a copy of items + for aid, insts in list(self._deployed.items()): for idx, inst in enumerate(insts): - if str(inst.appInstanceId) == app_instance_id: + # Compare using the instance id string + inst_id = getattr(inst.appInstanceId, "root", + str(inst.appInstanceId)) + if inst_id == app_instance_id: insts.pop(idx) if not insts: self._deployed.pop(aid, None) - return aid + # Return a plain string app_id + aid_str = getattr(aid, "root", str(aid)) + return aid_str return None - # --- Stopped --- def store_stopped_instance(self, app_id: str, app_instance_id: str) -> None: with self._lock: lst = self._stopped.setdefault(app_id, []) - if app_instance_id not in lst: # de-duplicate + if app_instance_id not in lst: lst.append(app_instance_id) def get_stopped_instances( @@ -159,3 +250,130 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): def remove_stopped_instances(self, app_id: str) -> None: with self._lock: self._stopped.pop(app_id, None) + + # ------------------------------------------------------------------------ + # GSMA + # ------------------------------------------------------------------------ + def store_app_gsma(self, app_id: str, model: ApplicationModel) -> None: + with self._lock: + self._apps_gsma[app_id] = model + + def get_app_gsma(self, app_id: str) -> Optional[ApplicationModel]: + with self._lock: + return self._apps_gsma.get(app_id) + + def list_apps_gsma(self) -> List[ApplicationModel]: + with self._lock: + return list(self._apps_gsma.values()) + + def delete_app_gsma(self, app_id: str) -> None: + with self._lock: + self._apps_gsma.pop(app_id, None) + + def store_deployment_gsma( + self, + app_id: str, + inst: AppInstance, + status: Optional[AppInstanceStatus] = None, # not persisted yet + ) -> None: + with self._lock: + self._deployed_gsma.setdefault(app_id, []).append(inst) + # If you later want to persist status per instance, keep a side map: + # self._status_gsma[inst.appInstIdentifier] = status + + def get_deployments_gsma(self, + app_id: Optional[str] = None + ) -> Dict[str, List[str]]: + with self._lock: + if app_id: + ids = [ + str(i.appInstIdentifier) + for i in self._deployed_gsma.get(app_id, []) + ] + return {app_id: ids} + return { + aid: [str(i.appInstIdentifier) for i in insts] + for aid, insts in self._deployed_gsma.items() + } + + def find_deployments_gsma( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + zone_id: Optional[str] = None, + ) -> List[AppInstance]: + with self._lock: + # Limit the search space if app_id is provided + iter_lists = ([self._deployed_gsma.get(app_id, [])] + if app_id else self._deployed_gsma.values()) + + # Fast path: instance id provided + if app_instance_id: + target_id = str(app_instance_id) + for insts in iter_lists: + for inst in insts: + if str(inst.appInstIdentifier) != target_id: + continue + if zone_id is not None and inst.zoneId != zone_id: + continue + return [inst] + return [] + + # General filtering + results: List[AppInstance] = [] + for insts in iter_lists: + for inst in insts: + if zone_id is not None and inst.zoneId != zone_id: + continue + results.append(inst) + return results + + def remove_deployment_gsma(self, app_instance_id: str) -> Optional[str]: + with self._lock: + for aid, insts in list(self._deployed_gsma.items()): + for idx, inst in enumerate(insts): + if str(inst.appInstIdentifier) == app_instance_id: + insts.pop(idx) + if not insts: + self._deployed_gsma.pop(aid, None) + return aid + return None + + def store_stopped_instance_gsma(self, app_id: str, + app_instance_id: str) -> None: + with self._lock: + lst = self._stopped_gsma.setdefault(app_id, []) + if app_instance_id not in lst: + lst.append(app_instance_id) + + def get_stopped_instances_gsma( + self, + app_id: Optional[str] = None + ) -> Union[List[str], Dict[str, List[str]]]: + with self._lock: + if app_id: + return list(self._stopped_gsma.get(app_id, [])) + return {aid: list(ids) for aid, ids in self._stopped_gsma.items()} + + def remove_stopped_instances_gsma(self, app_id: str) -> None: + with self._lock: + self._stopped_gsma.pop(app_id, None) + + # ------------------------------------------------------------------------ + # GSMA Artefacts + # ------------------------------------------------------------------------ + def store_artefact_gsma(self, artefact: Artefact) -> None: + with self._lock: + self._artefacts_gsma[artefact.artefactId] = artefact + + def get_artefact_gsma(self, artefact_id: str) -> Optional[Artefact]: + with self._lock: + return self._artefacts_gsma.get(artefact_id) + + def list_artefacts_gsma(self) -> List[Artefact]: + with self._lock: + return list(self._artefacts_gsma.values()) + + def delete_artefact_gsma(self, artefact_id: str) -> None: + with self._lock: + self._artefacts_gsma.pop(artefact_id, None) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py index 2d8e7ed..816eb1d 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py @@ -8,12 +8,88 @@ """ aerOS help methods """ +import uuid +import string from requests.exceptions import HTTPError, RequestException, Timeout import sunrise6g_opensdk.edgecloud.adapters.aeros.config as config import sunrise6g_opensdk.edgecloud.adapters.aeros.errors as errors from sunrise6g_opensdk.logger import setup_logger +_HEX = "0123456789abcdef" +_ALLOWED = set( + string.ascii_letters + + string.digits) # no underscore here; underscore is always escaped +_PREFIX = "A0_" # ensures name starts with a letter; stripped during decode + + +def encode_app_instance_name(original: str, *, max_len: int = 64) -> str: + """ + aerOS to CAMARA AppInstanceName encoder. + Reversibly encode `original` into a string matching ^[A-Za-z][A-Za-z0-9_]{1,63}$. + Uses underscore + two hex digits to escape any non [A-Za-z0-9] chars, including '_' itself. + If the encoded result would exceed `max_len`, raise ValueError (reversibility would be lost otherwise). + """ + out = [] + for ch in original: + if ch in _ALLOWED: + out.append(ch) + elif ch == "_": + out.append("_5f") + else: + # escape any other byte as _hh (lowercase hex) + out.append("_" + format(ord(ch), "02x")) + + enc = "".join(out) + + # must start with a letter + if not enc or enc[0] not in string.ascii_letters: + enc = _PREFIX + enc + + if len(enc) > max_len: + raise ValueError( + f"Encoded name exceeds {max_len} chars; cannot keep reversibility without external mapping." + ) + return enc + + +def decode_app_instance_name(encoded: str) -> str: + """ + CAMARA AppInstanceName to aerOS original app_id decoder. + Reverse of encode_app_instance_name. Restores the exact original string. + """ + s = encoded + if s.startswith(_PREFIX): + s = s[len(_PREFIX):] + + # walk and decode _hh sequences; underscores never appear unescaped in the encoding + i = 0 + out = [] + while i < len(s): + ch = s[i] + if ch != "_": + out.append(ch) + i += 1 + continue + + # expect two hex digits after underscore + if i + 2 >= len(s): + raise ValueError("Invalid escape at end of string.") + h1 = s[i + 1].lower() + h2 = s[i + 2].lower() + if h1 not in _HEX or h2 not in _HEX: + raise ValueError(f"Invalid escape sequence: _{h1}{h2}") + code = int(h1 + h2, 16) + out.append(chr(code)) + i += 3 + + return "".join(out) + + +def urn_to_uuid(urn: str) -> uuid.UUID: + """Convert a (ngsi-ld) URN string to a deterministic UUID.""" + return uuid.uuid5(uuid.NAMESPACE_URL, urn) + def catch_requests_exceptions(func): """ -- GitLab From 9e3ad2144204a57a61e15f4c258e2a99cb1b0e66 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Sun, 19 Oct 2025 10:15:28 +0300 Subject: [PATCH 4/9] Clear debug logging --- .../edgecloud/adapters/aeros/client.py | 47 +++++++++---------- .../adapters/aeros/continuum_client.py | 1 - .../storageManagement/inMemoryStorage.py | 13 ----- 3 files changed, 23 insertions(+), 38 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 3c8def8..4bf66d2 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -139,10 +139,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=camara_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) @@ -159,11 +159,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ aeros_client = ContinuumClient(self.base_url) ngsild_params = f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' - self.logger.debug( - "Querying infrastructure elements for zone %s with params: %s", - zone_id, - ngsild_params, - ) + if config.DEBUG: + self.logger.debug( + "Querying infrastructure elements for zone %s with params: %s", + zone_id, + ngsild_params, + ) try: # Query the infrastructure elements for the specified zonese aeros_response = aeros_client.query_entities(ngsild_params) @@ -172,7 +173,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # and return the details of the edge cloud zone camara_response = self.transform_infrastructure_elements( domain_ies=aeros_domain_ies, domain=zone_id) - self.logger.debug("Transformed response: %s", camara_response) + if config.DEBUG: + self.logger.debug("Transformed response: %s", camara_response) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, @@ -183,10 +185,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=aeros_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) @@ -384,8 +386,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ] tosca_str = camara2aeros_converter.generate_tosca( app_manifest=app_manifest, app_zones=aeros_domain_ids) - self.logger.info("Generated TOSCA YAML:") - self.logger.info(tosca_str) + if config.DEBUG: + self.logger.info("Generated TOSCA YAML:") + self.logger.info(tosca_str) # 4. Instantiate client and call continuum to deploy service try: @@ -463,7 +466,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): } self.logger.info("All app instances retrieved successfully") - self.logger.debug("Onboarded applications: %s", camara_response) + if config.DEBUG: + self.logger.debug("Onboarded applications: %s", camara_response) return build_custom_http_response( status_code=200, content=camara_response, @@ -491,16 +495,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise InvalidArgumentError("app_instance_id is required") # Look up the instance in CAMARA storage (returns List[AppInstanceInfo]) - self.logger.debug( - "@@@@@@ Retrieving deployed app instance '%s' (app_id=%s, region=%s) @@@@@@", - app_instance_id, app_id, region) matches = self.storage.find_deployments( app_id=app_id, app_instance_id=app_instance_id, region=region, ) - self.logger.debug("@@@ Deployed app instance matches: %s @@@", - matches) if not matches: # Be explicit in the error so callers know what was used to filter scope = [] @@ -626,10 +625,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=aeros_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) @@ -674,10 +673,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=aeros_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) @@ -719,10 +718,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=aeros_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index ac439ab..4614b98 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -127,7 +127,6 @@ class ContinuumClient: undeploy_url = f"{self.api_url}/hlo_fe/services/{service_id}" response = requests.delete(undeploy_url, headers=self.hlo_headers, timeout=15) if response is None: - self.logger.debug("In NONE Undeploy service URL: %s", undeploy_url) return None else: self.logger.debug("In OK Undeploy and text: %s", response.text) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py index 79e20b0..a2e27ef 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py @@ -182,26 +182,13 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): if app_instance_id: for insts in self._deployed.values(): for inst in insts: - self.logger.debug( - "186: Checking deployed instance id='%s' against '%s'", - str(inst.appInstanceId), app_instance_id) if str(inst.appInstanceId.root) == app_instance_id: if app_id and str(inst.appId) != app_id: - self.logger.debug( - "189: app_id mismatch: '%s' != '%s'", - str(inst.appId), app_id) return [] if region is not None and getattr( inst, "region", None) != region: - self.logger.debug( - "193: region mismatch: '%s' != '%s'", - getattr(inst, "region", None), region) return [] - self.logger.debug( - "197: Found matching instance: %s", inst) return [inst] - self.logger.debug("200: No matching instance found for id='%s'", - app_instance_id) return [] results: List[AppInstanceInfo] = [] -- GitLab From 5a788b907771eebc0c32561496d3e39f73b1e94b Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Sun, 19 Oct 2025 12:16:59 +0300 Subject: [PATCH 5/9] GSMA edge zones validated (3 tests) --- .../edgecloud/adapters/aeros/client.py | 39 ++--- .../converters/aeros2gsma_zone_details.py | 154 ++++++++++++++++++ 2 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 4bf66d2..4b8b4c6 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -5,10 +5,8 @@ # - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## -from time import sleep import uuid import json -import re from typing import Any, Dict, List, Optional from collections import defaultdict from pydantic import ValidationError @@ -18,9 +16,9 @@ from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.utils import ( urn_to_uuid, encode_app_instance_name) from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient -from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import gsma2aeros_converter from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage -from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import camara2aeros_converter +from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import ( + camara2aeros_converter, gsma2aeros_converter, aeros2gsma_zone_details) from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( AppStorageManager, ) from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError @@ -246,12 +244,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): domain, "reservedComputeResources": [{ "cpuArchType": "ISA_X86_64", - "numCPU": str(total_cpu), + "numCPU": int(total_cpu), "memory": total_ram, }], "computeResourceQuotaLimits": [{ "cpuArchType": "ISA_X86_64", - "numCPU": str(total_cpu * 2), # Assume quota is 2x total? + "numCPU": int(total_cpu * 2), # Assume quota is 2x total? "memory": total_ram * 2, }], "flavoursSupported": @@ -609,15 +607,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): aeros_response = aeros_client.query_entities(ngsild_params) aeros_domains = aeros_response.json() zone_list = [{ - "zoneId": - domain["id"], - "status": - domain["domainStatus"].split(":")[-1].lower(), - "geographyDetails": - "NOT_USED", + "zoneId": domain["id"], + "geolocation": "NOT_Available", + "geographyDetails": domain["description"], } for domain in aeros_domains] return build_custom_http_response( - status_code=aeros_domains.status_code, + status_code=aeros_response.status_code, content=zone_list, headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, @@ -658,15 +653,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Transform the IEs to required format # per domain and append to response list - camara_response = [] + gsma_response = [] for domain, ies in grouped_by_domain.items(): - result = self.transform_infrastructure_elements(domain_ies=ies, - domain=domain) - camara_response.append(result) + result = aeros2gsma_zone_details.transformer(domain_ies=ies, + domain=domain) + gsma_response.append(result) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, - content=camara_response, + content=gsma_response, headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=aeros_response.url, @@ -704,14 +699,16 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): aeros_domain_ies = aeros_response.json() # Transform the infrastructure elements into the required format # and return the details of the edge cloud zone - camara_response = self.transform_infrastructure_elements( + # camara_response = self.transform_infrastructure_elements( + # domain_ies=aeros_domain_ies, domain=zone_id) + gsma_response = aeros2gsma_zone_details.transformer( domain_ies=aeros_domain_ies, domain=zone_id) if config.DEBUG: - self.logger.debug("Transformed response: %s", camara_response) + self.logger.debug("Transformed response: %s", gsma_response) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, - content=camara_response, + content=gsma_response, headers={"Content-Type": "application/json"}, encoding=aeros_response.encoding, url=aeros_response.url, diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py new file mode 100644 index 0000000..b83228e --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py @@ -0,0 +1,154 @@ +''' +aeros2gsma_zone_details.py +''' +from typing import List, Dict, Any + + +def transformer(domain_ies: List[Dict[str, Any]], + domain: str) -> Dict[str, Any]: + """ + Transform aerOS InfrastructureElements into GSMA ZoneRegisteredData structure. + :param domain_ies: List of aerOS InfrastructureElement dicts + :param domain: The ID of the edge cloud zone (zoneId) + :return: Dict matching gsma_schemas.ZoneRegisteredData (JSON-serializable) + """ + + def map_cpu_arch_to_isa(urn: str) -> str: + """ + Map aerOS cpuArchitecture URN to GSMA ISA_* literal. + Examples: + 'urn:ngsi-ld:CpuArchitecture:x64' -> 'ISA_X86_64' + 'urn:ngsi-ld:CpuArchitecture:arm64' -> 'ISA_ARM_64' + 'urn:ngsi-ld:CpuArchitecture:arm32' -> 'ISA_ARM_64' (closest) + 'urn:ngsi-ld:CpuArchitecture:x86' -> 'ISA_X86' + Fallback: 'ISA_X86_64' + """ + if not isinstance(urn, str): + return "ISA_X86_64" + tail = urn.split(":")[-1].lower() + if tail in ("x64", "x86_64", "amd64"): + return "ISA_X86_64" + if tail in ("x86", "i386", "i686"): + return "ISA_X86" + if tail in ("arm64", "aarch64"): + return "ISA_ARM_64" + if tail in ("arm32", "arm"): + # GSMA only has ARM_64 vs X86/X86_64; pick closest + return "ISA_ARM_64" + return "ISA_X86_64" + + def map_cpu_arch_to_ostype_arch(urn: str) -> str: + """ + Map aerOS cpuArchitecture URN to OSType.architecture literal: 'x86_64' or 'x86'. + Use 'x86_64' for x64/arm64 (closest allowed), and 'x86' for x86/arm32. + """ + if not isinstance(urn, str): + return "x86_64" + tail = urn.split(":")[-1].lower() + if tail in ("x64", "x86_64", "amd64", "arm64", "aarch64"): + return "x86_64" + if tail in ("x86", "i386", "i686", "arm32", "arm"): + return "x86" + return "x86_64" + + def map_os_distribution(_urn: str) -> str: + """ + aerOS uses 'urn:ngsi-ld:OperatingSystem:Linux' etc. + map Linux -> UBUNTU (assume), else OTHER. + """ + if isinstance(_urn, str) and _urn.split(":")[-1].lower() == "linux": + return "UBUNTU" + return "OTHER" + + def default_os_version(dist: str) -> str: + # You asked to assume Ubuntu 22.04 LTS for Linux + return "OS_VERSION_UBUNTU_2204_LTS" if dist == "UBUNTU" else "OTHER" + + # Totals (aggregate over elements) + total_cpu = 0 + total_ram = 0 + total_disk = 0 + total_available_ram = 0 + total_available_disk = 0 + + flavours_supported: List[Dict[str, Any]] = [] + seen_cpu_isas: set[str] = set() + + for element in domain_ies: + cpu_cores = int(element.get("cpuCores", 0) or 0) + ram_cap = int(element.get("ramCapacity", 0) or 0) # MB? + avail_ram = int(element.get("availableRam", 0) or 0) # MB? + disk_cap = int(element.get("diskCapacity", 0) + or 0) # MB/GB? (pass-through) + avail_disk = int(element.get("availableDisk", 0) or 0) + + total_cpu += cpu_cores + total_ram += ram_cap + total_available_ram += avail_ram + total_disk += disk_cap + total_available_disk += avail_disk + + cpu_arch_urn = element.get("cpuArchitecture", "") + os_urn = element.get("operatingSystem", "") + + isa = map_cpu_arch_to_isa(cpu_arch_urn) + seen_cpu_isas.add(isa) + ost_arch = map_cpu_arch_to_ostype_arch(cpu_arch_urn) + dist = map_os_distribution(os_urn) + ver = default_os_version(dist) + + # Create a flavour per machine + flavour = { + "flavourId": + f"{element.get('hostname', 'host')}-{element.get('containerTechnology', 'CT')}", + "cpuArchType": + isa, # Literal ISA_* + "supportedOSTypes": [{ + "architecture": ost_arch, # 'x86_64' or 'x86' + "distribution": dist, # 'UBUNTU' or 'OTHER' + "version": ver, # 'OS_VERSION_UBUNTU_2204_LTS' or 'OTHER' + "license": "OS_LICENSE_TYPE_FREE", + }], + "numCPU": + cpu_cores, + "memorySize": + ram_cap, + "storageSize": + disk_cap, + } + flavours_supported.append(flavour) + + # Decide a single ISA for the aggregate reserved/quota entries + # Preference order: X86_64, ARM_64, X86 + def pick_aggregate_isa() -> str: + if "ISA_X86_64" in seen_cpu_isas: + return "ISA_X86_64" + if "ISA_ARM_64" in seen_cpu_isas: + return "ISA_ARM_64" + if "ISA_X86" in seen_cpu_isas: + return "ISA_X86" + # fallback + return "ISA_X86_64" + + agg_isa = pick_aggregate_isa() + + result = { + "zoneId": + domain, + "reservedComputeResources": [{ + "cpuArchType": agg_isa, + "numCPU": int( + total_cpu + ), # Same as Quotas untill we have somem policy or data to differentiate + "memory": total_ram, # ditto + }], + "computeResourceQuotaLimits": [{ + "cpuArchType": agg_isa, + "numCPU": int(total_cpu), + "memory": total_ram, + }], + "flavoursSupported": + flavours_supported, + } + + return result -- GitLab From dde919cde353293957f3b58375699656d0578209 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Thu, 23 Oct 2025 11:57:06 +0300 Subject: [PATCH 6/9] GSMA Validated --- .../edgecloud/adapters/aeros/client.py | 126 +++++++++++------ .../adapters/aeros/continuum_client.py | 4 +- .../aeros/converters/gsma2aeros_converter.py | 130 +++++++++++++----- .../edgecloud/adapters/aeros/utils.py | 24 ++++ 4 files changed, 206 insertions(+), 78 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 4b8b4c6..86700a5 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -14,7 +14,7 @@ from requests import Response from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.utils import ( - urn_to_uuid, encode_app_instance_name) + urn_to_uuid, encode_app_instance_name, map_aeros_service_status_to_gsma) from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import ( @@ -967,6 +967,25 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if not self.storage.get_app_gsma(app_id): raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + # CHECKME: update for GSMA + service_instances = self.storage.get_stopped_instances_gsma( + app_id=app_id) + if not service_instances: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' cannot be deleted — please stop it first" + ) + self.logger.debug( + "Deleting application with id: %s and instances: %s", + app_id, + service_instances, + ) + for service_instance in service_instances: + self._purge_deployed_app_from_continuum_gsma(service_instance) + self.logger.debug("successfully purged service instance: %s", + service_instance) + + self.storage.remove_stopped_instances_gsma(app_id) + self.storage.delete_app_gsma(app_id) return build_custom_http_response( @@ -983,6 +1002,23 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_id, e) raise EdgeCloudPlatformError(str(e)) + def _purge_deployed_app_from_continuum_gsma(self, + app_instance_id: str) -> None: + ''' + Purge the deployed application from aerOS continuum. + :param app_id: The application ID to purge + All instances of this app should be stopped + ''' + aeros_client = ContinuumClient(self.base_url) + response = aeros_client.purge_service(app_instance_id) + if response: + self.logger.debug("Purged deployed application with id: %s", + app_instance_id) + else: + raise EdgeCloudPlatformError( + f"Failed to purge service with id from the continuum '{app_instance_id}'" + ) + # ------------------------------------------------------------------------ # Application Deployment Management (GSMA) # ------------------------------------------------------------------------ @@ -1009,8 +1045,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): f"GSMA app '{payload.appId}' not found") # 2. Generate unique service ID - # (aerOS) service id <=> CAMARA appInstanceId - service_id = self._generate_service_id(onboarded_app.appId) + # (aerOS) service id <=> GSMA appInstanceId + service_id = self._generate_aeros_service_id( + self._generate_service_id(onboarded_app.appId)) # 3. Create TOSCA (yaml str) from GSMA onboarded_app + connected artefacts # GSMA app corresponds to aerOS Service @@ -1028,7 +1065,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): aeros_response = aeros_client.onboard_and_deploy_service( service_id, tosca_str=tosca_yaml) - if "serviceId" not in aeros_response: + if "serviceId" not in aeros_response.json(): raise EdgeCloudPlatformError( "Invalid response from onboard_service: missing 'serviceId'" ) @@ -1040,8 +1077,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): appInstIdentifier=service_id, ) status = gsma_schemas.AppInstanceStatus( - appInstanceState= - "DEPLOYED", # or "PENDING" if you simulate async + appInstanceState="PENDING", accesspointInfo=[], ) @@ -1050,21 +1086,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): status=status) # 6. Return expected format (deployment details) - body = { - "appId": payload.appId, - "appVersion": payload.appVersion, - "appProviderId": payload.appProviderId, - "zoneId": payload.zoneInfo.zoneId, - "appInstance": inst.model_dump(mode="json"), - "status": status.model_dump(mode="json"), - } + body = inst.model_dump(mode="json") return build_custom_http_response( - status_code=201, + status_code=202, content=body, headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, - ) + url=aeros_response.json().get("url", ""), + request=aeros_response.request) except EdgeCloudPlatformError as ex: self.logger.error("Failed to deploy app '%s': %s", onboarded_app.appId, str(ex)) @@ -1088,24 +1118,34 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if not self.storage.get_app_gsma(app_id): raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") - matches = self.storage.find_deployments_gsma( - app_id=app_id, - app_instance_id=app_instance_id, - zone_id=zone_id, + # 4. Instantiate client and call continuum to deploy servic + aeros_client = ContinuumClient(self.base_url) + aeros_response = aeros_client.query_entity( + entity_id=app_instance_id, ngsild_params='format=simplified') + + response_json = aeros_response.json() + content = gsma_schemas.AppInstanceStatus( + appInstanceState=map_aeros_service_status_to_gsma( + response_json.get("actionType")), + accesspointInfo=[{ + "service_status": + f'{self.base_url}/entities/{app_instance_id}' + }, { + "serviceComponents_status": + f'{self.base_url}/hlo_fe/services//{app_instance_id}' + }], ) - if not matches: - raise ResourceNotFoundError( - f"Deployment not found (app_id={app_id}, instance={app_instance_id}, zone={zone_id})" - ) - inst = matches[0] - body = inst.model_dump(mode="json") + validated_data = gsma_schemas.AppInstanceStatus.model_validate( + content) return build_custom_http_response( status_code=200, - content=body, + content=validated_data.model_dump(mode="json"), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, + url=aeros_response.url, + request=aeros_response.request, ) except EdgeCloudPlatformError: raise @@ -1115,8 +1155,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_instance_id, app_id, zone_id, e) raise EdgeCloudPlatformError(str(e)) - def get_all_deployed_apps_gsma(self, app_id: str, - app_provider: str) -> List: + def get_all_deployed_apps_gsma(self) -> Response: """ Retrieves all instances for a given application of partner OP @@ -1125,18 +1164,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: List with application instances details """ try: - app = self.storage.get_app_gsma(app_id) - if not app: - raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") - - # Optional provider check (keep if you want extra validation) - if app_provider and app.appProviderId != app_provider: - raise ResourceNotFoundError( - f"GSMA app '{app_id}' not found for provider '{app_provider}'" - ) - - insts = self.storage.find_deployments_gsma(app_id=app_id) + insts = self.storage.find_deployments_gsma() body = [i.model_dump(mode="json") for i in insts] + self.logger.info("All GSMA app instances retrieved successfully") + self.logger.debug("Deployed GSMA applications: %s", body) return build_custom_http_response( status_code=200, @@ -1148,8 +1179,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise except Exception as e: self.logger.exception( - "Unhandled error listing GSMA deployments for app '%s': %s", - app_id, e) + "Unhandled error listing GSMA deployments: '%s'", e) raise EdgeCloudPlatformError(str(e)) def undeploy_app_gsma(self, app_id: str, app_instance_id: str, @@ -1177,8 +1207,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): f"Deployment not found (app_id={app_id}, instance={app_instance_id}, zone={zone_id})" ) - # Placeholder: call aerOS undeploy here (GSMA → aerOS conversion) - # aeros_client.undeploy(instance_id=app_instance_id, zone_id=zone_id) + # 2. Call the external undeploy_service + aeros_client = ContinuumClient(self.base_url) + try: + aeros_response = aeros_client.undeploy_service(app_instance_id) + except Exception as e: + raise EdgeCloudPlatformError( + f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" + ) from e # Remove from deployed and mark as stopped so it can be purged later removed_app_id = self.storage.remove_deployment_gsma( @@ -1195,7 +1231,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "state": "TERMINATING", } return build_custom_http_response( - status_code=202, + status_code=aeros_response.status_code, content=body, headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index 4614b98..1ba4097 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -51,7 +51,7 @@ class ContinuumClient: } @catch_requests_exceptions - def query_entity(self, entity_id, ngsild_params) -> dict: + def query_entity(self, entity_id, ngsild_params) -> requests.Response: """ Query entity with ngsi-ld params :input @@ -70,7 +70,7 @@ class ContinuumClient: self.logger.debug( "Query entity response: %s %s", response.status_code, response.text ) - return response.json() + return response @catch_requests_exceptions def query_entities(self, ngsild_params) -> requests.Response: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py index a6cd1cd..c3a0bae 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py @@ -10,17 +10,30 @@ Notes: - Network ports are omitted for now (exposePorts = False). """ -from typing import Optional, Callable +from typing import Optional, Callable, Dict, Any, List import yaml from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.errors import ( - ResourceNotFoundError, InvalidArgumentError) + ResourceNotFoundError, + InvalidArgumentError, +) from sunrise6g_opensdk.logger import setup_logger from sunrise6g_opensdk.edgecloud.core import gsma_schemas from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( - TOSCA, NodeTemplate, CustomRequirement, HostRequirement, HostCapability, - Property as HostProperty, DomainIdOperator, NodeFilter, NetworkRequirement, - NetworkProperties, ExposedPort, PortProperties, ArtifactModel) + TOSCA, + NodeTemplate, + CustomRequirement, + HostRequirement, + HostCapability, + Property as HostProperty, + DomainIdOperator, + NodeFilter, + NetworkRequirement, + NetworkProperties, + ExposedPort, + PortProperties, + ArtifactModel, +) logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) @@ -31,14 +44,28 @@ def generate_tosca_from_gsma_with_artefacts( artefact_resolver: Callable[[str], Optional[gsma_schemas.Artefact]], ) -> str: """ - Build TOSCA from GSMA ApplicationModel by resolving each component's artefactId. - - - One Node (under NodeTemplates) per AppComponentSpec (connects to Artefacts). - - Image pulled from Artefact.componentSpec[i].images[0] (first image). - - Optional ports read from Artefact.componentSpec[i].exposedInterfaces (if present). - Expected dict fields (best-effort): {"protocol": "TCP", "port": 8080} + Build a TOSCA YAML from a GSMA `ApplicationModel` by resolving each component's `artefactId`. + + Rules/assumptions: + - One node_template per `AppComponentSpec` in the application model. + - Container image is taken from the first entry of `artefact.componentSpec[i].images`. + - Ports come (best-effort) from `exposedInterfaces` items in the matching componentSpec, e.g. {"protocol": "TCP", "port": 8080}. + - Host filter includes domain_id == `zone_id` and basic CPU/mem constraints. + - For PUBLICREPO artefacts: set `is_private=False` and omit credentials entirely. + For PRIVATEREPO artefacts: set `is_private=True` and include non-empty username/password if present. + - `cliArgs` are derived from `commandLineParams` dict: + - bool True -> "flag" + - key/value -> "key=value" + `envVars` are derived from `compEnvParams` list: + - [{"name": "KEY", "value": "VAL"}] -> [{"KEY": "VAL"}, ...] + - If a component name mismatch occurs between app and artefact, fall back to the first artefact componentSpec. + + :param app_model: GSMA ApplicationModel (already validated) + :param zone_id: Target aerOS domain id/zone urn for host node filter + :param artefact_resolver: Callable that returns an Artefact for a given artefactId + :return: TOSCA YAML string (tosca_simple_yaml_1_3) """ - node_templates = {} + node_templates: Dict[str, NodeTemplate] = {} for comp in app_model.appComponentSpecs: artefact = artefact_resolver(comp.artefactId) @@ -46,22 +73,20 @@ def generate_tosca_from_gsma_with_artefacts( raise ResourceNotFoundError( f"GSMA artefact '{comp.artefactId}' not found") - # We pick the FIRST componentSpec entry that matches componentName if present, - # else fall back to the first componentSpec entry. + # pick the componentSpec that matches componentName, else first comp_spec = None if artefact.componentSpec: - # try exact match by name for c in artefact.componentSpec: if c.componentName == comp.componentName: comp_spec = c break - if not comp_spec: + if comp_spec is None: comp_spec = artefact.componentSpec[0] else: raise InvalidArgumentError( f"Artefact '{artefact.artefactId}' has no componentSpec") - # Resolve image (first image in the list) + # Resolve container image image = comp_spec.images[ 0] if comp_spec.images else "docker.io/library/nginx:stable" if "/" in image: @@ -70,19 +95,53 @@ def generate_tosca_from_gsma_with_artefacts( else: repository_url, image_file = "docker_hub", image - # Build ports (best-effort read from exposedInterfaces) - ports = {} + # Ports (best-effort) from exposedInterfaces + ports: Dict[str, ExposedPort] = {} expose_ports = False if comp_spec.exposedInterfaces: for idx, iface in enumerate(comp_spec.exposedInterfaces): protocol = str(iface.get("protocol", "TCP")).lower() port = iface.get("port") if isinstance(port, int): - ports_id = f"if{idx}" - ports[ports_id] = ExposedPort(properties=PortProperties( + ports[f"if{idx}"] = ExposedPort(properties=PortProperties( protocol=[protocol], source=port)) expose_ports = True + # Build cliArgs as a list of dicts: [{"KEY": "VAL"}, {"FLAG": ""}, ...] + cli_args: List[Dict[str, str]] = [] + cmd = getattr(comp_spec, "commandLineParams", None) + + if isinstance(cmd, dict): + for k, v in cmd.items(): + if v is True: + cli_args.append({str(k): ""}) # flag without value + elif v is False or v is None: + continue + else: + cli_args.append({str(k): str(v)}) + elif isinstance(cmd, list): + # if someone passes ["--flag", "--opt=1"] style + for item in cmd: + if isinstance(item, str): + if "=" in item: + k, v = item.split("=", 1) + cli_args.append({k: v}) + else: + cli_args.append({item: ""}) + + # Build envVars from compEnvParams list of {"name": "...", "value": "..."} + env_vars: List[Dict[str, str]] = [] + if isinstance(getattr(comp_spec, "compEnvParams", None), list): + for item in comp_spec.compEnvParams: + if isinstance(item, dict): + if "name" in item and "value" in item: + env_vars.append( + {str(item["name"]): str(item["value"])}) + elif len(item) == 1: # already mapping-like {"KEY": "VAL"} + k, v = next(iter(item.items())) + env_vars.append({str(k): str(v)}) + + # Host filter (basic example) host_props = HostProperty( cpu_arch={"equal": "x64"}, realtime={"equal": False}, @@ -105,6 +164,17 @@ def generate_tosca_from_gsma_with_artefacts( ))), ] + # PUBLICREPO => is_private=False and omit credentials + repo_type = getattr(artefact, "repoType", None) + is_private = bool(repo_type == "PRIVATEREPO") + username = None + password = None + if is_private and artefact.artefactRepoLocation: + u = artefact.artefactRepoLocation.userName + p = artefact.artefactRepoLocation.password + username = u if u else None + password = p if p else None + node_templates[comp.componentName] = NodeTemplate( type="tosca.nodes.Container.Application", isJob=False, @@ -115,11 +185,9 @@ def generate_tosca_from_gsma_with_artefacts( file=image_file, type="tosca.artifacts.Deployment.Image.Container.Docker", repository=repository_url, - is_private=(artefact.repoType == "PRIVATEREPO"), - username=(artefact.artefactRepoLocation.userName - if artefact.artefactRepoLocation else None), - password=(artefact.artefactRepoLocation.password - if artefact.artefactRepoLocation else None), + is_private=is_private, # False for PUBLICREPO + username=username, # None for PUBLICREPO + password=password, # None for PUBLICREPO ) }, interfaces={ @@ -127,16 +195,15 @@ def generate_tosca_from_gsma_with_artefacts( "create": { "implementation": "application_image", "inputs": { - "cliArgs": - [], # could map comp_spec.commandLineParams later - "envVars": - [], # could map comp_spec.compEnvParams later - } + "cliArgs": cli_args, + "envVars": env_vars, + }, } } }, ) + # Assemble and dump TOSCA tosca = TOSCA( tosca_definitions_version="tosca_simple_yaml_1_3", description= @@ -146,6 +213,7 @@ def generate_tosca_from_gsma_with_artefacts( ) tosca_dict = tosca.model_dump(by_alias=True, exclude_none=True) + # Clean requirements lists from None entries for template in tosca_dict.get("node_templates", {}).values(): template["requirements"] = [{ k: v diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py index 816eb1d..4d9b621 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py @@ -91,6 +91,30 @@ def urn_to_uuid(urn: str) -> uuid.UUID: return uuid.uuid5(uuid.NAMESPACE_URL, urn) +def map_aeros_service_status_to_gsma(status: str) -> str: + """ + Map aerOS service lifecycle states to GSMA-compliant status values. + + aerOS → GSMA + DEPLOYING → PENDING + DESTROYING → TERMINATING + DEPLOYED → DEPLOYED + FINISHED → No_Match + No_Match → READY + urn:ngsi-ld:null → No Match + """ + mapping = { + "DEPLOYING": "PENDING", + "DESTROYING": "TERMINATING", + "DEPLOYED": "DEPLOYED", + "FINISHED": "READY", + # "urn:ngsi-ld:null": "READY", + } + if not status: + return "FAILED" + return mapping.get(status.strip().upper(), "FAILED") + + def catch_requests_exceptions(func): """ Decorator to catch and translate requests exceptions into custom app errors. -- GitLab From c780e71ad2ca7de5b77c2e977163b5055e2fb4c5 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Tue, 28 Oct 2025 12:02:10 +0200 Subject: [PATCH 7/9] payloads and configurations for aerOS e2e testing --- tests/edgecloud/test_config_camara.py | 14 +- tests/edgecloud/test_config_gsma.py | 188 ++++++++++++++++++++++++-- 2 files changed, 187 insertions(+), 15 deletions(-) diff --git a/tests/edgecloud/test_config_camara.py b/tests/edgecloud/test_config_camara.py index 6ce39c2..9455456 100644 --- a/tests/edgecloud/test_config_camara.py +++ b/tests/edgecloud/test_config_camara.py @@ -68,14 +68,14 @@ CONFIG = { }, "aeros": { # Basic identifiers - "ZONE_ID": "", - "APP_ID": "", + "ZONE_ID": "8a4d95e8-8550-5664-8c67-b6c0c602f9be", + "APP_ID": "aeros-app-1", # CAMARA onboard_app payload "APP_ONBOARD_MANIFEST": { - "appId": "", - "name": "aeros-SDK-app", + "appId": "aeros-app-1", + "name": "aeros_SDK_app", "version": "1.0.0", - "appProvider": "aeros", + "appProvider": "aerOS_SDK", "packageType": "CONTAINER", "appRepo": { "type": "PUBLICREPO", @@ -113,11 +113,11 @@ CONFIG = { }, # CAMARA deploy_app payload "APP_DEPLOY_PAYLOAD": { - "appId": "", + "appId": "aeros-app-1", "appZones": [ { "EdgeCloudZone": { - "edgeCloudZoneId": "", + "edgeCloudZoneId": "8a4d95e8-8550-5664-8c67-b6c0c602f9be", "edgeCloudZoneName": "aeros-zone-1", "edgeCloudZoneStatus": "active", "edgeCloudProvider": "NCSRD", diff --git a/tests/edgecloud/test_config_gsma.py b/tests/edgecloud/test_config_gsma.py index 4cb97df..e59daaa 100644 --- a/tests/edgecloud/test_config_gsma.py +++ b/tests/edgecloud/test_config_gsma.py @@ -2,8 +2,10 @@ CONFIG = { "i2edge": { "ZONE_ID": "f0662bfe-1d90-5f59-a759-c755b3b69b93", "APP_ONBOARD_MANIFEST_GSMA": { - "appId": "demo-app-id", - "appProviderId": "Y89TSlxMPDKlXZz7rN6vU2y", + "appId": + "demo-app-id", + "appProviderId": + "Y89TSlxMPDKlXZz7rN6vU2y", "appDeploymentZones": [ "Dmgoc-y2zv97lar0UKqQd53aS6MCTTdoGMY193yvRBYgI07zOAIktN2b9QB2THbl5Gqvbj5Zp92vmNeg7v4M" ], @@ -32,6 +34,20 @@ CONFIG = { ], "appStatusCallbackLink": "string", "edgeAppFQDN": "string", + "appComponentSpecs": [{ + "serviceNameNB": + "k8yyElSyJN4ctbNVqwodEQNUoGb2EzOEt4vQBjGnPii_5", + "serviceNameEW": + "iDm08OZN", + "componentName": + "HIEWqstajCmZJQmSFUj0kNHZ0xYvKWq720BKt8wjA41p", + "artefactId": + "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + }], + "appStatusCallbackLink": + "string", + "edgeAppFQDN": + "string", }, "APP_DEPLOY_PAYLOAD_GSMA": { "appId": "demo-app-id", @@ -56,20 +72,25 @@ CONFIG = { }, "appComponentSpecs": [ { - "serviceNameNB": "7CI_9d4lAK90vU4ASUkKxYdQjsv3y3IuwucISSQ6lG5_EMqeyVUHPIhwa5", - "serviceNameEW": "tPihoUFj30938Bu9blpsHkvsec1iA7gqZZRMpsx6o7aSSj5", + "serviceNameNB": + "7CI_9d4lAK90vU4ASUkKxYdQjsv3y3IuwucISSQ6lG5_EMqeyVUHPIhwa5", + "serviceNameEW": + "tPihoUFj30938Bu9blpsHkvsec1iA7gqZZRMpsx6o7aSSj5", "componentName": "YCAhqPadfld8y68wJfTc6QNGguI41z", "artefactId": "i2edgechart", }, { "serviceNameNB": "JCjR0Lc3J0sm2PcItECdbHXtpCLQCfq3B", - "serviceNameEW": "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", + "serviceNameEW": + "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", "componentName": "9aCfCEDe2Dv0Peg", "artefactId": "i2edgechart", }, { - "serviceNameNB": "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", - "serviceNameEW": "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", + "serviceNameNB": + "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", + "serviceNameEW": + "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", "componentName": "3kTa4zKEX", "artefactId": "i2edgechart", }, @@ -159,7 +180,158 @@ CONFIG = { }, }, "aeros": { - # PLACEHOLDER + "ZONE_ID": "urn:ngsi-ld:Domain:ncsrd01", + "ARTEFACT_ID": "artefact-nginx-001", + "ARTEFACT_NAME": "aeros-component", + "REPO_NAME": "dockerhub", + "REPO_TYPE": "PUBLICREPO", + "REPO_URL": "docker.io/library/nginx:stable", + "APP_ONBOARD_MANIFEST_GSMA": { + "appId": + "aeros-sdk-app", + "appProviderId": + "aeros-sdk-provider", + "appDeploymentZones": ["urn:ngsi-ld:Domain:ncsrd01"], + "appMetaData": { + "appName": "aeros_SDK_app", + "version": "string", + "appDescription": "test aeros sdk app", + "mobilitySupport": False, + "accessToken": "MfxADOjxDgBhMrqmBeG8XdQFLp2XviG3cZ_LM7uQKc9b", + "category": "IOT", + }, + "appQoSProfile": { + "latencyConstraints": "NONE", + "bandwidthRequired": 1, + "multiUserClients": "APP_TYPE_SINGLE_USER", + "noOfUsersPerAppInst": 1, + "appProvisioning": True, + }, + "appComponentSpecs": [{ + "serviceNameNB": + "gsma-deployed-app-service-nb", + "serviceNameEW": + "gsma-deployed-app-service-ew", + "componentName": + "nginx-component", + "artefactId": + "artefact-nginx-001", + }], + "appStatusCallbackLink": + "string", + "edgeAppFQDN": + "string", + }, + "APP_DEPLOY_PAYLOAD_GSMA": { + "appId": "aeros-sdk-app", + "appVersion": "1.0.0", + "appProviderId": "apps-sdk-deployer", + "zoneInfo": { + "zoneId": "urn:ngsi-ld:Domain:ncsrd01", + "flavourId": "FLAVOUR_BASIC", + "resourceConsumption": "RESERVED_RES_AVOID", + "resPool": "RESPOOL_DEFAULT", + }, + "appInstCallbackLink": "string", + }, + "PATCH_ONBOARDED_APP_GSMA": { + "appUpdQoSProfile": { + "latencyConstraints": "NONE", + "bandwidthRequired": 1, + "mobilitySupport": False, + "multiUserClients": "APP_TYPE_SINGLE_USER", + "noOfUsersPerAppInst": 1, + "appProvisioning": True, + }, + "appComponentSpecs": [ + { + "serviceNameNB": + "gsma-deployed-app-service-nb", + "serviceNameEW": + "gsma-deployed-app-service-ew", + "componentName": "nginx-component", + "artefactId": "artefact-nginx-001", + }, + { + "serviceNameNB": "JCjR0Lc3J0sm2PcItECdbHXtpCLQCfq3B", + "serviceNameEW": + "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", + "componentName": "9aCfCEDe2Dv0Peg", + "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + }, + { + "serviceNameNB": + "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", + "serviceNameEW": + "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", + "componentName": "3kTa4zKEX", + "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + }, + ], + }, + "ARTEFACT_PAYLOAD_GSMA": { + "artefactId": + "artefact-nginx-001", + "appProviderId": + "ncsrd-provider", + "artefactName": + "nginx-web-server", + "artefactVersionInfo": + "1.0.0", + "artefactDescription": + "Containerized Nginx Web Server", + "artefactVirtType": + "CONTAINER_TYPE", + "artefactFileName": + "nginx-web-server-1.0.0.tgz", + "artefactFileFormat": + "TARGZ", + "artefactDescriptorType": + "COMPONENTSPEC", + "repoType": + "PUBLICREPO", + "artefactRepoLocation": { + "repoURL": "docker.io/library/nginx:stable", + "userName": "", + "password": "", + "token": "" + }, + "artefactFile": + "", + "componentSpec": [{ + "componentName": + "nginx-component", + "images": ["docker.io/library/nginx:stable"], + "numOfInstances": + 1, + "restartPolicy": + "Always", + "commandLineParams": { + }, + "exposedInterfaces": [{ + "name": "http-api", + "protocol": "TCP", + "port": 8080 + }], + "computeResourceProfile": { + "cpu": "2", + "memory": "4Gi" + }, + "compEnvParams": [{ + "name": "TEST_ENV", + "value": "TEST_VALUE_ENV" + }], + "deploymentConfig": { + "replicaStrategy": "RollingUpdate", + "maxUnavailable": 1 + }, + "persistentVolumes": [{ + "name": "NOT_USE", + "mountPath": "NOT_USED", + "size": "NOT_USED" + }] + }] + } }, "kubernetes": { # PLACEHOLDER -- GitLab From 721a3fbc6471fe01b5db0be50263c298ff5de658 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Tue, 28 Oct 2025 12:14:29 +0200 Subject: [PATCH 8/9] Adaptations for artefacts & minor fixes --- tests/edgecloud/test_e2e_gsma.py | 35 +++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/tests/edgecloud/test_e2e_gsma.py b/tests/edgecloud/test_e2e_gsma.py index d2c361c..27e213a 100644 --- a/tests/edgecloud/test_e2e_gsma.py +++ b/tests/edgecloud/test_e2e_gsma.py @@ -32,6 +32,9 @@ from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( EdgeApplicationManager as I2EdgeClient, ) +from sunrise6g_opensdk.edgecloud.adapters.aeros.client import ( + EdgeApplicationManager as aerosClient, +) from sunrise6g_opensdk.edgecloud.core import gsma_schemas from tests.edgecloud.test_cases import test_cases from tests.edgecloud.test_config_gsma import CONFIG @@ -77,6 +80,11 @@ def test_config_gsma_compliance(edgecloud_client): if "PATCH_ONBOARDED_APP_GSMA" in config: patch_payload = config["PATCH_ONBOARDED_APP_GSMA"] gsma_schemas.PatchOnboardedAppGSMA(**patch_payload) + + # Validate ARTEFACT creation payload is GSMA-compliant + if "ARTEFACT_PAYLOAD_GSMA" in config: + artefact_payload = config["ARTEFACT_PAYLOAD_GSMA"] + gsma_schemas.Artefact(**artefact_payload) except Exception as e: pytest.fail(f"Configuration is not GSMA-compliant for {edgecloud_client.client_name}: {e}") @@ -173,6 +181,21 @@ def test_artefact_methods_gsma(edgecloud_client): pytest.fail(f"Artefact creation failed: {e}") +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_artefact_create_gsma(edgecloud_client): + config = CONFIG[edgecloud_client.client_name] + if isinstance(edgecloud_client, aerosClient): + try: + response = edgecloud_client.create_artefact_gsma( + request_body=config["ARTEFACT_PAYLOAD_GSMA"] + ) + assert response.status_code == 201 + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact creation failed: {e}") + + + + @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_artefact_gsma(edgecloud_client): config = CONFIG[edgecloud_client.client_name] @@ -204,7 +227,7 @@ def test_onboard_app_gsma(edgecloud_client): try: response = edgecloud_client.onboard_app_gsma(config["APP_ONBOARD_MANIFEST_GSMA"]) assert isinstance(response, Response) - assert response.status_code == 200 + assert response.status_code == 201 except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding failed: {e}") @@ -286,7 +309,7 @@ def test_get_all_deployed_apps_gsma(edgecloud_client): validated_instances = [] for instance_data in instances_data: - validated_instance = gsma_schemas.ZoneIdentifier(**instance_data) + validated_instance = gsma_schemas.AppInstance(**instance_data) validated_instances.append(validated_instance) except EdgeCloudPlatformError as e: @@ -327,6 +350,12 @@ def test_undeploy_app_gsma(edgecloud_client, app_instance_id_gsma): pytest.fail(f"App undeployment failed: {e}") +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_timer_wait_10_seconds_2(edgecloud_client): + time.sleep(10) + + + @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_patch_onboarded_app_gsma(edgecloud_client): config = CONFIG[edgecloud_client.client_name] @@ -347,7 +376,7 @@ def test_delete_onboarded_app_gsma(edgecloud_client): app_id = config["APP_ONBOARD_MANIFEST_GSMA"]["appId"] response = edgecloud_client.delete_onboarded_app_gsma(app_id) assert isinstance(response, Response) - assert response.status_code == 200 + assert response.status_code == 204 except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding deletion failed: {e}") -- GitLab From a7732ecdb74260ca943fdcd6c9d2da362faef1b1 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Wed, 29 Oct 2025 16:59:43 +0200 Subject: [PATCH 9/9] Fix pre-commit errors --- .gitlab-ci.yml | 4 +- .../edgecloud/adapters/aeros/client.py | 404 ++++++++---------- .../adapters/aeros/continuum_models.py | 128 +++--- .../converters/aeros2gsma_zone_details.py | 76 ++-- .../converters/camara2aeros_converter.py | 103 +++-- .../aeros/converters/gsma2aeros_converter.py | 89 ++-- .../edgecloud/adapters/aeros/errors.py | 9 +- .../aeros/storageManagement/__init__.py | 5 +- .../storageManagement/appStorageManager.py | 98 +++-- .../storageManagement/inMemoryStorage.py | 91 ++-- .../aeros/storageManagement/sqlite_storage.py | 121 +++--- .../edgecloud/adapters/aeros/utils.py | 18 +- tests/edgecloud/test_config_gsma.py | 166 +++---- tests/edgecloud/test_e2e_gsma.py | 11 +- 14 files changed, 645 insertions(+), 678 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 40698a0..cd4c94a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,8 +12,8 @@ validate-mr: before_script: - echo "Running merge request validation..." - pip install -r requirements.txt - - pip install -e . - - pip install isort black flake8 pytest + - pip install -e . + - pip install isort black flake8 pytest script: - echo "Running linters..." - isort src tests --check --profile black --filter-files diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 86700a5..3bea2d0 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -5,50 +5,54 @@ # - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## -import uuid import json -from typing import Any, Dict, List, Optional +import uuid from collections import defaultdict +from typing import Any, Dict, List, Optional + from pydantic import ValidationError from requests import Response from sunrise6g_opensdk.edgecloud.adapters.aeros import config -from sunrise6g_opensdk.edgecloud.adapters.aeros.utils import ( - urn_to_uuid, encode_app_instance_name, map_aeros_service_status_to_gsma) from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient -from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import ( - camara2aeros_converter, gsma2aeros_converter, aeros2gsma_zone_details) -from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( - AppStorageManager, ) -from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError + aeros2gsma_zone_details, + camara2aeros_converter, + gsma2aeros_converter, +) from sunrise6g_opensdk.edgecloud.adapters.aeros.errors import ( - ResourceNotFoundError, InvalidArgumentError, + ResourceNotFoundError, +) +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( + AppStorageManager, ) +from sunrise6g_opensdk.edgecloud.adapters.aeros.utils import ( + encode_app_instance_name, + map_aeros_service_status_to_gsma, + urn_to_uuid, +) +from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError +from sunrise6g_opensdk.edgecloud.core import camara_schemas, gsma_schemas from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( - EdgeCloudManagementInterface, ) + EdgeCloudManagementInterface, +) from sunrise6g_opensdk.edgecloud.core.utils import build_custom_http_response -from sunrise6g_opensdk.edgecloud.core import camara_schemas, gsma_schemas from sunrise6g_opensdk.logger import setup_logger class EdgeApplicationManager(EdgeCloudManagementInterface): """ - aerOS Edge Application Manager Adapter implementing CAMARA and GSMA APIs. + aerOS Edge Application Manager Adapter implementing CAMARA and GSMA APIs. """ - def __init__(self, - base_url: str, - storage: Optional[AppStorageManager] = None, - **kwargs): - ''' - storage can - ''' + def __init__(self, base_url: str, storage: Optional[AppStorageManager] = None, **kwargs): + """ + storage can + """ self.base_url = base_url - self.logger = setup_logger(__name__, - is_debug=True, - file_name=config.LOG_FILE) + self.logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) self.content_type_gsma = "application/json" self.encoding_gsma = "utf-8" self.storage = storage or inMemoryStorage.InMemoryAppStorage() @@ -77,9 +81,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ------------------------------------------------------------------------ # Zones methods - def get_edge_cloud_zones(self, - region: Optional[str] = None, - status: Optional[str] = None) -> Response: + def get_edge_cloud_zones( + self, region: Optional[str] = None, status: Optional[str] = None + ) -> Response: """ Retrieves a list of available Edge Cloud Zones. @@ -107,25 +111,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): status = "Active" if status_token == "functional" else "Unknown" zone = { - "edgeCloudZoneId": - str(urn_to_uuid(domain_id)), - "edgeCloudZoneName": - domain_id, # or domain_id.split(":")[-1] if you prefer short name - "edgeCloudProvider": - (domain.get("owner", ["unknown"])[0] if isinstance( - domain.get("owner"), list) else domain.get( - "owner", "unknown")), - "status": - status, - "geographyDetails": - "NOT_USED", + "edgeCloudZoneId": str(urn_to_uuid(domain_id)), + "edgeCloudZoneName": domain_id, # or domain_id.split(":")[-1] if you prefer short name + "edgeCloudProvider": ( + domain.get("owner", ["unknown"])[0] + if isinstance(domain.get("owner"), list) + else domain.get("owner", "unknown") + ), + "status": status, + "geographyDetails": "NOT_USED", } zone_list.append(zone) # Store zones keyed by the aerOS domain id - self.storage.store_zones( - {d["edgeCloudZoneName"]: d - for d in zone_list}) + self.storage.store_zones({d["edgeCloudZoneName"]: d for d in zone_list}) if config.DEBUG: self.logger.debug("aerOS Local domains store: %s", zone_list) return build_custom_http_response( @@ -146,9 +145,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.logger.error("Error retrieving edge cloud zones: %s", e) raise - def get_edge_cloud_zones_details(self, - zone_id: str, - flavour_id: Optional[str] = None) -> Dict: + def get_edge_cloud_zones_details(self, zone_id: str, flavour_id: Optional[str] = None) -> Dict: """ Get details of a specific edge cloud zone. :param zone_id: The ID of the edge cloud zone @@ -170,7 +167,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Transform the infrastructure elements into the required format # and return the details of the edge cloud zone camara_response = self.transform_infrastructure_elements( - domain_ies=aeros_domain_ies, domain=zone_id) + domain_ies=aeros_domain_ies, domain=zone_id + ) if config.DEBUG: self.logger.debug("Transformed response: %s", camara_response) # Return the transformed response @@ -192,9 +190,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.logger.error("Error retrieving edge cloud zones: %s", e) raise - def transform_infrastructure_elements(self, domain_ies: List[Dict[str, - Any]], - domain: str) -> Dict[str, Any]: + def transform_infrastructure_elements( + self, domain_ies: List[Dict[str, Any]], domain: str + ) -> Dict[str, Any]: """ Transform the infrastructure elements into a format suitable for the edge cloud zone details. @@ -219,41 +217,39 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Create a flavour per machine flavour = { - "flavourId": - f"{element.get('hostname')}-{element.get('containerTechnology')}", - "cpuArchType": - f"{element.get('cpuArchitecture')}", - "supportedOSTypes": [{ - "architecture": f"{element.get('cpuArchitecture')}", - "distribution": - f"{element.get('operatingSystem')}", # assume - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE", - }], - "numCPU": - element.get("cpuCores", 0), - "memorySize": - element.get("ramCapacity", 0), - "storageSize": - element.get("diskCapacity", 0), + "flavourId": f"{element.get('hostname')}-{element.get('containerTechnology')}", + "cpuArchType": f"{element.get('cpuArchitecture')}", + "supportedOSTypes": [ + { + "architecture": f"{element.get('cpuArchitecture')}", + "distribution": f"{element.get('operatingSystem')}", # assume + "version": "OS_VERSION_UBUNTU_2204_LTS", + "license": "OS_LICENSE_TYPE_FREE", + } + ], + "numCPU": element.get("cpuCores", 0), + "memorySize": element.get("ramCapacity", 0), + "storageSize": element.get("diskCapacity", 0), } flavours_supported.append(flavour) result = { - "zoneId": - domain, - "reservedComputeResources": [{ - "cpuArchType": "ISA_X86_64", - "numCPU": int(total_cpu), - "memory": total_ram, - }], - "computeResourceQuotaLimits": [{ - "cpuArchType": "ISA_X86_64", - "numCPU": int(total_cpu * 2), # Assume quota is 2x total? - "memory": total_ram * 2, - }], - "flavoursSupported": - flavours_supported, + "zoneId": domain, + "reservedComputeResources": [ + { + "cpuArchType": "ISA_X86_64", + "numCPU": int(total_cpu), + "memory": total_ram, + } + ], + "computeResourceQuotaLimits": [ + { + "cpuArchType": "ISA_X86_64", + "numCPU": int(total_cpu * 2), # Assume quota is 2x total? + "memory": total_ram * 2, + } + ], + "flavoursSupported": flavours_supported, } return result @@ -271,18 +267,17 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise EdgeCloudPlatformError("Missing 'appId' in app manifest") if self.storage.get_app(app_id=app_id): - raise EdgeCloudPlatformError( - f"Application with id '{app_id}' already exists") + raise EdgeCloudPlatformError(f"Application with id '{app_id}' already exists") self.storage.store_app(app_id, app_manifest) self.logger.debug("Onboarded application with id: %s", app_id) - submitted_app = camara_schemas.SubmittedApp( - appId=camara_schemas.AppId(app_id)) + submitted_app = camara_schemas.SubmittedApp(appId=camara_schemas.AppId(app_id)) return build_custom_http_response( status_code=201, content=submitted_app.model_dump(mode="json"), headers={"Content-Type": "application/json"}, - encoding="utf-8") + encoding="utf-8", + ) def get_all_onboarded_apps(self) -> Response: apps = self.storage.list_apps() @@ -297,8 +292,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_onboarded_app(self, app_id: str) -> Response: app_data = self.storage.get_app(app_id) if not app_data: - raise EdgeCloudPlatformError( - f"Application with id '{app_id}' does not exist") + raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") self.logger.debug("Retrieved application with id: %s", app_id) app_manifest_response = { @@ -315,8 +309,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def delete_onboarded_app(self, app_id: str) -> Response: app = self.storage.get_app(app_id) if not app: - raise EdgeCloudPlatformError( - f"Application with id '{app_id}' does not exist") + raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") service_instances = self.storage.get_stopped_instances(app_id=app_id) if not service_instances: @@ -330,8 +323,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) for service_instance in service_instances: self._purge_deployed_app_from_continuum(service_instance) - self.logger.debug("successfully purged service instance: %s", - service_instance) + self.logger.debug("successfully purged service instance: %s", service_instance) self.storage.remove_stopped_instances(app_id) self.storage.delete_app(app_id) @@ -346,20 +338,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) def _generate_service_id(self, app_id: str) -> str: - ''' + """ Generate a unique service ID for aerOS continuum. The service ID is in the format of a NGSI-LD URN with a random suffix. :param app_id: The application ID :return: The generated service ID - ''' + """ return f"{app_id}-{uuid.uuid4().hex[:4]}" def _generate_aeros_service_id(self, camara_app_instance_id: str) -> str: - ''' + """ Convert CAMARA appInstanceId to aerOS service ID. :param camara_app_instance_id: The CAMARA appInstanceId :return: The corresponding aerOS service ID - ''' + """ return f"urn:ngsi-ld:Service:{camara_app_instance_id}" # Instantiation methods @@ -367,8 +359,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # 1. Get app CAMARA manifest app_manifest = self.storage.get_app(app_id) if not app_manifest: - raise EdgeCloudPlatformError( - f"Application with id '{app_id}' does not exist") + raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") app_manifest = camara_schemas.AppManifest.model_validate(app_manifest) # 2. Generate unique service ID @@ -378,12 +369,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # 3. Convert dict to YAML string # 3a. Get aerOS domain IDs from zones uuids aeros_domain_ids = [ - self.storage.resolve_domain_id_by_zone_uuid( - z["EdgeCloudZone"]["edgeCloudZoneId"]) for z in app_zones + self.storage.resolve_domain_id_by_zone_uuid(z["EdgeCloudZone"]["edgeCloudZoneId"]) + for z in app_zones if z.get("EdgeCloudZone", {}).get("edgeCloudZoneId") ] tosca_str = camara2aeros_converter.generate_tosca( - app_manifest=app_manifest, app_zones=aeros_domain_ids) + app_manifest=app_manifest, app_zones=aeros_domain_ids + ) if config.DEBUG: self.logger.info("Generated TOSCA YAML:") self.logger.info(tosca_str) @@ -392,7 +384,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: aeros_client = ContinuumClient(self.base_url) aeros_response = aeros_client.onboard_and_deploy_service( - self._generate_aeros_service_id(service_id), tosca_str) + self._generate_aeros_service_id(service_id), tosca_str + ) if "serviceId" not in aeros_response.json(): raise EdgeCloudPlatformError( @@ -401,12 +394,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Build CAMARA-compliant info app_provider_id = app_manifest.appProvider.root - zone_id = app_zones[0].get("EdgeCloudZone", - {}).get("edgeCloudZoneId", - "default-zone") + zone_id = app_zones[0].get("EdgeCloudZone", {}).get("edgeCloudZoneId", "default-zone") app_instance_info = camara_schemas.AppInstanceInfo( - name=camara_schemas.AppInstanceName( - encode_app_instance_name(service_id)), + name=camara_schemas.AppInstanceName(encode_app_instance_name(service_id)), appId=camara_schemas.AppId(app_id), appInstanceId=camara_schemas.AppInstanceId(service_id), appProvider=camara_schemas.AppProvider(app_provider_id), @@ -424,10 +414,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): camara_response = app_instance_info.model_dump(mode="json") # Add mandatory Location header location_url = f"/appinstances/{service_id}" - camara_headers = { - "Content-Type": "application/json", - "Location": location_url - } + camara_headers = {"Content-Type": "application/json", "Location": location_url} return build_custom_http_response( status_code=aeros_response.status_code, @@ -435,7 +422,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): headers=camara_headers, encoding="utf-8", url=aeros_response.url, - request=aeros_response.request) + request=aeros_response.request, + ) except EdgeCloudPlatformError as ex: # Catch all platform-specific errors. # All custom exception types (InvalidArgumentError, UnauthenticatedError, etc.) @@ -451,14 +439,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): region: Optional[str] = None, ) -> Response: - instances = self.storage.find_deployments(app_id, app_instance_id, - region) + instances = self.storage.find_deployments(app_id, app_instance_id, region) # CAMARA spec format for multiple instances response camara_response = { "appInstances": [ - inst.model_dump( - mode="json") if hasattr(inst, "model_dump") else inst + inst.model_dump(mode="json") if hasattr(inst, "model_dump") else inst for inst in instances ] } @@ -475,10 +461,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # request=response.request, ) - def get_deployed_app(self, - app_instance_id: str, - app_id: Optional[str] = None, - region: Optional[str] = None) -> Response: + def get_deployed_app( + self, app_instance_id: str, app_id: Optional[str] = None, region: Optional[str] = None + ) -> Response: """ Placeholder implementation for CAMARA compliance. Retrieves information of a specific application instance. @@ -506,8 +491,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): scope.append(f"app_id={app_id}") if region: scope.append(f"region={region}") - raise ResourceNotFoundError( - f"Deployed app not found ({', '.join(scope)})") + raise ResourceNotFoundError(f"Deployed app not found ({', '.join(scope)})") # If multiple matched (shouldn't normally happen after filtering by instance id), # return the first deterministically. @@ -532,21 +516,25 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Defensive catch-all with context self.logger.exception( "Unhandled error retrieving deployed app instance '%s' (app_id=%s, region=%s): %s", - app_instance_id, app_id, region, e) + app_instance_id, + app_id, + region, + e, + ) raise EdgeCloudPlatformError(str(e)) def _purge_deployed_app_from_continuum(self, app_id: str) -> None: - ''' + """ Purge the deployed application from aerOS continuum. :param app_id: The application ID to purge All instances of this app should be stopped - ''' + """ aeros_client = ContinuumClient(self.base_url) - response = aeros_client.purge_service( - self._generate_aeros_service_id(app_id)) + response = aeros_client.purge_service(self._generate_aeros_service_id(app_id)) if response: - self.logger.debug("Purged deployed application with id: %s", - self._generate_aeros_service_id(app_id)) + self.logger.debug( + "Purged deployed application with id: %s", self._generate_aeros_service_id(app_id) + ) else: raise EdgeCloudPlatformError( f"Failed to purge service with id from the continuum '{app_id}'" @@ -555,17 +543,18 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def undeploy_app(self, app_instance_id: str) -> Response: # 1. Locate app_id corresponding to this instance and # remove from deployed instances for this appId - app_id = self.storage.remove_deployment( - app_instance_id=app_instance_id) + app_id = self.storage.remove_deployment(app_instance_id=app_instance_id) if not app_id: raise EdgeCloudPlatformError( - f"No deployed app instance with ID '{app_instance_id}' found") + f"No deployed app instance with ID '{app_instance_id}' found" + ) # 2. Call the external undeploy_service aeros_client = ContinuumClient(self.base_url) try: aeros_response = aeros_client.undeploy_service( - self._generate_aeros_service_id(app_instance_id)) + self._generate_aeros_service_id(app_instance_id) + ) except Exception as e: raise EdgeCloudPlatformError( f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" @@ -606,11 +595,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ngsild_params = "type=Domain&format=simplified" aeros_response = aeros_client.query_entities(ngsild_params) aeros_domains = aeros_response.json() - zone_list = [{ - "zoneId": domain["id"], - "geolocation": "NOT_Available", - "geographyDetails": domain["description"], - } for domain in aeros_domains] + zone_list = [ + { + "zoneId": domain["id"], + "geolocation": "NOT_Available", + "geographyDetails": domain["description"], + } + for domain in aeros_domains + ] return build_custom_http_response( status_code=aeros_response.status_code, content=zone_list, @@ -638,7 +630,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: Response with zones and detailed resource information. """ aeros_client = ContinuumClient(self.base_url) - ngsild_params = 'format=simplified&type=InfrastructureElement' + ngsild_params = "format=simplified&type=InfrastructureElement" try: # Query the infrastructure elements whithin the whole continuum @@ -655,8 +647,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # per domain and append to response list gsma_response = [] for domain, ies in grouped_by_domain.items(): - result = aeros2gsma_zone_details.transformer(domain_ies=ies, - domain=domain) + result = aeros2gsma_zone_details.transformer(domain_ies=ies, domain=domain) gsma_response.append(result) # Return the transformed response return build_custom_http_response( @@ -702,7 +693,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # camara_response = self.transform_infrastructure_elements( # domain_ies=aeros_domain_ies, domain=zone_id) gsma_response = aeros2gsma_zone_details.transformer( - domain_ies=aeros_domain_ies, domain=zone_id) + domain_ies=aeros_domain_ies, domain=zone_id + ) if config.DEBUG: self.logger.debug("Transformed response: %s", gsma_response) # Return the transformed response @@ -759,8 +751,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ art = self.storage.get_artefact_gsma(artefact_id) if not art: - raise ResourceNotFoundError( - f"GSMA artefact '{artefact_id}' not found") + raise ResourceNotFoundError(f"GSMA artefact '{artefact_id}' not found") return build_custom_http_response( status_code=200, content=art.model_dump(mode="json"), @@ -770,10 +761,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def list_artefacts_gsma(self): """List all GSMA Artefacts.""" - arts = [ - a.model_dump(mode="json") - for a in self.storage.list_artefacts_gsma() - ] + arts = [a.model_dump(mode="json") for a in self.storage.list_artefacts_gsma()] return build_custom_http_response( status_code=200, content=arts, @@ -789,13 +777,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: """ if not self.storage.get_artefact_gsma(artefact_id): - raise ResourceNotFoundError( - f"GSMA artefact '{artefact_id}' not found") + raise ResourceNotFoundError(f"GSMA artefact '{artefact_id}' not found") self.storage.delete_artefact_gsma(artefact_id) - return build_custom_http_response(status_code=204, - content=b"", - headers={}, - encoding=None) + return build_custom_http_response(status_code=204, content=b"", headers={}, encoding=None) # ------------------------------------------------------------------------ # Application Onboarding Management (GSMA) @@ -830,8 +814,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ try: # Validate input against GSMA schema - entry = gsma_schemas.AppOnboardManifestGSMA.model_validate( - request_body) + entry = gsma_schemas.AppOnboardManifestGSMA.model_validate(request_body) except ValidationError as e: self.logger.error("Invalid GSMA input schema: %s", e) raise InvalidArgumentError(str(e)) @@ -842,8 +825,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Ensure uniqueness if self.storage.get_app_gsma(app_model.appId): - raise InvalidArgumentError( - f"GSMA app '{app_model.appId}' already exists") + raise InvalidArgumentError(f"GSMA app '{app_model.appId}' already exists") # Store in GSMA apps storage self.storage.store_app_gsma(app_model.appId, app_model) @@ -859,8 +841,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.logger.error("Error during GSMA app onboarding: %s", e) raise except Exception as e: - self.logger.exception("Unhandled error during GSMA onboarding: %s", - e) + self.logger.exception("Unhandled error during GSMA onboarding: %s", e) raise EdgeCloudPlatformError(str(e)) def get_onboarded_app_gsma(self, app_id: str) -> Dict: @@ -885,8 +866,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.logger.error("Error retrieving GSMA app '%s': %s", app_id, e) raise except Exception as e: - self.logger.exception( - "Unhandled error retrieving GSMA app '%s': %s", app_id, e) + self.logger.exception("Unhandled error retrieving GSMA app '%s': %s", app_id, e) raise EdgeCloudPlatformError(str(e)) def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): @@ -899,8 +879,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: Response with updated application details. """ try: - patch = gsma_schemas.PatchOnboardedAppGSMA.model_validate( - request_body) + patch = gsma_schemas.PatchOnboardedAppGSMA.model_validate(request_body) except ValidationError as e: self.logger.error("Invalid GSMA patch schema: %s", e) raise InvalidArgumentError(str(e)) @@ -936,7 +915,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): serviceNameEW=p.serviceNameEW, componentName=p.componentName, artefactId=p.artefactId, - ) for p in patch.appComponentSpecs + ) + for p in patch.appComponentSpecs ] # Persist updated model @@ -952,8 +932,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.logger.error("Error updating GSMA app '%s': %s", app_id, e) raise except Exception as e: - self.logger.exception("Unhandled error patching GSMA app '%s': %s", - app_id, e) + self.logger.exception("Unhandled error patching GSMA app '%s': %s", app_id, e) raise EdgeCloudPlatformError(str(e)) def delete_onboarded_app_gsma(self, app_id: str): @@ -968,8 +947,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") # CHECKME: update for GSMA - service_instances = self.storage.get_stopped_instances_gsma( - app_id=app_id) + service_instances = self.storage.get_stopped_instances_gsma(app_id=app_id) if not service_instances: raise EdgeCloudPlatformError( f"Application with id '{app_id}' cannot be deleted — please stop it first" @@ -981,8 +959,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) for service_instance in service_instances: self._purge_deployed_app_from_continuum_gsma(service_instance) - self.logger.debug("successfully purged service instance: %s", - service_instance) + self.logger.debug("successfully purged service instance: %s", service_instance) self.storage.remove_stopped_instances_gsma(app_id) @@ -998,22 +975,19 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.logger.error("Error deleting GSMA app '%s': %s", app_id, e) raise except Exception as e: - self.logger.exception("Unhandled error deleting GSMA app '%s': %s", - app_id, e) + self.logger.exception("Unhandled error deleting GSMA app '%s': %s", app_id, e) raise EdgeCloudPlatformError(str(e)) - def _purge_deployed_app_from_continuum_gsma(self, - app_instance_id: str) -> None: - ''' + def _purge_deployed_app_from_continuum_gsma(self, app_instance_id: str) -> None: + """ Purge the deployed application from aerOS continuum. :param app_id: The application ID to purge All instances of this app should be stopped - ''' + """ aeros_client = ContinuumClient(self.base_url) response = aeros_client.purge_service(app_instance_id) if response: - self.logger.debug("Purged deployed application with id: %s", - app_instance_id) + self.logger.debug("Purged deployed application with id: %s", app_instance_id) else: raise EdgeCloudPlatformError( f"Failed to purge service with id from the continuum '{app_instance_id}'" @@ -1031,8 +1005,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: Dictionary with deployment details. """ try: - payload = gsma_schemas.AppDeployPayloadGSMA.model_validate( - request_body) + payload = gsma_schemas.AppDeployPayloadGSMA.model_validate(request_body) except ValidationError as e: self.logger.error("Invalid GSMA deploy schema: %s", e) raise InvalidArgumentError(str(e)) @@ -1041,13 +1014,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Ensure app exists onboarded_app = self.storage.get_app_gsma(payload.appId) if not onboarded_app: - raise ResourceNotFoundError( - f"GSMA app '{payload.appId}' not found") + raise ResourceNotFoundError(f"GSMA app '{payload.appId}' not found") # 2. Generate unique service ID # (aerOS) service id <=> GSMA appInstanceId service_id = self._generate_aeros_service_id( - self._generate_service_id(onboarded_app.appId)) + self._generate_service_id(onboarded_app.appId) + ) # 3. Create TOSCA (yaml str) from GSMA onboarded_app + connected artefacts # GSMA app corresponds to aerOS Service @@ -1063,7 +1036,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # 4. Instantiate client and call continuum to deploy servic aeros_client = ContinuumClient(self.base_url) aeros_response = aeros_client.onboard_and_deploy_service( - service_id, tosca_str=tosca_yaml) + service_id, tosca_str=tosca_yaml + ) if "serviceId" not in aeros_response.json(): raise EdgeCloudPlatformError( @@ -1081,9 +1055,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): accesspointInfo=[], ) - self.storage.store_deployment_gsma(onboarded_app.appId, - inst, - status=status) + self.storage.store_deployment_gsma(onboarded_app.appId, inst, status=status) # 6. Return expected format (deployment details) body = inst.model_dump(mode="json") @@ -1094,17 +1066,16 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=aeros_response.json().get("url", ""), - request=aeros_response.request) + request=aeros_response.request, + ) except EdgeCloudPlatformError as ex: - self.logger.error("Failed to deploy app '%s': %s", - onboarded_app.appId, str(ex)) + self.logger.error("Failed to deploy app '%s': %s", onboarded_app.appId, str(ex)) raise except Exception as e: self.logger.exception("Unhandled error during GSMA deploy: %s", e) raise EdgeCloudPlatformError(str(e)) - def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, - zone_id: str) -> Dict: + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: """ Retrieves an application instance details from partner OP. @@ -1121,23 +1092,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # 4. Instantiate client and call continuum to deploy servic aeros_client = ContinuumClient(self.base_url) aeros_response = aeros_client.query_entity( - entity_id=app_instance_id, ngsild_params='format=simplified') + entity_id=app_instance_id, ngsild_params="format=simplified" + ) response_json = aeros_response.json() content = gsma_schemas.AppInstanceStatus( - appInstanceState=map_aeros_service_status_to_gsma( - response_json.get("actionType")), - accesspointInfo=[{ - "service_status": - f'{self.base_url}/entities/{app_instance_id}' - }, { - "serviceComponents_status": - f'{self.base_url}/hlo_fe/services//{app_instance_id}' - }], + appInstanceState=map_aeros_service_status_to_gsma(response_json.get("actionType")), + accesspointInfo=[ + {"service_status": f"{self.base_url}/entities/{app_instance_id}"}, + { + "serviceComponents_status": f"{self.base_url}/hlo_fe/services//{app_instance_id}" + }, + ], ) - validated_data = gsma_schemas.AppInstanceStatus.model_validate( - content) + validated_data = gsma_schemas.AppInstanceStatus.model_validate(content) return build_custom_http_response( status_code=200, @@ -1152,7 +1121,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except Exception as e: self.logger.exception( "Unhandled error retrieving GSMA deployment '%s' (%s/%s): %s", - app_instance_id, app_id, zone_id, e) + app_instance_id, + app_id, + zone_id, + e, + ) raise EdgeCloudPlatformError(str(e)) def get_all_deployed_apps_gsma(self) -> Response: @@ -1178,12 +1151,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except EdgeCloudPlatformError: raise except Exception as e: - self.logger.exception( - "Unhandled error listing GSMA deployments: '%s'", e) + self.logger.exception("Unhandled error listing GSMA deployments: '%s'", e) raise EdgeCloudPlatformError(str(e)) - def undeploy_app_gsma(self, app_id: str, app_instance_id: str, - zone_id: str): + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): """ Terminate an application instance on a partner OP zone. @@ -1199,9 +1170,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Ensure the (app_id, instance, zone) exists matches = self.storage.find_deployments_gsma( - app_id=app_id, - app_instance_id=app_instance_id, - zone_id=zone_id) + app_id=app_id, app_instance_id=app_instance_id, zone_id=zone_id + ) if not matches: raise ResourceNotFoundError( f"Deployment not found (app_id={app_id}, instance={app_instance_id}, zone={zone_id})" @@ -1217,11 +1187,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) from e # Remove from deployed and mark as stopped so it can be purged later - removed_app_id = self.storage.remove_deployment_gsma( - app_instance_id) + removed_app_id = self.storage.remove_deployment_gsma(app_instance_id) if removed_app_id: - self.storage.store_stopped_instance_gsma( - removed_app_id, app_instance_id) + self.storage.store_stopped_instance_gsma(removed_app_id, app_instance_id) # Async-friendly: 202 Accepted (termination in progress) body = { @@ -1241,5 +1209,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except Exception as e: self.logger.exception( "Unhandled error undeploying GSMA app instance '%s' (app=%s zone=%s): %s", - app_instance_id, app_id, zone_id, e) + app_instance_id, + app_id, + zone_id, + e, + ) raise EdgeCloudPlatformError(str(e)) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py index a0a7fe1..d254840 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py @@ -1,100 +1,109 @@ -''' - aerOS continuum models -''' +""" +aerOS continuum models +""" + from enum import Enum -from typing import List, Dict, Any, Union, Optional +from typing import Any, Dict, List, Optional, Union + from pydantic import BaseModel, Field class ServiceNotFound(BaseModel): - ''' + """ Docstring - ''' + """ + detail: str = "Service not found" class CPUComparisonOperator(BaseModel): """ - CPU requirment for now is that usage should be less than + CPU requirment for now is that usage should be less than """ + less_or_equal: Union[float, None] = None class CPUArchComparisonOperator(BaseModel): """ - CPU arch requirment, equal to str + CPU arch requirment, equal to str """ + equal: Union[str, None] = None class MEMComparisonOperator(BaseModel): """ - RAM requirment for now is that available RAM should be more than + RAM requirment for now is that available RAM should be more than """ + greater_or_equal: Union[str, None] = None class EnergyEfficienyComparisonOperator(BaseModel): """ - Energy Efficiency requirment for now is that IE should have energy efficiency more than a % + Energy Efficiency requirment for now is that IE should have energy efficiency more than a % """ + greater_or_equal: Union[str, None] = None class GreenComparisonOperator(BaseModel): """ - IE Green requirment for now is that IE should have green energy mix which us more than a % + IE Green requirment for now is that IE should have green energy mix which us more than a % """ + greater_or_equal: Union[str, None] = None class RTComparisonOperator(BaseModel): """ - Real Time requirment T/F + Real Time requirment T/F """ + equal: Union[bool, None] = None class CpuArch(str, Enum): - ''' + """ Enumeration with possible cpu types - ''' + """ + x86_64 = "x86_64" arm64 = "arm64" arm32 = "arm32" class Coordinates(BaseModel): - ''' + """ IE coordinate requirements - ''' + """ + coordinates: List[List[float]] class DomainIdOperator(BaseModel): """ - CPU arch requirment, equal to str + CPU arch requirment, equal to str """ + equal: Union[str, None] = None class Property(BaseModel): - ''' + """ IE capabilities - ''' - cpu_usage: CPUComparisonOperator = Field( - default_factory=CPUComparisonOperator) - cpu_arch: CPUArchComparisonOperator = Field( - default_factory=CPUArchComparisonOperator) - mem_size: MEMComparisonOperator = Field( - default_factory=MEMComparisonOperator) - realtime: RTComparisonOperator = Field( - default_factory=RTComparisonOperator) + """ + + cpu_usage: CPUComparisonOperator = Field(default_factory=CPUComparisonOperator) + cpu_arch: CPUArchComparisonOperator = Field(default_factory=CPUArchComparisonOperator) + mem_size: MEMComparisonOperator = Field(default_factory=MEMComparisonOperator) + realtime: RTComparisonOperator = Field(default_factory=RTComparisonOperator) area: Coordinates = None energy_efficiency: EnergyEfficienyComparisonOperator = Field( - default_factory=EnergyEfficienyComparisonOperator) - green: GreenComparisonOperator = Field( - default_factory=GreenComparisonOperator) + default_factory=EnergyEfficienyComparisonOperator + ) + green: GreenComparisonOperator = Field(default_factory=GreenComparisonOperator) domain_id: DomainIdOperator = Field(default_factory=DomainIdOperator) # @field_validator('mem_size') @@ -108,71 +117,80 @@ class Property(BaseModel): class HostCapability(BaseModel): - ''' + """ Host properties - ''' + """ + properties: Property class NodeFilter(BaseModel): - ''' + """ Node filter, How to filter continuum IE and select canditate list - ''' + """ + properties: Optional[Dict[str, List[str]]] = None capabilities: Optional[List[Dict[str, HostCapability]]] = None class HostRequirement(BaseModel): - ''' + """ capabilities of node - ''' + """ + # node_filter: Dict[str, List[Dict[str, HostCapability]]] node_filter: NodeFilter class PortProperties(BaseModel): - ''' + """ Workload port description - ''' + """ + protocol: List[str] = Field(...) source: int = Field(...) class ExposedPort(BaseModel): - ''' + """ Workload exposed network ports - ''' + """ + properties: PortProperties = Field(...) class NetworkProperties(BaseModel): - ''' + """ Dict of network requirments, name of port and protperty = [protocol, port] mapping - ''' + """ + ports: Dict[str, ExposedPort] = Field(...) exposePorts: Optional[bool] class NetworkRequirement(BaseModel): - ''' + """ Top level key of network requirments - ''' + """ + properties: NetworkProperties class CustomRequirement(BaseModel): - ''' - Define a custom requirement type that can be either a host or a network requirement - ''' + """ + Define a custom requirement type that can be either a host or a network requirement + """ + host: HostRequirement = None network: NetworkRequirement = None class ArtifactModel(BaseModel): - ''' + """ Artifact has a useer defined id and then a dict with the following keys: - ''' + """ + file: str type: str repository: str @@ -182,9 +200,10 @@ class ArtifactModel(BaseModel): class NodeTemplate(BaseModel): - ''' + """ Node template "tosca.nodes.Container.Application" - ''' + """ + type: str requirements: List[CustomRequirement] artifacts: Dict[str, ArtifactModel] @@ -193,9 +212,10 @@ class NodeTemplate(BaseModel): class TOSCA(BaseModel): - ''' + """ The TOSCA structure - ''' + """ + tosca_definitions_version: str description: str serviceOverlay: Optional[bool] = False @@ -262,7 +282,7 @@ node_templates: domain_id: equal: urn:ngsi-ld:Domain:ncsrd01 properties: null - + diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py index b83228e..337818d 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py @@ -1,11 +1,11 @@ -''' +""" aeros2gsma_zone_details.py -''' -from typing import List, Dict, Any +""" +from typing import Any, Dict, List -def transformer(domain_ies: List[Dict[str, Any]], - domain: str) -> Dict[str, Any]: + +def transformer(domain_ies: List[Dict[str, Any]], domain: str) -> Dict[str, Any]: # noqa: C901 """ Transform aerOS InfrastructureElements into GSMA ZoneRegisteredData structure. :param domain_ies: List of aerOS InfrastructureElement dicts @@ -78,8 +78,7 @@ def transformer(domain_ies: List[Dict[str, Any]], cpu_cores = int(element.get("cpuCores", 0) or 0) ram_cap = int(element.get("ramCapacity", 0) or 0) # MB? avail_ram = int(element.get("availableRam", 0) or 0) # MB? - disk_cap = int(element.get("diskCapacity", 0) - or 0) # MB/GB? (pass-through) + disk_cap = int(element.get("diskCapacity", 0) or 0) # MB/GB? (pass-through) avail_disk = int(element.get("availableDisk", 0) or 0) total_cpu += cpu_cores @@ -99,22 +98,19 @@ def transformer(domain_ies: List[Dict[str, Any]], # Create a flavour per machine flavour = { - "flavourId": - f"{element.get('hostname', 'host')}-{element.get('containerTechnology', 'CT')}", - "cpuArchType": - isa, # Literal ISA_* - "supportedOSTypes": [{ - "architecture": ost_arch, # 'x86_64' or 'x86' - "distribution": dist, # 'UBUNTU' or 'OTHER' - "version": ver, # 'OS_VERSION_UBUNTU_2204_LTS' or 'OTHER' - "license": "OS_LICENSE_TYPE_FREE", - }], - "numCPU": - cpu_cores, - "memorySize": - ram_cap, - "storageSize": - disk_cap, + "flavourId": f"{element.get('hostname', 'host')}-{element.get('containerTechnology', 'CT')}", + "cpuArchType": isa, # Literal ISA_* + "supportedOSTypes": [ + { + "architecture": ost_arch, # 'x86_64' or 'x86' + "distribution": dist, # 'UBUNTU' or 'OTHER' + "version": ver, # 'OS_VERSION_UBUNTU_2204_LTS' or 'OTHER' + "license": "OS_LICENSE_TYPE_FREE", + } + ], + "numCPU": cpu_cores, + "memorySize": ram_cap, + "storageSize": disk_cap, } flavours_supported.append(flavour) @@ -133,22 +129,24 @@ def transformer(domain_ies: List[Dict[str, Any]], agg_isa = pick_aggregate_isa() result = { - "zoneId": - domain, - "reservedComputeResources": [{ - "cpuArchType": agg_isa, - "numCPU": int( - total_cpu - ), # Same as Quotas untill we have somem policy or data to differentiate - "memory": total_ram, # ditto - }], - "computeResourceQuotaLimits": [{ - "cpuArchType": agg_isa, - "numCPU": int(total_cpu), - "memory": total_ram, - }], - "flavoursSupported": - flavours_supported, + "zoneId": domain, + "reservedComputeResources": [ + { + "cpuArchType": agg_isa, + "numCPU": int( + total_cpu + ), # Same as Quotas untill we have somem policy or data to differentiate + "memory": total_ram, # ditto + } + ], + "computeResourceQuotaLimits": [ + { + "cpuArchType": agg_isa, + "numCPU": int(total_cpu), + "memory": total_ram, + } + ], + "flavoursSupported": flavours_supported, } return result diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py index 318d575..b7d03b0 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py @@ -1,36 +1,51 @@ -''' +""" Module: converter.py This module provides functions to convert application manifests into TOSCA models. It includes the `generate_tosca` function that constructs a TOSCA model based on the application manifest and associated app zones. -''' -from typing import List, Dict, Any +""" + +from typing import List + import yaml + from sunrise6g_opensdk.edgecloud.adapters.aeros import config -from sunrise6g_opensdk.logger import setup_logger -from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppManifest, VisibilityType from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( - TOSCA, NodeTemplate, CustomRequirement, HostRequirement, HostCapability, - Property as HostProperty, DomainIdOperator, NodeFilter, NetworkRequirement, - NetworkProperties, ExposedPort, PortProperties, ArtifactModel) + TOSCA, + ArtifactModel, + CustomRequirement, + DomainIdOperator, + ExposedPort, + HostCapability, + HostRequirement, + NetworkProperties, + NetworkRequirement, + NodeFilter, + NodeTemplate, + PortProperties, +) +from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( + Property as HostProperty, +) +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppManifest, VisibilityType +from sunrise6g_opensdk.logger import setup_logger logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) def generate_tosca(app_manifest: AppManifest, app_zones: List[str]) -> str: - ''' + """ Generate a TOSCA model from the application manifest and app zones. Args: app_manifest (AppManifest): The application manifest containing details about the app. app_zones (List[Dict[str, Any]]): List of app zones where the app will be deployed. Returns: TOSCA yaml as string which can be used in a POST request with applcation type yaml - ''' + """ component = app_manifest.componentSpec[0] image_path = app_manifest.appRepo.imagePath.root image_file = image_path.split("/")[-1] - repository_url = "/".join( - image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" + repository_url = "/".join(image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" zone_id = app_zones[0] logger.info("DEBUG : %s", app_manifest.requiredResources.root) @@ -38,22 +53,24 @@ def generate_tosca(app_manifest: AppManifest, app_zones: List[str]) -> str: res = app_manifest.requiredResources.root if hasattr(res, "applicationResources") and hasattr( - res.applicationResources.cpuPool.topology, "minNodeMemory"): + res.applicationResources.cpuPool.topology, "minNodeMemory" + ): min_node_memory = res.applicationResources.cpuPool.topology.minNodeMemory else: min_node_memory = 1024 # Build exposed network ports ports = { - iface.interfaceId: - ExposedPort(properties=PortProperties( - protocol=[iface.protocol.value.lower()], source=iface.port)) + iface.interfaceId: ExposedPort( + properties=PortProperties(protocol=[iface.protocol.value.lower()], source=iface.port) + ) for iface in component.networkInterfaces } expose_ports = any( iface.visibilityType == VisibilityType.VISIBILITY_EXTERNAL - for iface in component.networkInterfaces) + for iface in component.networkInterfaces + ) # Define host property constraints host_props = HostProperty( @@ -68,14 +85,18 @@ def generate_tosca(app_manifest: AppManifest, app_zones: List[str]) -> str: # Create Node compute and network requirements requirements = [ - CustomRequirement(network=NetworkRequirement( - properties=NetworkProperties(ports=ports, - exposePorts=expose_ports))), - CustomRequirement(host=HostRequirement(node_filter=NodeFilter( - capabilities=[{ - "host": HostCapability(properties=host_props) - }], - properties=None))) + CustomRequirement( + network=NetworkRequirement( + properties=NetworkProperties(ports=ports, exposePorts=expose_ports) + ) + ), + CustomRequirement( + host=HostRequirement( + node_filter=NodeFilter( + capabilities=[{"host": HostCapability(properties=host_props)}], properties=None + ) + ) + ), ] # Define the NodeTemplate node_template = NodeTemplate( @@ -83,40 +104,40 @@ def generate_tosca(app_manifest: AppManifest, app_zones: List[str]) -> str: isJob=False, requirements=requirements, artifacts={ - "application_image": - ArtifactModel( + "application_image": ArtifactModel( file=image_file, type="tosca.artifacts.Deployment.Image.Container.Docker", repository=repository_url, is_private=app_manifest.appRepo.type == "PRIVATEREPO", username=app_manifest.appRepo.userName, - password=app_manifest.appRepo.credentials) + password=app_manifest.appRepo.credentials, + ) }, interfaces={ "Standard": { "create": { "implementation": "application_image", - "inputs": { - "cliArgs": [], - "envVars": [] - } + "inputs": {"cliArgs": [], "envVars": []}, } } - }) + }, + ) # Assemble full TOSCA object - tosca = TOSCA(tosca_definitions_version="tosca_simple_yaml_1_3", - description=f"TOSCA for {app_manifest.name}", - serviceOverlay=False, - node_templates={component.componentName: node_template}) + tosca = TOSCA( + tosca_definitions_version="tosca_simple_yaml_1_3", + description=f"TOSCA for {app_manifest.name}", + serviceOverlay=False, + node_templates={component.componentName: node_template}, + ) tosca_dict = tosca.model_dump(by_alias=True, exclude_none=True) for template in tosca_dict.get("node_templates", {}).values(): - template["requirements"] = [{ - k: v - for k, v in req.items() if v is not None - } for req in template.get("requirements", [])] + template["requirements"] = [ + {k: v for k, v in req.items() if v is not None} + for req in template.get("requirements", []) + ] yaml_str = yaml.dump(tosca_dict, sort_keys=False) return yaml_str diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py index c3a0bae..bc30cd7 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py @@ -10,35 +10,39 @@ Notes: - Network ports are omitted for now (exposePorts = False). """ -from typing import Optional, Callable, Dict, Any, List +from typing import Callable, Dict, List, Optional + import yaml + from sunrise6g_opensdk.edgecloud.adapters.aeros import config -from sunrise6g_opensdk.edgecloud.adapters.aeros.errors import ( - ResourceNotFoundError, - InvalidArgumentError, -) -from sunrise6g_opensdk.logger import setup_logger -from sunrise6g_opensdk.edgecloud.core import gsma_schemas from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( TOSCA, - NodeTemplate, + ArtifactModel, CustomRequirement, - HostRequirement, - HostCapability, - Property as HostProperty, DomainIdOperator, - NodeFilter, - NetworkRequirement, - NetworkProperties, ExposedPort, + HostCapability, + HostRequirement, + NetworkProperties, + NetworkRequirement, + NodeFilter, + NodeTemplate, PortProperties, - ArtifactModel, ) +from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( + Property as HostProperty, +) +from sunrise6g_opensdk.edgecloud.adapters.aeros.errors import ( + InvalidArgumentError, + ResourceNotFoundError, +) +from sunrise6g_opensdk.edgecloud.core import gsma_schemas +from sunrise6g_opensdk.logger import setup_logger logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) -def generate_tosca_from_gsma_with_artefacts( +def generate_tosca_from_gsma_with_artefacts( # noqa: C901 app_model: gsma_schemas.ApplicationModel, zone_id: str, artefact_resolver: Callable[[str], Optional[gsma_schemas.Artefact]], @@ -70,8 +74,7 @@ def generate_tosca_from_gsma_with_artefacts( for comp in app_model.appComponentSpecs: artefact = artefact_resolver(comp.artefactId) if not artefact: - raise ResourceNotFoundError( - f"GSMA artefact '{comp.artefactId}' not found") + raise ResourceNotFoundError(f"GSMA artefact '{comp.artefactId}' not found") # pick the componentSpec that matches componentName, else first comp_spec = None @@ -83,12 +86,10 @@ def generate_tosca_from_gsma_with_artefacts( if comp_spec is None: comp_spec = artefact.componentSpec[0] else: - raise InvalidArgumentError( - f"Artefact '{artefact.artefactId}' has no componentSpec") + raise InvalidArgumentError(f"Artefact '{artefact.artefactId}' has no componentSpec") # Resolve container image - image = comp_spec.images[ - 0] if comp_spec.images else "docker.io/library/nginx:stable" + image = comp_spec.images[0] if comp_spec.images else "docker.io/library/nginx:stable" if "/" in image: repository_url = "/".join(image.split("/")[:-1]) image_file = image.split("/")[-1] @@ -103,8 +104,9 @@ def generate_tosca_from_gsma_with_artefacts( protocol = str(iface.get("protocol", "TCP")).lower() port = iface.get("port") if isinstance(port, int): - ports[f"if{idx}"] = ExposedPort(properties=PortProperties( - protocol=[protocol], source=port)) + ports[f"if{idx}"] = ExposedPort( + properties=PortProperties(protocol=[protocol], source=port) + ) expose_ports = True # Build cliArgs as a list of dicts: [{"KEY": "VAL"}, {"FLAG": ""}, ...] @@ -135,8 +137,7 @@ def generate_tosca_from_gsma_with_artefacts( for item in comp_spec.compEnvParams: if isinstance(item, dict): if "name" in item and "value" in item: - env_vars.append( - {str(item["name"]): str(item["value"])}) + env_vars.append({str(item["name"]): str(item["value"])}) elif len(item) == 1: # already mapping-like {"KEY": "VAL"} k, v = next(iter(item.items())) env_vars.append({str(k): str(v)}) @@ -153,15 +154,19 @@ def generate_tosca_from_gsma_with_artefacts( ) requirements = [ - CustomRequirement(network=NetworkRequirement( - properties=NetworkProperties(ports=ports, - exposePorts=expose_ports))), - CustomRequirement(host=HostRequirement(node_filter=NodeFilter( - capabilities=[{ - "host": HostCapability(properties=host_props) - }], - properties=None, - ))), + CustomRequirement( + network=NetworkRequirement( + properties=NetworkProperties(ports=ports, exposePorts=expose_ports) + ) + ), + CustomRequirement( + host=HostRequirement( + node_filter=NodeFilter( + capabilities=[{"host": HostCapability(properties=host_props)}], + properties=None, + ) + ) + ), ] # PUBLICREPO => is_private=False and omit credentials @@ -180,8 +185,7 @@ def generate_tosca_from_gsma_with_artefacts( isJob=False, requirements=requirements, artifacts={ - "application_image": - ArtifactModel( + "application_image": ArtifactModel( file=image_file, type="tosca.artifacts.Deployment.Image.Container.Docker", repository=repository_url, @@ -206,8 +210,7 @@ def generate_tosca_from_gsma_with_artefacts( # Assemble and dump TOSCA tosca = TOSCA( tosca_definitions_version="tosca_simple_yaml_1_3", - description= - f"GSMA->TOSCA for {app_model.appMetaData.appName} ({app_model.appId})", + description=f"GSMA->TOSCA for {app_model.appMetaData.appName} ({app_model.appId})", serviceOverlay=False, node_templates=node_templates, ) @@ -215,9 +218,9 @@ def generate_tosca_from_gsma_with_artefacts( tosca_dict = tosca.model_dump(by_alias=True, exclude_none=True) # Clean requirements lists from None entries for template in tosca_dict.get("node_templates", {}).values(): - template["requirements"] = [{ - k: v - for k, v in req.items() if v is not None - } for req in template.get("requirements", [])] + template["requirements"] = [ + {k: v for k, v in req.items() if v is not None} + for req in template.get("requirements", []) + ] return yaml.dump(tosca_dict, sort_keys=False) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py index 2ca44f4..16beed2 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py @@ -1,30 +1,35 @@ -''' +""" Custom aerOS adapter exceptions on top of EdgeCloudPlatformError -''' +""" from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError class InvalidArgumentError(EdgeCloudPlatformError): """400 Bad Request""" + pass class UnauthenticatedError(EdgeCloudPlatformError): """401 Unauthorized""" + pass class PermissionDeniedError(EdgeCloudPlatformError): """403 Forbidden""" + pass class ResourceNotFoundError(EdgeCloudPlatformError): """404 Not Found""" + pass class ServiceUnavailableError(EdgeCloudPlatformError): """503 Service Unavailable""" + pass diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py index 612b363..22b535e 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py @@ -1,5 +1,6 @@ -''' +""" This module contains the storage management implementations for aerOS. -''' +""" + from .inMemoryStorage import InMemoryAppStorage from .sqlite_storage import SQLiteAppStorage diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py index 9bff25a..c1cee65 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py @@ -1,15 +1,19 @@ -''' +""" # Class: AppStorageManager # Abstract base class for application storage backends. # This module defines the interface for managing application storage, -# ''' +#""" + from abc import ABC, abstractmethod from typing import Dict, List, Optional, Union + from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo -from sunrise6g_opensdk.edgecloud.core.gsma_schemas import (ApplicationModel, - AppInstance, - AppInstanceStatus, - Artefact) +from sunrise6g_opensdk.edgecloud.core.gsma_schemas import ( + AppInstance, + AppInstanceStatus, + ApplicationModel, + Artefact, +) class AppStorageManager(ABC): @@ -59,16 +63,16 @@ class AppStorageManager(ABC): pass @abstractmethod - def get_deployments(self, - app_id: Optional[str] = None) -> Dict[str, List[str]]: + def get_deployments(self, app_id: Optional[str] = None) -> Dict[str, List[str]]: pass @abstractmethod def find_deployments( - self, - app_id: Optional[str] = None, - app_instance_id: Optional[str] = None, - region: Optional[str] = None) -> List[AppInstanceInfo]: + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> List[AppInstanceInfo]: pass @abstractmethod @@ -77,14 +81,13 @@ class AppStorageManager(ABC): pass @abstractmethod - def store_stopped_instance(self, app_id: str, - app_instance_id: str) -> None: + def store_stopped_instance(self, app_id: str, app_instance_id: str) -> None: pass @abstractmethod def get_stopped_instances( - self, - app_id: Optional[str] = None) -> List[str] | Dict[str, List[str]]: + self, app_id: Optional[str] = None + ) -> List[str] | Dict[str, List[str]]: pass @abstractmethod @@ -96,34 +99,38 @@ class AppStorageManager(ABC): # ------------------------------------------------------------------------ @abstractmethod def store_app_gsma(self, app_id: str, model: ApplicationModel) -> None: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def get_app_gsma(self, app_id: str) -> Optional[ApplicationModel]: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def list_apps_gsma(self) -> List[ApplicationModel]: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def delete_app_gsma(self, app_id: str) -> None: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def store_deployment_gsma( - self, - app_id: str, - inst: AppInstance, - status: Optional[AppInstanceStatus] = None, # optional future use + self, + app_id: str, + inst: AppInstance, + status: Optional[AppInstanceStatus] = None, # optional future use ) -> None: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod - def get_deployments_gsma(self, - app_id: Optional[str] = None - ) -> Dict[str, List[str]]: - ... + def get_deployments_gsma(self, app_id: Optional[str] = None) -> Dict[str, List[str]]: + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def find_deployments_gsma( @@ -132,41 +139,48 @@ class AppStorageManager(ABC): app_instance_id: Optional[str] = None, zone_id: Optional[str] = None, ) -> List[AppInstance]: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def remove_deployment_gsma(self, app_instance_id: str) -> Optional[str]: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod - def store_stopped_instance_gsma(self, app_id: str, - app_instance_id: str) -> None: - ... + def store_stopped_instance_gsma(self, app_id: str, app_instance_id: str) -> None: + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def get_stopped_instances_gsma( - self, - app_id: Optional[str] = None + self, app_id: Optional[str] = None ) -> Union[List[str], Dict[str, List[str]]]: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def remove_stopped_instances_gsma(self, app_id: str) -> None: - ... + """Implement in subclass.""" + raise NotImplementedError # --- GSMA Artefacts --- @abstractmethod def store_artefact_gsma(self, artefact: Artefact) -> None: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def get_artefact_gsma(self, artefact_id: str) -> Optional[Artefact]: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def list_artefacts_gsma(self) -> List[Artefact]: - ... + """Implement in subclass.""" + raise NotImplementedError @abstractmethod def delete_artefact_gsma(self, artefact_id: str) -> None: - ... + """Implement in subclass.""" + raise NotImplementedError diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py index a2e27ef..df36810 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py @@ -8,19 +8,23 @@ from abc import ABCMeta from threading import RLock from typing import Dict, List, Optional, Union +from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( - AppStorageManager) + AppStorageManager, +) from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo -from sunrise6g_opensdk.edgecloud.core.gsma_schemas import (ApplicationModel, - AppInstance, - AppInstanceStatus, - Artefact) -from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.edgecloud.core.gsma_schemas import ( + AppInstance, + AppInstanceStatus, + ApplicationModel, + Artefact, +) from sunrise6g_opensdk.logger import setup_logger class SingletonMeta(ABCMeta): """Thread-safe Singleton metaclass (process-wide).""" + _instances: Dict[type, object] = {} _lock = RLock() @@ -51,26 +55,19 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): self._lock = RLock() # aerOS Domain → Zone mapping - self._zones: Dict[str, - Dict] = {} # {aeros_domain_id: camara_zone_dict} + self._zones: Dict[str, Dict] = {} # {aeros_domain_id: camara_zone_dict} # CAMARA stores self._apps: Dict[str, Dict] = {} # app_id -> manifest (CAMARA dict) - self._deployed: Dict[str, List[AppInstanceInfo]] = { - } # app_id -> [AppInstanceInfo] - self._stopped: Dict[str, - List[str]] = {} # app_id -> [stopped instance ids] + self._deployed: Dict[str, List[AppInstanceInfo]] = {} # app_id -> [AppInstanceInfo] + self._stopped: Dict[str, List[str]] = {} # app_id -> [stopped instance ids] # GSMA stores - self._apps_gsma: Dict[str, ApplicationModel] = { - } # app_id -> ApplicationModel - self._deployed_gsma: Dict[str, List[AppInstance]] = { - } # app_id -> [AppInstance] - self._stopped_gsma: Dict[str, List[str]] = { - } # app_id -> [stopped instance ids] + self._apps_gsma: Dict[str, ApplicationModel] = {} # app_id -> ApplicationModel + self._deployed_gsma: Dict[str, List[AppInstance]] = {} # app_id -> [AppInstance] + self._stopped_gsma: Dict[str, List[str]] = {} # app_id -> [stopped instance ids] - self._artefacts_gsma: Dict[str, - Artefact] = {} # artefact_id -> Artefact + self._artefacts_gsma: Dict[str, Artefact] = {} # artefact_id -> Artefact self._initialized = True @@ -157,18 +154,13 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): aid = getattr(app_instance.appId, "root", str(app_instance.appId)) self._deployed.setdefault(aid, []).append(app_instance) - def get_deployments(self, - app_id: Optional[str] = None) -> Dict[str, List[str]]: + def get_deployments(self, app_id: Optional[str] = None) -> Dict[str, List[str]]: with self._lock: if app_id: - ids = [ - str(i.appInstanceId) - for i in self._deployed.get(app_id, []) - ] + ids = [str(i.appInstanceId) for i in self._deployed.get(app_id, [])] return {app_id: ids} return { - aid: [str(i.appInstanceId) for i in insts] - for aid, insts in self._deployed.items() + aid: [str(i.appInstanceId) for i in insts] for aid, insts in self._deployed.items() } def find_deployments( @@ -185,8 +177,7 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): if str(inst.appInstanceId.root) == app_instance_id: if app_id and str(inst.appId) != app_id: return [] - if region is not None and getattr( - inst, "region", None) != region: + if region is not None and getattr(inst, "region", None) != region: return [] return [inst] return [] @@ -196,8 +187,7 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): if app_id and aid != app_id: continue for inst in insts: - if region is not None and getattr(inst, "region", - None) != region: + if region is not None and getattr(inst, "region", None) != region: continue results.append(inst) return results @@ -207,8 +197,7 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): for aid, insts in list(self._deployed.items()): for idx, inst in enumerate(insts): # Compare using the instance id string - inst_id = getattr(inst.appInstanceId, "root", - str(inst.appInstanceId)) + inst_id = getattr(inst.appInstanceId, "root", str(inst.appInstanceId)) if inst_id == app_instance_id: insts.pop(idx) if not insts: @@ -218,16 +207,14 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): return aid_str return None - def store_stopped_instance(self, app_id: str, - app_instance_id: str) -> None: + def store_stopped_instance(self, app_id: str, app_instance_id: str) -> None: with self._lock: lst = self._stopped.setdefault(app_id, []) if app_instance_id not in lst: lst.append(app_instance_id) def get_stopped_instances( - self, - app_id: Optional[str] = None + self, app_id: Optional[str] = None ) -> Union[List[str], Dict[str, List[str]]]: with self._lock: if app_id: @@ -258,25 +245,20 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): self._apps_gsma.pop(app_id, None) def store_deployment_gsma( - self, - app_id: str, - inst: AppInstance, - status: Optional[AppInstanceStatus] = None, # not persisted yet + self, + app_id: str, + inst: AppInstance, + status: Optional[AppInstanceStatus] = None, # not persisted yet ) -> None: with self._lock: self._deployed_gsma.setdefault(app_id, []).append(inst) # If you later want to persist status per instance, keep a side map: # self._status_gsma[inst.appInstIdentifier] = status - def get_deployments_gsma(self, - app_id: Optional[str] = None - ) -> Dict[str, List[str]]: + def get_deployments_gsma(self, app_id: Optional[str] = None) -> Dict[str, List[str]]: with self._lock: if app_id: - ids = [ - str(i.appInstIdentifier) - for i in self._deployed_gsma.get(app_id, []) - ] + ids = [str(i.appInstIdentifier) for i in self._deployed_gsma.get(app_id, [])] return {app_id: ids} return { aid: [str(i.appInstIdentifier) for i in insts] @@ -291,8 +273,9 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): ) -> List[AppInstance]: with self._lock: # Limit the search space if app_id is provided - iter_lists = ([self._deployed_gsma.get(app_id, [])] - if app_id else self._deployed_gsma.values()) + iter_lists = ( + [self._deployed_gsma.get(app_id, [])] if app_id else self._deployed_gsma.values() + ) # Fast path: instance id provided if app_instance_id: @@ -326,16 +309,14 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): return aid return None - def store_stopped_instance_gsma(self, app_id: str, - app_instance_id: str) -> None: + def store_stopped_instance_gsma(self, app_id: str, app_instance_id: str) -> None: with self._lock: lst = self._stopped_gsma.setdefault(app_id, []) if app_instance_id not in lst: lst.append(app_instance_id) def get_stopped_instances_gsma( - self, - app_id: Optional[str] = None + self, app_id: Optional[str] = None ) -> Union[List[str], Dict[str, List[str]]]: with self._lock: if app_id: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py index 9fcb360..ef1bf9b 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py @@ -1,15 +1,25 @@ -''' +""" SQLite storage implementation -''' -import sqlite3 +""" + import json +import sqlite3 from functools import wraps from typing import Dict, List, Optional, Union -from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo, AppInstanceName,\ - AppProvider, AppId, AppInstanceId, EdgeCloudZoneId, Status -from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager\ - import AppStorageManager + from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( + AppStorageManager, +) +from sunrise6g_opensdk.edgecloud.core.camara_schemas import ( + AppId, + AppInstanceId, + AppInstanceInfo, + AppInstanceName, + AppProvider, + EdgeCloudZoneId, + Status, +) from sunrise6g_opensdk.logger import setup_logger decorator_logger = setup_logger() @@ -34,9 +44,9 @@ def debug_log(msg: str): class SQLiteAppStorage(AppStorageManager): - ''' + """ SQLite storage implementation - ''' + """ @debug_log("Initializing SQLITE storage manager") def __init__(self, db_path: str = "app_storage.db"): @@ -50,13 +60,16 @@ class SQLiteAppStorage(AppStorageManager): if config.DEBUG: self.logger.info("Initializing db schema") cursor = self.conn.cursor() - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS apps ( app_id TEXT PRIMARY KEY, manifest TEXT ); - """) - cursor.execute(""" + """ + ) + cursor.execute( + """ CREATE TABLE IF NOT EXISTS deployments ( app_instance_id TEXT PRIMARY KEY, app_id TEXT, @@ -67,32 +80,34 @@ class SQLiteAppStorage(AppStorageManager): kubernetes_cluster_ref TEXT, edge_cloud_zone_id TEXT ); - """) - cursor.execute(""" + """ + ) + cursor.execute( + """ CREATE TABLE IF NOT EXISTS stopped ( app_id TEXT, app_instance_id TEXT ); - """) + """ + ) self.conn.commit() @debug_log("In SQLITE store_app method ") def store_app(self, app_id: str, manifest: Dict) -> None: self.conn.execute( "INSERT OR REPLACE INTO apps (app_id, manifest) VALUES (?, ?);", - (app_id, json.dumps(manifest))) + (app_id, json.dumps(manifest)), + ) self.conn.commit() @debug_log("In SQLITE get_app method ") def get_app(self, app_id: str) -> Optional[Dict]: - row = self.conn.execute("SELECT manifest FROM apps WHERE app_id = ?;", - (app_id, )).fetchone() + row = self.conn.execute("SELECT manifest FROM apps WHERE app_id = ?;", (app_id,)).fetchone() return json.loads(row[0]) if row else None @debug_log("In SQLITE app_exists method ") def app_exists(self, app_id: str) -> bool: - row = self.conn.execute("SELECT 1 FROM apps WHERE app_id = ?;", - (app_id, )).fetchone() + row = self.conn.execute("SELECT 1 FROM apps WHERE app_id = ?;", (app_id,)).fetchone() return row is not None @debug_log("In SQLITE list_apps method ") @@ -102,14 +117,16 @@ class SQLiteAppStorage(AppStorageManager): @debug_log("In SQLITE delete_app method ") def delete_app(self, app_id: str) -> None: - self.conn.execute("DELETE FROM apps WHERE app_id = ?;", (app_id, )) + self.conn.execute("DELETE FROM apps WHERE app_id = ?;", (app_id,)) self.conn.commit() @debug_log("In SQLITE store_deployment method ") def store_deployment(self, app_instance: AppInstanceInfo) -> None: - resolved_status = (str(app_instance.status.value) if hasattr( - app_instance.status, "value") else str(app_instance.status) - if app_instance.status else "unknown") + resolved_status = ( + str(app_instance.status.value) + if hasattr(app_instance.status, "value") + else str(app_instance.status) if app_instance.status else "unknown" + ) self.logger.info("Resolved status for DB insert: %s", resolved_status) self.conn.execute( @@ -124,11 +141,16 @@ class SQLiteAppStorage(AppStorageManager): str(app_instance.appId), str(app_instance.name.root), str(app_instance.appProvider.root), - str(app_instance.status.value) if hasattr( - app_instance.status, "value") else - str(app_instance.status) if app_instance.status else "unknown", - json.dumps(app_instance.componentEndpointInfo) - if app_instance.componentEndpointInfo else None, + ( + str(app_instance.status.value) + if hasattr(app_instance.status, "value") + else str(app_instance.status) if app_instance.status else "unknown" + ), + ( + json.dumps(app_instance.componentEndpointInfo) + if app_instance.componentEndpointInfo + else None + ), app_instance.kubernetesClusterRef, str(app_instance.edgeCloudZoneId.root), ), @@ -137,15 +159,13 @@ class SQLiteAppStorage(AppStorageManager): self.conn.commit() @debug_log("In SQLITE get_deployments method ") - def get_deployments(self, - app_id: Optional[str] = None) -> Dict[str, List[str]]: + def get_deployments(self, app_id: Optional[str] = None) -> Dict[str, List[str]]: if app_id: rows = self.conn.execute( - "SELECT app_id, app_instance_id FROM deployments WHERE app_id = ?;", - (app_id, )).fetchall() + "SELECT app_id, app_instance_id FROM deployments WHERE app_id = ?;", (app_id,) + ).fetchall() else: - rows = self.conn.execute( - "SELECT app_id, app_instance_id FROM deployments;").fetchall() + rows = self.conn.execute("SELECT app_id, app_instance_id FROM deployments;").fetchall() result: Dict[str, List[str]] = {} for app_id_val, instance_id in rows: @@ -179,45 +199,42 @@ class SQLiteAppStorage(AppStorageManager): name=AppInstanceName(row[2]), appProvider=AppProvider(row[3]), status=Status(row[4]) if row[4] else Status.unknown, - componentEndpointInfo=json.loads(row[5]) - if row[5] else None, + componentEndpointInfo=json.loads(row[5]) if row[5] else None, kubernetesClusterRef=row[6], edgeCloudZoneId=EdgeCloudZoneId(row[7]), - )) + ) + ) return result @debug_log("In SQLITE remove_deployments method ") def remove_deployment(self, app_instance_id: str) -> Optional[str]: row = self.conn.execute( - "SELECT app_id FROM deployments WHERE app_instance_id = ?;", - (app_instance_id, )).fetchone() - self.conn.execute("DELETE FROM deployments WHERE app_instance_id = ?;", - (app_instance_id, )) + "SELECT app_id FROM deployments WHERE app_instance_id = ?;", (app_instance_id,) + ).fetchone() + self.conn.execute("DELETE FROM deployments WHERE app_instance_id = ?;", (app_instance_id,)) self.conn.commit() return row[0] if row else None @debug_log("In SQLITE store_stopped_instance method ") - def store_stopped_instance(self, app_id: str, - app_instance_id: str) -> None: + def store_stopped_instance(self, app_id: str, app_instance_id: str) -> None: self.conn.execute( "INSERT INTO stopped (app_id, app_instance_id) VALUES (?, ?);", - (app_id, app_instance_id)) + (app_id, app_instance_id), + ) self.conn.commit() @debug_log("In SQLITE get_Stopped_instances method ") def get_stopped_instances( - self, - app_id: Optional[str] = None + self, app_id: Optional[str] = None ) -> Union[List[str], Dict[str, List[str]]]: if app_id: rows = self.conn.execute( - "SELECT app_instance_id FROM stopped WHERE app_id = ?;", - (app_id, )).fetchall() + "SELECT app_instance_id FROM stopped WHERE app_id = ?;", (app_id,) + ).fetchall() return [r[0] for r in rows] else: - rows = self.conn.execute( - "SELECT app_id, app_instance_id FROM stopped;").fetchall() + rows = self.conn.execute("SELECT app_id, app_instance_id FROM stopped;").fetchall() result: Dict[str, List[str]] = {} for aid, iid in rows: result.setdefault(aid, []).append(iid) @@ -225,5 +242,5 @@ class SQLiteAppStorage(AppStorageManager): @debug_log("In SQLITE remove_stopped_instances method ") def remove_stopped_instances(self, app_id: str) -> None: - self.conn.execute("DELETE FROM stopped WHERE app_id = ?;", (app_id, )) + self.conn.execute("DELETE FROM stopped WHERE app_id = ?;", (app_id,)) self.conn.commit() diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py index 4d9b621..281e354 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py @@ -8,8 +8,9 @@ """ aerOS help methods """ -import uuid import string +import uuid + from requests.exceptions import HTTPError, RequestException, Timeout import sunrise6g_opensdk.edgecloud.adapters.aeros.config as config @@ -18,8 +19,8 @@ from sunrise6g_opensdk.logger import setup_logger _HEX = "0123456789abcdef" _ALLOWED = set( - string.ascii_letters + - string.digits) # no underscore here; underscore is always escaped + string.ascii_letters + string.digits +) # no underscore here; underscore is always escaped _PREFIX = "A0_" # ensures name starts with a letter; stripped during decode @@ -60,7 +61,7 @@ def decode_app_instance_name(encoded: str) -> str: """ s = encoded if s.startswith(_PREFIX): - s = s[len(_PREFIX):] + s = s[len(_PREFIX) :] # walk and decode _hh sequences; underscores never appear unescaped in the encoding i = 0 @@ -139,11 +140,9 @@ def catch_requests_exceptions(func): elif status_code == 400: raise errors.InvalidArgumentError("Bad request") from e elif status_code == 503: - raise errors.ServiceUnavailableError( - "Service unavailable") from e + raise errors.ServiceUnavailableError("Service unavailable") from e - raise errors.EdgeCloudPlatformError( - f"Unhandled HTTP error: {status_code}") from e + raise errors.EdgeCloudPlatformError(f"Unhandled HTTP error: {status_code}") from e except Timeout as e: logger.warning("Timeout occurred: %s", e) @@ -173,7 +172,6 @@ def catch_requests_exceptions(func): logger.error("Request Headers: %s", e.request.headers) logger.error("Request Body: %s", e.request.body) - raise errors.EdgeCloudPlatformError( - "Unhandled request error") from e + raise errors.EdgeCloudPlatformError("Unhandled request error") from e return wrapper diff --git a/tests/edgecloud/test_config_gsma.py b/tests/edgecloud/test_config_gsma.py index e59daaa..14a4621 100644 --- a/tests/edgecloud/test_config_gsma.py +++ b/tests/edgecloud/test_config_gsma.py @@ -2,10 +2,8 @@ CONFIG = { "i2edge": { "ZONE_ID": "f0662bfe-1d90-5f59-a759-c755b3b69b93", "APP_ONBOARD_MANIFEST_GSMA": { - "appId": - "demo-app-id", - "appProviderId": - "Y89TSlxMPDKlXZz7rN6vU2y", + "appId": "demo-app-id", + "appProviderId": "Y89TSlxMPDKlXZz7rN6vU2y", "appDeploymentZones": [ "Dmgoc-y2zv97lar0UKqQd53aS6MCTTdoGMY193yvRBYgI07zOAIktN2b9QB2THbl5Gqvbj5Zp92vmNeg7v4M" ], @@ -34,20 +32,6 @@ CONFIG = { ], "appStatusCallbackLink": "string", "edgeAppFQDN": "string", - "appComponentSpecs": [{ - "serviceNameNB": - "k8yyElSyJN4ctbNVqwodEQNUoGb2EzOEt4vQBjGnPii_5", - "serviceNameEW": - "iDm08OZN", - "componentName": - "HIEWqstajCmZJQmSFUj0kNHZ0xYvKWq720BKt8wjA41p", - "artefactId": - "9c9143f0-f44f-49df-939e-1e8b891ba8f5", - }], - "appStatusCallbackLink": - "string", - "edgeAppFQDN": - "string", }, "APP_DEPLOY_PAYLOAD_GSMA": { "appId": "demo-app-id", @@ -72,25 +56,20 @@ CONFIG = { }, "appComponentSpecs": [ { - "serviceNameNB": - "7CI_9d4lAK90vU4ASUkKxYdQjsv3y3IuwucISSQ6lG5_EMqeyVUHPIhwa5", - "serviceNameEW": - "tPihoUFj30938Bu9blpsHkvsec1iA7gqZZRMpsx6o7aSSj5", + "serviceNameNB": "7CI_9d4lAK90vU4ASUkKxYdQjsv3y3IuwucISSQ6lG5_EMqeyVUHPIhwa5", + "serviceNameEW": "tPihoUFj30938Bu9blpsHkvsec1iA7gqZZRMpsx6o7aSSj5", "componentName": "YCAhqPadfld8y68wJfTc6QNGguI41z", "artefactId": "i2edgechart", }, { "serviceNameNB": "JCjR0Lc3J0sm2PcItECdbHXtpCLQCfq3B", - "serviceNameEW": - "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", + "serviceNameEW": "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", "componentName": "9aCfCEDe2Dv0Peg", "artefactId": "i2edgechart", }, { - "serviceNameNB": - "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", - "serviceNameEW": - "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", + "serviceNameNB": "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", + "serviceNameEW": "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", "componentName": "3kTa4zKEX", "artefactId": "i2edgechart", }, @@ -187,10 +166,8 @@ CONFIG = { "REPO_TYPE": "PUBLICREPO", "REPO_URL": "docker.io/library/nginx:stable", "APP_ONBOARD_MANIFEST_GSMA": { - "appId": - "aeros-sdk-app", - "appProviderId": - "aeros-sdk-provider", + "appId": "aeros-sdk-app", + "appProviderId": "aeros-sdk-provider", "appDeploymentZones": ["urn:ngsi-ld:Domain:ncsrd01"], "appMetaData": { "appName": "aeros_SDK_app", @@ -207,20 +184,16 @@ CONFIG = { "noOfUsersPerAppInst": 1, "appProvisioning": True, }, - "appComponentSpecs": [{ - "serviceNameNB": - "gsma-deployed-app-service-nb", - "serviceNameEW": - "gsma-deployed-app-service-ew", - "componentName": - "nginx-component", - "artefactId": - "artefact-nginx-001", - }], - "appStatusCallbackLink": - "string", - "edgeAppFQDN": - "string", + "appComponentSpecs": [ + { + "serviceNameNB": "gsma-deployed-app-service-nb", + "serviceNameEW": "gsma-deployed-app-service-ew", + "componentName": "nginx-component", + "artefactId": "artefact-nginx-001", + } + ], + "appStatusCallbackLink": "string", + "edgeAppFQDN": "string", }, "APP_DEPLOY_PAYLOAD_GSMA": { "appId": "aeros-sdk-app", @@ -245,93 +218,60 @@ CONFIG = { }, "appComponentSpecs": [ { - "serviceNameNB": - "gsma-deployed-app-service-nb", - "serviceNameEW": - "gsma-deployed-app-service-ew", + "serviceNameNB": "gsma-deployed-app-service-nb", + "serviceNameEW": "gsma-deployed-app-service-ew", "componentName": "nginx-component", "artefactId": "artefact-nginx-001", }, { "serviceNameNB": "JCjR0Lc3J0sm2PcItECdbHXtpCLQCfq3B", - "serviceNameEW": - "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", + "serviceNameEW": "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", "componentName": "9aCfCEDe2Dv0Peg", "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", }, { - "serviceNameNB": - "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", - "serviceNameEW": - "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", + "serviceNameNB": "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", + "serviceNameEW": "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", "componentName": "3kTa4zKEX", "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", }, ], }, "ARTEFACT_PAYLOAD_GSMA": { - "artefactId": - "artefact-nginx-001", - "appProviderId": - "ncsrd-provider", - "artefactName": - "nginx-web-server", - "artefactVersionInfo": - "1.0.0", - "artefactDescription": - "Containerized Nginx Web Server", - "artefactVirtType": - "CONTAINER_TYPE", - "artefactFileName": - "nginx-web-server-1.0.0.tgz", - "artefactFileFormat": - "TARGZ", - "artefactDescriptorType": - "COMPONENTSPEC", - "repoType": - "PUBLICREPO", + "artefactId": "artefact-nginx-001", + "appProviderId": "ncsrd-provider", + "artefactName": "nginx-web-server", + "artefactVersionInfo": "1.0.0", + "artefactDescription": "Containerized Nginx Web Server", + "artefactVirtType": "CONTAINER_TYPE", + "artefactFileName": "nginx-web-server-1.0.0.tgz", + "artefactFileFormat": "TARGZ", + "artefactDescriptorType": "COMPONENTSPEC", + "repoType": "PUBLICREPO", "artefactRepoLocation": { "repoURL": "docker.io/library/nginx:stable", "userName": "", "password": "", - "token": "" + "token": "", }, - "artefactFile": - "", - "componentSpec": [{ - "componentName": - "nginx-component", - "images": ["docker.io/library/nginx:stable"], - "numOfInstances": - 1, - "restartPolicy": - "Always", - "commandLineParams": { - }, - "exposedInterfaces": [{ - "name": "http-api", - "protocol": "TCP", - "port": 8080 - }], - "computeResourceProfile": { - "cpu": "2", - "memory": "4Gi" - }, - "compEnvParams": [{ - "name": "TEST_ENV", - "value": "TEST_VALUE_ENV" - }], - "deploymentConfig": { - "replicaStrategy": "RollingUpdate", - "maxUnavailable": 1 - }, - "persistentVolumes": [{ - "name": "NOT_USE", - "mountPath": "NOT_USED", - "size": "NOT_USED" - }] - }] - } + "artefactFile": "", + "componentSpec": [ + { + "componentName": "nginx-component", + "images": ["docker.io/library/nginx:stable"], + "numOfInstances": 1, + "restartPolicy": "Always", + "commandLineParams": {}, + "exposedInterfaces": [{"name": "http-api", "protocol": "TCP", "port": 8080}], + "computeResourceProfile": {"cpu": "2", "memory": "4Gi"}, + "compEnvParams": [{"name": "TEST_ENV", "value": "TEST_VALUE_ENV"}], + "deploymentConfig": {"replicaStrategy": "RollingUpdate", "maxUnavailable": 1}, + "persistentVolumes": [ + {"name": "NOT_USE", "mountPath": "NOT_USED", "size": "NOT_USED"} + ], + } + ], + }, }, "kubernetes": { # PLACEHOLDER diff --git a/tests/edgecloud/test_e2e_gsma.py b/tests/edgecloud/test_e2e_gsma.py index 27e213a..903fe21 100644 --- a/tests/edgecloud/test_e2e_gsma.py +++ b/tests/edgecloud/test_e2e_gsma.py @@ -28,13 +28,13 @@ import pytest from requests import Response from sunrise6g_opensdk.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.edgecloud.adapters.aeros.client import ( + EdgeApplicationManager as aerosClient, +) from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( EdgeApplicationManager as I2EdgeClient, ) -from sunrise6g_opensdk.edgecloud.adapters.aeros.client import ( - EdgeApplicationManager as aerosClient, -) from sunrise6g_opensdk.edgecloud.core import gsma_schemas from tests.edgecloud.test_cases import test_cases from tests.edgecloud.test_config_gsma import CONFIG @@ -80,7 +80,7 @@ def test_config_gsma_compliance(edgecloud_client): if "PATCH_ONBOARDED_APP_GSMA" in config: patch_payload = config["PATCH_ONBOARDED_APP_GSMA"] gsma_schemas.PatchOnboardedAppGSMA(**patch_payload) - + # Validate ARTEFACT creation payload is GSMA-compliant if "ARTEFACT_PAYLOAD_GSMA" in config: artefact_payload = config["ARTEFACT_PAYLOAD_GSMA"] @@ -194,8 +194,6 @@ def test_artefact_create_gsma(edgecloud_client): pytest.fail(f"Artefact creation failed: {e}") - - @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_artefact_gsma(edgecloud_client): config = CONFIG[edgecloud_client.client_name] @@ -355,7 +353,6 @@ def test_timer_wait_10_seconds_2(edgecloud_client): time.sleep(10) - @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_patch_onboarded_app_gsma(edgecloud_client): config = CONFIG[edgecloud_client.client_name] -- GitLab