diff --git a/edge_cloud_management_api/configs/env_config.py b/edge_cloud_management_api/configs/env_config.py index b8e709d2ff1e3e1c3eb8814aa2ec008a5ca9a77a..fc1a1f8be1dbaab399c8d9442c2cfbe27eaa12f5 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 d1a98c06a02295008c5c18ff445278b5167eaa76..6786ea89490855835adf4ad00bbfc64245529c08 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -4,7 +4,8 @@ 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 get_fed +from edge_cloud_management_api.services.storage_service import insert_zones +from edge_cloud_management_api.services.storage_service import get_fed, get_all_feds import json import re import uuid @@ -144,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 ( @@ -165,7 +221,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 +242,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", @@ -789,25 +870,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 @@ -820,7 +973,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 @@ -828,16 +981,99 @@ 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) + + 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 + + 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") + federation_context_id = fed.get("_id") + if not federation_context_id or not fed_token: + continue + 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({ - "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/controllers/edge_cloud_controller.py b/edge_cloud_management_api/controllers/edge_cloud_controller.py index 96fdc6cad30217508b09d8bbbd8c6eaa90196247..26c7323e3f872e71b53661662f6470bd5ee95666 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 @@ -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)$", ) @@ -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,25 @@ 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 and merge with local SRM zones.""" + cached = [] + try: + cached = get_zones() + except Exception as e: + logger.warning("Failed to read cached zones: %s", e) + + 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""" @@ -108,7 +129,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: diff --git a/edge_cloud_management_api/controllers/federation_manager_controller.py b/edge_cloud_management_api/controllers/federation_manager_controller.py index 05747f2529d9a7900ad08297447b57db754fa551..bff3f5031692a1b80eaf7c12a007e73196274d46 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: @@ -47,17 +49,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 +128,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() @@ -133,8 +148,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/federation_services.py b/edge_cloud_management_api/services/federation_services.py index 264bf24d7cf5056b32c24e164d7f54904740e2db..d947dd1261ab19f5d2ebc242d35810d225376a3a 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 @@ -232,6 +239,88 @@ 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 + + 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): @@ -239,19 +328,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/services/storage_service.py b/edge_cloud_management_api/services/storage_service.py index b7721aec456dbccc7211dce1d7bed0e4a5706d7b..5b31e7d0bb11abb98e6807777015cf6ca39522a4 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 fa05184d38f418b97c1ac69716cb328d661f56c9..5d5a0704ab13e320dc21e19c2d73ac21dc6bba67 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,98 @@ 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 + /{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 + content: + application/json: + schema: + $ref: '#/components/schemas/ZoneResourceInfo' + "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 + content: + application/json: + schema: + $ref: '#/components/schemas/ZoneReservationRemoval' + "404": + description: Zone not found /fed-context-id: get: tags: @@ -2164,6 +2256,81 @@ 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' + + OSType: + type: object + properties: + distribution: + type: string + version: + type: string + architecture: + type: string + license: + type: string FixedNetworkIds: minItems: 1 diff --git a/tests/unit/controllers/test_edge_cloud_controller.py b/tests/unit/controllers/test_edge_cloud_controller.py index 9699560377e18719638506419aff6a2ed3c30d7d..d624b36b66fe5a03a9c0a46ad66d99db9c8164c5 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,55 @@ 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_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", + 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 cac78b3752b24faa025779396cd2f527a8ba4628..a2906b012759a619fa3e24ed8a3b14f177f9633c 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()