diff --git a/.gitignore b/.gitignore index e4ee6ae84a6327ca17b787eee69a386630b5ca67..fc2253d378c92c97c5730a2a6d2cbe4cc037c702 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ htmlcov/ .python_version coverage.xml uv.lock + +# Runtime scratch files +.tmp/ diff --git a/edge_cloud_management_api/configs/env_config.py b/edge_cloud_management_api/configs/env_config.py index fc1a1f8be1dbaab399c8d9442c2cfbe27eaa12f5..2b161fb9c1801a36dcbadb68b64d53cd2e56b2b2 100644 --- a/edge_cloud_management_api/configs/env_config.py +++ b/edge_cloud_management_api/configs/env_config.py @@ -8,8 +8,8 @@ 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") + SRM_USERNAME: str = os.getenv("SRM_USERNAME") + SRM_PASSWORD: str = os.getenv("SRM_PASSWORD") HTTP_PROXY: str = os.getenv("HTTP_PROXY") FEDERATION_MANAGER_HOST=os.getenv("FEDERATION_MANAGER_HOST") TOKEN_ENDPOINT = os.getenv('TOKEN_ENDPOINT') diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 9bee8e9d14ce8790a66b8df80b5075cc1eebcdd5..065041a52f9965232a73a9b3b7acee924aa402f2 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -1,66 +1,138 @@ -from flask import jsonify, request -from pydantic import ValidationError -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, get_all_feds -import json -import re -import uuid +from flask import jsonify, request +from pydantic import ValidationError +from edge_cloud_management_api.models.application_models import AppManifest +from edge_cloud_management_api.managers.log_manager import logger +from edge_cloud_management_api.controllers.app_federation_helpers import ensure_gsma_id +from edge_cloud_management_api.controllers.app_federation_helpers import ensure_res_pool +from edge_cloud_management_api.controllers.app_federation_helpers import ensure_service_name +from edge_cloud_management_api.controllers.app_federation_helpers import get_catalog_app_provider_map +from edge_cloud_management_api.controllers.app_federation_helpers import iter_federated_instances +from edge_cloud_management_api.controllers.app_federation_helpers import normalize_federated_app_id +from edge_cloud_management_api.controllers.app_federation_helpers import normalize_federated_artefact_id +from edge_cloud_management_api.controllers.app_federation_helpers import normalize_federated_app_provider_id +from edge_cloud_management_api.controllers.app_federation_helpers import resolve_app_provider +from edge_cloud_management_api.controllers.app_federation_helpers import resolve_federated_app_identity +from edge_cloud_management_api.controllers.app_federation_helpers import resolve_federated_app_provider_id +from edge_cloud_management_api.controllers.app_instance_helpers import dedupe_app_instances +from edge_cloud_management_api.controllers.app_instance_helpers import enrich_instance_zone_from_catalog +from edge_cloud_management_api.controllers.app_instance_helpers import normalize_federated_app_instances +from edge_cloud_management_api.controllers.app_instance_helpers import normalize_local_app_instance +from edge_cloud_management_api.controllers.app_partner_orchestration import cleanup_federated_app +from edge_cloud_management_api.controllers.app_partner_orchestration import deploy_to_partner +from edge_cloud_management_api.controllers.app_partner_orchestration import resolve_target_zone +from edge_cloud_management_api.services.edge_cloud_services import SRMAPIClientFactory +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, get_all_feds +import json +import re +import uuid +from urllib.parse import urlsplit factory = FederationManagerClientFactory() federation_client = factory.create_federation_client() -class NotFound404Exception(Exception): - pass - - -def _ensure_gsma_id(value, pattern, prefix, min_len, max_len, fallback_source): - if value and re.match(pattern, value): - return value - base = re.sub(r"[^A-Za-z0-9_]", "", str(fallback_source or "")) - candidate = f"{prefix}{base}" - if not re.match(r"^[A-Za-z]", candidate): - candidate = f"{prefix}{candidate}" - candidate = candidate[:max_len] - if len(candidate) < min_len: - candidate = (candidate + uuid.uuid4().hex)[:max_len] - if not re.match(r"^[A-Za-z]", candidate): - candidate = f"{prefix}{candidate}" - candidate = candidate[:max_len] - return candidate - - -def _ensure_service_name(value, prefix, fallback_source): - pattern = r"^[A-Za-z0-9][A-Za-z0-9_]{6,62}[A-Za-z0-9]$" - if value and re.match(pattern, value): - return value - base = re.sub(r"[^A-Za-z0-9_]", "", str(fallback_source or "")) - candidate = f"{prefix}{base}" - candidate = re.sub(r"^_+", "", candidate) - candidate = candidate[:64] - if len(candidate) < 8: - candidate = (candidate + uuid.uuid4().hex)[:64] - if not re.match(r"^[A-Za-z0-9]", candidate): - candidate = f"s{candidate}" - if not re.match(r"[A-Za-z0-9]$", candidate): - candidate = f"{candidate}0" - return candidate[:64] - - -def _split_image_reference(image_path): - if not image_path: - return None, None - clean_path = str(image_path).lstrip("/") - parts = clean_path.split("/", 1) - if len(parts) == 1: - return "docker.io", clean_path - registry_candidate = parts[0] - if "." in registry_candidate or ":" in registry_candidate: - return registry_candidate, parts[1] - return "docker.io", clean_path +class NotFound404Exception(Exception): + pass + + +_ensure_gsma_id = ensure_gsma_id +_ensure_res_pool = ensure_res_pool +_ensure_service_name = ensure_service_name +_get_catalog_app_provider_map = get_catalog_app_provider_map +_normalize_federated_app_id = normalize_federated_app_id +_normalize_federated_artefact_id = normalize_federated_artefact_id +_normalize_federated_app_provider_id = normalize_federated_app_provider_id +_normalize_federated_app_instances = normalize_federated_app_instances +_normalize_local_app_instance = normalize_local_app_instance +_dedupe_app_instances = dedupe_app_instances +_enrich_instance_zone_from_catalog = enrich_instance_zone_from_catalog +_resolve_app_provider = resolve_app_provider +_resolve_federated_app_identity = resolve_federated_app_identity +_resolve_federated_app_provider_id = resolve_federated_app_provider_id + + +def _get_app_cleanup_metadata(api_client, app_id): + app_response = api_client.get_app(app_id) + if not isinstance(app_response, dict): + return None, None + + app_provider_id = app_response.get("appProvider") or app_response.get("appProviderId") + artefact_id = None + manifest = app_response.get("appManifest") + if isinstance(manifest, dict) and not app_provider_id: + app_provider_id = manifest.get("appProvider") or manifest.get("appProviderId") + app_component_specs = app_response.get("appComponentSpecs") + if isinstance(app_component_specs, list) and app_component_specs: + artefact_id = app_component_specs[0].get("artefactId") + if artefact_id is None and isinstance(manifest, dict): + manifest_component_specs = manifest.get("appComponentSpecs") + if isinstance(manifest_component_specs, list) and manifest_component_specs: + artefact_id = manifest_component_specs[0].get("artefactId") + return app_provider_id, artefact_id + + +def _cleanup_federated_app_before_local_delete(api_client, app_id): + feds = get_all_feds() + if not feds: + return None + + app_provider_id, artefact_id = _get_app_cleanup_metadata(api_client, app_id) + cleanup_response = cleanup_federated_app( + federation_client=federation_client, + feds=feds, + app_id=app_id, + app_provider_id=app_provider_id, + normalize_federated_app_id=_normalize_federated_app_id, + artefact_id=artefact_id, + normalize_federated_artefact_id=_normalize_federated_artefact_id, + resolve_federated_app_identity=_resolve_federated_app_identity, + ) + if cleanup_response is None: + return None + + _, cleanup_status = cleanup_response + if cleanup_status in (200, 202, 204): + return None + return cleanup_response + + +def _delete_local_app(api_client, app_id): + response = api_client.delete_app(appId=app_id) + if isinstance(response, dict): + status = int(response.get("status_code", 500)) + if status == 404: + return None + if status >= 400: + return jsonify(response), status + return "", 204 + + +def _iter_federated_instances(feds, app_id_provider_pairs): + return iter_federated_instances(federation_client, feds, app_id_provider_pairs) + + +def _split_image_reference(image_path): + if not image_path: + return None, None + if str(image_path).startswith(("http://", "https://")): + parsed = urlsplit(str(image_path)) + path = parsed.path.rstrip("/") + if not path: + return f"{parsed.scheme}://{parsed.netloc}", None + repo_path, _, image_ref = path.rpartition("/") + repo_url = f"{parsed.scheme}://{parsed.netloc}{repo_path or path}" + if not image_ref: + return repo_url, None + return repo_url, image_ref + clean_path = str(image_path).lstrip("/") + parts = clean_path.split("/", 1) + if len(parts) == 1: + return "docker.io/library", clean_path + registry_candidate = parts[0] + if "." in registry_candidate or ":" in registry_candidate: + return registry_candidate, parts[1] + return "docker.io", clean_path def _split_image_name_tag(image_ref): @@ -75,30 +147,18 @@ def _split_image_name_tag(image_ref): return image_ref, "latest" -def _ensure_res_pool(value, fallback_source): - pattern = r"^[A-Za-z0-9][A-Za-z0-9_]{6,30}[A-Za-z0-9]$" - if value and re.match(pattern, value): - return value - base = re.sub(r"[^A-Za-z0-9_]", "", str(fallback_source or "")) - candidate = f"respool{base}"[:32] - if len(candidate) < 8: - candidate = (candidate + uuid.uuid4().hex)[:32] - if not re.match(r"^[A-Za-z0-9]", candidate): - candidate = f"r{candidate}" - if not re.match(r"[A-Za-z0-9]$", candidate): - candidate = f"{candidate}0" - return candidate[:32] -def submit_app(body: dict): - """ - Controller for submitting application metadata. - """ - try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() - response = api_client.submit_app(body) - return response +def submit_app(body: dict): + """ + Controller for submitting application metadata. + """ + try: + AppManifest(**body) + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() + response = api_client.submit_app(body) + return response except ValidationError as e: return jsonify({"error": "Invalid input", "details": e.errors()}), 400 @@ -113,8 +173,8 @@ def submit_app(body: dict): def get_apps(x_correlator=None): """Retrieve metadata information of all applications""" try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() registered_apps = api_client.get_service_functions_catalogue() return registered_apps except Exception as e: @@ -124,16 +184,18 @@ def get_apps(x_correlator=None): ) -def get_app(appId, x_correlator=None): - """Retrieve the information of an Application""" - try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() - response = api_client.get_app(appId) - return response - - except NotFound404Exception: - return ( +def get_app(appId, x_correlator=None): + """Retrieve the information of an Application""" + try: + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() + response = api_client.get_app(appId) + if isinstance(response, dict) and int(response.get("status_code", 200)) >= 400: + return jsonify(response), int(response.get("status_code", 500)) + return response + + except NotFound404Exception: + return ( jsonify({"status": 404, "code": "NOT_FOUND", "message": "Resource does not exist"}), 404, ) @@ -145,71 +207,22 @@ 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) - 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 ( +def delete_app(appId, x_correlator=None): + """Delete Application metadata from an Edge Cloud Provider""" + try: + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() + cleanup_response = _cleanup_federated_app_before_local_delete(api_client, appId) + if cleanup_response is not None: + return cleanup_response + + result = _delete_local_app(api_client, appId) + if result is not None: + return result + return "", 204 + + except NotFound404Exception: + return ( jsonify({"status": 404, "code": "NOT_FOUND", "message": "Resource does not exist"}), 404, ) @@ -236,42 +249,29 @@ def create_app_instance(): "error": "Missing required fields: appId, appZones" }), 400 - pi_edge_client_factory = PiEdgeAPIClientFactory() - pi_edge_client = pi_edge_client_factory.create_pi_edge_api_client() + srm_client_factory = SRMAPIClientFactory() + srm_client = srm_client_factory.create_srm_api_client() first_zone = app_zones[0] if isinstance(app_zones, list) and app_zones else {} if not isinstance(first_zone, dict): first_zone = {} - zone_payload = ( - first_zone.get("EdgeCloudZone", {}) - if isinstance(first_zone.get("EdgeCloudZone", {}), dict) - else first_zone - ) - 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 + zone_payload = ( + first_zone.get("EdgeCloudZone", {}) + if isinstance(first_zone.get("EdgeCloudZone", {}), dict) + else first_zone + ) + edge_cloud_zone_id = None + edge_cloud_provider = None + if isinstance(zone_payload, dict): + edge_cloud_zone_id = zone_payload.get("edgeCloudZoneId") or zone_payload.get("zoneId") + edge_cloud_provider = zone_payload.get("edgeCloudProvider") + + zone = resolve_target_zone( + srm_client=srm_client, + edge_cloud_zone_id=edge_cloud_zone_id, + edge_cloud_provider=edge_cloud_provider, + zone_payload=zone_payload, + ) if not zone: return jsonify({ @@ -279,308 +279,52 @@ def create_app_instance(): "edgeCloudZoneId": edge_cloud_zone_id }), 404 - # ============================================================ - # PARTNER DEPLOYMENT (Federation path) - # ============================================================ - if zone.get("isLocal") == "false": - - # ============================================================ - # Step 1: Retrieve application metadata from SRM - # ============================================================ - app_response = pi_edge_client.get_app(appId=app_id) - appData = app_response.get("appManifest") if isinstance(app_response, dict) else None - if not isinstance(appData, dict): - appData = None - - if not appData: - return jsonify({ - "error": "Application manifest not found", - "appId": app_id - }), 404 - - # ============================================================ - # Step 2: Compose GSMA artefact payload - # artefactId == appId (INTENTIONAL) - # ============================================================ - artefact_id = app_id - app_provider_id = appData.get("appProvider") or appData.get("appProviderId") - app_name = appData.get("name") or appData.get("appName") or app_id - app_version = appData.get("version") or "v1" - app_repo = appData.get("appRepo", {}) - if not isinstance(app_repo, dict): - app_repo = {} - image_path = app_repo.get("imagePath") - repo_type = app_repo.get("type", "PUBLICREPO") - component_specs = appData.get("componentSpec", []) - component_name = None - if component_specs and isinstance(component_specs, list): - component_name = component_specs[0].get("componentName") - component_name = component_name or app_name - - service_name_nb = _ensure_service_name( - (component_specs[0].get("serviceNameNB") if component_specs else None), - "nb", - component_name, - ) - service_name_ew = _ensure_service_name( - (component_specs[0].get("serviceNameEW") if component_specs else None), - "ew", - component_name, - ) - - app_provider_id = _ensure_gsma_id( - app_provider_id, - r"^[A-Za-z][A-Za-z0-9_]{7,63}$", - "provider", - 8, - 64, - app_id, - ) - app_name = _ensure_gsma_id( - app_name, - r"^[A-Za-z][A-Za-z0-9_]{7,31}$", - "app", - 8, - 32, - app_id, - ) - access_token = _ensure_gsma_id( - appData.get("accessToken"), - r"^[A-Za-z][A-Za-z0-9_]{31,63}$", - "token", - 32, - 64, - app_id, - ) - repo_url, image_ref = _split_image_reference(image_path) - if not repo_url or not image_ref: - return jsonify({ - "error": "Application manifest missing imagePath", - "appId": app_id - }), 400 - image_name, image_tag = _split_image_name_tag(image_ref) - app_deployment_zones = appData.get("appDeploymentZones") - if not isinstance(app_deployment_zones, list) or not app_deployment_zones: - app_deployment_zones = [edge_cloud_zone_id] - app_qos = appData.get("appQoSProfile", {}) - if not isinstance(app_qos, dict): - app_qos = {} - bandwidth_required = app_qos.get("bandwidthRequired", 1) - multi_user_clients = app_qos.get("multiUserClients", "APP_TYPE_SINGLE_USER") - no_of_users = app_qos.get("noOfUsersPerAppInst", 1) - app_provisioning = app_qos.get("appProvisioning", True) - latency_constraints = app_qos.get("latencyConstraints", "NONE") - app_status_callback_link = ( - appData.get("appStatusCallbackLink") - or appData.get("statusCallbackLink") - or "http://callback.local" - ) - edge_app_fqdn = f"{app_name.lower().replace('_', '-')}.edge.local" - - artefact = { - "artefactId": artefact_id, - "appProviderId": app_provider_id, - "artefactName": image_name, - "artefactVersionInfo": image_tag, - "artefactVirtType": "CONTAINER_TYPE", - "artefactDescriptorType": "COMPONENTSPEC", - "repoType": repo_type, - "artefactRepoLocation": { - "repoURL": repo_url - }, - "componentSpec": [ - { - "componentName": component_name or app_name, - "images": [image_ref], - "numOfInstances": 1, - "restartPolicy": "RESTART_POLICY_ALWAYS", - "computeResourceProfile": { - "cpuArchType": "ISA_X86_64", - "numCPU": "100m", - "memory": 128 - } - } - ] - } - - fed_record = get_fed( - zone.get("fedContextId") - ) - if not fed_record or "token" not in fed_record: - return jsonify({ - "error": "Federation token not found", - "federationContextId": zone.get("fedContextId") - }), 404 - fed_token = fed_record.get("token") - - print("\n========== OEG → FM ARTEFACT PAYLOAD ==========") - print(json.dumps(artefact, indent=2)) - print("================================================\n") - - # ============================================================ - # Step 3: Create artefact at Federation Manager - # ============================================================ - artefact_body, artefact_status = federation_client.create_artefact( - artefact=artefact, - federation_context_id=zone.get("fedContextId"), - token=fed_token - ) - print(f"\n========== ARTEFACT CREATION RESPONSE ==========") - print(f"Status: {artefact_status}") - print(f"Response: {json.dumps(artefact_body, indent=2)}") - print("================================================\n") - - # Idempotency: duplicate artefact = success - if artefact_status == 422 and "duplicate key" in str(artefact_body): - logger.info("Artefact already exists in FM, continuing") - artefact_status = 200 - - if artefact_status not in (200, 409): - return jsonify({ - "error": "Artefact creation failed", - "fm_response": artefact_body - }), artefact_status - - # ============================================================ - # Step 4: Onboard application at partner OP (HARDCODED FOR TESTING) - # ============================================================ - fed_context_id = zone.get("fedContextId") - logger.info(f"Federation Context ID from zone: {fed_context_id}") - logger.info(f"Federation Manager URL: {federation_client.base_url}") - print(f"\n========== FEDERATION CONTEXT DEBUG ==========") - print(f"Using Federation Context ID: {fed_context_id}") - print(f"Federation Manager Base URL: {federation_client.base_url}") - print(f"Full Onboard URL: {federation_client.base_url}/{fed_context_id}/application/onboarding") - print("===============================================\n") - print("\n========== CHECKING FEDERATION IDS ==========") - fed_ids_body, fed_ids_status = federation_client.get_federation_context_ids(token=fed_token) - print(f"Status: {fed_ids_status}") - print(f"Federation IDs: {json.dumps(fed_ids_body, indent=2)}") - print(f"Looking for: {zone.get('fedContextId')}") - print("=============================================\n") - print(f"\n========== HEADERS DEBUG ==========") - print(f"X-Partner-API-Root: {federation_client.partner_root}") - print(f"Authorization: Bearer {fed_token[:20]}...") - print("===================================\n") - print ("DEBUG: About to check partner status...") - try: - print("\n========== CHECKING PARTNER STATUS ==========") - partner_body, partner_status = federation_client.get_partner( - federation_context_id=zone.get("fedContextId"), - token=fed_token - ) - print(f"Partner Status: {partner_status}") - print(f"Partner Info: {json.dumps(partner_body, indent=2)}") - print("=============================================\n") - except Exception as e: - print(f"\n========== PARTNER CHECK ERROR ==========") - print(f"Error getting partner status: {str(e)}") - print("=========================================\n") - - - - onboard_app = { - "appId": app_id, - "appProviderId": app_provider_id, - "appMetaData": { - "appName": app_name, - "version": app_version, - "accessToken": access_token - }, - "appQoSProfile": { - "latencyConstraints": latency_constraints, - "bandwidthRequired": bandwidth_required, - "multiUserClients": multi_user_clients, - "noOfUsersPerAppInst": no_of_users, - "appProvisioning": app_provisioning - }, - "appComponentSpecs": [ - { - "artefactId": artefact_id, - "componentName": component_name, - "serviceNameNB": service_name_nb, - "serviceNameEW": service_name_ew - } - ], - "appStatusCallbackLink": app_status_callback_link, - "appDeploymentZones": app_deployment_zones, - "edgeAppFQDN": edge_app_fqdn - } - - print("\n========== OEG → FM ONBOARD PAYLOAD ==========") - print(json.dumps(onboard_app, indent=2)) - print("==============================================\n") - - - - onboard_app_body, onboard_app_status = federation_client.onboard_application( - federation_context_id=zone.get("fedContextId"), - body=onboard_app, - token=fed_token - ) - print(f"\n========== ONBOARD RESPONSE DEBUG ==========") - print(f"Status: {onboard_app_status}") - print(f"Response Body: {json.dumps(onboard_app_body, indent=2)}") - print("============================================\n") - - if onboard_app_status not in (200, 202, 409): - return jsonify({ - "error": "Application onboarding failed", - "fm_response": onboard_app_body - }), onboard_app_status - - - - # ============================================================ - # Step 5: Deploy application at partner OP (HARDCODED FOR TESTING) - # ============================================================ - res_pool_value = _ensure_res_pool( - zone.get("resPool"), - zone.get("edgeCloudZoneId") - ) - deploy_app = { - "appId": app_id, - "appProviderId": app_provider_id, - "appVersion": app_version, - "appInstCallbackLink": appData.get("appInstCallbackLink", ""), - "zoneInfo": { - "zoneId": zone.get("edgeCloudZoneId"), - "flavourId": zone.get("flavourId", "default"), - "resPool": res_pool_value, - "resourceConsumption": "RESERVED_RES_AVOID" - } - } - - print("\n========== OEG → FM DEPLOY PAYLOAD ==========") - print(json.dumps(deploy_app, indent=2)) - print("=============================================\n") - - deploy_app_body, deploy_app_status = federation_client.deploy_app_partner( - federation_context_id=zone.get("fedContextId"), - body=deploy_app, - token=fed_token - ) - - if deploy_app_status in (200, 201, 202): - return jsonify({ - "message": "Application deployed successfully at partner OP", - "appId": app_id, - "deployment_response": deploy_app_body - }), deploy_app_status - else: - return jsonify({ - "error": "Application deployment failed", - "fm_response": deploy_app_body - }), deploy_app_status + # ============================================================ + # PARTNER DEPLOYMENT (Federation path) + # ============================================================ + if zone.get("isLocal") == "false": + app_response = srm_client.get_app(appId=app_id) + appData = app_response.get("appManifest") if isinstance(app_response, dict) else None + if not isinstance(appData, dict): + appData = None + + if not appData: + return jsonify({ + "error": "Application manifest not found", + "appId": app_id + }), 404 + return deploy_to_partner( + federation_client=federation_client, + zone=zone, + app_id=app_id, + app_data=appData, + split_image_reference=_split_image_reference, + split_image_name_tag=_split_image_name_tag, + ensure_gsma_id=_ensure_gsma_id, + ensure_service_name=_ensure_service_name, + ensure_res_pool=_ensure_res_pool, + normalize_federated_app_id=_normalize_federated_app_id, + normalize_federated_app_provider_id=_normalize_federated_app_provider_id, + normalize_federated_artefact_id=_normalize_federated_artefact_id, + ) # ============================================================ # LOCAL DEPLOYMENT (SRM path) # ============================================================ - logger.info(f"Proceeding with LOCAL deployment for appId={app_id}") - - try: - logger.debug("Sending deployment request to SRM") - response = pi_edge_client.deploy_service_function(data=body) + logger.info(f"Proceeding with LOCAL deployment for appId={app_id}") + + try: + logger.debug("Sending deployment request to SRM") + local_deploy_body = dict(body) + local_zone = { + "edgeCloudZoneId": zone.get("edgeCloudZoneId"), + "edgeCloudZoneName": zone.get("edgeCloudZoneName"), + "edgeCloudProvider": zone.get("edgeCloudProvider"), + "edgeCloudZoneStatus": zone.get("edgeCloudZoneStatus"), + "edgeCloudRegion": zone.get("edgeCloudRegion"), + } + local_deploy_body["appZones"] = [{"EdgeCloudZone": local_zone}] + response = srm_client.deploy_service_function(data=local_deploy_body) if isinstance(response, dict) and "error" in response: logger.warning( @@ -612,99 +356,79 @@ def create_app_instance(): "details": str(e) }), 500 -def get_app_instance(app_id=None, x_correlator=None, app_instance_id=None, region=None): +def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=None, app_instance_id=None, appInstanceId=None, region=None): """ Retrieve application instances from the database. Supports filtering by app_id, app_instance_id, and region. """ - try: - 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()["appInstances"] - 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 + try: + app_id = app_id or appId + app_instance_id = app_instance_id or appInstanceId + instances = [] + srm_client_factory = SRMAPIClientFactory() + srm_client = srm_client_factory.create_srm_api_client() + + local_response = srm_client.get_app_instances() + local_instances = [] + if isinstance(local_response, dict): + local_instances = local_response.get("appInstances", []) + elif isinstance(local_response, list): + local_instances = local_response + + for instance in local_instances: + normalized_instance = _normalize_local_app_instance(instance) + if normalized_instance: + if app_id and normalized_instance.get("appId") and normalized_instance.get("appId") != app_id: + continue + instances.append(normalized_instance) + + feds = get_all_feds() + if app_id: + app_provider_id = _resolve_app_provider(srm_client, app_id) + if app_provider_id: + for app_id_value, _federated_app_id, _federation_context_id, _fed_token, zone_provider, fed_instances in _iter_federated_instances( + feds, + [(app_id, app_provider_id)], + ): + instances.extend( + _normalize_federated_app_instances( + fed_instances, + zone_provider=zone_provider, + region=region, + ) + ) + else: + logger.info("Skipping federated lookup; no appProviderId for appId=%s", app_id) + else: + app_provider_map = _get_catalog_app_provider_map(srm_client) + for app_id_value, _federated_app_id, _federation_context_id, _fed_token, zone_provider, fed_instances in _iter_federated_instances( + feds, + app_provider_map.items(), + ): + instances.extend( + _normalize_federated_app_instances( + fed_instances, + zone_provider=zone_provider, + region=region, + ) + ) + + if app_instance_id: + instances = [ + instance for instance in instances + if instance.get("appInstanceId") == app_instance_id + ] + if region: + instances = [ + instance for instance in instances + if isinstance(instance.get("edgeCloudZone"), dict) + and instance["edgeCloudZone"].get("edgeCloudRegion") == region + ] + + instances = _dedupe_app_instances(instances) + instances = [_enrich_instance_zone_from_catalog(instance) for instance in instances] + + return jsonify(instances), 200 except Exception as e: logger.exception("Failed to retrieve app instances") @@ -723,9 +447,9 @@ 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() - response = pi_edge_client.delete_app_instance(appInstanceId) + srm_client_factory = SRMAPIClientFactory() + srm_client = srm_client_factory.create_srm_api_client() + response = srm_client.delete_app_instance(appInstanceId) if isinstance(response, dict) and response.get("status_code") != 404: status_code = response.get("status_code", 500) return jsonify(response), status_code @@ -740,77 +464,36 @@ def delete_app_instance(appInstanceId: str, x_correlator=None): 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 + app_provider_map = _get_catalog_app_provider_map(srm_client) + + feds = get_all_feds() + for app_id_value, federated_app_id, federation_context_id, fed_token, _zone_provider, fed_instances in _iter_federated_instances( + feds, + app_provider_map.items(), + ): + 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=federated_app_id, + app_instance_id=appInstanceId, + zone_id=zone_id, + token=fed_token, + ) + if remove_status >= 400 and "App instance not found" in str(remove_response): + return "", 204 + return jsonify(remove_response), remove_status return jsonify({ "error": response.get("error") if isinstance(response, dict) else response.text, diff --git a/edge_cloud_management_api/controllers/app_federation_helpers.py b/edge_cloud_management_api/controllers/app_federation_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..608fbdb5230d1cacbeeb828be1fa813c3d483590 --- /dev/null +++ b/edge_cloud_management_api/controllers/app_federation_helpers.py @@ -0,0 +1,165 @@ +import re +import uuid + + +def ensure_gsma_id(value, pattern, prefix, min_len, max_len, fallback_source): + if value and re.match(pattern, value): + return value + base = re.sub(r"[^A-Za-z0-9_]", "", str(fallback_source or "")) + candidate = f"{prefix}{base}" + if not re.match(r"^[A-Za-z]", candidate): + candidate = f"{prefix}{candidate}" + candidate = candidate[:max_len] + if len(candidate) < min_len: + candidate = (candidate + uuid.uuid4().hex)[:max_len] + if not re.match(r"^[A-Za-z]", candidate): + candidate = f"{prefix}{candidate}" + candidate = candidate[:max_len] + return candidate + + +def ensure_service_name(value, prefix, fallback_source): + pattern = r"^[A-Za-z0-9][A-Za-z0-9_]{6,62}[A-Za-z0-9]$" + if value and re.match(pattern, value): + return value + base = re.sub(r"[^A-Za-z0-9_]", "", str(fallback_source or "")) + candidate = f"{prefix}{base}" + candidate = re.sub(r"^_+", "", candidate) + candidate = candidate[:64] + if len(candidate) < 8: + candidate = (candidate + uuid.uuid4().hex)[:64] + if not re.match(r"^[A-Za-z0-9]", candidate): + candidate = f"s{candidate}" + if not re.match(r"[A-Za-z0-9]$", candidate): + candidate = f"{candidate}0" + return candidate[:64] + + +def ensure_res_pool(value, fallback_source): + pattern = r"^[A-Za-z0-9][A-Za-z0-9_]{6,30}[A-Za-z0-9]$" + if value and re.match(pattern, value): + return value + base = re.sub(r"[^A-Za-z0-9_]", "", str(fallback_source or "")) + candidate = f"respool{base}"[:32] + if len(candidate) < 8: + candidate = (candidate + uuid.uuid4().hex)[:32] + if not re.match(r"^[A-Za-z0-9]", candidate): + candidate = f"r{candidate}" + if not re.match(r"[A-Za-z0-9]$", candidate): + candidate = f"{candidate}0" + return candidate[:32] + + +def normalize_federated_app_id(app_id): + pattern = ( + r"^(?:[A-Za-z][A-Za-z0-9_]{7,63}|" + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$" + ) + if app_id and re.match(pattern, str(app_id)): + return app_id + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"federated-app:{app_id}")) + + +def normalize_federated_app_provider_id(app_provider_id, fallback_source): + return ensure_gsma_id( + app_provider_id, + r"^[A-Za-z][A-Za-z0-9_]{7,63}$", + "provider", + 8, + 64, + fallback_source, + ) + + +def resolve_federated_app_provider_id(app_id_value, app_provider_id): + if not app_provider_id: + return None + return normalize_federated_app_provider_id(app_provider_id, app_id_value) + + +def resolve_federated_app_identity(app_id_value, app_provider_id): + if not app_id_value or not app_provider_id: + return None, None + return ( + normalize_federated_app_id(app_id_value), + resolve_federated_app_provider_id(app_id_value, app_provider_id), + ) + + +def normalize_federated_artefact_id(federation_context_id, artefact_id, fallback_source): + if artefact_id and re.match( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + str(artefact_id), + ): + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{federation_context_id}:{artefact_id}")) + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{federation_context_id}:{fallback_source}")) + + +def resolve_app_provider(srm_client, 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 = srm_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 + + +def get_catalog_app_provider_map(srm_client): + app_provider_map = {} + apps = srm_client.get_service_functions_catalogue() + if not isinstance(apps, list): + return 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(srm_client, app_id_value, app_payload=app) + if provider: + app_provider_map[app_id_value] = provider + return app_provider_map + + +def iter_federated_instances(federation_client, feds, app_id_provider_pairs): + 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 + + zone_provider = fed.get("partnerOPFederationId") or "unknown" + for app_id_value, app_provider_id in app_id_provider_pairs: + federated_app_id, federated_app_provider_id = resolve_federated_app_identity( + app_id_value, + app_provider_id, + ) + if not federated_app_id or not federated_app_provider_id: + continue + fed_instances, fed_code = federation_client.get_all_app_instances( + federation_context_id=federation_context_id, + app_id=federated_app_id, + app_provider_id=federated_app_provider_id, + token=fed_token, + ) + if fed_code != 200 or not isinstance(fed_instances, list): + continue + yield app_id_value, federated_app_id, federation_context_id, fed_token, zone_provider, fed_instances diff --git a/edge_cloud_management_api/controllers/app_instance_helpers.py b/edge_cloud_management_api/controllers/app_instance_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..77e2ab409f262b9654839ce4b39b716f28f50a45 --- /dev/null +++ b/edge_cloud_management_api/controllers/app_instance_helpers.py @@ -0,0 +1,186 @@ +from edge_cloud_management_api.services.storage_service import get_zone +from edge_cloud_management_api.controllers.edge_cloud_controller import get_local_zones + + +def _find_local_zone(zone_id, zone_provider=None): + for zone in get_local_zones(): + if not isinstance(zone, dict): + continue + if zone.get("edgeCloudZoneId") != zone_id: + continue + if zone_provider and zone.get("edgeCloudProvider") != zone_provider: + continue + return zone + return None + + +def normalize_local_app_instance(instance): + if not isinstance(instance, dict): + return None + + app_instance_id = instance.get("appInstanceId") or instance.get("appInstIdentifier") + if not app_instance_id: + return None + + status = instance.get("status") or instance.get("appInstanceState") or "unknown" + edge_cloud_zone = instance.get("edgeCloudZone") + if not isinstance(edge_cloud_zone, dict): + zone_id = instance.get("edgeCloudZoneId") or instance.get("zoneId") + zone_name = instance.get("edgeCloudZoneName") or instance.get("zoneName") or "unknown" + edge_cloud_provider = instance.get("edgeCloudProvider") or "unknown" + if zone_id: + edge_cloud_zone = { + "edgeCloudZoneId": zone_id, + "edgeCloudZoneName": zone_name, + "edgeCloudProvider": edge_cloud_provider, + "edgeCloudZoneStatus": instance.get("edgeCloudZoneStatus") or "unknown", + "edgeCloudRegion": instance.get("edgeCloudRegion") or "unknown", + } + + normalized = { + "appInstanceId": app_instance_id, + "status": status, + } + if instance.get("appId"): + normalized["appId"] = instance.get("appId") + if edge_cloud_zone: + normalized["edgeCloudZone"] = edge_cloud_zone + if instance.get("componentEndpointInfo"): + normalized["componentEndpointInfo"] = instance.get("componentEndpointInfo") + elif instance.get("accesspointInfo"): + normalized["componentEndpointInfo"] = instance.get("accesspointInfo") + if instance.get("kubernetesClusterRef"): + normalized["kubernetesClusterRef"] = instance.get("kubernetesClusterRef") + return normalized + + +def enrich_instance_zone_from_catalog(instance): + if not isinstance(instance, dict): + return instance + + zone = instance.get("edgeCloudZone") + if not isinstance(zone, dict): + return instance + + zone_id = zone.get("edgeCloudZoneId") + if not zone_id: + return instance + + zone_provider = zone.get("edgeCloudProvider") + stored_zone = None + if zone_provider and zone_provider != "unknown": + stored_zone = get_zone(zone_id, zone_provider) + else: + stored_zone = _find_local_zone(zone_id) + if not isinstance(stored_zone, dict): + stored_zone = get_zone(zone_id) + if not isinstance(stored_zone, dict): + return instance + + enriched = dict(instance) + enriched_zone = dict(zone) + for key in ( + "edgeCloudZoneId", + "edgeCloudZoneName", + "edgeCloudProvider", + "edgeCloudZoneStatus", + "edgeCloudRegion", + ): + if enriched_zone.get(key) in (None, "unknown") and stored_zone.get(key) is not None: + enriched_zone[key] = stored_zone.get(key) + enriched["edgeCloudZone"] = enriched_zone + return enriched + + +def normalize_federated_app_instances(fed_instances, zone_provider=None, region=None): + normalized_instances = [] + if not isinstance(fed_instances, list): + return normalized_instances + + for zone_info in fed_instances: + if not isinstance(zone_info, dict): + continue + zone_id = zone_info.get("zoneId") + for instance in zone_info.get("appInstanceInfo", []) or []: + if not isinstance(instance, dict): + continue + app_instance_id = instance.get("appInstIdentifier") or instance.get("appInstanceId") + if not app_instance_id: + continue + status = instance.get("appInstanceState") or "unknown" + if isinstance(status, str) and status.startswith("Error 404"): + continue + normalized = { + "appInstanceId": app_instance_id, + "status": status, + } + if instance.get("appId"): + normalized["appId"] = instance.get("appId") + if zone_id: + normalized["edgeCloudZone"] = { + "edgeCloudZoneId": zone_id, + "edgeCloudZoneName": "unknown", + "edgeCloudProvider": zone_provider or "unknown", + "edgeCloudZoneStatus": "unknown", + "edgeCloudRegion": region or "unknown", + } + normalized_instances.append(normalized) + return normalized_instances + + +def dedupe_app_instances(instances): + deduped = [] + by_instance_id = {} + + def zone_quality(instance): + zone = instance.get("edgeCloudZone") + if not isinstance(zone, dict): + return 0 + + score = 0 + if zone.get("edgeCloudProvider") and zone.get("edgeCloudProvider") != "unknown": + score += 4 + if zone.get("edgeCloudZoneName") and zone.get("edgeCloudZoneName") != "unknown": + score += 2 + if zone.get("edgeCloudZoneStatus") and zone.get("edgeCloudZoneStatus") != "unknown": + score += 1 + return score + + def merge_instances(existing, incoming): + merged = dict(existing) + + for key, value in incoming.items(): + if key == "edgeCloudZone" and isinstance(value, dict): + existing_zone = merged.get("edgeCloudZone") + if not isinstance(existing_zone, dict) or zone_quality(incoming) > zone_quality(existing): + merged["edgeCloudZone"] = dict(value) + else: + zone = dict(existing_zone) + for zone_key, zone_value in value.items(): + if zone_key not in zone or zone.get(zone_key) in (None, "unknown"): + zone[zone_key] = zone_value + merged["edgeCloudZone"] = zone + continue + + if key not in merged or merged.get(key) in (None, "unknown", []): + merged[key] = value + + return merged + + for instance in instances: + if not isinstance(instance, dict): + continue + app_instance_id = instance.get("appInstanceId") + if app_instance_id: + existing = by_instance_id.get(app_instance_id) + if existing is not None: + merged = merge_instances(existing, instance) + by_instance_id[app_instance_id] = merged + for index, deduped_instance in enumerate(deduped): + if deduped_instance.get("appInstanceId") == app_instance_id: + deduped[index] = merged + break + continue + by_instance_id[app_instance_id] = instance + deduped.append(instance) + return deduped diff --git a/edge_cloud_management_api/controllers/app_partner_orchestration.py b/edge_cloud_management_api/controllers/app_partner_orchestration.py new file mode 100644 index 0000000000000000000000000000000000000000..3d94ff26a9d7fc3d066aa3df92dd2bd39c45eed8 --- /dev/null +++ b/edge_cloud_management_api/controllers/app_partner_orchestration.py @@ -0,0 +1,415 @@ +import json + +from flask import jsonify + +from edge_cloud_management_api.controllers.edge_cloud_controller import get_local_zones +from edge_cloud_management_api.managers.log_manager import logger +from edge_cloud_management_api.services.storage_service import get_fed +from edge_cloud_management_api.services.storage_service import get_zone +from edge_cloud_management_api.services.storage_service import insert_zones + + +def resolve_target_zone(srm_client, edge_cloud_zone_id, edge_cloud_provider, zone_payload): + zone = get_zone(edge_cloud_zone_id, edge_cloud_provider) if edge_cloud_zone_id else None + if not zone and edge_cloud_zone_id: + try: + zones = get_local_zones() + if isinstance(zones, list): + matching_local_zones = [] + for candidate_zone in zones: + if not isinstance(candidate_zone, dict): + continue + if edge_cloud_provider and candidate_zone.get("edgeCloudProvider") == edge_cloud_provider: + matching_local_zones.append(candidate_zone) + if candidate_zone.get("edgeCloudZoneId") != edge_cloud_zone_id: + continue + if edge_cloud_provider and candidate_zone.get("edgeCloudProvider") != edge_cloud_provider: + continue + if candidate_zone.get("edgeCloudProvider") == edge_cloud_provider or not edge_cloud_provider: + zone = candidate_zone + break + if not zone and edge_cloud_zone_id == "default" and len(matching_local_zones) == 1: + zone = matching_local_zones[0] + 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 = dict(zone_payload) + zone.setdefault("isLocal", "true") + + return zone + + +def cleanup_federated_app( + federation_client, + feds, + app_id, + app_provider_id, + normalize_federated_app_id, + artefact_id, + normalize_federated_artefact_id, + resolve_federated_app_identity, +): + if not feds: + return None + + cleanup_performed = False + federated_app_id = normalize_federated_app_id(app_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 + if app_provider_id: + federated_app_id, federated_app_provider_id = resolve_federated_app_identity(app_id, app_provider_id) + fed_instances, fed_code = federation_client.get_all_app_instances( + federation_context_id=federation_context_id, + app_id=federated_app_id, + app_provider_id=federated_app_provider_id, + token=fed_token, + ) + if fed_code == 200 and isinstance(fed_instances, list): + cleanup_performed = True + 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=federated_app_id, + app_instance_id=instance_id, + zone_id=zone_id, + token=fed_token, + ) + elif fed_code not in (404, 422): + return jsonify(fed_instances), fed_code + + remove_response = federation_client.delete_onboarded_app( + federation_context_id, + federated_app_id, + fed_token, + ) + remove_status = int(remove_response.get("status_code", 500)) if isinstance(remove_response, dict) else 500 + if remove_status in (200, 202, 204, 404): + cleanup_performed = True + if app_provider_id: + federated_artefact_id = normalize_federated_artefact_id( + federation_context_id, + artefact_id, + app_id, + ) + artefact_response = federation_client.delete_artefact( + federation_context_id, + federated_artefact_id, + fed_token, + ) + artefact_status = int(artefact_response.get("status_code", 500)) if isinstance(artefact_response, dict) else 500 + if artefact_status not in (200, 202, 204, 404): + return jsonify(artefact_response), artefact_status + continue + return jsonify(remove_response), remove_status + + if cleanup_performed: + return "", 204 + return None + + +def deploy_to_partner( + federation_client, + zone, + app_id, + app_data, + split_image_reference, + split_image_name_tag, + ensure_gsma_id, + ensure_service_name, + ensure_res_pool, + normalize_federated_app_id, + normalize_federated_app_provider_id, + normalize_federated_artefact_id, +): + federated_app_id = normalize_federated_app_id(app_id) + + artefact_id = None + app_provider_id = app_data.get("appProvider") or app_data.get("appProviderId") + app_name = app_data.get("name") or app_data.get("appName") or app_id + app_version = app_data.get("version") or "v1" + app_repo = app_data.get("appRepo", {}) + if not isinstance(app_repo, dict): + app_repo = {} + image_path = app_repo.get("imagePath") + repo_type = app_repo.get("type", "PUBLICREPO") + component_specs = app_data.get("componentSpec", []) + network_interfaces = [] + required_resources = app_data.get("requiredResources") or {} + if component_specs and isinstance(component_specs, list): + network_interfaces = component_specs[0].get("networkInterfaces", []) or [] + app_component_specs = app_data.get("appComponentSpecs", []) + if isinstance(app_component_specs, list) and app_component_specs: + artefact_id = app_component_specs[0].get("artefactId") + component_name = None + if component_specs and isinstance(component_specs, list): + component_name = component_specs[0].get("componentName") + component_name = component_name or app_name + component_name = ensure_service_name(component_name, "cmp", app_name) + artefact_id = normalize_federated_artefact_id(zone.get("fedContextId"), artefact_id, app_id) + + service_name_nb = ensure_service_name( + component_specs[0].get("serviceNameNB") if component_specs else None, + "nb", + component_name, + ) + service_name_ew = ensure_service_name( + component_specs[0].get("serviceNameEW") if component_specs else None, + "ew", + component_name, + ) + + app_provider_id = ensure_gsma_id( + app_provider_id, + r"^[A-Za-z][A-Za-z0-9_]{7,63}$", + "provider", + 8, + 64, + app_id, + ) + federated_app_provider_id = normalize_federated_app_provider_id(app_provider_id, app_id) + app_name = ensure_gsma_id( + app_name, + r"^[A-Za-z][A-Za-z0-9_]{7,31}$", + "app", + 8, + 32, + app_id, + ) + access_token = ensure_gsma_id( + app_data.get("accessToken"), + r"^[A-Za-z][A-Za-z0-9_]{31,63}$", + "token", + 32, + 64, + app_id, + ) + package_type = (app_data.get("packageType") or "CONTAINER").upper() + artefact_descriptor_type = "HELM" if package_type == "HELM" else "COMPONENTSPEC" + + repo_url, image_ref = split_image_reference(image_path) + if not repo_url or not image_ref: + return jsonify({ + "error": "Application manifest missing imagePath", + "appId": app_id, + }), 400 + image_name, image_tag = split_image_name_tag(image_ref) + edge_cloud_zone_id = zone.get("edgeCloudZoneId") + app_deployment_zones = app_data.get("appDeploymentZones") + if not isinstance(app_deployment_zones, list) or not app_deployment_zones: + app_deployment_zones = [edge_cloud_zone_id] + app_qos = app_data.get("appQoSProfile", {}) + if not isinstance(app_qos, dict): + app_qos = {} + bandwidth_required = app_qos.get("bandwidthRequired", 1) + multi_user_clients = app_qos.get("multiUserClients", "APP_TYPE_SINGLE_USER") + no_of_users = app_qos.get("noOfUsersPerAppInst", 1) + app_provisioning = app_qos.get("appProvisioning", True) + latency_constraints = app_qos.get("latencyConstraints", "NONE") + app_status_callback_link = ( + app_data.get("appStatusCallbackLink") + or app_data.get("statusCallbackLink") + or "http://callback.local" + ) + edge_app_fqdn = f"{app_name.lower().replace('_', '-')}.edge.local" + + artefact = { + "artefactId": artefact_id, + "appProviderId": app_provider_id, + "artefactName": image_name, + "artefactVersionInfo": image_tag, + "artefactVirtType": "CONTAINER_TYPE", + "artefactDescriptorType": artefact_descriptor_type, + "repoType": repo_type, + "artefactRepoLocation": {"repoURL": repo_url}, + "componentSpec": [{ + "componentName": component_name or app_name, + "images": [image_ref], + "numOfInstances": 1, + "restartPolicy": "RESTART_POLICY_ALWAYS", + "computeResourceProfile": { + "cpuArchType": "ISA_X86_64", + "numCPU": "100m", + "memory": 128, + }, + }], + } + + fed_record = get_fed(zone.get("fedContextId")) + if not fed_record or "token" not in fed_record: + return jsonify({ + "error": "Federation token not found", + "federationContextId": zone.get("fedContextId"), + }), 404 + fed_token = fed_record.get("token") + + print("\n========== OEG → FM ARTEFACT PAYLOAD ==========") + print(json.dumps(artefact, indent=2)) + print("================================================\n") + + artefact_body, artefact_status = federation_client.create_artefact( + artefact=artefact, + federation_context_id=zone.get("fedContextId"), + token=fed_token, + ) + print("\n========== ARTEFACT CREATION RESPONSE ==========") + print(f"Status: {artefact_status}") + print(f"Response: {json.dumps(artefact_body, indent=2)}") + print("================================================\n") + + if artefact_status == 422 and "duplicate key" in str(artefact_body): + logger.info("Artefact already exists in FM, continuing") + artefact_status = 200 + + if artefact_status not in (200, 409): + return jsonify({ + "error": "Artefact creation failed", + "fm_response": artefact_body, + }), artefact_status + + fed_context_id = zone.get("fedContextId") + logger.info(f"Federation Context ID from zone: {fed_context_id}") + logger.info(f"Federation Manager URL: {federation_client.base_url}") + print(f"\n========== FEDERATION CONTEXT DEBUG ==========") + print(f"Using Federation Context ID: {fed_context_id}") + print(f"Federation Manager Base URL: {federation_client.base_url}") + print(f"Full Onboard URL: {federation_client.base_url}/{fed_context_id}/application/onboarding") + print("===============================================\n") + print("\n========== CHECKING FEDERATION IDS ==========") + fed_ids_body, fed_ids_status = federation_client.get_federation_context_ids(token=fed_token) + print(f"Status: {fed_ids_status}") + print(f"Federation IDs: {json.dumps(fed_ids_body, indent=2)}") + print(f"Looking for: {zone.get('fedContextId')}") + print("=============================================\n") + print(f"\n========== HEADERS DEBUG ==========") + print(f"X-Partner-API-Root: {federation_client.partner_root}") + print(f"Authorization: Bearer {fed_token[:20]}...") + print("===================================\n") + print("DEBUG: About to check partner status...") + try: + print("\n========== CHECKING PARTNER STATUS ==========") + partner_body, partner_status = federation_client.get_partner( + federation_context_id=zone.get("fedContextId"), + token=fed_token, + ) + print(f"Partner Status: {partner_status}") + print(f"Partner Info: {json.dumps(partner_body, indent=2)}") + print("=============================================\n") + except Exception as exc: + print("\n========== PARTNER CHECK ERROR ==========") + print(f"Error getting partner status: {str(exc)}") + print("=========================================\n") + + onboard_app = { + "appId": federated_app_id, + "appProviderId": federated_app_provider_id, + "appMetaData": { + "appName": app_name, + "version": app_version, + "accessToken": access_token, + }, + "appQoSProfile": { + "latencyConstraints": latency_constraints, + "bandwidthRequired": bandwidth_required, + "multiUserClients": multi_user_clients, + "noOfUsersPerAppInst": no_of_users, + "appProvisioning": app_provisioning, + }, + "appComponentSpecs": [{ + "artefactId": artefact_id, + "componentName": component_name, + "serviceNameNB": service_name_nb, + "serviceNameEW": service_name_ew, + "exposedInterfaces": [ + { + "interfaceId": interface.get("interfaceId") or f"{component_name}_{index}", + "commProtocol": interface.get("protocol", "TCP"), + "commPort": interface.get("port"), + "visibilityType": interface.get("visibilityType", "VISIBILITY_EXTERNAL"), + } + for index, interface in enumerate(network_interfaces) + if interface.get("port") is not None + ], + "computeResourceProfile": { + "cpuArchType": "ISA_X86_64", + "numCPU": required_resources.get("numCPU") or "100m", + "memory": required_resources.get("memory") or 128, + "diskStorage": required_resources.get("storage"), + "gpu": required_resources.get("gpu") or [], + }, + }], + "appStatusCallbackLink": app_status_callback_link, + "appDeploymentZones": app_deployment_zones, + "edgeAppFQDN": edge_app_fqdn, + } + + print("\n========== OEG → FM ONBOARD PAYLOAD ==========") + print(json.dumps(onboard_app, indent=2)) + print("==============================================\n") + + onboard_app_body, onboard_app_status = federation_client.onboard_application( + federation_context_id=zone.get("fedContextId"), + body=onboard_app, + token=fed_token, + ) + print("\n========== ONBOARD RESPONSE DEBUG ==========") + print(f"Status: {onboard_app_status}") + print(f"Response Body: {json.dumps(onboard_app_body, indent=2)}") + print("============================================\n") + + if onboard_app_status not in (200, 202, 409): + return jsonify({ + "error": "Application onboarding failed", + "fm_response": onboard_app_body, + }), onboard_app_status + + res_pool_value = ensure_res_pool(zone.get("resPool"), zone.get("edgeCloudZoneId")) + deploy_app = { + "appId": federated_app_id, + "appProviderId": federated_app_provider_id, + "appVersion": app_version, + "appInstCallbackLink": app_data.get("appInstCallbackLink", ""), + "zoneInfo": { + "zoneId": zone.get("edgeCloudZoneId"), + "flavourId": zone.get("flavourId") or zone.get("edgeCloudZoneId") or "default", + "resPool": res_pool_value, + "resourceConsumption": "RESERVED_RES_AVOID", + }, + } + + print("\n========== OEG → FM DEPLOY PAYLOAD ==========") + print(json.dumps(deploy_app, indent=2)) + print("=============================================\n") + + deploy_app_body, deploy_app_status = federation_client.deploy_app_partner( + federation_context_id=zone.get("fedContextId"), + body=deploy_app, + token=fed_token, + ) + + if deploy_app_status in (200, 201, 202): + return jsonify({ + "message": "Application deployed successfully at partner OP", + "appId": app_id, + "deployment_response": deploy_app_body, + }), deploy_app_status + + return jsonify({ + "error": "Application deployment failed", + "fm_response": deploy_app_body, + }), deploy_app_status diff --git a/edge_cloud_management_api/controllers/edge_cloud_controller.py b/edge_cloud_management_api/controllers/edge_cloud_controller.py index 26c7323e3f872e71b53661662f6470bd5ee95666..12c299d169bd4d6e75f0bc5b8a9f4bd49def2482 100644 --- a/edge_cloud_management_api/controllers/edge_cloud_controller.py +++ b/edge_cloud_management_api/controllers/edge_cloud_controller.py @@ -3,22 +3,11 @@ from pydantic import BaseModel, Field, ValidationError 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, get_zones +from edge_cloud_management_api.services.edge_cloud_services import SRMAPIClientFactory +from edge_cloud_management_api.services.storage_service import get_zones from edge_cloud_management_api.services.federation_services import FederationManagerClientFactory -try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() - zones = api_client.edge_cloud_zones() - for zone in zones: - zone['_id'] = zone.get('edgeCloudZoneId') - zone['isLocal'] = 'true' - insert_zones(zones) -except Exception as e: - logger.error(e.args) - factory = FederationManagerClientFactory() federation_client = factory.create_federation_client() @@ -46,11 +35,11 @@ class EdgeCloudQueryParams(BaseModel): def get_local_zones() -> list[dict]: """ - Get local Operator Platform available zones from PiEdge Service Resource Manager. + Get local Operator Platform available zones from the Service Resource Manager. """ try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() result = api_client.edge_cloud_zones() if isinstance(result, dict) and "error" in result: @@ -69,35 +58,22 @@ 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 = [] +def get_partner_zones() -> list[dict]: + """Retrieve persisted partner zones only.""" try: - cached = get_zones() + zones = 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 + logger.warning("Failed to read partner zones: %s", e) + return [] + return [ + zone for zone in (zones or []) + if isinstance(zone, dict) and zone.get("isLocal") == "false" + ] def get_all_cloud_zones() -> List[EdgeCloudZone]: """Get all available zones from local and federated Operator Platforms""" - # Convert dicts to EdgeCloudZone - # local_zones = [EdgeCloudZone(**z) for z in get_local_zones()] - - # Federated zones are already EdgeCloudZone instances - # federated_zones = get_federated_zones() - # return local_zones + federated_zones - return get_local_zones() + get_federated_zones() + return get_local_zones() + get_partner_zones() + get_federated_zones() def get_edge_cloud_zones(x_correlator: str | None = None, region=None, status=None): # noqa: E501 """Retrieve a list of the operators Edge Cloud Zones and their status @@ -129,7 +105,11 @@ 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_cached_zones()] + response = [ + zone.model_dump() + for zone in (EdgeCloudZone(**zone_dict) for zone_dict in get_all_cloud_zones()) + if query_region_matches(zone) and query_status_matches(zone) + ] return jsonify(response), 200 except ValidationError as e: @@ -148,7 +128,7 @@ def get_edge_cloud_zones(x_correlator: str | None = None, region=None, status=No def edge_cloud_zone_details(zoneId: str) -> dict: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() result = api_client.edge_cloud_zone_details(zone_id=zoneId) return result diff --git a/edge_cloud_management_api/controllers/federation_manager_controller.py b/edge_cloud_management_api/controllers/federation_manager_controller.py index bff3f5031692a1b80eaf7c12a007e73196274d46..7cf8afe7e427a6c1a435312156bea4105d1273b0 100644 --- a/edge_cloud_management_api/controllers/federation_manager_controller.py +++ b/edge_cloud_management_api/controllers/federation_manager_controller.py @@ -2,6 +2,7 @@ from flask import request, jsonify import logging import connexion from requests.exceptions import Timeout, ConnectionError +from pydantic import ValidationError from edge_cloud_management_api.managers.log_manager import logger import requests from edge_cloud_management_api.configs.env_config import config @@ -9,6 +10,8 @@ from edge_cloud_management_api.services.storage_service import insert_zones from edge_cloud_management_api.services.storage_service import insert_federation, get_fed, get_all_feds from edge_cloud_management_api.services.federation_services import FederationManagerClientFactory +from edge_cloud_management_api.models.federation_manager_models import FederationRequestData +from edge_cloud_management_api.models.federation_manager_models import ZoneRegistrationRequestData token_headers = {'Authorization': 'Basic b3JpZ2luYXRpbmctb3AtMTpkZDd2TndGcWpOcFl3YWdobEV3TWJ3MTBnMGtsV0RIYg==', 'Content-Type': 'application/x-www-form-urlencoded' @@ -23,50 +26,48 @@ factory = FederationManagerClientFactory() federation_client = factory.create_federation_client() +def _store_partner_zones(federation_context_id, provider, availability_zones, accepted_zone_ids=None): + zones_to_insert = [] + accepted_zone_ids = set(accepted_zone_ids or []) + for zone in availability_zones or []: + zone_id = zone.get('zoneId') if isinstance(zone, dict) else None + if not zone_id: + continue + if accepted_zone_ids and zone_id not in accepted_zone_ids: + continue + inserted_item = { + '_id': zone_id, + 'edgeCloudProvider': provider, + 'edgeCloudZoneId': zone_id, + 'edgeCloudZoneName': zone.get('geographyDetails'), + 'edgeCloudZoneStatus': 'unknown', + 'isLocal': 'false', + 'fedContextId': federation_context_id, + } + zones_to_insert.append(inserted_item) + if zones_to_insert: + insert_zones(zones_to_insert) + + def create_federation(): """POST /partner - Create federation with partner OP.""" body = request.get_json() + try: + FederationRequestData(**body) + except ValidationError as error: + return jsonify({"error": "Invalid input", "details": error.errors()}), 400 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} + fed = { + '_id': response.get('federationContextId'), + 'token': token, + 'partnerOPFederationId': response.get('partnerOPFederationId'), + } if code==200: - provider = response.get('partnerOPFederationId') - av_zones = response.get('offeredAvailabilityZones') - zones_to_insert = [] - for zone in av_zones: - inserted_item = {'_id': zone.get('zoneId'), - 'edgeCloudProvider': provider, - 'edgeCloudZoneId': zone.get('zoneId'), - 'edgeCloudZoneName': zone.get('geographyDetails'), - 'edgeCloudZoneStatus': 'unknown', - 'isLocal': 'false', - 'fedContextId': response.get('federationContextId') - } - zones_to_insert.append(inserted_item) - insert_zones(zones_to_insert) insert_federation(fed) - - 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): @@ -132,9 +133,29 @@ def request_zone_synch(federationContextId): body = {} if not body.get("availZoneNotifLink"): body["availZoneNotifLink"] = config.AVAIL_ZONE_NOTIF_LINK + try: + ZoneRegistrationRequestData(**body) + except ValidationError as error: + return jsonify({"error": "Invalid input", "details": error.errors()}), 400 response, code = federation_client.request_zone_sync( federation_context_id=federationContextId, body=body, token=token ) + if code == 200: + federation_response, federation_code = federation_client.get_partner(federationContextId, token) + if federation_code == 200: + fed = get_fed(federationContextId) or {} + _store_partner_zones( + federationContextId, + federation_response.get('partnerOPFederationId') or fed.get('partnerOPFederationId'), + federation_response.get('offeredAvailabilityZones'), + body.get('acceptedAvailabilityZones'), + ) + else: + logger.warning( + "Unable to refresh partner zones after subscription: %s - %s", + federation_code, + federation_response, + ) return jsonify(response), code def get_zone_resource_info(federationContextId, zoneId): diff --git a/edge_cloud_management_api/controllers/network_functions_controller.py b/edge_cloud_management_api/controllers/network_functions_controller.py index 87e88ef26a839c368cb3290c1ec95a9d42bdd001..6e7d913b1edc483449bb4d874357f41b261ac7bd 100644 --- a/edge_cloud_management_api/controllers/network_functions_controller.py +++ b/edge_cloud_management_api/controllers/network_functions_controller.py @@ -1,7 +1,7 @@ from flask import jsonify from pydantic import ValidationError #Field 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.edge_cloud_services import SRMAPIClientFactory def create_qod_session(body: dict): """ @@ -12,8 +12,8 @@ def create_qod_session(body: dict): # validated_data = AppManifest(**body) # validated_data_dict = validated_data.model_dump(mode="json") # validated_data_dict["_id"] = str(uuid.uuid4()) - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.create_qod_session(body) return response @@ -35,8 +35,8 @@ def delete_qod_session(sessionId: str): # validated_data = AppManifest(**body) # validated_data_dict = validated_data.model_dump(mode="json") # validated_data_dict["_id"] = str(uuid.uuid4()) - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.delete_qod_session(sessionId=sessionId) return response @@ -58,8 +58,8 @@ def get_qod_session(sessionId: str): # validated_data = AppManifest(**body) # validated_data_dict = validated_data.model_dump(mode="json") # validated_data_dict["_id"] = str(uuid.uuid4()) - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.get_qod_session(sessionId=sessionId) # Insert into MongoDB # with MongoManager() as db: @@ -81,8 +81,8 @@ def get_qod_session(sessionId: str): def create_traffic_influence_resource(body: dict): try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.create_traffic_influence_resource(body) return response except ValidationError as e: @@ -96,8 +96,8 @@ def create_traffic_influence_resource(body: dict): def get_traffic_influence_resource(id: str): try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.get_traffic_influence_resource(id) return response except ValidationError as e: @@ -112,8 +112,8 @@ def get_traffic_influence_resource(id: str): def delete_traffic_influence_resource(id: str): try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.delete_traffic_influence_resource(id) return response except ValidationError as e: @@ -127,8 +127,8 @@ def delete_traffic_influence_resource(id: str): def get_all_traffic_influence_resources(): try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.get_all_traffic_influence_resources() return response except ValidationError as e: @@ -146,8 +146,8 @@ def retrieve_location(body: dict): Forwards the request to the SRM which delegates to the configured network adapter. """ try: - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.retrieve_location(body) return response diff --git a/edge_cloud_management_api/models/application_models.py b/edge_cloud_management_api/models/application_models.py index e454a35bceb60dcbe4c546c2536e67d97153c2a0..cbfeca8fa584e366e358197627375220697e5b39 100644 --- a/edge_cloud_management_api/models/application_models.py +++ b/edge_cloud_management_api/models/application_models.py @@ -5,8 +5,8 @@ #from edge_cloud_management_api.models.edge_cloud_models import EdgeCloudZone -from pydantic import BaseModel, HttpUrl, Field, UUID4 -from typing import Any, List, Optional +from pydantic import BaseModel, Field, UUID4 +from typing import Any, List, Literal, Optional, Union from enum import Enum from edge_cloud_management_api.models.edge_cloud_models import EdgeCloudZone # <-- you should IMPORT this properly @@ -44,27 +44,70 @@ class ComponentSpec(BaseModel): networkInterfaces: List[NetworkInterface] -class AppRepo(BaseModel): +class AppRepo(BaseModel): class AppRepoAuthType(str, Enum): DOCKER = "DOCKER" HTTP_BASIC = "HTTP_BASIC" HTTP_BEARER = "HTTP_BEARER" NONE = "NONE" - type: str # PRIVATEREPO or PUBLICREPO - imagePath: HttpUrl - userName: Optional[str] - credentials: Optional[str] # max 128 characters - authType: Optional[AppRepoAuthType] - checksum: Optional[str] - - -class AppManifest(BaseModel): - class PackageType(str, Enum): - QCOW2 = "QCOW2" - OVA = "OVA" - CONTAINER = "CONTAINER" - HELM = "HELM" + type: Literal["PRIVATEREPO", "PUBLICREPO"] + imagePath: str = Field(..., max_length=2048) + userName: Optional[str] = Field(default=None, max_length=64) + credentials: Optional[str] = Field(default=None, max_length=2048) + authType: Optional[AppRepoAuthType] = None + checksum: Optional[str] = Field(default=None, max_length=128) + + +class ContainerResources(BaseModel): + infraKind: Literal["container"] + numCPU: Any + memory: int = Field(..., ge=1, le=16384) + storage: Optional[Any] = None + gpu: Optional[Any] = None + + +class VmResources(BaseModel): + infraKind: Literal["virtualMachine"] + numCPU: int = Field(..., ge=1, le=256) + memory: int = Field(..., ge=1, le=32768) + additionalStorages: Optional[Any] = None + gpu: Optional[Any] = None + + +class DockerComposeResources(BaseModel): + infraKind: Literal["dockerCompose"] + numCPU: int = Field(..., ge=1, le=256) + memory: int = Field(..., ge=1, le=16384) + storage: Optional[Any] = None + gpu: Optional[Any] = None + + +class KubernetesResources(BaseModel): + infraKind: Literal["kubernetes"] + applicationResources: Any + isStandalone: bool + version: Optional[str] = None + additionalStorage: Optional[str] = None + networking: Optional[Any] = None + addons: Optional[Any] = None + + +RequiredResources = Union[ + KubernetesResources, + VmResources, + ContainerResources, + DockerComposeResources, +] + + +class AppManifest(BaseModel): + class PackageType(str, Enum): + QCOW2 = "QCOW2" + OVA = "OVA" + CONTAINER = "CONTAINER" + HELM = "HELM" + CSAR = "CSAR" class OperatingSystem(BaseModel): architecture: str # x86_64, x86 @@ -72,14 +115,15 @@ class AppManifest(BaseModel): version: str license: str - name: str = Field(..., pattern="^[A-Za-z][A-Za-z0-9_]{1,63}$") - appProvider: str = Field(..., pattern="^[A-Za-z][A-Za-z0-9_]{7,63}$") + appId: UUID4 + name: str = Field(..., pattern="^[A-Za-z][A-Za-z0-9_]{1,63}$") + appProvider: str = Field(..., pattern="^[A-Za-z][A-Za-z0-9_]{7,63}$") version: str packageType: PackageType - operatingSystem: Optional[OperatingSystem] + operatingSystem: Optional[OperatingSystem] = None appRepo: AppRepo - requiredResources: Optional[Any] # Could be KubernetesResources, ContainerResources, etc. - componentSpec: List[ComponentSpec] + requiredResources: RequiredResources + componentSpec: List[ComponentSpec] class AppZones(BaseModel): diff --git a/edge_cloud_management_api/models/federation_manager_models.py b/edge_cloud_management_api/models/federation_manager_models.py index 8540639f8ab8bb378766df5b80048c39badc5517..6e6601773ab85df3fdd21c7aab3f8409609b3c14 100644 --- a/edge_cloud_management_api/models/federation_manager_models.py +++ b/edge_cloud_management_api/models/federation_manager_models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel #Field +from pydantic import BaseModel, RootModel #Field from typing import List, Optional @@ -7,8 +7,8 @@ class MobileNetworkIds(BaseModel): mcc: str -class FixedNetworkIds(BaseModel): - __root__: List[str] +class FixedNetworkIds(RootModel[List[str]]): + pass class CallbackCredentials(BaseModel): @@ -30,23 +30,39 @@ class ZoneDetails(BaseModel): geolocation: str +class ZoneRegistrationRequestData(BaseModel): + acceptedAvailabilityZones: List[str] + availZoneNotifLink: str + + +class ZoneResourceInfo(BaseModel): + zoneId: str + computeResourceQuotaLimits: Optional[List[dict]] = None + reservedComputeResources: Optional[List[dict]] = None + flavoursSupported: Optional[List[dict]] = None + + +class ZoneRegistrationResponseData(BaseModel): + acceptedZoneResourceInfo: List[ZoneResourceInfo] + + class FederationRequestData(BaseModel): - origOPFederationId: str - origOPCountryCode: Optional[str] - origOPMobileNetworkCodes: Optional[MobileNetworkIds] - origOPFixedNetworkCodes: Optional[List[str]] + origOPFederationId: Optional[str] = None + origOPCountryCode: Optional[str] = None + origOPMobileNetworkCodes: Optional[MobileNetworkIds] = None + origOPFixedNetworkCodes: Optional[List[str]] = None initialDate: str partnerStatusLink: str - partnerCallbackCredentials: Optional[CallbackCredentials] + partnerCallbackCredentials: Optional[CallbackCredentials] = None class FederationResponseData(BaseModel): federationContextId: str - partnerOPFederationId: str - partnerOPCountryCode: Optional[str] - partnerOPMobileNetworkCodes: Optional[MobileNetworkIds] - partnerOPFixedNetworkCodes: Optional[List[str]] - offeredAvailabilityZones: Optional[List[ZoneDetails]] + partnerOPFederationId: Optional[str] = None + partnerOPCountryCode: Optional[str] = None + partnerOPMobileNetworkCodes: Optional[MobileNetworkIds] = None + partnerOPFixedNetworkCodes: Optional[List[str]] = None + offeredAvailabilityZones: Optional[List[ZoneDetails]] = None platformCaps: List[str] - edgeDiscoveryServiceEndPoint: Optional[ServiceEndpoint] - lcmServiceEndPoint: Optional[ServiceEndpoint] + edgeDiscoveryServiceEndPoint: Optional[ServiceEndpoint] = None + lcmServiceEndPoint: Optional[ServiceEndpoint] = None diff --git a/edge_cloud_management_api/services/edge_cloud_services.py b/edge_cloud_management_api/services/edge_cloud_services.py index 88358c8b51a3374ac06315e3357fadf91ec33eac..c1bee7d0ac6a6af34cc48640bbc496a2f56806cc 100644 --- a/edge_cloud_management_api/services/edge_cloud_services.py +++ b/edge_cloud_management_api/services/edge_cloud_services.py @@ -9,7 +9,7 @@ proxies = { "https": config.HTTP_PROXY, } -class PiEdgeAPIClient: +class SRMAPIClient: def __init__(self, base_url, username, password): self.base_url = base_url self.username = username @@ -350,17 +350,17 @@ class PiEdgeAPIClient: error_body = {"error": response.text} return error_body, response.status_code -class PiEdgeAPIClientFactory: +class SRMAPIClientFactory: """ - Factory class to create instances of PiEdgeAPIClient. + Factory class to create instances of SRMAPIClient. """ def __init__(self): self.default_base_url = config.SRM_HOST - self.default_username = config.PI_EDGE_USERNAME - self.default_password = config.PI_EDGE_PASSWORD + self.default_username = config.SRM_USERNAME + self.default_password = config.SRM_PASSWORD - def create_pi_edge_api_client(self, base_url=None, username=None, password=None): + def create_srm_api_client(self, base_url=None, username=None, password=None): """ Factory method to create a new SRMAPIClient instance. @@ -370,7 +370,7 @@ class PiEdgeAPIClientFactory: password (str): The password for authentication. If None, the default is used. Returns: - PiEdgeAPIClient: A new instance of the PiEdgeAPIClient. + SRMAPIClient: A new instance of the SRMAPIClient. """ if base_url is None: base_url = self.default_base_url @@ -379,12 +379,12 @@ class PiEdgeAPIClientFactory: if password is None: password = self.default_password - return PiEdgeAPIClient(base_url=base_url, username=username, password=password) + return SRMAPIClient(base_url=base_url, username=username, password=password) if __name__ == "__main__": - pi_edge_factory = PiEdgeAPIClientFactory() - api_client = pi_edge_factory.create_pi_edge_api_client() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() edge_zones = api_client.edge_cloud_zones() logger.error("Edge zones:", edge_zones) diff --git a/edge_cloud_management_api/services/federation_services.py b/edge_cloud_management_api/services/federation_services.py index d947dd1261ab19f5d2ebc242d35810d225376a3a..d23b245f9ddfb381b5f28dad42ca3e37d82ff949 100644 --- a/edge_cloud_management_api/services/federation_services.py +++ b/edge_cloud_management_api/services/federation_services.py @@ -3,7 +3,7 @@ import requests from requests.exceptions import Timeout, ConnectionError 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.edge_cloud_services import SRMAPIClientFactory from edge_cloud_management_api.services.storage_service import delete_fed, delete_partner_zones class FederationManagerClient: @@ -76,6 +76,7 @@ class FederationManagerClient: url = f"{self.base_url}/{federation_context_id}/partner" try: response = requests.delete(url, headers=self._get_headers(token), timeout=10) + response.raise_for_status() if response.content: delete_fed(federation_context_id) delete_partner_zones() @@ -436,6 +437,29 @@ class FederationManagerClient: logger.error(f"Create artefact unexpected error: {e}") return {"error": str(e)}, 500 + def delete_artefact(self, federation_context_id: str, artefact_id: str, token: str): + url = f"{self.base_url}/{federation_context_id}/artefact/{artefact_id}" + try: + response = requests.delete(url, headers=self._get_headers(token), timeout=120) + try: + body = response.json() + except ValueError: + body = response.text + response.raise_for_status() + return {"message": body, "status_code": response.status_code} + except Timeout: + logger.error("Delete artefact timed out") + return {"error": "Request timed out", "status_code": 408} + except ConnectionError: + logger.error("Delete artefact connection error") + return {"error": "Connection error", "status_code": 503} + except requests.exceptions.HTTPError as http_err: + logger.error(f"Delete artefact HTTP error: {http_err}") + return {"error": str(http_err), "status_code": response.status_code} + except Exception as e: + logger.error(f"Delete artefact unexpected error: {e}") + return {"error": str(e), "status_code": 500} + class FederationManagerClientFactory: def __init__(self): self.default_base_url = config.FEDERATION_MANAGER_HOST @@ -453,7 +477,7 @@ class FederationManagerClientFactory: if not partner_zones: return {"message": "No partner zones to onboard"}, 200 - srm_client = PiEdgeAPIClientFactory().create_pi_edge_api_client() + srm_client = SRMAPIClientFactory().create_srm_api_client() app_data = srm_client.get_app(app_id) if not app_data or "error" in app_data: diff --git a/edge_cloud_management_api/services/storage_service.py b/edge_cloud_management_api/services/storage_service.py index 5b31e7d0bb11abb98e6807777015cf6ca39522a4..a6feda9a296598eb763744852b63cc7d49194826 100644 --- a/edge_cloud_management_api/services/storage_service.py +++ b/edge_cloud_management_api/services/storage_service.py @@ -4,19 +4,41 @@ import pymongo storage_url = mongo_host = config.MONGO_URI mydb_mongo = 'oeg_storage' + +def _zone_storage_id(zone: dict): + provider = zone.get('edgeCloudProvider') or 'unknown' + zone_id = zone.get('edgeCloudZoneId') or zone.get('zoneId') + if not zone_id: + return zone.get('_id') + return f"{provider}::{zone_id}" + def insert_zones(zone_list: list): collection = "zones" myclient = pymongo.MongoClient(storage_url) mydbmongo = myclient[mydb_mongo] col = mydbmongo[collection] - col.insert_many(zone_list) + for zone in zone_list: + normalized_zone = dict(zone) + normalized_zone['_id'] = _zone_storage_id(normalized_zone) + col.replace_one({'_id': normalized_zone['_id']}, normalized_zone, upsert=True) -def get_zone(zone_id: str): +def get_zone(zone_id: str, provider: str | None = None): collection = "zones" myclient = pymongo.MongoClient(storage_url) mydbmongo = myclient[mydb_mongo] col = mydbmongo[collection] - zone = col.find_one({'_id': zone_id}) + query = {'edgeCloudZoneId': zone_id} + if provider: + query['edgeCloudProvider'] = provider + zone = col.find_one(query) + else: + zone = col.find_one({**query, 'isLocal': 'true'}) + if zone is None: + zone = col.find_one(query) + if zone is None and provider: + zone = col.find_one({'_id': f"{provider}::{zone_id}"}) + if zone is None and not provider: + zone = col.find_one({'_id': zone_id}) return zone def get_zones(): diff --git a/edge_cloud_management_api/specification/openapi.yaml b/edge_cloud_management_api/specification/openapi.yaml index cbfba64464345cc178be1187fe05233f0a953668..7ff46f33842c42473fe54dfd15cbffb9870f8ced 100644 --- a/edge_cloud_management_api/specification/openapi.yaml +++ b/edge_cloud_management_api/specification/openapi.yaml @@ -454,7 +454,7 @@ paths: # kubernetesClusterRef: # $ref: "#/components/schemas/KubernetesClusterRef" appZones: - $ref: "#/components/schemas/EdgeCloudZones" + $ref: "#/components/schemas/AppZones" required: true responses: "202": @@ -1943,9 +1943,6 @@ components: edgeApplicationAPI: null inline_response_200_1: - required: - - edgeDiscoveryServiceEndPoint - - lcmServiceEndPoint type: object properties: edgeDiscoveryServiceEndPoint: @@ -1961,6 +1958,10 @@ components: type: array items: $ref: '#/components/schemas/ZoneDetails' + platformCaps: + type: array + items: + type: string example: allowedFixedNetworkIds: - allowedFixedNetworkIds @@ -1990,13 +1991,13 @@ components: inline_response_200_2: required: - - FederationContextId + - federationContextId type: object properties: - FederationContextId: + federationContextId: $ref: '#/components/schemas/FederationContextId' example: - FederationContextId: FederationContextId + federationContextId: federationContextId EdgeCloudProvider: @@ -2012,20 +2013,10 @@ components: EdgeCloudZones: type: array items: - $ref: "#/components/schemas/ZoneObject" - minItems: 1 + $ref: "#/components/schemas/EdgeCloudZone" description: | A collection of Edge Cloud Zones where the Application Provider can instantiate an Application Instance. - additionalProperties: false - - ZoneObject: - type: object - required: - - EdgeCloudZone - properties: - EdgeCloudZone: - $ref: "#/components/schemas/EdgeCloudZone" EdgeCloudZoneId: type: string @@ -2072,7 +2063,6 @@ components: - active - inactive - unknown - default: unknown ErrorInfo: type: object @@ -2232,7 +2222,6 @@ components: FederationRequestData: required: - initialDate - - origOPFederationId - partnerStatusLink type: object properties: @@ -2257,7 +2246,6 @@ components: FederationResponseData: required: - federationContextId - - partnerOPFederationId - platformCaps type: object properties: diff --git a/tests/component/controllers/test_app_controllers.py b/tests/component/controllers/test_app_controllers.py index 22ca4b6864efac99de9417094ad719e3bc628db5..1ba17c3f0367ddcfed304c2ada7830d1e138285d 100644 --- a/tests/component/controllers/test_app_controllers.py +++ b/tests/component/controllers/test_app_controllers.py @@ -13,17 +13,14 @@ def test_app(): @pytest.mark.component -@patch("edge_cloud_management_api.controllers.app_controllers.PiEdgeAPIClientFactory") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") def test_delete_app(mock_factory_class, test_app: Flask): - """Test delete_app returns dict""" + """Test delete_app returns SRM response as-is""" app_id = "mock-app-id" mock_client = MagicMock() - mock_response = MagicMock() - mock_response.json.return_value = {"result": "deleted"} - mock_response.status_code = 200 - mock_client.delete_app.return_value = mock_response + mock_client.delete_app.return_value = {"result": "deleted"} - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client with test_app.test_request_context(): result = app_controllers.delete_app(app_id) @@ -33,62 +30,82 @@ def test_delete_app(mock_factory_class, test_app: Flask): @pytest.mark.component -@patch("edge_cloud_management_api.controllers.app_controllers.PiEdgeAPIClientFactory") -def test_create_app_instance(mock_factory_class, test_app: Flask): - """Test create_app_instance returns accepted response""" +@patch("edge_cloud_management_api.controllers.app_controllers.resolve_target_zone") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_create_app_instance(mock_factory_class, mock_resolve_target_zone, test_app: Flask): + """Test create_app_instance returns local deployment response""" body = { "appId": "mock-app-id", - "edgeCloudZoneId": "zone-1", - "kubernetesClusterRef": "cluster-1" + "appZones": [{ + "EdgeCloudZone": { + "edgeCloudZoneId": "zone-1", + "edgeCloudProvider": "Local Operator", + } + }], } mock_client = MagicMock() mock_client.deploy_service_function.return_value = {"deploymentId": "xyz-123"} - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + mock_resolve_target_zone.return_value = { + "edgeCloudZoneId": "zone-1", + "edgeCloudProvider": "Local Operator", + "isLocal": "true", + } with test_app.test_request_context(json=body): response, status_code = app_controllers.create_app_instance() assert status_code == 202 data = response.get_json() - assert "Application mock-app-id instantiation accepted" in data["message"] + assert data["message"] == "Application deployed locally" + assert data["appId"] == "mock-app-id" + assert data["response"] == {"deploymentId": "xyz-123"} + mock_client.deploy_service_function.assert_called_once_with(data={ + "appId": "mock-app-id", + "appZones": [{ + "EdgeCloudZone": { + "edgeCloudZoneId": "zone-1", + "edgeCloudZoneName": None, + "edgeCloudProvider": "Local Operator", + "edgeCloudZoneStatus": None, + "edgeCloudRegion": None, + } + }], + }) @pytest.mark.component -@patch("edge_cloud_management_api.controllers.app_controllers.PiEdgeAPIClientFactory") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") def test_get_app_instance(mock_factory_class, test_app: Flask): - """Test get_app_instance returns app instances""" + """Test get_app_instance returns normalized app instances""" mock_client = MagicMock() mock_client.get_app_instances.return_value = [{"appInstanceId": "abc123"}] - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client with test_app.test_request_context(): response, status_code = app_controllers.get_app_instance() assert status_code == 200 data = response.get_json() - assert "appInstanceInfo" in data - assert isinstance(data["appInstanceInfo"], list) - assert data["appInstanceInfo"][0]["appInstanceId"] == "abc123" + assert isinstance(data, list) + assert data[0]["appInstanceId"] == "abc123" + assert data[0]["status"] == "unknown" @pytest.mark.component -@patch("edge_cloud_management_api.controllers.app_controllers.PiEdgeAPIClientFactory") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") def test_delete_app_instance(mock_factory_class, test_app: Flask): - """Test delete_app_instance returns dict""" + """Test delete_app_instance returns Flask response for local delete""" app_instance_id = "instance-123" mock_client = MagicMock() - mock_response = MagicMock() - mock_response.json.return_value = {"result": "Deleted"} - mock_response.status_code = 200 - mock_client.delete_app_instance.return_value = mock_response + mock_client.delete_app_instance.return_value = {"result": "Deleted", "status_code": 200} - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client with test_app.test_request_context(): - result = app_controllers.delete_app_instance(app_instance_id) - - assert isinstance(result, dict) - assert result == {"result": "Deleted"} + response, status_code = app_controllers.delete_app_instance(app_instance_id) + assert status_code == 200 + assert response.get_json() == {"result": "Deleted", "status_code": 200} diff --git a/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py new file mode 100644 index 0000000000000000000000000000000000000000..f75df589b172cb9e2d36550d596bc8b58340242d --- /dev/null +++ b/tests/unit/controllers/test_app_controllers.py @@ -0,0 +1,676 @@ +from edge_cloud_management_api.controllers import app_controllers +from unittest.mock import MagicMock, patch + +from flask import Flask + + +def test_split_image_reference_preserves_helm_repo_url(): + repo_url, image_ref = app_controllers._split_image_reference( + "https://charts.bitnami.com/bitnami/helm/example-chart:0.1.0" + ) + + assert repo_url == "https://charts.bitnami.com/bitnami/helm" + assert image_ref == "example-chart:0.1.0" + + +def test_split_image_reference_preserves_container_registry_behavior(): + repo_url, image_ref = app_controllers._split_image_reference( + "ghcr.io/example/image:1.2.3" + ) + + assert repo_url == "ghcr.io" + assert image_ref == "example/image:1.2.3" + + +def test_resolve_federated_app_provider_id_normalizes_local_provider_name(): + provider_id = app_controllers._resolve_federated_app_provider_id( + "app-123", + "Local Operator", + ) + + assert provider_id == "providerapp123" + + +def test_resolve_federated_app_identity_normalizes_app_and_provider_ids(): + app_id, provider_id = app_controllers._resolve_federated_app_identity( + "playground-oeg-nginx", + "Local Operator", + ) + + assert app_id == "3d8467a8-9d80-50d2-afa2-65374b46c378" + assert provider_id == "providerplaygroundoegnginx" + + +@patch("edge_cloud_management_api.controllers.app_partner_orchestration.get_local_zones") +@patch("edge_cloud_management_api.controllers.app_partner_orchestration.get_zone") +def test_resolve_target_zone_maps_default_local_alias_to_live_zone(mock_get_zone, mock_get_local_zones): + mock_get_zone.return_value = None + mock_get_local_zones.return_value = [{ + "edgeCloudZoneId": "7f1c87c2-1de3-44bb-9888-a1067b425775", + "edgeCloudZoneName": "221aba31da3c", + "edgeCloudProvider": "Local Operator", + "edgeCloudZoneStatus": "active", + "edgeCloudRegion": "unknown", + "isLocal": "true", + }] + + zone = app_controllers.resolve_target_zone( + srm_client=MagicMock(), + edge_cloud_zone_id="default", + edge_cloud_provider="Local Operator", + zone_payload={ + "edgeCloudZoneId": "default", + "edgeCloudProvider": "Local Operator", + }, + ) + + assert zone == { + "edgeCloudZoneId": "7f1c87c2-1de3-44bb-9888-a1067b425775", + "edgeCloudZoneName": "221aba31da3c", + "edgeCloudProvider": "Local Operator", + "edgeCloudZoneStatus": "active", + "edgeCloudRegion": "unknown", + "isLocal": "true", + } + + +def test_submit_app_rejects_non_uuid_app_id(): + app = Flask(__name__) + app.config["TESTING"] = True + + body = { + "appId": "playground-oeg-nginx", + "name": "playground_oeg_nginx", + "appProvider": "Local_Operator", + "version": "1", + "packageType": "CONTAINER", + "appRepo": { + "type": "PUBLICREPO", + "imagePath": "https://docker.io/library/nginx:latest", + }, + "componentSpec": [{ + "componentName": "frontend", + "networkInterfaces": [{ + "interfaceId": "eth0", + "protocol": "TCP", + "port": 80, + "visibilityType": "VISIBILITY_EXTERNAL", + }], + }], + "requiredResources": { + "infraKind": "container", + "numCPU": "100m", + "memory": 512, + }, + } + + with app.test_request_context(json=body): + response, status_code = app_controllers.submit_app(body) + + assert status_code == 400 + payload = response.get_json() + assert payload["error"] == "Invalid input" + assert "appId" in str(payload["details"]) + + +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_submit_app_accepts_camara_valid_container_manifest(mock_factory_class): + app = Flask(__name__) + app.config["TESTING"] = True + + body = { + "appId": "4d3b2f0e-6e5e-4c4d-9c7a-2c7b6de8a101", + "name": "playground_oeg_nginx", + "appProvider": "Local_Oper", + "version": "1", + "packageType": "CONTAINER", + "appRepo": { + "type": "PUBLICREPO", + "imagePath": "nginx", + }, + "requiredResources": { + "infraKind": "container", + "numCPU": "100m", + "memory": 512, + }, + "componentSpec": [{ + "componentName": "frontend", + "networkInterfaces": [{ + "interfaceId": "eth0", + "protocol": "TCP", + "port": 80, + "visibilityType": "VISIBILITY_EXTERNAL", + }], + }], + } + + mock_client = MagicMock() + mock_client.submit_app.return_value = {"appId": body["appId"]} + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + + with app.test_request_context(json=body): + response = app_controllers.submit_app(body) + + assert response == {"appId": body["appId"]} + mock_client.submit_app.assert_called_once_with(body) + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") +@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_get_app_instance_uses_normalized_provider_for_federated_lookup( + mock_factory_class, + mock_federation_client, + mock_get_all_feds, +): + app = Flask(__name__) + app.config["TESTING"] = True + + mock_client = MagicMock() + mock_client.get_app_instances.return_value = {"appInstances": []} + mock_client.get_app.return_value = { + "appManifest": { + "appProvider": "Local Operator", + } + } + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + mock_get_all_feds.return_value = [{"_id": "fed-1", "token": "token-1", "partnerOPFederationId": "Remote Operator"}] + mock_federation_client.get_all_app_instances.return_value = ([{ + "zoneId": "default", + "appInstanceInfo": [{"appInstIdentifier": "inst-1", "appInstanceState": "ready"}], + }], 200) + + with app.test_request_context(): + response, status_code = app_controllers.get_app_instance(app_id="app-123") + + assert status_code == 200 + assert response.get_json() == [{ + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "unknown", + "edgeCloudProvider": "Remote Operator", + "edgeCloudZoneStatus": "unknown", + "edgeCloudRegion": "unknown", + }, + }] + mock_federation_client.get_all_app_instances.assert_called_once_with( + federation_context_id="fed-1", + app_id="a522ed85-40f3-5e11-8b55-8e0e516331e1", + app_provider_id="providerapp123", + token="token-1", + ) + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") +@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_get_app_instance_dedupes_local_and_federated_results( + mock_factory_class, + mock_federation_client, + mock_get_all_feds, +): + app = Flask(__name__) + app.config["TESTING"] = True + + mock_client = MagicMock() + mock_client.get_app_instances.return_value = { + "appInstances": [{ + "appId": "app-123", + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "unknown", + "edgeCloudProvider": "unknown", + "edgeCloudZoneStatus": "unknown", + "edgeCloudRegion": "unknown", + }, + }] + } + mock_client.get_app.return_value = { + "appManifest": { + "appProvider": "Local Operator", + } + } + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + mock_get_all_feds.return_value = [{"_id": "fed-1", "token": "token-1", "partnerOPFederationId": "Remote Operator"}] + mock_federation_client.get_all_app_instances.return_value = ([{ + "zoneId": "default", + "appInstanceInfo": [{ + "appId": "app-123", + "appInstIdentifier": "inst-1", + "appInstanceState": "ready", + }], + }], 200) + + with app.test_request_context(): + response, status_code = app_controllers.get_app_instance(app_id="app-123") + + assert status_code == 200 + assert response.get_json() == [{ + "appId": "app-123", + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "unknown", + "edgeCloudProvider": "Remote Operator", + "edgeCloudZoneStatus": "unknown", + "edgeCloudRegion": "unknown", + }, + }] + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") +@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_get_app_instance_ignores_federated_tombstones_from_partner_lookup( + mock_factory_class, + mock_federation_client, + mock_get_all_feds, +): + app = Flask(__name__) + app.config["TESTING"] = True + + mock_client = MagicMock() + mock_client.get_app_instances.return_value = {"appInstances": []} + mock_client.get_app.return_value = { + "appManifest": { + "appProvider": "Local Operator", + } + } + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + mock_get_all_feds.return_value = [{"_id": "fed-1", "token": "token-1", "partnerOPFederationId": "Remote Operator"}] + mock_federation_client.get_all_app_instances.return_value = ([{ + "zoneId": "default", + "appInstanceInfo": [{ + "appInstIdentifier": "inst-1", + "appInstanceState": "Error 404 - b'{\"detail\": {\"error\": \"App instance not found\"}}'", + }], + }], 200) + + with app.test_request_context(): + response, status_code = app_controllers.get_app_instance(app_id="app-123") + + assert status_code == 200 + assert response.get_json() == [] + + +def test_dedupe_app_instances_prefers_richer_zone_identity(): + deduped = app_controllers._dedupe_app_instances([ + { + "appId": "app-123", + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "unknown", + "edgeCloudProvider": "unknown", + "edgeCloudZoneStatus": "unknown", + "edgeCloudRegion": "unknown", + }, + }, + { + "appId": "app-123", + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "zone-default", + "edgeCloudProvider": "Local Operator", + "edgeCloudZoneStatus": "active", + "edgeCloudRegion": "unknown", + }, + }, + ]) + + assert deduped == [{ + "appId": "app-123", + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "zone-default", + "edgeCloudProvider": "Local Operator", + "edgeCloudZoneStatus": "active", + "edgeCloudRegion": "unknown", + }, + }] + + +@patch("edge_cloud_management_api.controllers.app_instance_helpers.get_zone") +@patch("edge_cloud_management_api.controllers.app_instance_helpers.get_local_zones") +def test_enrich_instance_zone_from_catalog_uses_live_local_zone_first(mock_get_local_zones, mock_get_zone): + mock_get_local_zones.return_value = [{ + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "zone-default", + "edgeCloudProvider": "Local Operator", + "edgeCloudZoneStatus": "active", + "edgeCloudRegion": "unknown", + }] + mock_get_zone.return_value = None + + enriched = app_controllers._enrich_instance_zone_from_catalog({ + "appId": "app-123", + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "unknown", + "edgeCloudProvider": "unknown", + "edgeCloudZoneStatus": "unknown", + "edgeCloudRegion": "unknown", + }, + }) + + assert enriched == { + "appId": "app-123", + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "zone-default", + "edgeCloudProvider": "Local Operator", + "edgeCloudZoneStatus": "active", + "edgeCloudRegion": "unknown", + }, + } + mock_get_zone.assert_not_called() + + +@patch("edge_cloud_management_api.controllers.app_instance_helpers.get_zone") +def test_enrich_instance_zone_from_catalog_uses_stored_provider_identity(mock_get_zone): + mock_get_zone.return_value = { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "zone-default", + "edgeCloudProvider": "Local Operator", + "edgeCloudZoneStatus": "active", + "edgeCloudRegion": "unknown", + } + + enriched = app_controllers._enrich_instance_zone_from_catalog({ + "appId": "app-123", + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "unknown", + "edgeCloudProvider": "unknown", + "edgeCloudZoneStatus": "unknown", + "edgeCloudRegion": "unknown", + }, + }) + + assert enriched == { + "appId": "app-123", + "appInstanceId": "inst-1", + "status": "ready", + "edgeCloudZone": { + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "zone-default", + "edgeCloudProvider": "Local Operator", + "edgeCloudZoneStatus": "active", + "edgeCloudRegion": "unknown", + }, + } + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_get_app_instance_returns_empty_list_when_no_instances(mock_factory_class, _mock_get_all_feds): + app = Flask(__name__) + app.config["TESTING"] = True + + mock_client = MagicMock() + mock_client.get_app_instances.return_value = {"appInstances": []} + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + + with app.test_request_context(): + response, status_code = app_controllers.get_app_instance() + + assert status_code == 200 + assert response.get_json() == [] + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_get_app_instance_supports_openapi_query_param_names(mock_factory_class, _mock_get_all_feds): + app = Flask(__name__) + app.config["TESTING"] = True + + mock_client = MagicMock() + mock_client.get_app_instances.return_value = { + "appInstances": [ + {"appInstanceId": "inst-1", "appId": "app-1", "status": "ready"}, + {"appInstanceId": "inst-2", "appId": "app-2", "status": "failed"}, + ] + } + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + + with app.test_request_context(): + response, status_code = app_controllers.get_app_instance(appId="app-1", appInstanceId="inst-1") + + assert status_code == 200 + assert response.get_json() == [{ + "appId": "app-1", + "appInstanceId": "inst-1", + "status": "ready", + }] + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_get_app_instance_filters_local_results_by_app_id(mock_factory_class, _mock_get_all_feds): + app = Flask(__name__) + app.config["TESTING"] = True + + mock_client = MagicMock() + mock_client.get_app_instances.return_value = { + "appInstances": [ + {"appInstanceId": "inst-1", "appId": "app-1", "status": "ready"}, + {"appInstanceId": "inst-2", "appId": "app-2", "status": "failed"}, + ] + } + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + + with app.test_request_context(): + response, status_code = app_controllers.get_app_instance(app_id="app-1") + + assert status_code == 200 + assert response.get_json() == [{ + "appId": "app-1", + "appInstanceId": "inst-1", + "status": "ready", + }] + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") +@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_delete_app_instance_uses_normalized_federated_app_id_for_partner_removal( + mock_factory_class, + mock_federation_client, + mock_get_all_feds, +): + app = Flask(__name__) + app.config["TESTING"] = True + + mock_client = MagicMock() + mock_client.delete_app_instance.return_value = {"status_code": 404, "error": "not found"} + mock_client.get_service_functions_catalogue.return_value = [{ + "appId": "playground-oeg-nginx", + "appProvider": "Local Operator", + }] + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + + mock_get_all_feds.return_value = [{ + "_id": "fed-1", + "token": "token-1", + "partnerOPFederationId": "Remote Operator", + }] + mock_federation_client.get_all_app_instances.return_value = ([{ + "zoneId": "default", + "appInstanceInfo": [{ + "appInstIdentifier": "inst-remote-1", + "appInstanceState": "ready", + }], + }], 200) + mock_federation_client.remove_app_instance.return_value = ({"termination": "accepted"}, 200) + + with app.test_request_context(): + response, status_code = app_controllers.delete_app_instance("inst-remote-1") + + assert status_code == 200 + assert response.get_json() == {"termination": "accepted"} + mock_federation_client.remove_app_instance.assert_called_once_with( + federation_context_id="fed-1", + app_id="3d8467a8-9d80-50d2-afa2-65374b46c378", + app_instance_id="inst-remote-1", + zone_id="default", + token="token-1", + ) + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") +@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_delete_app_instance_treats_missing_partner_instance_as_already_deleted( + mock_factory_class, + mock_federation_client, + mock_get_all_feds, +): + app = Flask(__name__) + app.config["TESTING"] = True + + mock_client = MagicMock() + mock_client.delete_app_instance.return_value = {"status_code": 404, "error": "not found"} + mock_client.get_service_functions_catalogue.return_value = [{ + "appId": "playground-oeg-nginx", + "appProvider": "Local Operator", + }] + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + + mock_get_all_feds.return_value = [{ + "_id": "fed-1", + "token": "token-1", + "partnerOPFederationId": "Remote Operator", + }] + mock_federation_client.get_all_app_instances.return_value = ([{ + "zoneId": "default", + "appInstanceInfo": [{ + "appInstIdentifier": "inst-remote-1", + "appInstanceState": "instantiating", + }], + }], 200) + mock_federation_client.remove_app_instance.return_value = ({ + "error": { + "detail": "Partner API error: HTTP 500: App instance not found", + } + }, 500) + + with app.test_request_context(): + response, status_code = app_controllers.delete_app_instance("inst-remote-1") + + assert status_code == 204 + assert response == "" + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") +@patch("edge_cloud_management_api.controllers.app_controllers.cleanup_federated_app") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") +def test_delete_app_runs_federated_cleanup_before_local_delete( + mock_factory_class, + mock_cleanup_federated_app, + mock_get_all_feds, +): + app = Flask(__name__) + app.config["TESTING"] = True + + mock_client = MagicMock() + mock_client.get_app.return_value = { + "appComponentSpecs": [{ + "artefactId": "12345678-1234-1234-9234-123456789abc", + }], + "appManifest": { + "appProvider": "Local Operator", + } + } + mock_client.delete_app.return_value = {"status_code": 204} + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client + + mock_get_all_feds.return_value = [{"_id": "fed-1", "token": "token-1"}] + mock_cleanup_federated_app.return_value = ("", 204) + + with app.test_request_context(): + response = app_controllers.delete_app("app-123") + + assert response == {"status_code": 204} + mock_cleanup_federated_app.assert_called_once() + mock_client.delete_app.assert_called_once_with(appId="app-123") + + +@patch("edge_cloud_management_api.controllers.app_partner_orchestration.jsonify") +def test_cleanup_federated_app_handles_dict_delete_response(mock_jsonify): + federation_client = MagicMock() + federation_client.get_all_app_instances.return_value = ([{ + "zoneId": "default", + "appInstanceInfo": [{"appInstIdentifier": "inst-1"}], + }], 200) + federation_client.remove_app_instance.return_value = ({"termination": "accepted"}, 200) + federation_client.delete_onboarded_app.return_value = { + "message": "Deleted successfully", + "status_code": 204, + } + federation_client.delete_artefact.return_value = { + "message": "Deleted successfully", + "status_code": 204, + } + + result = app_controllers.cleanup_federated_app( + federation_client=federation_client, + feds=[{"_id": "fed-1", "token": "token-1"}], + app_id="app-123", + app_provider_id="Local Operator", + normalize_federated_app_id=app_controllers._normalize_federated_app_id, + artefact_id="12345678-1234-1234-9234-123456789abc", + normalize_federated_artefact_id=app_controllers._normalize_federated_artefact_id, + resolve_federated_app_identity=app_controllers._resolve_federated_app_identity, + ) + + assert result == ("", 204) + mock_jsonify.assert_not_called() + federation_client.delete_artefact.assert_called_once_with( + "fed-1", + "f2175bf7-71e5-54aa-99f1-a72de468ac60", + "token-1", + ) + + +@patch("edge_cloud_management_api.controllers.app_partner_orchestration.jsonify") +def test_cleanup_federated_app_ignores_missing_artefact(mock_jsonify): + federation_client = MagicMock() + federation_client.get_all_app_instances.return_value = ([], 200) + federation_client.delete_onboarded_app.return_value = { + "message": "Deleted successfully", + "status_code": 204, + } + federation_client.delete_artefact.return_value = { + "error": "not found", + "status_code": 404, + } + + result = app_controllers.cleanup_federated_app( + federation_client=federation_client, + feds=[{"_id": "fed-1", "token": "token-1"}], + app_id="app-123", + app_provider_id="Local Operator", + normalize_federated_app_id=app_controllers._normalize_federated_app_id, + artefact_id="12345678-1234-1234-9234-123456789abc", + normalize_federated_artefact_id=app_controllers._normalize_federated_artefact_id, + resolve_federated_app_identity=app_controllers._resolve_federated_app_identity, + ) + + assert result == ("", 204) + mock_jsonify.assert_not_called() diff --git a/tests/unit/controllers/test_edge_cloud_controller.py b/tests/unit/controllers/test_edge_cloud_controller.py index d624b36b66fe5a03a9c0a46ad66d99db9c8164c5..c75ce46623e07cc2b6f160af3ec0f1290c1ea7e8 100644 --- a/tests/unit/controllers/test_edge_cloud_controller.py +++ b/tests/unit/controllers/test_edge_cloud_controller.py @@ -4,7 +4,8 @@ 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_all_cloud_zones, + get_partner_zones, get_edge_cloud_zones, ) from edge_cloud_management_api.app import get_app_instance @@ -25,9 +26,9 @@ def mock_zones(): @pytest.fixture -def mock_get_cached_zones(mock_zones): +def mock_get_all_cloud_zones(mock_zones): with patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_cached_zones", + "edge_cloud_management_api.controllers.edge_cloud_controller.get_all_cloud_zones", return_value=mock_zones, ) as mock_function: yield mock_function @@ -38,11 +39,11 @@ def mock_get_cached_zones(mock_zones): "x_correlator, region, status, expected_response_status, expected_count", [ (None, None, None, 200, 3), # No filters applied (returns all) - (None, "Region2", None, 200, 3), # Function does not filter, still returns all - (None, None, "inactive", 200, 3), - (None, None, "active", 200, 3), - (None, "Region1", "active", 200, 3), - (None, "Region3", None, 200, 3), + (None, "Region2", None, 200, 1), + (None, None, "inactive", 200, 2), + (None, None, "active", 200, 1), + (None, "Region1", "active", 200, 1), + (None, "Region3", None, 200, 0), (None, None, "invalid", 400, 0), # This is the only test expecting validation error ], ) @@ -52,7 +53,7 @@ def test_get_edge_cloud_zones( status, expected_response_status, expected_count, - mock_get_cached_zones: MagicMock, + mock_get_all_cloud_zones: MagicMock, test_app: Flask, ): """ @@ -67,27 +68,30 @@ def test_get_edge_cloud_zones( 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 data = response.get_json() assert isinstance(data, list) assert len(data) == expected_count - mock_get_cached_zones.assert_called_once() + mock_get_all_cloud_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): +def test_get_partner_zones_returns_only_partner_zones(mock_zones): + partner_zone = dict(mock_zones[0]) + partner_zone["isLocal"] = "false" + local_zone = dict(mock_zones[1]) + local_zone["isLocal"] = "true" with patch( "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", - return_value=mock_zones, + return_value=[partner_zone, local_zone], ): - assert get_cached_zones() == mock_zones + assert get_partner_zones() == [partner_zone] @pytest.mark.unit -def test_get_cached_zones_merges_local(mock_zones): +def test_get_all_cloud_zones_merges_live_local_with_partner(mock_zones): local_zone = { "edgeCloudZoneId": "local-zone", "edgeCloudZoneName": "local-zone", @@ -97,13 +101,43 @@ def test_get_cached_zones_merges_local(mock_zones): } with patch( "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", - return_value=mock_zones, + return_value=[{**mock_zones[0], "isLocal": "false"}], ), patch( "edge_cloud_management_api.controllers.edge_cloud_controller.get_local_zones", return_value=[local_zone], ): - result = get_cached_zones() + result = get_all_cloud_zones() assert local_zone in result + assert mock_zones[0] in result + + +@pytest.mark.unit +def test_get_all_cloud_zones_ignores_persisted_local_zones(): + stale_cached_local_zone = { + "_id": "Local Operator::default", + "edgeCloudZoneId": "default", + "edgeCloudZoneName": "zone-default", + "edgeCloudZoneStatus": "active", + "edgeCloudProvider": "Local Operator", + "edgeCloudRegion": "unknown", + "isLocal": "true", + } + live_local_zone = { + "edgeCloudZoneId": "069a2791-71b5-40e5-a415-3820ecf2aa87", + "edgeCloudZoneName": "bdd776dc8c06", + "edgeCloudZoneStatus": "active", + "edgeCloudProvider": "Local Operator", + "edgeCloudRegion": "unknown", + } + with patch( + "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", + return_value=[stale_cached_local_zone], + ), patch( + "edge_cloud_management_api.controllers.edge_cloud_controller.get_local_zones", + return_value=[live_local_zone], + ): + result = get_all_cloud_zones() + assert result == [live_local_zone] def test_get_cached_zones_fallback_to_srm(mock_zones): @@ -114,4 +148,4 @@ def test_get_cached_zones_fallback_to_srm(mock_zones): "edge_cloud_management_api.controllers.edge_cloud_controller.get_local_zones", return_value=mock_zones, ): - assert get_cached_zones() == mock_zones + assert get_all_cloud_zones() == mock_zones diff --git a/tests/unit/controllers/test_federation_manager_controller.py b/tests/unit/controllers/test_federation_manager_controller.py index a2906b012759a619fa3e24ed8a3b14f177f9633c..24a8920c63532ac64faeed61c1672e29588ce87f 100644 --- a/tests/unit/controllers/test_federation_manager_controller.py +++ b/tests/unit/controllers/test_federation_manager_controller.py @@ -13,8 +13,11 @@ def test_app(): @pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.FederationManagerClientFactory") -def test_create_federation(mock_factory_class, test_app: Flask): +@patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_federation") +@patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_zones") +@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_create_federation(mock_federation_client, _mock_get_token, mock_insert_zones, mock_insert_federation, test_app: Flask): """Test create_federation returns federation data""" body = { "origOPFederationId": "orig-123", @@ -22,83 +25,182 @@ def test_create_federation(mock_factory_class, test_app: Flask): "partnerStatusLink": "https://callback.example.com/status" } - mock_client = MagicMock() - mock_client.post_partner.return_value = {"federationContextId": "abc", "partnerOPFederationId": "partner-xyz"} - mock_factory_class.return_value.create_federation_client.return_value = mock_client + mock_federation_client.post_partner.return_value = ( + {"federationContextId": "abc", "partnerOPFederationId": "partner-xyz"}, + 200, + ) with test_app.test_request_context(json=body): response, status = federation_manager_controller.create_federation() assert status == 200 - data = response.get_json() - assert data is not None + data = response assert "federationContextId" in data assert data["federationContextId"] == "abc" + mock_insert_federation.assert_called_once_with({ + "_id": "abc", + "token": "token", + "partnerOPFederationId": "partner-xyz", + }) + mock_insert_zones.assert_not_called() + + +@pytest.mark.component +@patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_federation") +@patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_zones") +@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_create_federation_accepts_gsma_minimum_request( + mock_federation_client, + _mock_get_token, + mock_insert_zones, + mock_insert_federation, + test_app: Flask, +): + body = { + "initialDate": "2024-01-01T00:00:00Z", + "partnerStatusLink": "https://callback.example.com/status" + } + + mock_federation_client.post_partner.return_value = ( + {"federationContextId": "abc"}, + 200, + ) + + with test_app.test_request_context(json=body): + response, status = federation_manager_controller.create_federation() + + assert status == 200 + data = response + assert data == {"federationContextId": "abc"} + mock_insert_federation.assert_called_once_with({ + "_id": "abc", + "token": "token", + "partnerOPFederationId": None, + }) + mock_insert_zones.assert_not_called() + + +@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_create_federation_rejects_missing_required_fields(mock_federation_client, _mock_get_token, test_app: Flask): + body = { + "initialDate": "2024-01-01T00:00:00Z" + } + + with test_app.test_request_context(json=body): + response, status = federation_manager_controller.create_federation() + + assert status == 400 + payload = response.get_json() + assert payload["error"] == "Invalid input" + assert "partnerStatusLink" in str(payload["details"]) + mock_federation_client.post_partner.assert_not_called() @pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.FederationManagerClientFactory") -def test_get_federation(mock_factory_class, test_app: Flask): +@patch("edge_cloud_management_api.controllers.federation_manager_controller.get_fed", return_value={"token": "token"}) +@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") +def test_get_federation(mock_federation_client, _mock_get_fed, test_app: Flask): federation_context_id = "abc" - mock_client = MagicMock() - mock_client.get_partner.return_value = {"some": "data"} - mock_factory_class.return_value.create_federation_client.return_value = mock_client + mock_federation_client.get_partner.return_value = ({ + "offeredAvailabilityZones": [{"zoneId": "zone-1", "geographyDetails": "Zone 1", "geolocation": "0.0,0.0"}], + "platformCaps": ["homeRouting"], + }, 200) with test_app.test_request_context(): response, status = federation_manager_controller.get_federation(federation_context_id) assert status == 200 - assert response.get_json() == {"some": "data"} + assert response["platformCaps"] == ["homeRouting"] @pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.FederationManagerClientFactory") -def test_delete_federation(mock_factory_class, test_app: Flask): +@patch("edge_cloud_management_api.controllers.federation_manager_controller.get_fed", return_value={"token": "token"}) +@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") +def test_delete_federation(mock_federation_client, _mock_get_fed, test_app: Flask): federation_context_id = "abc" - mock_client = MagicMock() - mock_client.delete_partner.return_value = {"result": "Deleted"} - mock_factory_class.return_value.create_federation_client.return_value = mock_client + mock_federation_client.delete_partner.return_value = ({"result": "Deleted"}, 200) with test_app.test_request_context(): response, status = federation_manager_controller.delete_federation(federation_context_id) assert status == 200 - assert response.get_json() == {"result": "Deleted"} + assert response == {"result": "Deleted"} @pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.FederationManagerClientFactory") -def test_get_federation_context_ids(mock_factory_class, test_app: Flask): - mock_client = MagicMock() - mock_client.get_federation_context_ids.return_value = {"FederationContextId": "ctx-123"} - mock_factory_class.return_value.create_federation_client.return_value = mock_client +@patch("edge_cloud_management_api.controllers.federation_manager_controller.get_all_feds", return_value=[{"token": "token"}]) +@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") +def test_get_federation_context_ids(mock_federation_client, _mock_get_all_feds, test_app: Flask): + mock_federation_client.get_federation_context_ids.return_value = ({"federationContextId": "ctx-123"}, 200) with test_app.test_request_context(): response, status = federation_manager_controller.get_federation_context_ids() assert status == 200 - assert response.get_json() == {"FederationContextId": "ctx-123"} + assert response == {"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.get_fed", return_value={"partnerOPFederationId": "partner-xyz"}) @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): +def test_request_zone_synch(mock_federation_client, _mock_get_fed, _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) + mock_federation_client.get_partner.return_value = ({ + "offeredAvailabilityZones": [ + {"zoneId": "zone-1", "geographyDetails": "Zone 1"}, + {"zoneId": "zone-2", "geographyDetails": "Zone 2"}, + ], + }, 200) - with test_app.test_request_context(json=body): - response, status = federation_manager_controller.request_zone_synch(federation_context_id) + with patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_zones") as mock_insert_zones: + 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() + mock_federation_client.get_partner.assert_called_once_with(federation_context_id, "token") + mock_insert_zones.assert_called_once_with([ + { + "_id": "zone-1", + "edgeCloudProvider": "partner-xyz", + "edgeCloudZoneId": "zone-1", + "edgeCloudZoneName": "Zone 1", + "edgeCloudZoneStatus": "unknown", + "isLocal": "false", + "fedContextId": federation_context_id, + } + ]) + + +@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_rejects_missing_accepted_zones(mock_federation_client, _mock_get_token, test_app: Flask): + federation_context_id = "ctx-123" + body = { + "availZoneNotifLink": "http://callback.local" + } + + with test_app.test_request_context(json=body): + response, status = federation_manager_controller.request_zone_synch(federation_context_id) + + assert status == 400 + payload = response.get_json() + assert payload["error"] == "Invalid input" + assert "acceptedAvailabilityZones" in str(payload["details"]) + mock_federation_client.request_zone_sync.assert_not_called() @pytest.mark.component diff --git a/tests/unit/controllers/test_network_functions_controller.py b/tests/unit/controllers/test_network_functions_controller.py index 1ad0683795fc9ff89a16a9776fe2d186a84484c5..4801ed1f0efd20dee5c37d07b47a5334ac725248 100644 --- a/tests/unit/controllers/test_network_functions_controller.py +++ b/tests/unit/controllers/test_network_functions_controller.py @@ -28,12 +28,12 @@ SAMPLE_LOCATION_RESPONSE = { @pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.PiEdgeAPIClientFactory") +@patch("edge_cloud_management_api.controllers.network_functions_controller.SRMAPIClientFactory") def test_retrieve_location_success(mock_factory_class, test_app: Flask): """Successful location retrieval returns the SRM response as-is.""" mock_client = MagicMock() mock_client.retrieve_location.return_value = SAMPLE_LOCATION_RESPONSE - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client with test_app.test_request_context(): result = network_functions_controller.retrieve_location(SAMPLE_LOCATION_REQUEST) @@ -43,13 +43,13 @@ def test_retrieve_location_success(mock_factory_class, test_app: Flask): @pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.PiEdgeAPIClientFactory") +@patch("edge_cloud_management_api.controllers.network_functions_controller.SRMAPIClientFactory") def test_retrieve_location_srm_error_is_relayed(mock_factory_class, test_app: Flask): """When the SRM returns an error tuple (body, status), the controller relays it.""" error_body = {"error": "Device not found"} mock_client = MagicMock() mock_client.retrieve_location.return_value = (error_body, 404) - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client with test_app.test_request_context(): result = network_functions_controller.retrieve_location(SAMPLE_LOCATION_REQUEST) @@ -58,12 +58,12 @@ def test_retrieve_location_srm_error_is_relayed(mock_factory_class, test_app: Fl @pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.PiEdgeAPIClientFactory") +@patch("edge_cloud_management_api.controllers.network_functions_controller.SRMAPIClientFactory") def test_retrieve_location_connection_error(mock_factory_class, test_app: Flask): """When the SRM is unreachable, the controller returns a 500 error.""" mock_client = MagicMock() mock_client.retrieve_location.side_effect = ConnectionError("Connection refused") - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client with test_app.test_request_context(): response, status = network_functions_controller.retrieve_location(SAMPLE_LOCATION_REQUEST) @@ -75,7 +75,7 @@ def test_retrieve_location_connection_error(mock_factory_class, test_app: Flask) @pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.PiEdgeAPIClientFactory") +@patch("edge_cloud_management_api.controllers.network_functions_controller.SRMAPIClientFactory") def test_retrieve_location_with_ipv4(mock_factory_class, test_app: Flask): """Location retrieval works with IPv4 device identifier.""" ipv4_request = { @@ -90,7 +90,7 @@ def test_retrieve_location_with_ipv4(mock_factory_class, test_app: Flask): } mock_client = MagicMock() mock_client.retrieve_location.return_value = SAMPLE_LOCATION_RESPONSE - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client + mock_factory_class.return_value.create_srm_api_client.return_value = mock_client with test_app.test_request_context(): result = network_functions_controller.retrieve_location(ipv4_request)