From d4edce5c1235b5f5f40dcb7c5b25cadb910b038d Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 30 Jan 2026 18:41:59 +0100 Subject: [PATCH 01/10] Improve federation zone sync and zone refresh --- .../configs/env_config.py | 19 ++++----- .../controllers/app_controllers.py | 38 +++++++++++++++--- .../federation_manager_controller.py | 40 ++++++++++++------- .../services/federation_services.py | 27 +++++++++---- .../specification/openapi.yaml | 31 +++++++++++++- 5 files changed, 116 insertions(+), 39 deletions(-) diff --git a/edge_cloud_management_api/configs/env_config.py b/edge_cloud_management_api/configs/env_config.py index b8e709d..fc1a1f8 100644 --- a/edge_cloud_management_api/configs/env_config.py +++ b/edge_cloud_management_api/configs/env_config.py @@ -5,15 +5,16 @@ from dotenv import load_dotenv load_dotenv() -class Configuration(BaseSettings): - MONGO_URI: str = os.getenv("MONGO_URI") - SRM_HOST: str = os.getenv("SRM_HOST") - PI_EDGE_USERNAME: str = os.getenv("PI_EDGE_USERNAME") - PI_EDGE_PASSWORD: str = os.getenv("PI_EDGE_PASSWORD") - HTTP_PROXY: str = os.getenv("HTTP_PROXY") - FEDERATION_MANAGER_HOST=os.getenv("FEDERATION_MANAGER_HOST") - TOKEN_ENDPOINT = os.getenv('TOKEN_ENDPOINT') - PARTNER_API_ROOT = os.getenv('PARTNER_API_ROOT') +class Configuration(BaseSettings): + MONGO_URI: str = os.getenv("MONGO_URI") + SRM_HOST: str = os.getenv("SRM_HOST") + PI_EDGE_USERNAME: str = os.getenv("PI_EDGE_USERNAME") + PI_EDGE_PASSWORD: str = os.getenv("PI_EDGE_PASSWORD") + HTTP_PROXY: str = os.getenv("HTTP_PROXY") + FEDERATION_MANAGER_HOST=os.getenv("FEDERATION_MANAGER_HOST") + TOKEN_ENDPOINT = os.getenv('TOKEN_ENDPOINT') + PARTNER_API_ROOT = os.getenv('PARTNER_API_ROOT') + AVAIL_ZONE_NOTIF_LINK = os.getenv('AVAIL_ZONE_NOTIF_LINK') config = Configuration() diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index d1a98c0..99f7f03 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -4,6 +4,7 @@ from edge_cloud_management_api.managers.log_manager import logger from edge_cloud_management_api.services.edge_cloud_services import PiEdgeAPIClientFactory from edge_cloud_management_api.services.federation_services import FederationManagerClientFactory from edge_cloud_management_api.services.storage_service import get_zone +from edge_cloud_management_api.services.storage_service import insert_zones from edge_cloud_management_api.services.storage_service import get_fed import json import re @@ -165,7 +166,7 @@ def delete_app(appId, x_correlator=None): ) -def create_app_instance(): +def create_app_instance(): logger.info("Received request to create app instance") try: @@ -186,12 +187,37 @@ def create_app_instance(): first_zone = app_zones[0] if isinstance(app_zones, list) and app_zones else {} if not isinstance(first_zone, dict): first_zone = {} - edge_cloud_zone_id = ( - first_zone - .get("EdgeCloudZone", {}) - .get("edgeCloudZoneId") + zone_payload = ( + first_zone.get("EdgeCloudZone", {}) + if isinstance(first_zone.get("EdgeCloudZone", {}), dict) + else first_zone ) - zone = get_zone(edge_cloud_zone_id) + edge_cloud_zone_id = None + if isinstance(zone_payload, dict): + edge_cloud_zone_id = zone_payload.get("edgeCloudZoneId") or zone_payload.get("zoneId") + + zone = get_zone(edge_cloud_zone_id) if edge_cloud_zone_id else None + if not zone and edge_cloud_zone_id: + try: + zones = pi_edge_client.edge_cloud_zones() + if isinstance(zones, list): + for z in zones: + if isinstance(z, dict) and z.get("edgeCloudZoneId") == edge_cloud_zone_id: + z["_id"] = edge_cloud_zone_id + z["isLocal"] = "true" + insert_zones([z]) + zone = z + break + except Exception as exc: + logger.info(f"Failed to refresh zones from SRM: {exc}") + + if not zone and edge_cloud_zone_id and isinstance(zone_payload, dict): + zone_payload = dict(zone_payload) + zone_payload["_id"] = edge_cloud_zone_id + zone_payload.setdefault("isLocal", "true") + insert_zones([zone_payload]) + zone = zone_payload + if not zone: return jsonify({ "error": "Edge Cloud Zone not found", diff --git a/edge_cloud_management_api/controllers/federation_manager_controller.py b/edge_cloud_management_api/controllers/federation_manager_controller.py index 05747f2..0e29199 100644 --- a/edge_cloud_management_api/controllers/federation_manager_controller.py +++ b/edge_cloud_management_api/controllers/federation_manager_controller.py @@ -47,17 +47,24 @@ def create_federation(): insert_zones(zones_to_insert) insert_federation(fed) - zone_ids = [zone.get('zoneId') for zone in av_zones] - callback_url = body.get('availZoneNotifLink') # Optional from request - zone_response, zone_code = federation_client.subscribe_to_zones( - response.get('federationContextId'), - zone_ids, - token, - callback_url - ) - if zone_code != 200: - logger.warning(f"Zone subscription returned non-200: {zone_code} - {zone_response}") - + avail_zone_notif_link = config.AVAIL_ZONE_NOTIF_LINK or body.get("availZoneNotifLink") + accepted_availability_zones = [] + for zone in av_zones or []: + if isinstance(zone, dict) and zone.get("zoneId"): + accepted_availability_zones.append({"zoneId": zone.get("zoneId")}) + if accepted_availability_zones: + zone_response, zone_code = federation_client.subscribe_to_zones( + response.get("federationContextId"), + accepted_availability_zones, + token, + avail_zone_notif_link, + ) + if zone_code != 200: + logger.warning( + "Zone subscription returned non-200: %s - %s", + zone_code, + zone_response, + ) return response, code def get_federation(federationContextId): @@ -119,8 +126,14 @@ def delete_onboarded_app(federationContextId, appId): def request_zone_synch(federationContextId): token = __get_token() body = request.get_json() - response = federation_client.request_zone_sync(federation_context_id=federationContextId, body=body, token=token) - return jsonify(response) + if not body: + body = {} + if not body.get("availZoneNotifLink"): + body["availZoneNotifLink"] = config.AVAIL_ZONE_NOTIF_LINK + response, code = federation_client.request_zone_sync( + federation_context_id=federationContextId, body=body, token=token + ) + return jsonify(response), code def get_zone_resource_info(federationContextId, zoneId): token = __get_token() @@ -137,4 +150,3 @@ def __get_token(): token = bearer.split()[1] # __token = requests.post(TOKEN_ENDPOINT, headers=token_headers, data=data).json().get('access_token') return token - diff --git a/edge_cloud_management_api/services/federation_services.py b/edge_cloud_management_api/services/federation_services.py index 264bf24..d3c7963 100644 --- a/edge_cloud_management_api/services/federation_services.py +++ b/edge_cloud_management_api/services/federation_services.py @@ -30,9 +30,12 @@ class FederationManagerClient: try: response = requests.post(url, json=data, headers=headers, timeout=20) + try: + body = response.json() + except ValueError: + body = response.text response.raise_for_status() - print(response.json()) - return response.json(), 200 + return body, response.status_code except Timeout: logger.error("POST /partner timed out") return {"error": "Request timed out"}, 408 @@ -41,7 +44,11 @@ class FederationManagerClient: return {"error": "Connection error"}, 504 except requests.exceptions.HTTPError as http_err: logger.error(f"POST /partner HTTP error: {http_err}") - return {'Error': http_err.response.json().get('detail')}, response.status_code + try: + body = http_err.response.json() + except ValueError: + body = http_err.response.text + return {"error": body}, http_err.response.status_code except Exception as e: logger.error(f"POST /partner unexpected error: {e}") return {"error": str(e)}, 500 @@ -239,19 +246,23 @@ class FederationManagerClient: url = f"{self.base_url}/{federation_context_id}/zones" try: response = requests.post(url, headers=self._get_headers(token), json=body, timeout=10) - return response.json() + try: + response_body = response.json() + except ValueError: + response_body = response.text + return response_body, response.status_code except Timeout: logger.error("Zone synchronization timed out") - return {"error": "Request timed out", "status_code": 408} + return {"error": "Request timed out"}, 408 except ConnectionError: logger.error("Zone synchronization connection error") - return {"error": "Connection error", "status_code": 503} + return {"error": "Connection error"}, 503 except requests.exceptions.HTTPError as http_err: logger.error(f"Zone synchronization HTTP error: {http_err}") - return {"error": str(http_err), "status_code": response.status_code} + return {"error": str(http_err)}, response.status_code except Exception as e: logger.error(f"Zone synchronization unexpected error: {e}") - return {"error": str(e), "status_code": 500} + return {"error": str(e)}, 500 def subscribe_to_zones(self, federation_context_id: str, accepted_zone_ids: list, token: str, callback_url: str = None): diff --git a/edge_cloud_management_api/specification/openapi.yaml b/edge_cloud_management_api/specification/openapi.yaml index fa05184..d62d34c 100644 --- a/edge_cloud_management_api/specification/openapi.yaml +++ b/edge_cloud_management_api/specification/openapi.yaml @@ -1104,8 +1104,8 @@ paths: schema: $ref: '#/components/schemas/FederationContextId' responses: - "200": - description: Federation removed successfully + "200": + description: Federation removed successfully # "401": # description: Unauthorized # content: @@ -1124,6 +1124,33 @@ paths: # application/problem+json: # schema: # $ref: '#/components/schemas/ProblemDetails' + /{federationContextId}/zones: + post: + tags: + - FederationManagement + summary: Subscribe to availability zones for a federation context. + operationId: edge_cloud_management_api.controllers.federation_manager_controller.request_zone_synch + parameters: + - name: federationContextId + in: path + required: true + style: simple + explode: false + schema: + $ref: '#/components/schemas/FederationContextId' + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Zone subscription accepted + "400": + description: Bad request + "404": + description: Federation not found /fed-context-id: get: tags: -- GitLab From 3d47b7b7958a9b56a8e155055664bcf0cbd0df0e Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 30 Jan 2026 19:34:26 +0100 Subject: [PATCH 02/10] add zones endpoints and token fallback --- .../controllers/edge_cloud_controller.py | 26 +++- .../federation_manager_controller.py | 12 +- .../services/storage_service.py | 9 +- .../specification/openapi.yaml | 136 +++++++++++++++++- 4 files changed, 174 insertions(+), 9 deletions(-) diff --git a/edge_cloud_management_api/controllers/edge_cloud_controller.py b/edge_cloud_management_api/controllers/edge_cloud_controller.py index 96fdc6c..fee445b 100644 --- a/edge_cloud_management_api/controllers/edge_cloud_controller.py +++ b/edge_cloud_management_api/controllers/edge_cloud_controller.py @@ -4,7 +4,7 @@ from typing import List from edge_cloud_management_api.configs.env_config import config from edge_cloud_management_api.managers.log_manager import logger from edge_cloud_management_api.services.edge_cloud_services import PiEdgeAPIClientFactory -from edge_cloud_management_api.services.storage_service import insert_zones +from edge_cloud_management_api.services.storage_service import insert_zones, get_zones from edge_cloud_management_api.services.federation_services import FederationManagerClientFactory @@ -56,7 +56,9 @@ def get_local_zones() -> list[dict]: if isinstance(result, dict) and "error" in result: logger.error(f"SRM error: {result['error']}") return [] - return result + if isinstance(result, list): + return result + return [] except Exception as e: logger.exception("Unexpected error while retrieving local zones from SRM: %s", e) @@ -67,6 +69,16 @@ def get_federated_zones() -> List[EdgeCloudZone]: """get partner/federated Operator Platform available zones from Federation Manager""" return [] +def get_cached_zones() -> list[dict]: + """Retrieve cached zones from storage, falling back to SRM if empty.""" + try: + cached = get_zones() + if cached: + return cached + except Exception as e: + logger.warning("Failed to read cached zones: %s", e) + return get_local_zones() + def get_all_cloud_zones() -> List[EdgeCloudZone]: """Get all available zones from local and federated Operator Platforms""" @@ -108,7 +120,7 @@ def get_edge_cloud_zones(x_correlator: str | None = None, region=None, status=No def query_status_matches(zone: EdgeCloudZone) -> bool: return query_params.status is None or zone.edgeCloudZoneStatus == query_params.status - response = [EdgeCloudZone(**zone).model_dump() for zone in get_all_cloud_zones()] + response = [EdgeCloudZone(**zone).model_dump() for zone in get_cached_zones()] return jsonify(response), 200 except ValidationError as e: @@ -131,3 +143,11 @@ def edge_cloud_zone_details(zoneId: str) -> dict: api_client = pi_edge_factory.create_pi_edge_api_client() result = api_client.edge_cloud_zone_details(zone_id=zoneId) return result + + +def get_edge_cloud_zones_alias(x_correlator: str | None = None, region=None, status=None): + return get_edge_cloud_zones(x_correlator=x_correlator, region=region, status=status) + + +def edge_cloud_zone_details_alias(zoneId: str) -> dict: + return edge_cloud_zone_details(zoneId) diff --git a/edge_cloud_management_api/controllers/federation_manager_controller.py b/edge_cloud_management_api/controllers/federation_manager_controller.py index 0e29199..d9cdca3 100644 --- a/edge_cloud_management_api/controllers/federation_manager_controller.py +++ b/edge_cloud_management_api/controllers/federation_manager_controller.py @@ -146,7 +146,11 @@ def remove_zone_sync(federationContextId, zoneId): return jsonify(response) def __get_token(): - bearer = connexion.request.headers['Authorization'] - token = bearer.split()[1] - # __token = requests.post(TOKEN_ENDPOINT, headers=token_headers, data=data).json().get('access_token') - return token + bearer = connexion.request.headers.get('Authorization') + if bearer: + parts = bearer.split() + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] + if TOKEN_ENDPOINT: + return requests.post(TOKEN_ENDPOINT, headers=token_headers, data=data).json().get('access_token') + return None diff --git a/edge_cloud_management_api/services/storage_service.py b/edge_cloud_management_api/services/storage_service.py index b7721ae..5b31e7d 100644 --- a/edge_cloud_management_api/services/storage_service.py +++ b/edge_cloud_management_api/services/storage_service.py @@ -19,6 +19,14 @@ def get_zone(zone_id: str): zone = col.find_one({'_id': zone_id}) return zone +def get_zones(): + collection = "zones" + myclient = pymongo.MongoClient(storage_url) + mydbmongo = myclient[mydb_mongo] + col = mydbmongo[collection] + zones = col.find() + return list(zones) + def delete_partner_zones(): collection = "zones" myclient = pymongo.MongoClient(storage_url) @@ -55,4 +63,3 @@ def delete_fed(fed_context_id: str): mydbmongo = myclient[mydb_mongo] col = mydbmongo[collection] col.delete_one({'_id': fed_context_id}) - diff --git a/edge_cloud_management_api/specification/openapi.yaml b/edge_cloud_management_api/specification/openapi.yaml index d62d34c..4ce0beb 100644 --- a/edge_cloud_management_api/specification/openapi.yaml +++ b/edge_cloud_management_api/specification/openapi.yaml @@ -625,7 +625,7 @@ paths: List of the operators Edge Cloud Zones and their status, ordering the results by location and filtering by status (active/inactive/unknown) - operationId: edge_cloud_management_api.controllers.edge_cloud_controller.get_edge_cloud_zones + operationId: edge_cloud_management_api.controllers.edge_cloud_controller.get_edge_cloud_zones_alias parameters: - $ref: "#/components/parameters/x-correlator" - name: region @@ -665,6 +665,83 @@ paths: "503": $ref: "#/components/responses/503" /edge-cloud-zones/{zoneId}: + get: + tags: + - Edge Cloud Zones + summary: Retrieve the details of an Edge Cloud Zone + description: | + List of the operators Edge Cloud Zones and their + status, ordering the results by location and filtering by + status (active/inactive/unknown) + operationId: edge_cloud_management_api.controllers.edge_cloud_controller.edge_cloud_zone_details_alias + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: zoneId + in: path + description: | + UID of the specific edge cloud zone + required: true + style: simple + schema: + type: string + responses: + "200": + description: | + Successful response, returning the Edge Cloud Zone details + "404": + $ref: "#/components/responses/404" + /zones: + get: + # security: + # - openId: + # - edge-application-management:edge-cloud-zones:read + tags: + - Edge Cloud Zones + summary: Retrieve a list of the operators Edge Cloud Zones and their status + description: | + List of the operators Edge Cloud Zones and their + status, ordering the results by location and filtering by + status (active/inactive/unknown) + operationId: edge_cloud_management_api.controllers.edge_cloud_controller.get_edge_cloud_zones + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: region + description: | + Human readable name of the geographical Edge Cloud Region of + the Edge Cloud. Defined by the Edge Cloud Provider. + in: query + required: false + schema: + $ref: "#/components/schemas/EdgeCloudRegion" + - name: status + description: Human readable status of the Edge Cloud Zone + in: query + required: false + schema: + $ref: "#/components/schemas/EdgeCloudZoneStatus" + responses: + "200": + description: | + Successful response, returning the + Available Edge Cloud Zones. + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + $ref: "#/components/schemas/EdgeCloudZones" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "503": + $ref: "#/components/responses/503" + /zones/{zoneId}: get: tags: - Edge Cloud Zones @@ -1151,6 +1228,63 @@ paths: description: Bad request "404": description: Federation not found + /{federationContextId}/zones/{zoneId}: + get: + tags: + - FederationManagement + summary: Retrieve reserved resources for a federated zone. + description: | + Retrieves details about the computation and network resources that the + partner OP has reserved for this zone. + operationId: edge_cloud_management_api.controllers.federation_manager_controller.get_zone_resource_info + parameters: + - name: federationContextId + in: path + required: true + style: simple + explode: false + schema: + $ref: '#/components/schemas/FederationContextId' + - name: zoneId + in: path + required: true + style: simple + explode: false + schema: + type: string + responses: + "200": + description: Zone resource info retrieved + "404": + description: Zone not found + delete: + tags: + - FederationManagement + summary: Remove availability zone reservation for a federation context. + description: | + Originating OP informs partner OP that it will no longer access the + specified zone. + operationId: edge_cloud_management_api.controllers.federation_manager_controller.remove_zone_sync + parameters: + - name: federationContextId + in: path + required: true + style: simple + explode: false + schema: + $ref: '#/components/schemas/FederationContextId' + - name: zoneId + in: path + required: true + style: simple + explode: false + schema: + type: string + responses: + "200": + description: Zone reservation removed + "404": + description: Zone not found /fed-context-id: get: tags: -- GitLab From bd16fcbdeef2347ca4a62ebb822c34d48811472c Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 30 Jan 2026 19:40:12 +0100 Subject: [PATCH 03/10] add tests and zone resource schemas --- .../federation_manager_controller.py | 4 +- .../specification/openapi.yaml | 71 +++++++++++++++++++ .../controllers/test_edge_cloud_controller.py | 39 ++++++++-- .../test_federation_manager_controller.py | 50 +++++++++++++ 4 files changed, 156 insertions(+), 8 deletions(-) diff --git a/edge_cloud_management_api/controllers/federation_manager_controller.py b/edge_cloud_management_api/controllers/federation_manager_controller.py index d9cdca3..bff3f50 100644 --- a/edge_cloud_management_api/controllers/federation_manager_controller.py +++ b/edge_cloud_management_api/controllers/federation_manager_controller.py @@ -27,7 +27,9 @@ def create_federation(): """POST /partner - Create federation with partner OP.""" body = request.get_json() - token = requests.post(TOKEN_ENDPOINT, headers=token_headers, data=data).json().get('access_token') + token = __get_token() + if not token: + return jsonify({"error": "Unable to obtain access token"}), 500 response, code = federation_client.post_partner(body, token) fed = {'_id': response.get('federationContextId'), 'token': token} if code==200: diff --git a/edge_cloud_management_api/specification/openapi.yaml b/edge_cloud_management_api/specification/openapi.yaml index 4ce0beb..d86d6b1 100644 --- a/edge_cloud_management_api/specification/openapi.yaml +++ b/edge_cloud_management_api/specification/openapi.yaml @@ -1255,6 +1255,10 @@ paths: responses: "200": description: Zone resource info retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/ZoneResourceInfo' "404": description: Zone not found delete: @@ -1283,6 +1287,10 @@ paths: responses: "200": description: Zone reservation removed + content: + application/json: + schema: + $ref: '#/components/schemas/ZoneReservationRemoval' "404": description: Zone not found /fed-context-id: @@ -2325,6 +2333,69 @@ components: $ref: '#/components/schemas/ZoneIdentifier' availZoneNotifLink: $ref: '#/components/schemas/Uri' + + ZoneResourceInfo: + type: object + properties: + zoneId: + $ref: '#/components/schemas/ZoneIdentifier' + computeResourceQuotaLimits: + type: array + items: + $ref: '#/components/schemas/ComputeResource' + reservedComputeResources: + type: array + items: + $ref: '#/components/schemas/ComputeResource' + flavoursSupported: + type: array + items: + $ref: '#/components/schemas/ZoneFlavour' + + ZoneReservationRemoval: + type: object + properties: + status: + type: string + example: + status: deleted + + ComputeResource: + type: object + properties: + cpuArchType: + $ref: '#/components/schemas/CPUArchType' + numCPU: + type: integer + format: int32 + memory: + type: integer + format: int32 + hugepages: + type: array + items: + type: string + + ZoneFlavour: + type: object + properties: + flavourId: + type: string + cpuArchType: + $ref: '#/components/schemas/CPUArchType' + numCPU: + type: integer + format: int32 + memorySize: + type: integer + format: int32 + storageSize: + type: integer + format: int32 + supportedOSTypes: + type: array + items: + $ref: '#/components/schemas/OSType' FixedNetworkIds: minItems: 1 diff --git a/tests/unit/controllers/test_edge_cloud_controller.py b/tests/unit/controllers/test_edge_cloud_controller.py index 9699560..7665c95 100644 --- a/tests/unit/controllers/test_edge_cloud_controller.py +++ b/tests/unit/controllers/test_edge_cloud_controller.py @@ -4,6 +4,7 @@ import pytest from unittest.mock import MagicMock, patch from flask import Flask from edge_cloud_management_api.controllers.edge_cloud_controller import ( + get_cached_zones, get_edge_cloud_zones, ) from edge_cloud_management_api.app import get_app_instance @@ -24,9 +25,9 @@ def mock_zones(): @pytest.fixture -def mock_get_all_cloud_zones(mock_zones): +def mock_get_cached_zones(mock_zones): with patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_all_cloud_zones", + "edge_cloud_management_api.controllers.edge_cloud_controller.get_cached_zones", return_value=mock_zones, ) as mock_function: yield mock_function @@ -51,7 +52,7 @@ def test_get_edge_cloud_zones( status, expected_response_status, expected_count, - mock_get_all_cloud_zones: MagicMock, + mock_get_cached_zones: MagicMock, test_app: Flask, ): """ @@ -62,12 +63,36 @@ def test_get_edge_cloud_zones( assert response_status == expected_response_status if expected_response_status == 400: - assert response.json["code"] == "VALIDATION_ERROR" + data = response.get_json() + assert data is not None + assert data["code"] == "VALIDATION_ERROR" elif expected_response_status == 200: # Since the function does not filter, always expect all mock zones - assert isinstance(response.json, list) - assert len(response.json) == expected_count - mock_get_all_cloud_zones.assert_called_once() + data = response.get_json() + assert isinstance(data, list) + assert len(data) == expected_count + mock_get_cached_zones.assert_called_once() else: # Defensive: should not get here assert False, "Unexpected response status" + + +@pytest.mark.unit +def test_get_cached_zones_returns_cached(mock_zones): + with patch( + "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", + return_value=mock_zones, + ): + assert get_cached_zones() == mock_zones + + +@pytest.mark.unit +def test_get_cached_zones_fallback_to_srm(mock_zones): + with patch( + "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", + side_effect=Exception("db error"), + ), patch( + "edge_cloud_management_api.controllers.edge_cloud_controller.get_local_zones", + return_value=mock_zones, + ): + assert get_cached_zones() == mock_zones diff --git a/tests/unit/controllers/test_federation_manager_controller.py b/tests/unit/controllers/test_federation_manager_controller.py index cac78b3..a2906b0 100644 --- a/tests/unit/controllers/test_federation_manager_controller.py +++ b/tests/unit/controllers/test_federation_manager_controller.py @@ -31,6 +31,7 @@ def test_create_federation(mock_factory_class, test_app: Flask): assert status == 200 data = response.get_json() + assert data is not None assert "federationContextId" in data assert data["federationContextId"] == "abc" @@ -79,3 +80,52 @@ def test_get_federation_context_ids(mock_factory_class, test_app: Flask): assert status == 200 assert response.get_json() == {"FederationContextId": "ctx-123"} + + +@pytest.mark.component +@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") +@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") +def test_request_zone_synch(mock_federation_client, _mock_get_token, test_app: Flask): + federation_context_id = "ctx-123" + body = { + "acceptedAvailabilityZones": ["zone-1"], + "availZoneNotifLink": "http://callback.local" + } + mock_federation_client.request_zone_sync.return_value = ({"status": "ok"}, 200) + + with test_app.test_request_context(json=body): + response, status = federation_manager_controller.request_zone_synch(federation_context_id) + + assert status == 200 + assert response.get_json() == {"status": "ok"} + mock_federation_client.request_zone_sync.assert_called_once() + + +@pytest.mark.component +@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") +@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") +def test_get_zone_resource_info(mock_federation_client, _mock_get_token, test_app: Flask): + federation_context_id = "ctx-123" + zone_id = "zone-1" + mock_federation_client.get_zone_resource_info.return_value = {"zoneId": zone_id} + + with test_app.test_request_context(): + response = federation_manager_controller.get_zone_resource_info(federation_context_id, zone_id) + + assert response.get_json() == {"zoneId": zone_id} + mock_federation_client.get_zone_resource_info.assert_called_once() + + +@pytest.mark.component +@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") +@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") +def test_remove_zone_sync(mock_federation_client, _mock_get_token, test_app: Flask): + federation_context_id = "ctx-123" + zone_id = "zone-1" + mock_federation_client.remove_zone_sync.return_value = {"status": "deleted"} + + with test_app.test_request_context(): + response = federation_manager_controller.remove_zone_sync(federation_context_id, zone_id) + + assert response.get_json() == {"status": "deleted"} + mock_federation_client.remove_zone_sync.assert_called_once() -- GitLab From c7dfb8c4630ca7741a7fd58467fb79a7f50dbfed Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sat, 31 Jan 2026 20:56:38 +0100 Subject: [PATCH 04/10] merge local zones and drop /zones --- .../controllers/edge_cloud_controller.py | 24 +++--- .../specification/openapi.yaml | 79 +------------------ .../controllers/test_edge_cloud_controller.py | 19 +++++ 3 files changed, 33 insertions(+), 89 deletions(-) diff --git a/edge_cloud_management_api/controllers/edge_cloud_controller.py b/edge_cloud_management_api/controllers/edge_cloud_controller.py index fee445b..2d12062 100644 --- a/edge_cloud_management_api/controllers/edge_cloud_controller.py +++ b/edge_cloud_management_api/controllers/edge_cloud_controller.py @@ -70,14 +70,23 @@ def get_federated_zones() -> List[EdgeCloudZone]: return [] def get_cached_zones() -> list[dict]: - """Retrieve cached zones from storage, falling back to SRM if empty.""" + """Retrieve cached zones and merge with local SRM zones.""" + cached = [] try: cached = get_zones() - if cached: - return cached except Exception as e: logger.warning("Failed to read cached zones: %s", e) - return get_local_zones() + + merged = list(cached or []) + existing_ids = { + zone.get("edgeCloudZoneId") for zone in merged if isinstance(zone, dict) + } + for zone in get_local_zones(): + zone_id = zone.get("edgeCloudZoneId") if isinstance(zone, dict) else None + if zone_id and zone_id not in existing_ids: + merged.append(zone) + existing_ids.add(zone_id) + return merged def get_all_cloud_zones() -> List[EdgeCloudZone]: """Get all available zones from local and federated Operator Platforms""" @@ -144,10 +153,3 @@ def edge_cloud_zone_details(zoneId: str) -> dict: result = api_client.edge_cloud_zone_details(zone_id=zoneId) return result - -def get_edge_cloud_zones_alias(x_correlator: str | None = None, region=None, status=None): - return get_edge_cloud_zones(x_correlator=x_correlator, region=region, status=status) - - -def edge_cloud_zone_details_alias(zoneId: str) -> dict: - return edge_cloud_zone_details(zoneId) diff --git a/edge_cloud_management_api/specification/openapi.yaml b/edge_cloud_management_api/specification/openapi.yaml index d86d6b1..f59d888 100644 --- a/edge_cloud_management_api/specification/openapi.yaml +++ b/edge_cloud_management_api/specification/openapi.yaml @@ -621,83 +621,6 @@ paths: - Edge Cloud Zones summary: Retrieve a list of the operators Edge Cloud Zones and their status - description: | - List of the operators Edge Cloud Zones and their - status, ordering the results by location and filtering by - status (active/inactive/unknown) - operationId: edge_cloud_management_api.controllers.edge_cloud_controller.get_edge_cloud_zones_alias - parameters: - - $ref: "#/components/parameters/x-correlator" - - name: region - description: | - Human readable name of the geographical Edge Cloud Region of - the Edge Cloud. Defined by the Edge Cloud Provider. - in: query - required: false - schema: - $ref: "#/components/schemas/EdgeCloudRegion" - - name: status - description: Human readable status of the Edge Cloud Zone - in: query - required: false - schema: - $ref: "#/components/schemas/EdgeCloudZoneStatus" - responses: - "200": - description: | - Successful response, returning the - Available Edge Cloud Zones. - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/EdgeCloudZones" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - "503": - $ref: "#/components/responses/503" - /edge-cloud-zones/{zoneId}: - get: - tags: - - Edge Cloud Zones - summary: Retrieve the details of an Edge Cloud Zone - description: | - List of the operators Edge Cloud Zones and their - status, ordering the results by location and filtering by - status (active/inactive/unknown) - operationId: edge_cloud_management_api.controllers.edge_cloud_controller.edge_cloud_zone_details_alias - parameters: - - $ref: "#/components/parameters/x-correlator" - - name: zoneId - in: path - description: | - UID of the specific edge cloud zone - required: true - style: simple - schema: - type: string - responses: - "200": - description: | - Successful response, returning the Edge Cloud Zone details - "404": - $ref: "#/components/responses/404" - /zones: - get: - # security: - # - openId: - # - edge-application-management:edge-cloud-zones:read - tags: - - Edge Cloud Zones - summary: Retrieve a list of the operators Edge Cloud Zones and their status description: | List of the operators Edge Cloud Zones and their status, ordering the results by location and filtering by @@ -741,7 +664,7 @@ paths: $ref: "#/components/responses/500" "503": $ref: "#/components/responses/503" - /zones/{zoneId}: + /edge-cloud-zones/{zoneId}: get: tags: - Edge Cloud Zones diff --git a/tests/unit/controllers/test_edge_cloud_controller.py b/tests/unit/controllers/test_edge_cloud_controller.py index 7665c95..d624b36 100644 --- a/tests/unit/controllers/test_edge_cloud_controller.py +++ b/tests/unit/controllers/test_edge_cloud_controller.py @@ -87,6 +87,25 @@ def test_get_cached_zones_returns_cached(mock_zones): @pytest.mark.unit +def test_get_cached_zones_merges_local(mock_zones): + local_zone = { + "edgeCloudZoneId": "local-zone", + "edgeCloudZoneName": "local-zone", + "edgeCloudZoneStatus": "unknown", + "edgeCloudProvider": "local", + "edgeCloudRegion": "local", + } + with patch( + "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", + return_value=mock_zones, + ), patch( + "edge_cloud_management_api.controllers.edge_cloud_controller.get_local_zones", + return_value=[local_zone], + ): + result = get_cached_zones() + assert local_zone in result + + def test_get_cached_zones_fallback_to_srm(mock_zones): with patch( "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", -- GitLab From f69d8260ed918af09ee88a713813fa988e3b12fa Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sat, 31 Jan 2026 21:05:08 +0100 Subject: [PATCH 05/10] add missing OSType schema --- edge_cloud_management_api/specification/openapi.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/edge_cloud_management_api/specification/openapi.yaml b/edge_cloud_management_api/specification/openapi.yaml index f59d888..5d5a070 100644 --- a/edge_cloud_management_api/specification/openapi.yaml +++ b/edge_cloud_management_api/specification/openapi.yaml @@ -2319,6 +2319,18 @@ components: type: array items: $ref: '#/components/schemas/OSType' + + OSType: + type: object + properties: + distribution: + type: string + version: + type: string + architecture: + type: string + license: + type: string FixedNetworkIds: minItems: 1 -- GitLab From 299bbeb1af0a31ebbd74b290460889e827e01c2a Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sat, 31 Jan 2026 21:10:02 +0100 Subject: [PATCH 06/10] relax edge cloud zone validation --- .../controllers/edge_cloud_controller.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/edge_cloud_management_api/controllers/edge_cloud_controller.py b/edge_cloud_management_api/controllers/edge_cloud_controller.py index 2d12062..26c7323 100644 --- a/edge_cloud_management_api/controllers/edge_cloud_controller.py +++ b/edge_cloud_management_api/controllers/edge_cloud_controller.py @@ -31,14 +31,14 @@ class EdgeCloudZone(BaseModel): pattern="^(active|inactive|unknown)$", ) edgeCloudProvider: str = Field(..., description="Name of the Edge Cloud Provider") - edgeCloudRegion: str | None = Field(..., description="Region of the Edge Cloud Zone") + edgeCloudRegion: str | None = Field(None, description="Region of the Edge Cloud Zone") class EdgeCloudQueryParams(BaseModel): x_correlator: str | None region: str | None status: str | None = Field( - ..., + None, description="Status of the Edge Cloud Zone", pattern="^(active|inactive|unknown)$", ) @@ -152,4 +152,3 @@ def edge_cloud_zone_details(zoneId: str) -> dict: api_client = pi_edge_factory.create_pi_edge_api_client() result = api_client.edge_cloud_zone_details(zone_id=zoneId) return result - -- GitLab From 9cc6e69893a2dbd9056ce2ad96db5578ec031ae3 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sat, 31 Jan 2026 21:25:00 +0100 Subject: [PATCH 07/10] aggregate app instances across federations --- .../controllers/app_controllers.py | 102 +++++++++++++++--- .../services/federation_services.py | 31 ++++++ 2 files changed, 118 insertions(+), 15 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 99f7f03..7ea6e6b 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -5,7 +5,7 @@ from edge_cloud_management_api.services.edge_cloud_services import PiEdgeAPIClie from edge_cloud_management_api.services.federation_services import FederationManagerClientFactory from edge_cloud_management_api.services.storage_service import get_zone from edge_cloud_management_api.services.storage_service import insert_zones -from edge_cloud_management_api.services.storage_service import get_fed +from edge_cloud_management_api.services.storage_service import get_fed, get_all_feds import json import re import uuid @@ -815,25 +815,97 @@ def create_app_instance(): }), 500 ''' -def get_app_instance(app_id=None, x_correlator=None, app_instance_id=None, region=None): +def get_app_instance(app_id=None, x_correlator=None, app_instance_id=None, region=None): """ Retrieve application instances from the database. Supports filtering by app_id, app_instance_id, and region. """ try: - instances = None - pi_edge_client_factory = PiEdgeAPIClientFactory() - pi_edge_client = pi_edge_client_factory.create_pi_edge_api_client() - - if app_id is None and app_instance_id is None: - instances = pi_edge_client.get_app_instances() - - if not instances: - return jsonify({ - "status": 404, - "code": "NOT_FOUND", - "message": "No application instances found for the given parameters." - }), 404 + instances = [] + pi_edge_client_factory = PiEdgeAPIClientFactory() + pi_edge_client = pi_edge_client_factory.create_pi_edge_api_client() + + if app_instance_id is None: + local_instances = pi_edge_client.get_app_instances() + if isinstance(local_instances, list): + instances.extend(local_instances) + + def resolve_app_provider(app_id_value, app_payload=None): + if isinstance(app_payload, dict): + provider = app_payload.get("appProvider") or app_payload.get("appProviderId") + if provider: + return provider + manifest = app_payload.get("appManifest") + if isinstance(manifest, dict): + provider = manifest.get("appProvider") or manifest.get("appProviderId") + if provider: + return provider + app_response = pi_edge_client.get_app(app_id_value) + if isinstance(app_response, dict): + manifest = app_response.get("appManifest") + if isinstance(manifest, dict): + provider = manifest.get("appProvider") or manifest.get("appProviderId") + if provider: + return provider + provider = app_response.get("appProvider") or app_response.get("appProviderId") + if provider: + return provider + return None + + feds = get_all_feds() + if app_id: + app_provider_id = resolve_app_provider(app_id) + if app_provider_id: + for fed in feds: + fed_token = fed.get("token") + federation_context_id = fed.get("_id") + if not federation_context_id or not fed_token: + continue + fed_instances, fed_code = federation_client.get_all_app_instances( + federation_context_id=federation_context_id, + app_id=app_id, + app_provider_id=app_provider_id, + token=fed_token, + ) + if fed_code == 200 and isinstance(fed_instances, list): + instances.extend(fed_instances) + else: + logger.info("Skipping federated lookup; no appProviderId for appId=%s", app_id) + else: + apps = pi_edge_client.get_service_functions_catalogue() + if isinstance(apps, list): + app_provider_map = {} + for app in apps: + if not isinstance(app, dict): + continue + app_id_value = app.get("appId") or app.get("id") + if not app_id_value: + continue + provider = resolve_app_provider(app_id_value, app_payload=app) + if provider: + app_provider_map[app_id_value] = provider + + for app_id_value, app_provider_id in app_provider_map.items(): + for fed in feds: + fed_token = fed.get("token") + federation_context_id = fed.get("_id") + if not federation_context_id or not fed_token: + continue + fed_instances, fed_code = federation_client.get_all_app_instances( + federation_context_id=federation_context_id, + app_id=app_id_value, + app_provider_id=app_provider_id, + token=fed_token, + ) + if fed_code == 200 and isinstance(fed_instances, list): + instances.extend(fed_instances) + + if not instances: + return jsonify({ + "status": 404, + "code": "NOT_FOUND", + "message": "No application instances found for the given parameters." + }), 404 return jsonify({"appInstanceInfo": instances}), 200 diff --git a/edge_cloud_management_api/services/federation_services.py b/edge_cloud_management_api/services/federation_services.py index d3c7963..d279b0e 100644 --- a/edge_cloud_management_api/services/federation_services.py +++ b/edge_cloud_management_api/services/federation_services.py @@ -239,6 +239,37 @@ class FederationManagerClient: logger.error(f"POST /application/lcm unexpected error: {e}") return {"error": str(e)}, 500 + def get_all_app_instances(self, federation_context_id: str, app_id: str, app_provider_id: str, token: str): + url = ( + f"{self.base_url}/{federation_context_id}/application/lcm/app/{app_id}" + f"/appProvider/{app_provider_id}" + ) + try: + response = requests.get(url, headers=self._get_headers(token), timeout=10) + response.raise_for_status() + return response.json(), response.status_code + except Timeout: + logger.error("GET /application/lcm/app/{appId}/appProvider/{appProviderId} timed out") + return {"error": "Request timed out"}, 408 + except ConnectionError: + logger.error("GET /application/lcm/app/{appId}/appProvider/{appProviderId} connection error") + return {"error": "Connection error"}, 503 + except requests.exceptions.HTTPError as http_err: + logger.error(f"GET /application/lcm/app/{app_id}/appProvider/{app_provider_id} HTTP error: {http_err}") + try: + body = http_err.response.json() + except ValueError: + body = http_err.response.text + return {"error": body}, http_err.response.status_code + except Exception as e: + logger.error( + "GET /application/lcm/app/%s/appProvider/%s unexpected error: %s", + app_id, + app_provider_id, + e, + ) + return {"error": str(e)}, 500 + '''---AVAILABILITY ZONE INFO SYNCHRONIZATION---''' def request_zone_sync(self, federation_context_id: str, body: dict, token: str): -- GitLab From 51a46511fcde854fe4e5f5267eaf085a08df307a Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sat, 31 Jan 2026 21:30:32 +0100 Subject: [PATCH 08/10] delete app instances across federations --- .../controllers/app_controllers.py | 61 ++++++++++++++++--- .../services/federation_services.py | 51 ++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 7ea6e6b..e9db970 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -918,7 +918,7 @@ def get_app_instance(app_id=None, x_correlator=None, app_instance_id=None, regio }), 500 -def delete_app_instance(appInstanceId: str, x_correlator=None): +def delete_app_instance(appInstanceId: str, x_correlator=None): """ Terminate an Application Instance @@ -926,16 +926,63 @@ def delete_app_instance(appInstanceId: str, x_correlator=None): - Returns 204 if deleted, 404 if not found. """ try: - pi_edge_client_factory = PiEdgeAPIClientFactory() - pi_edge_client = pi_edge_client_factory.create_pi_edge_api_client() + pi_edge_client_factory = PiEdgeAPIClientFactory() + pi_edge_client = pi_edge_client_factory.create_pi_edge_api_client() response = pi_edge_client.delete_app_instance(appInstanceId) - if isinstance(response, dict): + if isinstance(response, dict) and response.get("status_code") != 404: status_code = response.get("status_code", 500) return jsonify(response), status_code + if not isinstance(response, dict) and response.status_code != 404: + return jsonify({ + "result": response.text, + "status": response.status_code + }), response.status_code + + if not isinstance(response, dict): + status_code = response.status_code + else: + status_code = response.get("status_code", 404) + + instances_response = get_app_instance() + if isinstance(instances_response, tuple): + instances_payload, _instances_status = instances_response + else: + instances_payload = instances_response + instances_data = instances_payload.get_json() if hasattr(instances_payload, "get_json") else None + instances_list = [] + if isinstance(instances_data, dict): + instances_list = instances_data.get("appInstanceInfo", []) + + for instance in instances_list: + if not isinstance(instance, dict): + continue + instance_id = instance.get("appInstIdentifier") or instance.get("appInstanceId") + if instance_id != appInstanceId: + continue + fed_context_id = instance.get("federationContextId") or instance.get("fedContextId") + zone_id = instance.get("zoneId") or instance.get("edgeCloudZoneId") + app_id = instance.get("appId") + if not (fed_context_id and zone_id and app_id): + continue + fed = get_fed(fed_context_id) + if not fed: + continue + fed_token = fed.get("token") + if not fed_token: + continue + remove_response, remove_status = federation_client.remove_app_instance( + federation_context_id=fed_context_id, + app_id=app_id, + app_instance_id=appInstanceId, + zone_id=zone_id, + token=fed_token, + ) + return jsonify(remove_response), remove_status + return jsonify({ - "result": response.text, - "status": response.status_code - }), response.status_code + "error": response.get("error") if isinstance(response, dict) else response.text, + "status_code": status_code + }), status_code except Exception as e: return ( diff --git a/edge_cloud_management_api/services/federation_services.py b/edge_cloud_management_api/services/federation_services.py index d279b0e..d947dd1 100644 --- a/edge_cloud_management_api/services/federation_services.py +++ b/edge_cloud_management_api/services/federation_services.py @@ -270,6 +270,57 @@ class FederationManagerClient: ) return {"error": str(e)}, 500 + def remove_app_instance( + self, + federation_context_id: str, + app_id: str, + app_instance_id: str, + zone_id: str, + token: str, + ): + url = ( + f"{self.base_url}/{federation_context_id}/application/lcm/app/{app_id}" + f"/instance/{app_instance_id}/zone/{zone_id}" + ) + try: + response = requests.delete(url, headers=self._get_headers(token), timeout=10) + try: + body = response.json() + except ValueError: + body = response.text + response.raise_for_status() + return body, response.status_code + except Timeout: + logger.error("DELETE /application/lcm/app/{appId}/instance/{appInstanceId}/zone/{zoneId} timed out") + return {"error": "Request timed out"}, 408 + except ConnectionError: + logger.error( + "DELETE /application/lcm/app/{appId}/instance/{appInstanceId}/zone/{zoneId} connection error" + ) + return {"error": "Connection error"}, 503 + except requests.exceptions.HTTPError as http_err: + logger.error( + "DELETE /application/lcm/app/%s/instance/%s/zone/%s HTTP error: %s", + app_id, + app_instance_id, + zone_id, + http_err, + ) + try: + body = http_err.response.json() + except ValueError: + body = http_err.response.text + return {"error": body}, http_err.response.status_code + except Exception as e: + logger.error( + "DELETE /application/lcm/app/%s/instance/%s/zone/%s unexpected error: %s", + app_id, + app_instance_id, + zone_id, + e, + ) + return {"error": str(e)}, 500 + '''---AVAILABILITY ZONE INFO SYNCHRONIZATION---''' def request_zone_sync(self, federation_context_id: str, body: dict, token: str): -- GitLab From 7d85d3c28d13c7164b0d0b2a0bfacc2a7612db68 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sat, 31 Jan 2026 21:33:42 +0100 Subject: [PATCH 09/10] terminate federated app instances by id --- .../controllers/app_controllers.py | 100 ++++++++++++------ 1 file changed, 68 insertions(+), 32 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index e9db970..ade2401 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -943,41 +943,77 @@ def delete_app_instance(appInstanceId: str, x_correlator=None): else: status_code = response.get("status_code", 404) - instances_response = get_app_instance() - if isinstance(instances_response, tuple): - instances_payload, _instances_status = instances_response - else: - instances_payload = instances_response - instances_data = instances_payload.get_json() if hasattr(instances_payload, "get_json") else None - instances_list = [] - if isinstance(instances_data, dict): - instances_list = instances_data.get("appInstanceInfo", []) + def resolve_app_provider(app_id_value, app_payload=None): + if isinstance(app_payload, dict): + provider = app_payload.get("appProvider") or app_payload.get("appProviderId") + if provider: + return provider + manifest = app_payload.get("appManifest") + if isinstance(manifest, dict): + provider = manifest.get("appProvider") or manifest.get("appProviderId") + if provider: + return provider + app_response = pi_edge_client.get_app(app_id_value) + if isinstance(app_response, dict): + manifest = app_response.get("appManifest") + if isinstance(manifest, dict): + provider = manifest.get("appProvider") or manifest.get("appProviderId") + if provider: + return provider + provider = app_response.get("appProvider") or app_response.get("appProviderId") + if provider: + return provider + return None - for instance in instances_list: - if not isinstance(instance, dict): - continue - instance_id = instance.get("appInstIdentifier") or instance.get("appInstanceId") - if instance_id != appInstanceId: - continue - fed_context_id = instance.get("federationContextId") or instance.get("fedContextId") - zone_id = instance.get("zoneId") or instance.get("edgeCloudZoneId") - app_id = instance.get("appId") - if not (fed_context_id and zone_id and app_id): - continue - fed = get_fed(fed_context_id) - if not fed: - continue + apps = pi_edge_client.get_service_functions_catalogue() + app_provider_map = {} + if isinstance(apps, list): + for app in apps: + if not isinstance(app, dict): + continue + app_id_value = app.get("appId") or app.get("id") + if not app_id_value: + continue + provider = resolve_app_provider(app_id_value, app_payload=app) + if provider: + app_provider_map[app_id_value] = provider + + feds = get_all_feds() + for fed in feds: fed_token = fed.get("token") - if not fed_token: + federation_context_id = fed.get("_id") + if not federation_context_id or not fed_token: continue - remove_response, remove_status = federation_client.remove_app_instance( - federation_context_id=fed_context_id, - app_id=app_id, - app_instance_id=appInstanceId, - zone_id=zone_id, - token=fed_token, - ) - return jsonify(remove_response), remove_status + for app_id_value, app_provider_id in app_provider_map.items(): + fed_instances, fed_code = federation_client.get_all_app_instances( + federation_context_id=federation_context_id, + app_id=app_id_value, + app_provider_id=app_provider_id, + token=fed_token, + ) + if fed_code != 200 or not isinstance(fed_instances, list): + continue + for zone_info in fed_instances: + if not isinstance(zone_info, dict): + continue + zone_id = zone_info.get("zoneId") + instances_list = zone_info.get("appInstanceInfo", []) + if not zone_id or not isinstance(instances_list, list): + continue + for instance in instances_list: + if not isinstance(instance, dict): + continue + instance_id = instance.get("appInstIdentifier") or instance.get("appInstanceId") + if instance_id != appInstanceId: + continue + remove_response, remove_status = federation_client.remove_app_instance( + federation_context_id=federation_context_id, + app_id=app_id_value, + app_instance_id=appInstanceId, + zone_id=zone_id, + token=fed_token, + ) + return jsonify(remove_response), remove_status return jsonify({ "error": response.get("error") if isinstance(response, dict) else response.text, -- GitLab From a4dbc0ebcc3c5f67b6a6d00d9bdae2f4e6a0dfe4 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sat, 31 Jan 2026 21:37:49 +0100 Subject: [PATCH 10/10] delete federated app metadata when SRM fails --- .../controllers/app_controllers.py | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index ade2401..6786ea8 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -145,13 +145,68 @@ def get_app(appId, x_correlator=None): ) -def delete_app(appId, x_correlator=None): - """Delete Application metadata from an Edge Cloud Provider""" - try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() - response = api_client.delete_app(appId=appId) - return response +def delete_app(appId, x_correlator=None): + """Delete Application metadata from an Edge Cloud Provider""" + try: + pi_edge_factory = PiEdgeAPIClientFactory() + api_client = pi_edge_factory.create_pi_edge_api_client() + response = api_client.delete_app(appId=appId) + if isinstance(response, dict) and int(response.get("status_code", 500)) >= 400: + logger.info("SRM app delete failed, attempting federation cleanup") + else: + return response + + feds = get_all_feds() + if feds: + app_provider_id = None + app_response = api_client.get_app(appId) + if isinstance(app_response, dict): + app_provider_id = app_response.get("appProvider") or app_response.get("appProviderId") + manifest = app_response.get("appManifest") + if isinstance(manifest, dict) and not app_provider_id: + app_provider_id = manifest.get("appProvider") or manifest.get("appProviderId") + + for fed in feds: + fed_token = fed.get("token") + federation_context_id = fed.get("_id") + if not federation_context_id or not fed_token: + continue + if app_provider_id: + fed_instances, fed_code = federation_client.get_all_app_instances( + federation_context_id=federation_context_id, + app_id=appId, + app_provider_id=app_provider_id, + token=fed_token, + ) + if fed_code == 200 and isinstance(fed_instances, list): + for zone_info in fed_instances: + if not isinstance(zone_info, dict): + continue + zone_id = zone_info.get("zoneId") + instances_list = zone_info.get("appInstanceInfo", []) + if not zone_id or not isinstance(instances_list, list): + continue + for instance in instances_list: + if not isinstance(instance, dict): + continue + instance_id = instance.get("appInstIdentifier") + if not instance_id: + continue + federation_client.remove_app_instance( + federation_context_id=federation_context_id, + app_id=appId, + app_instance_id=instance_id, + zone_id=zone_id, + token=fed_token, + ) + + remove_response, remove_status = federation_client.delete_onboarded_app( + federation_context_id, appId, fed_token + ) + if remove_status in (200, 202, 204): + return jsonify(remove_response), remove_status + + return response except NotFound404Exception: return ( -- GitLab