From cb9452d874dbef57ac29fc2b128b35e11dec9b5d Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 10:19:47 +0200 Subject: [PATCH 1/5] Add SRM FM internal endpoints --- .../src/controllers/fm_internal_controller.py | 372 ++++++++++++++++++ .../src/swagger/swagger.yaml | 187 +++++++++ 2 files changed, 559 insertions(+) create mode 100644 service-resource-manager-implementation/src/controllers/fm_internal_controller.py diff --git a/service-resource-manager-implementation/src/controllers/fm_internal_controller.py b/service-resource-manager-implementation/src/controllers/fm_internal_controller.py new file mode 100644 index 0000000..487fac5 --- /dev/null +++ b/service-resource-manager-implementation/src/controllers/fm_internal_controller.py @@ -0,0 +1,372 @@ +import connexion +import logging + +from requests import Response as RequestsResponse + +from src.controllers.edge_cloud_management_controller import edgecloud_adapter + + +logger = logging.getLogger(__name__) + + +def _normalize_adapter_response(response): + if isinstance(response, RequestsResponse): + try: + data = response.json() + except ValueError: + data = response.text + return data, response.status_code + return response + + +def _call_first_available(target, *method_names): + for method_name in method_names: + method = getattr(target, method_name, None) + if method: + return method + raise AttributeError(f"None of these methods exist on {type(target).__name__}: {method_names}") + + +def _repo_type_to_package_type(repo_data): + descriptor_type = (repo_data.get("artefactDescriptorType") or "").upper() + virt_type = (repo_data.get("artefactVirtType") or "").upper() + if descriptor_type == "HELM": + return "HELM" + if virt_type == "VM_TYPE": + return "VM" + return "CONTAINER" + + +def _repo_credentials(repo_location): + if not isinstance(repo_location, dict): + return {} + return { + "repoURL": repo_location.get("repoURL"), + "userName": repo_location.get("userName") or "", + "password": repo_location.get("password") or "", + "token": repo_location.get("token") or "", + } + + +def _build_app_repo_from_artefact(artefact_data): + repo_location = _repo_credentials(artefact_data.get("artefactRepoLocation")) + repo_url = repo_location.get("repoURL") + image_path = repo_url + + if repo_url and artefact_data.get("artefactName") and artefact_data.get("artefactVersionInfo"): + version = artefact_data.get("artefactVersionInfo") + if repo_url.endswith("/"): + image_path = f"{repo_url}{artefact_data.get('artefactName')}:{version}" + elif "/" in repo_url and not repo_url.endswith(f":{version}"): + image_path = f"{repo_url.rstrip('/')}/{artefact_data.get('artefactName')}:{version}" + + return { + "imagePath": image_path, + "repoURL": repo_url, + "userName": repo_location.get("userName"), + "credentials": repo_location.get("password") or repo_location.get("token"), + } + + +def _normalize_component_specs(component_specs): + normalized_specs = [] + for component in component_specs or []: + normalized_component = dict(component) + network_interfaces = [] + for interface in component.get("exposedInterfaces") or []: + port = interface.get("commPort") + if port is None: + continue + network_interfaces.append( + { + "port": port, + "protocol": (interface.get("commProtocol") or "TCP").upper(), + "visibilityType": interface.get("visibilityType") or "VISIBILITY_EXTERNAL", + } + ) + if network_interfaces: + normalized_component["networkInterfaces"] = network_interfaces + normalized_specs.append(normalized_component) + return normalized_specs + + +def _build_manifest_from_gsma_onboarding(request_data): + component_specs = _normalize_component_specs(request_data.get("appComponentSpecs") or []) + artefact_id = None + if component_specs: + artefact_id = component_specs[0].get("artefactId") + + artefact_data = None + if artefact_id: + get_artefact_method = _call_first_available( + edgecloud_adapter, + "get_artefact", + "get_artefact_gsma", + ) + artefact_response = _normalize_adapter_response(get_artefact_method(artefact_id)) + if isinstance(artefact_response, tuple): + artefact_data, status = artefact_response + if status != 200: + return artefact_data, status + + app_meta_data = request_data.get("appMetaData") or {} + manifest = { + "appId": request_data.get("appId"), + "name": app_meta_data.get("appName") or request_data.get("appId"), + "appName": app_meta_data.get("appName") or request_data.get("appId"), + "version": app_meta_data.get("version") or "1.0.0", + "appDescription": app_meta_data.get("appDescription") or "", + "appProvider": request_data.get("appProviderId"), + "packageType": _repo_type_to_package_type(artefact_data or {}), + "appComponentSpecs": component_specs, + "appDeploymentZones": [ + {"zoneId": zone_id} for zone_id in request_data.get("appDeploymentZones") or [] + ], + } + + if artefact_data: + manifest["appRepo"] = _build_app_repo_from_artefact(artefact_data) + + return manifest, 200 + + +def _normalize_onboarding_response(response): + normalized = _normalize_adapter_response(response) + if isinstance(normalized, tuple): + data, status = normalized + if status == 201: + return data, 200 + return data, status + return normalized + + +def _normalize_delete_response(response): + normalized = _normalize_adapter_response(response) + if isinstance(normalized, tuple): + data, status = normalized + if status == 204: + return "deleted", 200 + return data, status + return normalized + + +def _build_deploy_request_from_gsma(request_data): + zone_info = request_data.get("zoneInfo") or {} + zone_id = zone_info.get("zoneId") + app_zones = [] + if zone_id: + app_zones.append({"zoneId": zone_id}) + return request_data.get("appId"), app_zones + + +def _build_zone_summary(zone): + zone_id = zone.get("zoneId") or zone.get("edgeCloudZoneId") + return { + "zoneId": zone_id, + "edgeCloudRegion": zone.get("edgeCloudRegion", "unknown"), + } + + +def _build_zone_details(zone_id, zone): + return { + "zoneId": zone_id, + "edgeCloudRegion": zone.get("edgeCloudRegion", "unknown"), + "reservedComputeResources": [], + "computeResourceQuotaLimits": [], + "flavoursSupported": [ + { + "flavourId": zone_id, + "numCPU": 0, + "memorySize": 0, + "storageSize": 0, + "supportedOSTypes": ["OTHER"], + "cpuArchType": "OTHER", + "gpu": [], + "fpga": 0, + "vpu": 0, + "hugepages": [], + "cpuExclusivity": False, + } + ], + "networkResources": {}, + "zoneServiceLevelObjsInfo": {}, + } + + +def _extract_onboarded_app(response): + normalized = _normalize_adapter_response(response) + if isinstance(normalized, tuple): + data, status = normalized + if isinstance(data, dict) and "appManifest" in data: + return data["appManifest"], status + return data, status + return normalized + + +def _extract_deployed_app(response): + normalized = _normalize_adapter_response(response) + if isinstance(normalized, tuple): + data, status = normalized + if isinstance(data, dict) and "appInstance" in data: + return data["appInstance"], status + return data, status + return normalized + + +def get_zones_list(): + response = edgecloud_adapter.get_edge_cloud_zones() + normalized = _normalize_adapter_response(response) + if isinstance(normalized, tuple): + data, status = normalized + if isinstance(data, list): + data = [_build_zone_summary(zone) for zone in data] + return data, status + return normalized + + +def get_zones(): + response = edgecloud_adapter.get_edge_cloud_zones() + normalized = _normalize_adapter_response(response) + if isinstance(normalized, tuple): + data, status = normalized + if isinstance(data, list): + data = [ + _build_zone_details(zone.get("zoneId") or zone.get("edgeCloudZoneId"), zone) + for zone in data + ] + return data, status + return normalized + + +def get_zone(zone_id: str): + response = edgecloud_adapter.get_edge_cloud_zones() + normalized = _normalize_adapter_response(response) + if isinstance(normalized, tuple): + data, status = normalized + if status != 200: + return data, status + for zone in data: + current_zone_id = zone.get("zoneId") or zone.get("edgeCloudZoneId") + if current_zone_id == zone_id: + return _build_zone_details(zone_id, zone), 200 + return {"error": "Zone not found"}, 404 + return normalized + + +def create_artefact(): + if not connexion.request.is_json: + return {"error": "Wrong request schema"}, 400 + create_artefact_method = _call_first_available( + edgecloud_adapter, + "create_artefact", + "create_artefact_gsma", + ) + response = create_artefact_method(connexion.request.get_json()) + return _normalize_adapter_response(response) + + +def delete_artefact(artefact_id: str): + delete_artefact_method = _call_first_available( + edgecloud_adapter, + "delete_artefact", + "delete_artefact_gsma", + ) + response = delete_artefact_method(artefact_id) + return _normalize_delete_response(response) + + +def create_onboarding(): + if not connexion.request.is_json: + return {"error": "Wrong request schema"}, 400 + manifest, status = _build_manifest_from_gsma_onboarding(connexion.request.get_json()) + if status != 200: + return manifest, status + response = edgecloud_adapter.onboard_app(manifest) + return _normalize_onboarding_response(response) + + +def get_onboarding(app_id: str): + response = edgecloud_adapter.get_onboarded_app(app_id) + return _extract_onboarded_app(response) + + +def update_onboarding(app_id: str): + if not connexion.request.is_json: + return {"error": "Wrong request schema"}, 400 + request_data = connexion.request.get_json() + response = edgecloud_adapter.get_onboarded_app(app_id) + normalized = _extract_onboarded_app(response) + if isinstance(normalized, tuple): + data, status = normalized + if status != 200: + return data, status + if isinstance(data, dict): + if request_data.get("appUpdQoSProfile"): + data["appUpdQoSProfile"] = request_data.get("appUpdQoSProfile") + if request_data.get("appComponentSpecs"): + data["appComponentSpecs"] = _normalize_component_specs(request_data.get("appComponentSpecs")) + patch_method = _call_first_available( + edgecloud_adapter, + "patch_onboarded_app", + "patch_onboarded_app_gsma", + ) + patch_response = patch_method(app_id, data) + normalized_patch = _normalize_adapter_response(patch_response) + if isinstance(normalized_patch, tuple): + patch_data, patch_status = normalized_patch + return patch_data, patch_status + return normalized_patch + return normalized + + +def delete_onboarding(app_id: str): + response = edgecloud_adapter.delete_onboarded_app(app_id) + return _normalize_delete_response(response) + + +def deploy_app(): + if not connexion.request.is_json: + return {"error": "Wrong request schema"}, 400 + request_data = connexion.request.get_json() + app_id, app_zones = _build_deploy_request_from_gsma(request_data) + response = edgecloud_adapter.deploy_app(app_id, app_zones) + normalized = _extract_deployed_app(response) + if isinstance(normalized, tuple): + data, status = normalized + if status >= 400: + return data, status + if isinstance(data, dict): + return { + "appInstIdentifier": data.get("appInstanceId"), + "zoneID": data.get("edgeCloudZoneId"), + }, status + return normalized + + +def get_deployed_app(app_id: str, app_instance_id: str, zone_id: str): + response = edgecloud_adapter.get_deployed_app(app_instance_id) + normalized = _extract_deployed_app(response) + if isinstance(normalized, tuple): + data, status = normalized + if status != 200: + return data, status + if data.get("appId") != app_id or data.get("edgeCloudZoneId") != zone_id: + return {"error": "App instance not found"}, 404 + return { + "appInstanceState": data.get("appInstanceState"), + "accesspointInfo": data.get("accesspointInfo", []), + }, status + return normalized + + +def undeploy_app(app_id: str, app_instance_id: str, zone_id: str): + response = edgecloud_adapter.get_deployed_app(app_instance_id) + normalized = _extract_deployed_app(response) + if isinstance(normalized, tuple): + data, status = normalized + if status != 200: + return data, status + if data.get("appId") != app_id or data.get("edgeCloudZoneId") != zone_id: + return {"error": "App instance not found"}, 404 + response = edgecloud_adapter.undeploy_app(app_instance_id) + return _normalize_delete_response(response) diff --git a/service-resource-manager-implementation/src/swagger/swagger.yaml b/service-resource-manager-implementation/src/swagger/swagger.yaml index a5a7645..66b1c2a 100644 --- a/service-resource-manager-implementation/src/swagger/swagger.yaml +++ b/service-resource-manager-implementation/src/swagger/swagger.yaml @@ -16,6 +16,193 @@ externalDocs: servers: - url: http://vitrualserver:8080/srm/1.0.0 paths: + /internal/fm/zones/list: + get: + tags: + - FM Internal + summary: Returns GSMA zone list for FM + operationId: get_zones_list + responses: + "200": + description: Zone list retrieved + x-openapi-router-controller: src.controllers.fm_internal_controller + /internal/fm/zones: + get: + tags: + - FM Internal + summary: Returns GSMA zone details for FM + operationId: get_zones + responses: + "200": + description: Zone details retrieved + x-openapi-router-controller: src.controllers.fm_internal_controller + /internal/fm/zones/{zone_id}: + get: + tags: + - FM Internal + summary: Returns GSMA zone detail by id for FM + operationId: get_zone + parameters: + - name: zone_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Zone detail retrieved + x-openapi-router-controller: src.controllers.fm_internal_controller + /internal/fm/artefacts: + post: + tags: + - FM Internal + summary: Create artefact through GSMA adapter + operationId: create_artefact + responses: + "200": + description: Artefact created + "201": + description: Artefact created + x-openapi-router-controller: src.controllers.fm_internal_controller + /internal/fm/artefacts/{artefact_id}: + delete: + tags: + - FM Internal + summary: Delete artefact through GSMA adapter + operationId: delete_artefact + parameters: + - name: artefact_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Artefact deleted + "204": + description: Artefact deleted + x-openapi-router-controller: src.controllers.fm_internal_controller + /internal/fm/onboardings: + post: + tags: + - FM Internal + summary: Create onboarding through GSMA adapter + operationId: create_onboarding + responses: + "200": + description: Onboarding created + x-openapi-router-controller: src.controllers.fm_internal_controller + /internal/fm/onboardings/{app_id}: + get: + tags: + - FM Internal + summary: Get onboarding through GSMA adapter + operationId: get_onboarding + parameters: + - name: app_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Onboarding retrieved + x-openapi-router-controller: src.controllers.fm_internal_controller + patch: + tags: + - FM Internal + summary: Update onboarding through GSMA adapter + operationId: update_onboarding + parameters: + - name: app_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Onboarding updated + x-openapi-router-controller: src.controllers.fm_internal_controller + delete: + tags: + - FM Internal + summary: Delete onboarding through GSMA adapter + operationId: delete_onboarding + parameters: + - name: app_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Onboarding deleted + "204": + description: Onboarding deleted + x-openapi-router-controller: src.controllers.fm_internal_controller + /internal/fm/deployments: + post: + tags: + - FM Internal + summary: Deploy app through GSMA adapter + operationId: deploy_app + responses: + "202": + description: Deployment accepted + x-openapi-router-controller: src.controllers.fm_internal_controller + /internal/fm/deployments/{app_id}/instances/{app_instance_id}/zones/{zone_id}: + get: + tags: + - FM Internal + summary: Get deployed app through GSMA adapter + operationId: get_deployed_app + parameters: + - name: app_id + in: path + required: true + schema: + type: string + - name: app_instance_id + in: path + required: true + schema: + type: string + - name: zone_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Deployment retrieved + x-openapi-router-controller: src.controllers.fm_internal_controller + delete: + tags: + - FM Internal + summary: Delete deployed app through GSMA adapter + operationId: undeploy_app + parameters: + - name: app_id + in: path + required: true + schema: + type: string + - name: app_instance_id + in: path + required: true + schema: + type: string + - name: zone_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Deployment deleted + "204": + description: Deployment deleted + x-openapi-router-controller: src.controllers.fm_internal_controller /node/{node_id}: get: tags: -- GitLab From 5f2e8dc26348624e9869ae80ac223b2584c2e3fd Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 10:20:04 +0200 Subject: [PATCH 2/5] Bump SRM SDK for FM integration --- service-resource-manager-implementation/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service-resource-manager-implementation/requirements.txt b/service-resource-manager-implementation/requirements.txt index bd15bd2..e2b52f2 100644 --- a/service-resource-manager-implementation/requirements.txt +++ b/service-resource-manager-implementation/requirements.txt @@ -5,4 +5,4 @@ requests==2.32.4 psycopg2-binary urllib3 pydantic-extra-types==2.10.3 -sunrise6g-opensdk==1.1.1 \ No newline at end of file +sunrise6g-opensdk==2.0.0 -- GitLab From c10752d848ffc7b785cae4c6d34bd7d4ff914dfa Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 12:17:38 +0200 Subject: [PATCH 3/5] fix: rebuild OCI chart references from artefacts --- .../src/controllers/fm_internal_controller.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service-resource-manager-implementation/src/controllers/fm_internal_controller.py b/service-resource-manager-implementation/src/controllers/fm_internal_controller.py index 487fac5..f186e94 100644 --- a/service-resource-manager-implementation/src/controllers/fm_internal_controller.py +++ b/service-resource-manager-implementation/src/controllers/fm_internal_controller.py @@ -59,6 +59,8 @@ def _build_app_repo_from_artefact(artefact_data): image_path = f"{repo_url}{artefact_data.get('artefactName')}:{version}" elif "/" in repo_url and not repo_url.endswith(f":{version}"): image_path = f"{repo_url.rstrip('/')}/{artefact_data.get('artefactName')}:{version}" + else: + image_path = f"{repo_url.rstrip('/')}/{artefact_data.get('artefactName')}:{version}" return { "imagePath": image_path, -- GitLab From fb67c92460fc33e9913447edd91856c9e3faef4f Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Wed, 29 Apr 2026 13:33:43 +0200 Subject: [PATCH 4/5] fix: make network adapter init non-fatal --- .../controllers/network_functions_controller.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/service-resource-manager-implementation/src/controllers/network_functions_controller.py b/service-resource-manager-implementation/src/controllers/network_functions_controller.py index cd95b55..5751c5f 100644 --- a/service-resource-manager-implementation/src/controllers/network_functions_controller.py +++ b/service-resource-manager-implementation/src/controllers/network_functions_controller.py @@ -2,6 +2,7 @@ from os import environ import logging import connexion from sunrise6g_opensdk.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.network.core.common import CoreHttpError from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest logger = logging.getLogger(__name__) @@ -49,6 +50,15 @@ def _safe_http_json_response(response): return {"error": "Invalid response from adapter", "details": str(e)}, 502 +def _map_core_http_error(err: CoreHttpError): + status_code = err.status_code or 502 + if isinstance(err.body, (dict, list)): + return err.body, status_code + if err.body: + return {"error": err.body}, status_code + return {"error": str(err)}, status_code + + def create_qod_session(): if connexion.request.is_json: try: @@ -79,6 +89,9 @@ def get_qod_session(session_id: str): "code": "UNAVAILABLE", "message": "Service unavailable: Network Adapter", }, 503 + except CoreHttpError as ce_: + logger.error(ce_) + return _map_core_http_error(ce_) except Exception as ce_: logger.error(ce_) return {"error": str(ce_)}, 500 @@ -95,6 +108,9 @@ def delete_qod_session(session_id: str): "code": "UNAVAILABLE", "message": "Service unavailable: Network Adapter", }, 503 + except CoreHttpError as ce_: + logger.error(ce_) + return _map_core_http_error(ce_) except Exception as ce_: logger.error(ce_) return {"error": str(ce_)}, 500 -- GitLab From 99bed9fc3554b40b0ce80d156649b4197390bbae Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Tue, 5 May 2026 16:54:44 +0200 Subject: [PATCH 5/5] fix(srm): normalize adapter-backed federation flows --- .../edge_cloud_management_controller.py | 4 +++ .../src/controllers/fm_internal_controller.py | 30 +++++++++++-------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/service-resource-manager-implementation/src/controllers/edge_cloud_management_controller.py b/service-resource-manager-implementation/src/controllers/edge_cloud_management_controller.py index 5b6267e..abff91e 100644 --- a/service-resource-manager-implementation/src/controllers/edge_cloud_management_controller.py +++ b/service-resource-manager-implementation/src/controllers/edge_cloud_management_controller.py @@ -48,6 +48,10 @@ def _safe_http_json_response(response): if isinstance(response, (list, dict)): return response, 200 + # Adapter returned normalized tuple + if isinstance(response, tuple) and len(response) == 2: + return response + # Adapter returned nothing if response is None: return {"error": "Adapter returned no response"}, 502 diff --git a/service-resource-manager-implementation/src/controllers/fm_internal_controller.py b/service-resource-manager-implementation/src/controllers/fm_internal_controller.py index f186e94..4e59fa8 100644 --- a/service-resource-manager-implementation/src/controllers/fm_internal_controller.py +++ b/service-resource-manager-implementation/src/controllers/fm_internal_controller.py @@ -52,15 +52,18 @@ def _build_app_repo_from_artefact(artefact_data): repo_location = _repo_credentials(artefact_data.get("artefactRepoLocation")) repo_url = repo_location.get("repoURL") image_path = repo_url - - if repo_url and artefact_data.get("artefactName") and artefact_data.get("artefactVersionInfo"): - version = artefact_data.get("artefactVersionInfo") - if repo_url.endswith("/"): - image_path = f"{repo_url}{artefact_data.get('artefactName')}:{version}" - elif "/" in repo_url and not repo_url.endswith(f":{version}"): - image_path = f"{repo_url.rstrip('/')}/{artefact_data.get('artefactName')}:{version}" - else: - image_path = f"{repo_url.rstrip('/')}/{artefact_data.get('artefactName')}:{version}" + artefact_name = artefact_data.get("artefactName") + version = artefact_data.get("artefactVersionInfo") + + if repo_url and version: + if repo_url.endswith(f":{version}"): + image_path = repo_url + elif repo_url.endswith(f"/{artefact_name}"): + image_path = f"{repo_url}:{version}" + elif artefact_name: + image_path = f"{repo_url.rstrip('/')}/{artefact_name}:{version}" + elif artefact_name and version: + image_path = f"{artefact_name}:{version}" return { "imagePath": image_path, @@ -102,8 +105,8 @@ def _build_manifest_from_gsma_onboarding(request_data): if artefact_id: get_artefact_method = _call_first_available( edgecloud_adapter, - "get_artefact", "get_artefact_gsma", + "get_artefact", ) artefact_response = _normalize_adapter_response(get_artefact_method(artefact_id)) if isinstance(artefact_response, tuple): @@ -209,6 +212,9 @@ def _extract_deployed_app(response): normalized = _normalize_adapter_response(response) if isinstance(normalized, tuple): data, status = normalized + if isinstance(data, dict) and data.get("edgeCloudZoneId") is None and data.get("zoneId") is not None: + data = dict(data) + data["edgeCloudZoneId"] = data.get("zoneId") if isinstance(data, dict) and "appInstance" in data: return data["appInstance"], status return data, status @@ -260,8 +266,8 @@ def create_artefact(): return {"error": "Wrong request schema"}, 400 create_artefact_method = _call_first_available( edgecloud_adapter, - "create_artefact", "create_artefact_gsma", + "create_artefact", ) response = create_artefact_method(connexion.request.get_json()) return _normalize_adapter_response(response) @@ -355,7 +361,7 @@ def get_deployed_app(app_id: str, app_instance_id: str, zone_id: str): if data.get("appId") != app_id or data.get("edgeCloudZoneId") != zone_id: return {"error": "App instance not found"}, 404 return { - "appInstanceState": data.get("appInstanceState"), + "appInstanceState": data.get("appInstanceState") or data.get("status"), "accesspointInfo": data.get("accesspointInfo", []), }, status return normalized -- GitLab