From 0737a71c0545687b171ddc25f5a201b413f53527 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 11:03:21 +0200 Subject: [PATCH 01/21] fix: stabilize federated zone handling in OEG --- .../controllers/app_controllers.py | 99 ++++++++++--------- .../controllers/edge_cloud_controller.py | 23 +++-- .../federation_manager_controller.py | 2 +- .../services/storage_service.py | 28 +++++- 4 files changed, 96 insertions(+), 56 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 9bee8e9..4d16d84 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -242,36 +242,42 @@ def create_app_instance(): first_zone = app_zones[0] if isinstance(app_zones, list) and app_zones else {} if not isinstance(first_zone, dict): first_zone = {} - 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 = 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 = pi_edge_client.edge_cloud_zones() + if isinstance(zones, list): + for z in zones: + if not isinstance(z, dict): + continue + if z.get("edgeCloudZoneId") != edge_cloud_zone_id: + continue + if edge_cloud_provider and z.get("edgeCloudProvider") != edge_cloud_provider: + continue + if z.get("edgeCloudProvider") == edge_cloud_provider or not edge_cloud_provider: + 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.setdefault("isLocal", "true") + insert_zones([zone_payload]) + zone = get_zone(edge_cloud_zone_id, edge_cloud_provider) or zone_payload if not zone: return jsonify({ @@ -302,20 +308,25 @@ def create_app_instance(): # 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 + artefact_id = None + 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", []) + app_component_specs = appData.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 + if not artefact_id: + artefact_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(app_id))) service_name_nb = _ensure_service_name( (component_specs[0].get("serviceNameNB") if component_specs else None), diff --git a/edge_cloud_management_api/controllers/edge_cloud_controller.py b/edge_cloud_management_api/controllers/edge_cloud_controller.py index 26c7323..409fc58 100644 --- a/edge_cloud_management_api/controllers/edge_cloud_controller.py +++ b/edge_cloud_management_api/controllers/edge_cloud_controller.py @@ -13,7 +13,6 @@ try: 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: @@ -77,15 +76,23 @@ def get_cached_zones() -> list[dict]: 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) - } + merged = [] + existing_zone_keys = set() + for zone in cached or []: + if not isinstance(zone, dict): + continue + zone_key = (zone.get("edgeCloudProvider"), zone.get("edgeCloudZoneId")) + if zone_key in existing_zone_keys: + continue + merged.append(zone) + existing_zone_keys.add(zone_key) 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: + zone_key = None + if isinstance(zone, dict): + zone_key = (zone.get("edgeCloudProvider"), zone.get("edgeCloudZoneId")) + if zone_key and zone_key not in existing_zone_keys: merged.append(zone) - existing_ids.add(zone_id) + existing_zone_keys.add(zone_key) return merged def get_all_cloud_zones() -> List[EdgeCloudZone]: diff --git a/edge_cloud_management_api/controllers/federation_manager_controller.py b/edge_cloud_management_api/controllers/federation_manager_controller.py index bff3f50..978963d 100644 --- a/edge_cloud_management_api/controllers/federation_manager_controller.py +++ b/edge_cloud_management_api/controllers/federation_manager_controller.py @@ -53,7 +53,7 @@ def create_federation(): 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")}) + accepted_availability_zones.append(zone.get("zoneId")) if accepted_availability_zones: zone_response, zone_code = federation_client.subscribe_to_zones( response.get("federationContextId"), diff --git a/edge_cloud_management_api/services/storage_service.py b/edge_cloud_management_api/services/storage_service.py index 5b31e7d..a6feda9 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(): -- GitLab From fa82401198032811dc9a91a95c3c022cbf3dcce4 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 11:07:25 +0200 Subject: [PATCH 02/21] fix: normalize federated application identifiers in OEG --- .../controllers/app_controllers.py | 114 +++++++++++------- 1 file changed, 71 insertions(+), 43 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 4d16d84..0831877 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -75,19 +75,41 @@ 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 +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] + 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 submit_app(body: dict): @@ -298,16 +320,18 @@ def create_app_instance(): 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) - # ============================================================ + if not appData: + return jsonify({ + "error": "Application manifest not found", + "appId": app_id + }), 404 + + federated_app_id = _normalize_federated_app_id(app_id) + + # ============================================================ + # Step 2: Compose GSMA artefact payload + # artefactId == appId (INTENTIONAL) + # ============================================================ artefact_id = None app_provider_id = appData.get("appProvider") or appData.get("appProviderId") app_name = appData.get("name") or appData.get("appName") or app_id @@ -339,18 +363,22 @@ def create_app_instance(): 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", + 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, @@ -490,13 +518,13 @@ def create_app_instance(): - onboard_app = { - "appId": app_id, - "appProviderId": app_provider_id, - "appMetaData": { - "appName": app_name, - "version": app_version, - "accessToken": access_token + 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, @@ -549,13 +577,13 @@ def create_app_instance(): 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"), + deploy_app = { + "appId": federated_app_id, + "appProviderId": federated_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" -- GitLab From 88149e4751c0fa32c5163781160f45f0ba2820b1 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 11:12:39 +0200 Subject: [PATCH 03/21] fix: scope federated artefacts per federation context --- .../controllers/app_controllers.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 0831877..90e41d2 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -110,6 +110,16 @@ def _normalize_federated_app_provider_id(app_provider_id, fallback_source): 64, fallback_source, ) + + +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 submit_app(body: dict): @@ -349,8 +359,11 @@ def create_app_instance(): if component_specs and isinstance(component_specs, list): component_name = component_specs[0].get("componentName") component_name = component_name or app_name - if not artefact_id: - artefact_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(app_id))) + 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), -- GitLab From e1ec809d6a15d214b9fa52498d72b865d89e9744 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 12:17:38 +0200 Subject: [PATCH 04/21] fix: preserve federated Helm repository metadata --- .tmp/helm-federation-e2e-manual.md | 455 ++++++++++++++++++ .../controllers/app_controllers.py | 52 +- .../unit/controllers/test_app_controllers.py | 19 + 3 files changed, 508 insertions(+), 18 deletions(-) create mode 100644 .tmp/helm-federation-e2e-manual.md create mode 100644 tests/unit/controllers/test_app_controllers.py diff --git a/.tmp/helm-federation-e2e-manual.md b/.tmp/helm-federation-e2e-manual.md new file mode 100644 index 0000000..c40bb14 --- /dev/null +++ b/.tmp/helm-federation-e2e-manual.md @@ -0,0 +1,455 @@ +# Helm Federation E2E Manual + +## Purpose + +Validate the OEG federated Helm flow against the local Federation Manager deployment and confirm that OEG preserves full HTTP(S) Helm repository URLs when building the GSMA artefact payload. + +## Prerequisites + +- Local federation stack running from: + `FederationManager/federation-manager-i2cat/src/test/local-deployment/docker-compose.yml` +- OEG test container running as `oeg-local-test` on `http://127.0.0.1:8085` +- OEG container configured with: + - `SRM_HOST=http://srm-local:8080/srm/1.0.0` + - `FEDERATION_MANAGER_HOST=http://federation-manager-local:8989/operatorplatform/federation/v1` + - `PARTNER_API_ROOT=http://federation-manager-remote:8989` + - `MONGO_URI=mongodb://mongodb-local:27017` + +## 1. Verify stack + +Run: + +```bash +docker compose -f /home/sergio/i2cat/OperatorPlatform/FederationManager/federation-manager-i2cat/src/test/local-deployment/docker-compose.yml ps +docker ps --format '{{.Names}} {{.Ports}}' +``` + +Expected relevant containers: + +- `federation-manager-local` +- `federation-manager-remote` +- `srm-local` +- `srm-remote` +- `lite2edge-local` +- `lite2edge-remote` +- `mongodb-local` +- `keycloak-local` +- `oeg-local-test` + +## 2. Restart OEG after code changes + +```bash +docker restart oeg-local-test +``` + +## 3. Check OEG sees the federated zone + +```bash +curl -sS http://127.0.0.1:8085/oeg/1.0.0/edge-cloud-zones +``` + +Expected to include a federated zone similar to: + +```json +{ + "edgeCloudProvider": "i2cat", + "edgeCloudZoneId": "default" +} +``` + +## 4. Register a Helm app with a full HTTPS chart URL + +```bash +curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/apps' \ + -H 'Content-Type: application/json' \ + --data '{ + "appId":"550e8400-e29b-41d4-a716-446655440111", + "name":"bitnamihelm", + "appProvider":"i2cat", + "version":"15.14.0", + "packageType":"HELM", + "operatingSystem":{ + "architecture":"x86_64", + "family":"UBUNTU", + "version":"OS_VERSION_UBUNTU_2204_LTS", + "license":"OS_LICENSE_TYPE_FREE" + }, + "appRepo":{ + "type":"PUBLICREPO", + "imagePath":"https://charts.bitnami.com/bitnami/nginx:15.14.0" + }, + "componentSpec":[{ + "componentName":"nginx", + "networkInterfaces":[{ + "interfaceId":"eth0", + "protocol":"TCP", + "port":80, + "visibilityType":"VISIBILITY_EXTERNAL" + }] + }] + }' +``` + +Expected: HTTP `200` and an onboarded app response from OEG. + +## 5. Trigger federated deployment through OEG + +```bash +curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/appinstances' \ + -H 'Content-Type: application/json' \ + --data '{ + "appId":"550e8400-e29b-41d4-a716-446655440111", + "appZones":[{ + "EdgeCloudZone":{ + "edgeCloudZoneId":"default", + "edgeCloudZoneName":"unknown", + "edgeCloudProvider":"i2cat", + "edgeCloudZoneStatus":"unknown", + "edgeCloudRegion":"unknown" + } + }] + }' +``` + +## 6. Inspect OEG payloads + +```bash +docker logs --since 2m oeg-local-test +``` + +Expected artefact payload fragment: + +```json +"artefactRepoLocation": { + "repoURL": "https://charts.bitnami.com/bitnami" +} +``` + +Expected onboard payload fragment: + +```json +"appDeploymentZones": [ + "default" +] +``` + +Expected normalized component payload fragment: + +```json +"appComponentSpecs": [ + { + "componentName": "cmpbitnamihelm0" + } +] +``` + +## 7. Inspect downstream services + +```bash +docker logs --since 2m federation-manager-local +docker logs --since 2m srm-remote +docker logs --since 2m lite2edge-remote +``` + +Expected current outcome: + +- OEG artefact upload reaches FM successfully. +- OEG onboarding reaches FM successfully with HTTP `202`. +- OEG deployment reaches FM and downstream lite2edge Helm install. +- Current remaining failure is downstream Helm repository access, for example: + +```text +Helm install failed: Error: INSTALLATION FAILED: looks like "https://charts.bitnami.com/bitnami" is not a valid chart repository or cannot be reached: Get "https://repo.broadcom.com/bitnami-files/index.yaml": EOF +``` + +## Interpretation + +- If OEG logs show `repoURL: https://charts.bitnami.com/bitnami`, the repository parsing fix is working. +- If the flow fails later during Helm install or external repo fetch, OEG is no longer the blocker for this issue. + +## Post-VPN Recheck + +After disabling the corporate VPN, re-run direct chart access tests from `lite2edge-remote`: + +```bash +docker exec lite2edge-remote \ + helm show chart nginx --repo https://charts.bitnami.com/bitnami --version 15.14.0 + +docker exec lite2edge-remote \ + helm show chart oci://registry-1.docker.io/bitnamicharts/nginx --version 15.14.0 +``` + +Expected after VPN disable: + +- Both commands succeed. +- This confirms the earlier certificate failures were environment-related. + +Then replay the federated public Helm deployment through OEG using a fresh app id, for example `550e8400-e29b-41d4-a716-446655440444`. + +Observed result in this session: + +- OEG artefact payload is correct: + +```json +"artefactRepoLocation": { + "repoURL": "https://charts.bitnami.com/bitnami" +} +``` + +- OEG onboarding succeeds with `202`. +- Deployment reaches real Helm install. +- Remaining failure is downstream Helm TLS/handshake behavior to the Bitnami/Broadcom endpoint: + +```text +Helm install failed: Error: INSTALLATION FAILED: looks like "https://charts.bitnami.com/bitnami" is not a valid chart repository or cannot be reached: Get "https://repo.broadcom.com/bitnami-files/index.yaml": remote error: tls: handshake failure +``` + +Additional direct runtime check: + +```bash +docker exec lite2edge-remote python - <<'PY' +import urllib.request +with urllib.request.urlopen("https://repo.broadcom.com/bitnami-files/index.yaml", timeout=20) as r: + print(r.status) +PY +``` + +Observed in this session: + +- Direct Python HTTPS access reached the endpoint and returned HTTP `403` rather than failing TLS. +- That means the remaining issue is narrower than raw connectivity: it is specific to Helm's access path/handshake to that repository in the current runtime. + +## OCI Comparison + +To distinguish repo-format issues from network/TLS issues, a local plain-HTTP OCI registry can be used. + +### Start a local OCI registry + +```bash +docker run -d --name local-oci-registry \ + --network local-deployment_remote-net \ + -p 5000:5000 \ + registry:2 +``` + +### Package and push a local chart + +```bash +mkdir -p /tmp/oeg-chart-packages +helm package \ + /home/sergio/i2cat/OperatorPlatform/helm/oop-platform-chart/charts/oeg \ + --destination /tmp/oeg-chart-packages + +helm push /tmp/oeg-chart-packages/oeg-1.0.0.tgz \ + oci://127.0.0.1:5000/helm \ + --plain-http +``` + +### Verify direct OCI access from lite2edge + +```bash +docker exec lite2edge-remote \ + helm show chart oci://local-oci-registry:5000/helm/oeg \ + --version 1.0.0 \ + --plain-http +``` + +Expected: chart metadata is returned successfully. + +### Register an OCI-backed Helm app in OEG + +```bash +curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/apps' \ + -H 'Content-Type: application/json' \ + --data '{ + "appId":"550e8400-e29b-41d4-a716-446655440333", + "name":"ocitestfresh", + "appProvider":"i2cat", + "version":"1.0.0", + "packageType":"HELM", + "operatingSystem":{ + "architecture":"x86_64", + "family":"UBUNTU", + "version":"OS_VERSION_UBUNTU_2204_LTS", + "license":"OS_LICENSE_TYPE_FREE" + }, + "appRepo":{ + "type":"PUBLICREPO", + "imagePath":"local-oci-registry:5000/helm/oeg:1.0.0" + }, + "componentSpec":[{ + "componentName":"frontend", + "networkInterfaces":[{ + "interfaceId":"eth0", + "protocol":"TCP", + "port":80, + "visibilityType":"VISIBILITY_EXTERNAL" + }] + }] + }' +``` + +### Deploy the OCI-backed app through OEG + +```bash +curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/appinstances' \ + -H 'Content-Type: application/json' \ + --data '{ + "appId":"550e8400-e29b-41d4-a716-446655440333", + "appZones":[{ + "EdgeCloudZone":{ + "edgeCloudZoneId":"default", + "edgeCloudZoneName":"unknown", + "edgeCloudProvider":"i2cat", + "edgeCloudZoneStatus":"unknown", + "edgeCloudRegion":"unknown" + } + }] + }' +``` + +### Current findings from OCI path + +- Direct OCI access from `lite2edge-remote` works. +- Public HTTPS Helm and public OCI both fail in `lite2edge-remote` because of TLS trust issues, not because OCI is mandatory. +- The federated OCI path exposed additional translation bugs: + - `lite2edge` needed to treat `repoURL` values like `host:port` as OCI registries. + - FM remote had a `countryCode` null crash in onboarding view. + - SRM was reconstructing OCI app manifests incorrectly in some paths. + +### Current conclusion + +The system does not appear to require OCI-only URLs. + +- Classic Helm repos are supported by code path. +- OCI registries are also supported by code path. +- The public Bitnami failure was due to TLS trust in the runtime container. +- The local OCI registry is useful as a deterministic test source while continuing to fix downstream translation issues. + +## Known-Good Success Paths + +### A. Direct local deployment success on lite2edge + +Use a chart source that does not hardcode conflicting cluster resources. A working example in this session was Bitnami nginx via OCI. + +Onboard directly to `lite2edge-remote`: + +```bash +curl -sS -X POST 'http://127.0.0.1:8751/api/v1/apps/onboard' \ + -H 'Content-Type: application/json' \ + --data '{ + "appId":"local-oci-nginx", + "name":"localnginxoci", + "version":"15.14.0", + "packageType":"HELM", + "appProvider":"local-provider", + "appRepo":{ + "type":"PUBLICREPO", + "repoURL":"registry-1.docker.io", + "imagePath":"registry-1.docker.io/bitnamicharts/nginx:15.14.0" + }, + "appComponentSpecs":[{ + "artefactId":"local-oci-nginx-art", + "componentName":"frontend", + "serviceNameNB":"nbfrontend0", + "serviceNameEW":"ewfrontend0" + }], + "appDeploymentZones":[{"zoneId":"default"}] + }' +``` + +Deploy directly: + +```bash +curl -sS -X POST 'http://127.0.0.1:8751/api/v1/apps/local-oci-nginx/deploy' \ + -H 'Content-Type: application/json' \ + --data '{"appId":"local-oci-nginx","appZones":[]}' +``` + +Expected result in this session: + +```json +{ + "appInstanceState": "instantiating", + "packageType": "HELM" +} +``` + +### B. Federated deployment success through OEG + +Register a fresh OCI-backed app in OEG: + +```bash +curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/apps' \ + -H 'Content-Type: application/json' \ + --data '{ + "appId":"550e8400-e29b-41d4-a716-446655440555", + "name":"federatednginxoci", + "appProvider":"i2cat", + "version":"15.14.0", + "packageType":"HELM", + "operatingSystem":{ + "architecture":"x86_64", + "family":"UBUNTU", + "version":"OS_VERSION_UBUNTU_2204_LTS", + "license":"OS_LICENSE_TYPE_FREE" + }, + "appRepo":{ + "type":"PUBLICREPO", + "imagePath":"registry-1.docker.io/bitnamicharts/nginx:15.14.0" + }, + "componentSpec":[{ + "componentName":"frontend", + "networkInterfaces":[{ + "interfaceId":"eth0", + "protocol":"TCP", + "port":80, + "visibilityType":"VISIBILITY_EXTERNAL" + }] + }] + }' +``` + +Deploy it to the federated zone: + +```bash +curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/appinstances' \ + -H 'Content-Type: application/json' \ + --data '{ + "appId":"550e8400-e29b-41d4-a716-446655440555", + "appZones":[{ + "EdgeCloudZone":{ + "edgeCloudZoneId":"default", + "edgeCloudZoneName":"unknown", + "edgeCloudProvider":"i2cat", + "edgeCloudZoneStatus":"unknown", + "edgeCloudRegion":"unknown" + } + }] + }' +``` + +Expected result in this session: + +```json +{ + "message":"Application deployed successfully at partner OP", + "deployment_response":{ + "appInstIdentifier":"...", + "zoneId":"default" + } +} +``` + +Useful validation logs: + +```bash +docker logs --since 30s oeg-local-test +docker logs --since 30s lite2edge-remote +``` + +Expected downstream evidence from `lite2edge-remote`: + +```text +POST /api/v1/apps/550e8400-e29b-41d4-a716-446655440555/deploy HTTP/1.1" 202 Accepted +``` diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 90e41d2..08db23a 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -1,14 +1,15 @@ -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.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 urllib.parse import urlsplit factory = FederationManagerClientFactory() federation_client = factory.create_federation_client() @@ -50,13 +51,23 @@ def _ensure_service_name(value, prefix, fallback_source): 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 +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", clean_path registry_candidate = parts[0] if "." in registry_candidate or ":" in registry_candidate: return registry_candidate, parts[1] @@ -359,6 +370,11 @@ def create_app_instance(): 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, diff --git a/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py new file mode 100644 index 0000000..3a42f49 --- /dev/null +++ b/tests/unit/controllers/test_app_controllers.py @@ -0,0 +1,19 @@ +from edge_cloud_management_api.controllers import app_controllers + + +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" -- GitLab From abb34b7b8fea435278d231c0874148c8a76c2780 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 17:25:18 +0200 Subject: [PATCH 05/21] refactor: split federation create from zone subscription and fix provider fallback --- .../federation_manager_controller.py | 78 +++++++++++-------- .../test_federation_manager_controller.py | 46 +++++++++-- 2 files changed, 82 insertions(+), 42 deletions(-) diff --git a/edge_cloud_management_api/controllers/federation_manager_controller.py b/edge_cloud_management_api/controllers/federation_manager_controller.py index 978963d..07ca5dc 100644 --- a/edge_cloud_management_api/controllers/federation_manager_controller.py +++ b/edge_cloud_management_api/controllers/federation_manager_controller.py @@ -23,6 +23,29 @@ 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.""" @@ -31,42 +54,13 @@ def create_federation(): 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(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): @@ -135,6 +129,22 @@ def request_zone_synch(federationContextId): 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/tests/unit/controllers/test_federation_manager_controller.py b/tests/unit/controllers/test_federation_manager_controller.py index a2906b0..bfb3d8f 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,9 +25,10 @@ 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() @@ -34,6 +38,12 @@ def test_create_federation(mock_factory_class, test_app: Flask): assert data is not None 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 @@ -84,21 +94,41 @@ def test_get_federation_context_ids(mock_factory_class, test_app: Flask): @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 -- GitLab From 0c2e6a53adf9951a1f4c1a609ecccd6a6d4c2351 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 09:31:11 +0200 Subject: [PATCH 06/21] fix: normalize federated app instance lookups and CAMARA responses --- .../controllers/app_controllers.py | 325 ++++++++++++------ .../unit/controllers/test_app_controllers.py | 103 ++++++ 2 files changed, 320 insertions(+), 108 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 08db23a..d698063 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -123,6 +123,85 @@ def _normalize_federated_app_provider_id(app_provider_id, 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 _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 _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 + normalized = { + "appInstanceId": app_instance_id, + "status": instance.get("appInstanceState") or "unknown", + } + 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 _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}-" @@ -214,13 +293,14 @@ def delete_app(appId, x_correlator=None): 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 app_provider_id: + federated_app_provider_id = _resolve_federated_app_provider_id(appId, 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=federated_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): @@ -680,99 +760,127 @@ 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 = [] + pi_edge_client_factory = PiEdgeAPIClientFactory() + pi_edge_client = pi_edge_client_factory.create_pi_edge_api_client() + + local_response = pi_edge_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: + instances.append(normalized_instance) + + 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: + federated_app_provider_id = _resolve_federated_app_provider_id(app_id, 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=federated_app_provider_id, + token=fed_token, + ) + if fed_code == 200: + instances.extend( + _normalize_federated_app_instances( + fed_instances, + zone_provider=(fed.get("partnerOPFederationId") or "unknown"), + region=region, + ) + ) + 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(): + federated_app_provider_id = _resolve_federated_app_provider_id(app_id_value, 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_value, + app_provider_id=federated_app_provider_id, + token=fed_token, + ) + if fed_code == 200: + instances.extend( + _normalize_federated_app_instances( + fed_instances, + zone_provider=(fed.get("partnerOPFederationId") or "unknown"), + 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 + ] + + return jsonify(instances), 200 except Exception as e: logger.exception("Failed to retrieve app instances") @@ -844,18 +952,19 @@ def delete_app_instance(appInstanceId: str, x_correlator=None): 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, - ) + 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(): + federated_app_provider_id = _resolve_federated_app_provider_id(app_id_value, app_provider_id) + fed_instances, fed_code = federation_client.get_all_app_instances( + federation_context_id=federation_context_id, + app_id=app_id_value, + app_provider_id=federated_app_provider_id, + token=fed_token, + ) if fed_code != 200 or not isinstance(fed_instances, list): continue for zone_info in fed_instances: diff --git a/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py index 3a42f49..b5e7cd2 100644 --- a/tests/unit/controllers/test_app_controllers.py +++ b/tests/unit/controllers/test_app_controllers.py @@ -1,4 +1,7 @@ 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(): @@ -17,3 +20,103 @@ def test_split_image_reference_preserves_container_registry_behavior(): 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" + + +@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.PiEdgeAPIClientFactory") +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_pi_edge_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="app-123", + app_provider_id="providerapp123", + token="token-1", + ) + + +@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) +@patch("edge_cloud_management_api.controllers.app_controllers.PiEdgeAPIClientFactory") +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_pi_edge_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.PiEdgeAPIClientFactory") +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_pi_edge_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", + }] -- GitLab From 4e041cfb5cb316bbbf3b77cd1d39722edd5efab0 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 09:40:55 +0200 Subject: [PATCH 07/21] refactor: extract federated app instance lookup helpers --- .../controllers/app_controllers.py | 262 ++++++++---------- 1 file changed, 114 insertions(+), 148 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index d698063..79b2b9d 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -202,6 +202,69 @@ def _normalize_federated_app_instances(fed_instances, zone_provider=None, region return normalized_instances +def _resolve_app_provider(pi_edge_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 = 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 + + +def _get_catalog_app_provider_map(pi_edge_client): + app_provider_map = {} + apps = pi_edge_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(pi_edge_client, app_id_value, app_payload=app) + if provider: + app_provider_map[app_id_value] = provider + return app_provider_map + + +def _iter_federated_instances(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_provider_id = _resolve_federated_app_provider_id(app_id_value, app_provider_id) + fed_instances, fed_code = federation_client.get_all_app_instances( + federation_context_id=federation_context_id, + app_id=app_id_value, + 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, federation_context_id, fed_token, zone_provider, fed_instances + + 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}-" @@ -784,89 +847,36 @@ def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=Non if normalized_instance: instances.append(normalized_instance) - 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) + app_provider_id = _resolve_app_provider(pi_edge_client, app_id) if app_provider_id: - federated_app_provider_id = _resolve_federated_app_provider_id(app_id, 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=federated_app_provider_id, - token=fed_token, - ) - if fed_code == 200: - instances.extend( - _normalize_federated_app_instances( - fed_instances, - zone_provider=(fed.get("partnerOPFederationId") or "unknown"), - region=region, - ) + for app_id_value, _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: - 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(): - federated_app_provider_id = _resolve_federated_app_provider_id(app_id_value, 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_value, - app_provider_id=federated_app_provider_id, - token=fed_token, - ) - if fed_code == 200: - instances.extend( - _normalize_federated_app_instances( - fed_instances, - zone_provider=(fed.get("partnerOPFederationId") or "unknown"), - region=region, - ) - ) + app_provider_map = _get_catalog_app_provider_map(pi_edge_client) + for app_id_value, _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 = [ @@ -916,78 +926,34 @@ 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(): - federated_app_provider_id = _resolve_federated_app_provider_id(app_id_value, app_provider_id) - fed_instances, fed_code = federation_client.get_all_app_instances( - federation_context_id=federation_context_id, - app_id=app_id_value, - app_provider_id=federated_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(pi_edge_client) + + feds = get_all_feds() + for app_id_value, 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=app_id_value, + app_instance_id=appInstanceId, + zone_id=zone_id, + token=fed_token, + ) + return jsonify(remove_response), remove_status return jsonify({ "error": response.get("error") if isinstance(response, dict) else response.text, -- GitLab From ad02d4dde898afbcfbd422a4922491af159562f6 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 09:52:51 +0200 Subject: [PATCH 08/21] fix: dedupe overlapping app instance results --- .../controllers/app_controllers.py | 17 ++++++ .../unit/controllers/test_app_controllers.py | 60 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 79b2b9d..cc53832 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -202,6 +202,21 @@ def _normalize_federated_app_instances(fed_instances, zone_provider=None, region return normalized_instances +def _dedupe_app_instances(instances): + deduped = [] + seen_instance_ids = set() + for instance in instances: + if not isinstance(instance, dict): + continue + app_instance_id = instance.get("appInstanceId") + if app_instance_id and app_instance_id in seen_instance_ids: + continue + if app_instance_id: + seen_instance_ids.add(app_instance_id) + deduped.append(instance) + return deduped + + def _resolve_app_provider(pi_edge_client, app_id_value, app_payload=None): if isinstance(app_payload, dict): provider = app_payload.get("appProvider") or app_payload.get("appProviderId") @@ -890,6 +905,8 @@ def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=Non and instance["edgeCloudZone"].get("edgeCloudRegion") == region ] + instances = _dedupe_app_instances(instances) + return jsonify(instances), 200 except Exception as e: diff --git a/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py index b5e7cd2..4f35406 100644 --- a/tests/unit/controllers/test_app_controllers.py +++ b/tests/unit/controllers/test_app_controllers.py @@ -79,6 +79,66 @@ def test_get_app_instance_uses_normalized_provider_for_federated_lookup( ) +@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.PiEdgeAPIClientFactory") +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_pi_edge_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": "unknown", + "edgeCloudZoneStatus": "unknown", + "edgeCloudRegion": "unknown", + }, + }] + + @patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) @patch("edge_cloud_management_api.controllers.app_controllers.PiEdgeAPIClientFactory") def test_get_app_instance_returns_empty_list_when_no_instances(mock_factory_class, _mock_get_all_feds): -- GitLab From d9390a91531ddeb2db1fff00451ad4738581a143 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 10:10:42 +0200 Subject: [PATCH 09/21] refactor: replace PiEdge naming with SRM clients --- .../configs/env_config.py | 4 +- .../controllers/app_controllers.py | 56 +++++++++---------- .../controllers/edge_cloud_controller.py | 16 +++--- .../network_functions_controller.py | 34 +++++------ .../services/edge_cloud_services.py | 20 +++---- .../services/federation_services.py | 4 +- .../controllers/test_app_controllers.py | 17 +++--- .../unit/controllers/test_app_controllers.py | 16 +++--- .../test_network_functions_controller.py | 16 +++--- 9 files changed, 91 insertions(+), 92 deletions(-) diff --git a/edge_cloud_management_api/configs/env_config.py b/edge_cloud_management_api/configs/env_config.py index fc1a1f8..2b161fb 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 cc53832..7867fe8 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -1,7 +1,7 @@ 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.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 insert_zones @@ -217,7 +217,7 @@ def _dedupe_app_instances(instances): return deduped -def _resolve_app_provider(pi_edge_client, app_id_value, app_payload=None): +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: @@ -228,7 +228,7 @@ def _resolve_app_provider(pi_edge_client, app_id_value, app_payload=None): if provider: return provider - app_response = pi_edge_client.get_app(app_id_value) + app_response = srm_client.get_app(app_id_value) if isinstance(app_response, dict): manifest = app_response.get("appManifest") if isinstance(manifest, dict): @@ -241,9 +241,9 @@ def _resolve_app_provider(pi_edge_client, app_id_value, app_payload=None): return None -def _get_catalog_app_provider_map(pi_edge_client): +def _get_catalog_app_provider_map(srm_client): app_provider_map = {} - apps = pi_edge_client.get_service_functions_catalogue() + apps = srm_client.get_service_functions_catalogue() if not isinstance(apps, list): return app_provider_map @@ -253,7 +253,7 @@ def _get_catalog_app_provider_map(pi_edge_client): app_id_value = app.get("appId") or app.get("id") if not app_id_value: continue - provider = _resolve_app_provider(pi_edge_client, app_id_value, app_payload=app) + 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 @@ -295,8 +295,8 @@ 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() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.submit_app(body) return response @@ -313,8 +313,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: @@ -327,8 +327,8 @@ 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() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() response = api_client.get_app(appId) return response @@ -348,8 +348,8 @@ 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() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_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") @@ -437,8 +437,8 @@ 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): @@ -457,7 +457,7 @@ def create_app_instance(): 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 = pi_edge_client.edge_cloud_zones() + zones = srm_client.edge_cloud_zones() if isinstance(zones, list): for z in zones: if not isinstance(z, dict): @@ -494,7 +494,7 @@ def create_app_instance(): # ============================================================ # Step 1: Retrieve application metadata from SRM # ============================================================ - app_response = pi_edge_client.get_app(appId=app_id) + 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 @@ -806,7 +806,7 @@ def create_app_instance(): try: logger.debug("Sending deployment request to SRM") - response = pi_edge_client.deploy_service_function(data=body) + response = srm_client.deploy_service_function(data=body) if isinstance(response, dict) and "error" in response: logger.warning( @@ -847,10 +847,10 @@ def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=Non app_id = app_id or appId app_instance_id = app_instance_id or appInstanceId instances = [] - 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() - local_response = pi_edge_client.get_app_instances() + local_response = srm_client.get_app_instances() local_instances = [] if isinstance(local_response, dict): local_instances = local_response.get("appInstances", []) @@ -864,7 +864,7 @@ def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=Non feds = get_all_feds() if app_id: - app_provider_id = _resolve_app_provider(pi_edge_client, app_id) + app_provider_id = _resolve_app_provider(srm_client, app_id) if app_provider_id: for app_id_value, _federation_context_id, _fed_token, zone_provider, fed_instances in _iter_federated_instances( feds, @@ -880,7 +880,7 @@ def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=Non else: logger.info("Skipping federated lookup; no appProviderId for appId=%s", app_id) else: - app_provider_map = _get_catalog_app_provider_map(pi_edge_client) + app_provider_map = _get_catalog_app_provider_map(srm_client) for app_id_value, _federation_context_id, _fed_token, zone_provider, fed_instances in _iter_federated_instances( feds, app_provider_map.items(), @@ -926,9 +926,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 @@ -943,7 +943,7 @@ def delete_app_instance(appInstanceId: str, x_correlator=None): else: status_code = response.get("status_code", 404) - app_provider_map = _get_catalog_app_provider_map(pi_edge_client) + app_provider_map = _get_catalog_app_provider_map(srm_client) feds = get_all_feds() for app_id_value, federation_context_id, fed_token, _zone_provider, fed_instances in _iter_federated_instances( diff --git a/edge_cloud_management_api/controllers/edge_cloud_controller.py b/edge_cloud_management_api/controllers/edge_cloud_controller.py index 409fc58..349bc2e 100644 --- a/edge_cloud_management_api/controllers/edge_cloud_controller.py +++ b/edge_cloud_management_api/controllers/edge_cloud_controller.py @@ -3,14 +3,14 @@ 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.edge_cloud_services import SRMAPIClientFactory from edge_cloud_management_api.services.storage_service import insert_zones, 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() + srm_factory = SRMAPIClientFactory() + api_client = srm_factory.create_srm_api_client() zones = api_client.edge_cloud_zones() for zone in zones: zone['isLocal'] = 'true' @@ -45,11 +45,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: @@ -155,7 +155,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/network_functions_controller.py b/edge_cloud_management_api/controllers/network_functions_controller.py index 87e88ef..6e7d913 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/services/edge_cloud_services.py b/edge_cloud_management_api/services/edge_cloud_services.py index 88358c8..c1bee7d 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 d947dd1..0f0a7cb 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: @@ -453,7 +453,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/tests/component/controllers/test_app_controllers.py b/tests/component/controllers/test_app_controllers.py index 22ca4b6..fce6c32 100644 --- a/tests/component/controllers/test_app_controllers.py +++ b/tests/component/controllers/test_app_controllers.py @@ -13,7 +13,7 @@ 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""" app_id = "mock-app-id" @@ -23,7 +23,7 @@ def test_delete_app(mock_factory_class, test_app: Flask): mock_response.status_code = 200 mock_client.delete_app.return_value = mock_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 = app_controllers.delete_app(app_id) @@ -33,7 +33,7 @@ def test_delete_app(mock_factory_class, test_app: Flask): @pytest.mark.component -@patch("edge_cloud_management_api.controllers.app_controllers.PiEdgeAPIClientFactory") +@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") def test_create_app_instance(mock_factory_class, test_app: Flask): """Test create_app_instance returns accepted response""" body = { @@ -44,7 +44,7 @@ def test_create_app_instance(mock_factory_class, test_app: Flask): 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 with test_app.test_request_context(json=body): response, status_code = app_controllers.create_app_instance() @@ -55,12 +55,12 @@ def test_create_app_instance(mock_factory_class, test_app: Flask): @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""" 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() @@ -73,7 +73,7 @@ def test_get_app_instance(mock_factory_class, test_app: Flask): @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""" app_instance_id = "instance-123" @@ -84,11 +84,10 @@ def test_delete_app_instance(mock_factory_class, test_app: Flask): mock_response.status_code = 200 mock_client.delete_app_instance.return_value = mock_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 = app_controllers.delete_app_instance(app_instance_id) assert isinstance(result, dict) assert result == {"result": "Deleted"} - diff --git a/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py index 4f35406..3c4e72b 100644 --- a/tests/unit/controllers/test_app_controllers.py +++ b/tests/unit/controllers/test_app_controllers.py @@ -33,7 +33,7 @@ def test_resolve_federated_app_provider_id_normalizes_local_provider_name(): @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.PiEdgeAPIClientFactory") +@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, @@ -49,7 +49,7 @@ def test_get_app_instance_uses_normalized_provider_for_federated_lookup( "appProvider": "Local Operator", } } - 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_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", @@ -81,7 +81,7 @@ def test_get_app_instance_uses_normalized_provider_for_federated_lookup( @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.PiEdgeAPIClientFactory") +@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, @@ -110,7 +110,7 @@ def test_get_app_instance_dedupes_local_and_federated_results( "appProvider": "Local Operator", } } - 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_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", @@ -140,14 +140,14 @@ def test_get_app_instance_dedupes_local_and_federated_results( @patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) -@patch("edge_cloud_management_api.controllers.app_controllers.PiEdgeAPIClientFactory") +@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_pi_edge_api_client.return_value = mock_client + 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() @@ -157,7 +157,7 @@ def test_get_app_instance_returns_empty_list_when_no_instances(mock_factory_clas @patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) -@patch("edge_cloud_management_api.controllers.app_controllers.PiEdgeAPIClientFactory") +@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 @@ -169,7 +169,7 @@ def test_get_app_instance_supports_openapi_query_param_names(mock_factory_class, {"appInstanceId": "inst-2", "appId": "app-2", "status": "failed"}, ] } - 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 app.test_request_context(): response, status_code = app_controllers.get_app_instance(appId="app-1", appInstanceId="inst-1") diff --git a/tests/unit/controllers/test_network_functions_controller.py b/tests/unit/controllers/test_network_functions_controller.py index 1ad0683..4801ed1 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) -- GitLab From d2518cc77ae30407c017f9adace839065345e11b Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 11:16:03 +0200 Subject: [PATCH 10/21] fix: filter app instance results by app id --- .../controllers/app_controllers.py | 2 ++ .../unit/controllers/test_app_controllers.py | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 7867fe8..1a49021 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -860,6 +860,8 @@ def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=Non 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() diff --git a/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py index 3c4e72b..e01c922 100644 --- a/tests/unit/controllers/test_app_controllers.py +++ b/tests/unit/controllers/test_app_controllers.py @@ -180,3 +180,29 @@ def test_get_app_instance_supports_openapi_query_param_names(mock_factory_class, "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", + }] -- GitLab From e5ed80e501a238a597f850cbb1be32131b9073a0 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 11:18:13 +0200 Subject: [PATCH 11/21] chore: ignore runtime scratch files in .tmp/ --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e4ee6ae..fc2253d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ htmlcov/ .python_version coverage.xml uv.lock + +# Runtime scratch files +.tmp/ -- GitLab From 34b0dab50d440eb3fd146cb05c8a8d426aa32ca3 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 12:48:33 +0200 Subject: [PATCH 12/21] fix: preserve zone identity in app instance responses --- .../controllers/app_controllers.py | 84 ++++++++++++++++++- .../unit/controllers/test_app_controllers.py | 81 +++++++++++++++++- 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 1a49021..d1cfb96 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -169,6 +169,38 @@ def _normalize_local_app_instance(instance): 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 = get_zone(zone_id, zone_provider) if zone_provider and zone_provider != "unknown" else 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): @@ -204,15 +236,58 @@ def _normalize_federated_app_instances(fed_instances, zone_provider=None, region def _dedupe_app_instances(instances): deduped = [] - seen_instance_ids = set() + 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 and app_instance_id in seen_instance_ids: - continue if app_instance_id: - seen_instance_ids.add(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 @@ -908,6 +983,7 @@ def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=Non ] instances = _dedupe_app_instances(instances) + instances = [_enrich_instance_zone_from_catalog(instance) for instance in instances] return jsonify(instances), 200 diff --git a/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py index e01c922..d4dcfd7 100644 --- a/tests/unit/controllers/test_app_controllers.py +++ b/tests/unit/controllers/test_app_controllers.py @@ -132,13 +132,92 @@ def test_get_app_instance_dedupes_local_and_federated_results( "edgeCloudZone": { "edgeCloudZoneId": "default", "edgeCloudZoneName": "unknown", - "edgeCloudProvider": "unknown", + "edgeCloudProvider": "Remote Operator", "edgeCloudZoneStatus": "unknown", "edgeCloudRegion": "unknown", }, }] +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_controllers.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): -- GitLab From afb0fc56afe379d235d3958bb872c64a130ac5e4 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 14:50:33 +0200 Subject: [PATCH 13/21] chore: drop temporary helm federation notes --- .tmp/helm-federation-e2e-manual.md | 455 ----------------------------- 1 file changed, 455 deletions(-) delete mode 100644 .tmp/helm-federation-e2e-manual.md diff --git a/.tmp/helm-federation-e2e-manual.md b/.tmp/helm-federation-e2e-manual.md deleted file mode 100644 index c40bb14..0000000 --- a/.tmp/helm-federation-e2e-manual.md +++ /dev/null @@ -1,455 +0,0 @@ -# Helm Federation E2E Manual - -## Purpose - -Validate the OEG federated Helm flow against the local Federation Manager deployment and confirm that OEG preserves full HTTP(S) Helm repository URLs when building the GSMA artefact payload. - -## Prerequisites - -- Local federation stack running from: - `FederationManager/federation-manager-i2cat/src/test/local-deployment/docker-compose.yml` -- OEG test container running as `oeg-local-test` on `http://127.0.0.1:8085` -- OEG container configured with: - - `SRM_HOST=http://srm-local:8080/srm/1.0.0` - - `FEDERATION_MANAGER_HOST=http://federation-manager-local:8989/operatorplatform/federation/v1` - - `PARTNER_API_ROOT=http://federation-manager-remote:8989` - - `MONGO_URI=mongodb://mongodb-local:27017` - -## 1. Verify stack - -Run: - -```bash -docker compose -f /home/sergio/i2cat/OperatorPlatform/FederationManager/federation-manager-i2cat/src/test/local-deployment/docker-compose.yml ps -docker ps --format '{{.Names}} {{.Ports}}' -``` - -Expected relevant containers: - -- `federation-manager-local` -- `federation-manager-remote` -- `srm-local` -- `srm-remote` -- `lite2edge-local` -- `lite2edge-remote` -- `mongodb-local` -- `keycloak-local` -- `oeg-local-test` - -## 2. Restart OEG after code changes - -```bash -docker restart oeg-local-test -``` - -## 3. Check OEG sees the federated zone - -```bash -curl -sS http://127.0.0.1:8085/oeg/1.0.0/edge-cloud-zones -``` - -Expected to include a federated zone similar to: - -```json -{ - "edgeCloudProvider": "i2cat", - "edgeCloudZoneId": "default" -} -``` - -## 4. Register a Helm app with a full HTTPS chart URL - -```bash -curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/apps' \ - -H 'Content-Type: application/json' \ - --data '{ - "appId":"550e8400-e29b-41d4-a716-446655440111", - "name":"bitnamihelm", - "appProvider":"i2cat", - "version":"15.14.0", - "packageType":"HELM", - "operatingSystem":{ - "architecture":"x86_64", - "family":"UBUNTU", - "version":"OS_VERSION_UBUNTU_2204_LTS", - "license":"OS_LICENSE_TYPE_FREE" - }, - "appRepo":{ - "type":"PUBLICREPO", - "imagePath":"https://charts.bitnami.com/bitnami/nginx:15.14.0" - }, - "componentSpec":[{ - "componentName":"nginx", - "networkInterfaces":[{ - "interfaceId":"eth0", - "protocol":"TCP", - "port":80, - "visibilityType":"VISIBILITY_EXTERNAL" - }] - }] - }' -``` - -Expected: HTTP `200` and an onboarded app response from OEG. - -## 5. Trigger federated deployment through OEG - -```bash -curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/appinstances' \ - -H 'Content-Type: application/json' \ - --data '{ - "appId":"550e8400-e29b-41d4-a716-446655440111", - "appZones":[{ - "EdgeCloudZone":{ - "edgeCloudZoneId":"default", - "edgeCloudZoneName":"unknown", - "edgeCloudProvider":"i2cat", - "edgeCloudZoneStatus":"unknown", - "edgeCloudRegion":"unknown" - } - }] - }' -``` - -## 6. Inspect OEG payloads - -```bash -docker logs --since 2m oeg-local-test -``` - -Expected artefact payload fragment: - -```json -"artefactRepoLocation": { - "repoURL": "https://charts.bitnami.com/bitnami" -} -``` - -Expected onboard payload fragment: - -```json -"appDeploymentZones": [ - "default" -] -``` - -Expected normalized component payload fragment: - -```json -"appComponentSpecs": [ - { - "componentName": "cmpbitnamihelm0" - } -] -``` - -## 7. Inspect downstream services - -```bash -docker logs --since 2m federation-manager-local -docker logs --since 2m srm-remote -docker logs --since 2m lite2edge-remote -``` - -Expected current outcome: - -- OEG artefact upload reaches FM successfully. -- OEG onboarding reaches FM successfully with HTTP `202`. -- OEG deployment reaches FM and downstream lite2edge Helm install. -- Current remaining failure is downstream Helm repository access, for example: - -```text -Helm install failed: Error: INSTALLATION FAILED: looks like "https://charts.bitnami.com/bitnami" is not a valid chart repository or cannot be reached: Get "https://repo.broadcom.com/bitnami-files/index.yaml": EOF -``` - -## Interpretation - -- If OEG logs show `repoURL: https://charts.bitnami.com/bitnami`, the repository parsing fix is working. -- If the flow fails later during Helm install or external repo fetch, OEG is no longer the blocker for this issue. - -## Post-VPN Recheck - -After disabling the corporate VPN, re-run direct chart access tests from `lite2edge-remote`: - -```bash -docker exec lite2edge-remote \ - helm show chart nginx --repo https://charts.bitnami.com/bitnami --version 15.14.0 - -docker exec lite2edge-remote \ - helm show chart oci://registry-1.docker.io/bitnamicharts/nginx --version 15.14.0 -``` - -Expected after VPN disable: - -- Both commands succeed. -- This confirms the earlier certificate failures were environment-related. - -Then replay the federated public Helm deployment through OEG using a fresh app id, for example `550e8400-e29b-41d4-a716-446655440444`. - -Observed result in this session: - -- OEG artefact payload is correct: - -```json -"artefactRepoLocation": { - "repoURL": "https://charts.bitnami.com/bitnami" -} -``` - -- OEG onboarding succeeds with `202`. -- Deployment reaches real Helm install. -- Remaining failure is downstream Helm TLS/handshake behavior to the Bitnami/Broadcom endpoint: - -```text -Helm install failed: Error: INSTALLATION FAILED: looks like "https://charts.bitnami.com/bitnami" is not a valid chart repository or cannot be reached: Get "https://repo.broadcom.com/bitnami-files/index.yaml": remote error: tls: handshake failure -``` - -Additional direct runtime check: - -```bash -docker exec lite2edge-remote python - <<'PY' -import urllib.request -with urllib.request.urlopen("https://repo.broadcom.com/bitnami-files/index.yaml", timeout=20) as r: - print(r.status) -PY -``` - -Observed in this session: - -- Direct Python HTTPS access reached the endpoint and returned HTTP `403` rather than failing TLS. -- That means the remaining issue is narrower than raw connectivity: it is specific to Helm's access path/handshake to that repository in the current runtime. - -## OCI Comparison - -To distinguish repo-format issues from network/TLS issues, a local plain-HTTP OCI registry can be used. - -### Start a local OCI registry - -```bash -docker run -d --name local-oci-registry \ - --network local-deployment_remote-net \ - -p 5000:5000 \ - registry:2 -``` - -### Package and push a local chart - -```bash -mkdir -p /tmp/oeg-chart-packages -helm package \ - /home/sergio/i2cat/OperatorPlatform/helm/oop-platform-chart/charts/oeg \ - --destination /tmp/oeg-chart-packages - -helm push /tmp/oeg-chart-packages/oeg-1.0.0.tgz \ - oci://127.0.0.1:5000/helm \ - --plain-http -``` - -### Verify direct OCI access from lite2edge - -```bash -docker exec lite2edge-remote \ - helm show chart oci://local-oci-registry:5000/helm/oeg \ - --version 1.0.0 \ - --plain-http -``` - -Expected: chart metadata is returned successfully. - -### Register an OCI-backed Helm app in OEG - -```bash -curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/apps' \ - -H 'Content-Type: application/json' \ - --data '{ - "appId":"550e8400-e29b-41d4-a716-446655440333", - "name":"ocitestfresh", - "appProvider":"i2cat", - "version":"1.0.0", - "packageType":"HELM", - "operatingSystem":{ - "architecture":"x86_64", - "family":"UBUNTU", - "version":"OS_VERSION_UBUNTU_2204_LTS", - "license":"OS_LICENSE_TYPE_FREE" - }, - "appRepo":{ - "type":"PUBLICREPO", - "imagePath":"local-oci-registry:5000/helm/oeg:1.0.0" - }, - "componentSpec":[{ - "componentName":"frontend", - "networkInterfaces":[{ - "interfaceId":"eth0", - "protocol":"TCP", - "port":80, - "visibilityType":"VISIBILITY_EXTERNAL" - }] - }] - }' -``` - -### Deploy the OCI-backed app through OEG - -```bash -curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/appinstances' \ - -H 'Content-Type: application/json' \ - --data '{ - "appId":"550e8400-e29b-41d4-a716-446655440333", - "appZones":[{ - "EdgeCloudZone":{ - "edgeCloudZoneId":"default", - "edgeCloudZoneName":"unknown", - "edgeCloudProvider":"i2cat", - "edgeCloudZoneStatus":"unknown", - "edgeCloudRegion":"unknown" - } - }] - }' -``` - -### Current findings from OCI path - -- Direct OCI access from `lite2edge-remote` works. -- Public HTTPS Helm and public OCI both fail in `lite2edge-remote` because of TLS trust issues, not because OCI is mandatory. -- The federated OCI path exposed additional translation bugs: - - `lite2edge` needed to treat `repoURL` values like `host:port` as OCI registries. - - FM remote had a `countryCode` null crash in onboarding view. - - SRM was reconstructing OCI app manifests incorrectly in some paths. - -### Current conclusion - -The system does not appear to require OCI-only URLs. - -- Classic Helm repos are supported by code path. -- OCI registries are also supported by code path. -- The public Bitnami failure was due to TLS trust in the runtime container. -- The local OCI registry is useful as a deterministic test source while continuing to fix downstream translation issues. - -## Known-Good Success Paths - -### A. Direct local deployment success on lite2edge - -Use a chart source that does not hardcode conflicting cluster resources. A working example in this session was Bitnami nginx via OCI. - -Onboard directly to `lite2edge-remote`: - -```bash -curl -sS -X POST 'http://127.0.0.1:8751/api/v1/apps/onboard' \ - -H 'Content-Type: application/json' \ - --data '{ - "appId":"local-oci-nginx", - "name":"localnginxoci", - "version":"15.14.0", - "packageType":"HELM", - "appProvider":"local-provider", - "appRepo":{ - "type":"PUBLICREPO", - "repoURL":"registry-1.docker.io", - "imagePath":"registry-1.docker.io/bitnamicharts/nginx:15.14.0" - }, - "appComponentSpecs":[{ - "artefactId":"local-oci-nginx-art", - "componentName":"frontend", - "serviceNameNB":"nbfrontend0", - "serviceNameEW":"ewfrontend0" - }], - "appDeploymentZones":[{"zoneId":"default"}] - }' -``` - -Deploy directly: - -```bash -curl -sS -X POST 'http://127.0.0.1:8751/api/v1/apps/local-oci-nginx/deploy' \ - -H 'Content-Type: application/json' \ - --data '{"appId":"local-oci-nginx","appZones":[]}' -``` - -Expected result in this session: - -```json -{ - "appInstanceState": "instantiating", - "packageType": "HELM" -} -``` - -### B. Federated deployment success through OEG - -Register a fresh OCI-backed app in OEG: - -```bash -curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/apps' \ - -H 'Content-Type: application/json' \ - --data '{ - "appId":"550e8400-e29b-41d4-a716-446655440555", - "name":"federatednginxoci", - "appProvider":"i2cat", - "version":"15.14.0", - "packageType":"HELM", - "operatingSystem":{ - "architecture":"x86_64", - "family":"UBUNTU", - "version":"OS_VERSION_UBUNTU_2204_LTS", - "license":"OS_LICENSE_TYPE_FREE" - }, - "appRepo":{ - "type":"PUBLICREPO", - "imagePath":"registry-1.docker.io/bitnamicharts/nginx:15.14.0" - }, - "componentSpec":[{ - "componentName":"frontend", - "networkInterfaces":[{ - "interfaceId":"eth0", - "protocol":"TCP", - "port":80, - "visibilityType":"VISIBILITY_EXTERNAL" - }] - }] - }' -``` - -Deploy it to the federated zone: - -```bash -curl -sS -X POST 'http://127.0.0.1:8085/oeg/1.0.0/appinstances' \ - -H 'Content-Type: application/json' \ - --data '{ - "appId":"550e8400-e29b-41d4-a716-446655440555", - "appZones":[{ - "EdgeCloudZone":{ - "edgeCloudZoneId":"default", - "edgeCloudZoneName":"unknown", - "edgeCloudProvider":"i2cat", - "edgeCloudZoneStatus":"unknown", - "edgeCloudRegion":"unknown" - } - }] - }' -``` - -Expected result in this session: - -```json -{ - "message":"Application deployed successfully at partner OP", - "deployment_response":{ - "appInstIdentifier":"...", - "zoneId":"default" - } -} -``` - -Useful validation logs: - -```bash -docker logs --since 30s oeg-local-test -docker logs --since 30s lite2edge-remote -``` - -Expected downstream evidence from `lite2edge-remote`: - -```text -POST /api/v1/apps/550e8400-e29b-41d4-a716-446655440555/deploy HTTP/1.1" 202 Accepted -``` -- GitLab From 6a16b9dfe7dd80e887d0a1f1bdece4b08143a66d Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Mon, 27 Apr 2026 10:53:34 +0200 Subject: [PATCH 14/21] refactor: split app controller federation helpers Move federated orchestration and app-instance normalization out of app_controllers while keeping controller behavior stable and preserving the normalized federated app handling validated in smoke and controller tests. --- .../controllers/app_controllers.py | 789 +++--------------- .../controllers/app_federation_helpers.py | 165 ++++ .../controllers/app_instance_helpers.py | 167 ++++ .../controllers/app_partner_orchestration.py | 356 ++++++++ .../controllers/test_app_controllers.py | 50 +- .../unit/controllers/test_app_controllers.py | 138 ++- 6 files changed, 946 insertions(+), 719 deletions(-) create mode 100644 edge_cloud_management_api/controllers/app_federation_helpers.py create mode 100644 edge_cloud_management_api/controllers/app_instance_helpers.py create mode 100644 edge_cloud_management_api/controllers/app_partner_orchestration.py diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index d1cfb96..5d49e08 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -1,6 +1,24 @@ from flask import jsonify, request from pydantic import ValidationError 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 @@ -14,43 +32,30 @@ 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] - - +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 _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 @@ -86,283 +91,6 @@ 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 _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 _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 = get_zone(zone_id, zone_provider) if zone_provider and zone_provider != "unknown" else 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 - normalized = { - "appInstanceId": app_instance_id, - "status": instance.get("appInstanceState") or "unknown", - } - 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 - - -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(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_provider_id = _resolve_federated_app_provider_id(app_id_value, app_provider_id) - fed_instances, fed_code = federation_client.get_all_app_instances( - federation_context_id=federation_context_id, - app_id=app_id_value, - 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, federation_context_id, fed_token, zone_provider, fed_instances - - -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 submit_app(body: dict): @@ -431,56 +159,26 @@ def delete_app(appId, x_correlator=None): 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): + 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: - federated_app_provider_id = _resolve_federated_app_provider_id(appId, 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=federated_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 + manifest = app_response.get("appManifest") + if isinstance(manifest, dict) and not app_provider_id: + app_provider_id = manifest.get("appProvider") or manifest.get("appProviderId") + + cleanup_response = cleanup_federated_app( + federation_client=federation_client, + feds=feds, + app_id=appId, + app_provider_id=app_provider_id, + normalize_federated_app_id=_normalize_federated_app_id, + resolve_federated_app_identity=_resolve_federated_app_identity, + ) + if cleanup_response is not None: + return cleanup_response return response @@ -529,31 +227,12 @@ def create_app_instance(): edge_cloud_zone_id = zone_payload.get("edgeCloudZoneId") or zone_payload.get("zoneId") edge_cloud_provider = zone_payload.get("edgeCloudProvider") - 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 = srm_client.edge_cloud_zones() - if isinstance(zones, list): - for z in zones: - if not isinstance(z, dict): - continue - if z.get("edgeCloudZoneId") != edge_cloud_zone_id: - continue - if edge_cloud_provider and z.get("edgeCloudProvider") != edge_cloud_provider: - continue - if z.get("edgeCloudProvider") == edge_cloud_provider or not edge_cloud_provider: - 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.setdefault("isLocal", "true") - insert_zones([zone_payload]) - zone = get_zone(edge_cloud_zone_id, edge_cloud_provider) or zone_payload + 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({ @@ -561,319 +240,35 @@ 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 - # ============================================================ + # ============================================================ + # 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 + 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 - - federated_app_id = _normalize_federated_app_id(app_id) - - # ============================================================ - # Step 2: Compose GSMA artefact payload - # artefactId == appId (INTENTIONAL) - # ============================================================ - artefact_id = None - 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", []) - app_component_specs = appData.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, + 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, ) - 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( - 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": 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 - } - ], - "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": federated_app_id, - "appProviderId": federated_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 - # ============================================================ # LOCAL DEPLOYMENT (SRM path) # ============================================================ @@ -943,7 +338,7 @@ def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=Non if app_id: app_provider_id = _resolve_app_provider(srm_client, app_id) if app_provider_id: - for app_id_value, _federation_context_id, _fed_token, zone_provider, fed_instances in _iter_federated_instances( + 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)], ): @@ -958,7 +353,7 @@ def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=Non 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, _federation_context_id, _fed_token, zone_provider, fed_instances in _iter_federated_instances( + 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(), ): @@ -1024,7 +419,7 @@ def delete_app_instance(appInstanceId: str, x_correlator=None): app_provider_map = _get_catalog_app_provider_map(srm_client) feds = get_all_feds() - for app_id_value, federation_context_id, fed_token, _zone_provider, fed_instances in _iter_federated_instances( + 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(), ): @@ -1043,11 +438,13 @@ def delete_app_instance(appInstanceId: str, x_correlator=None): continue remove_response, remove_status = federation_client.remove_app_instance( federation_context_id=federation_context_id, - app_id=app_id_value, + 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({ 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 0000000..608fbdb --- /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 0000000..a446693 --- /dev/null +++ b/edge_cloud_management_api/controllers/app_instance_helpers.py @@ -0,0 +1,167 @@ +from edge_cloud_management_api.services.storage_service import get_zone + + +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 = get_zone(zone_id, zone_provider) if zone_provider and zone_provider != "unknown" else 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 0000000..ccc2a6e --- /dev/null +++ b/edge_cloud_management_api/controllers/app_partner_orchestration.py @@ -0,0 +1,356 @@ +import json + +from flask import jsonify + +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 = srm_client.edge_cloud_zones() + if isinstance(zones, list): + for candidate_zone in zones: + if not isinstance(candidate_zone, dict): + continue + 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: + candidate_zone["isLocal"] = "true" + insert_zones([candidate_zone]) + zone = candidate_zone + 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.setdefault("isLocal", "true") + insert_zones([zone_payload]) + zone = get_zone(edge_cloud_zone_id, edge_cloud_provider) or zone_payload + + return zone + + +def cleanup_federated_app(federation_client, feds, app_id, app_provider_id, normalize_federated_app_id, resolve_federated_app_identity): + if not feds: + return None + + 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): + 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, + ) + + remove_response, remove_status = federation_client.delete_onboarded_app( + federation_context_id, + normalize_federated_app_id(app_id), + fed_token, + ) + if remove_status in (200, 202, 204): + return jsonify(remove_response), remove_status + + 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", []) + 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, + ) + 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": "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") + + 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, + }], + "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", "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/tests/component/controllers/test_app_controllers.py b/tests/component/controllers/test_app_controllers.py index fce6c32..194ed4b 100644 --- a/tests/component/controllers/test_app_controllers.py +++ b/tests/component/controllers/test_app_controllers.py @@ -15,13 +15,10 @@ def test_app(): @pytest.mark.component @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_srm_api_client.return_value = mock_client @@ -33,31 +30,43 @@ def test_delete_app(mock_factory_class, test_app: Flask): @pytest.mark.component +@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, test_app: Flask): - """Test create_app_instance returns accepted response""" +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_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"} @pytest.mark.component @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_srm_api_client.return_value = mock_client @@ -67,27 +76,24 @@ def test_get_app_instance(mock_factory_class, test_app: Flask): 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.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_srm_api_client.return_value = mock_client with test_app.test_request_context(): - result = app_controllers.delete_app_instance(app_instance_id) + response, status_code = app_controllers.delete_app_instance(app_instance_id) - assert isinstance(result, dict) - assert result == {"result": "Deleted"} + 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 index d4dcfd7..dccd93f 100644 --- a/tests/unit/controllers/test_app_controllers.py +++ b/tests/unit/controllers/test_app_controllers.py @@ -31,6 +31,16 @@ def test_resolve_federated_app_provider_id_normalizes_local_provider_name(): 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_controllers.get_all_feds") @patch("edge_cloud_management_api.controllers.app_controllers.federation_client") @patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") @@ -73,7 +83,7 @@ def test_get_app_instance_uses_normalized_provider_for_federated_lookup( }] mock_federation_client.get_all_app_instances.assert_called_once_with( federation_context_id="fed-1", - app_id="app-123", + app_id="a522ed85-40f3-5e11-8b55-8e0e516331e1", app_provider_id="providerapp123", token="token-1", ) @@ -139,6 +149,41 @@ def test_get_app_instance_dedupes_local_and_federated_results( }] +@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([ { @@ -285,3 +330,94 @@ def test_get_app_instance_filters_local_results_by_app_id(mock_factory_class, _m "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 == "" -- GitLab From 481fc88ed0a62a4259535a5c344275923fed5469 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Mon, 27 Apr 2026 11:36:44 +0200 Subject: [PATCH 15/21] fix: align app manifest validation with CAMARA Validate POST /apps against a CAMARA-aligned app manifest model, require UUID appId and requiredResources, keep optional fields optional, and accept CAMARA-style repository URIs without over-constraining appRepo metadata. --- .../controllers/app_controllers.py | 16 ++-- .../models/application_models.py | 88 ++++++++++++++----- .../unit/controllers/test_app_controllers.py | 83 ++++++++++++++++- 3 files changed, 157 insertions(+), 30 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 5d49e08..6092ad8 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -1,5 +1,6 @@ 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 @@ -93,15 +94,16 @@ def _split_image_name_tag(image_ref): -def submit_app(body: dict): - """ - Controller for submitting application metadata. - """ - try: +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 + response = api_client.submit_app(body) + return response except ValidationError as e: return jsonify({"error": "Invalid input", "details": e.errors()}), 400 diff --git a/edge_cloud_management_api/models/application_models.py b/edge_cloud_management_api/models/application_models.py index e454a35..cbfeca8 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/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py index dccd93f..d8e2939 100644 --- a/tests/unit/controllers/test_app_controllers.py +++ b/tests/unit/controllers/test_app_controllers.py @@ -41,6 +41,87 @@ def test_resolve_federated_app_identity_normalizes_app_and_provider_ids(): assert provider_id == "providerplaygroundoegnginx" +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") @@ -226,7 +307,7 @@ def test_dedupe_app_instances_prefers_richer_zone_identity(): }] -@patch("edge_cloud_management_api.controllers.app_controllers.get_zone") +@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", -- GitLab From d0603a206d8a3f0293fb0c0d55c901fc6b2c5f22 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Mon, 27 Apr 2026 13:27:53 +0200 Subject: [PATCH 16/21] fix: align zone contracts and federated app cleanup --- .../controllers/app_controllers.py | 41 ++++--- .../controllers/app_instance_helpers.py | 21 +++- .../controllers/app_partner_orchestration.py | 26 ++-- .../controllers/edge_cloud_controller.py | 59 +++------ .../federation_manager_controller.py | 11 ++ .../models/federation_manager_models.py | 46 ++++--- .../specification/openapi.yaml | 30 ++--- .../unit/controllers/test_app_controllers.py | 97 +++++++++++++++ .../controllers/test_edge_cloud_controller.py | 70 ++++++++--- .../test_federation_manager_controller.py | 112 ++++++++++++++---- 10 files changed, 365 insertions(+), 148 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 6092ad8..21c8a36 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -23,7 +23,6 @@ from edge_cloud_management_api.controllers.app_partner_orchestration import reso 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 insert_zones from edge_cloud_management_api.services.storage_service import get_fed, get_all_feds import json import re @@ -150,27 +149,21 @@ def get_app(appId, x_correlator=None): ) -def delete_app(appId, x_correlator=None): - """Delete Application metadata from an Edge Cloud Provider""" - try: +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() - 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") + 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") + if feds: cleanup_response = cleanup_federated_app( federation_client=federation_client, feds=feds, @@ -180,9 +173,15 @@ def delete_app(appId, x_correlator=None): resolve_federated_app_identity=_resolve_federated_app_identity, ) if cleanup_response is not None: - return cleanup_response - - return response + cleanup_body, cleanup_status = cleanup_response + if cleanup_status not in (200, 202, 204): + return cleanup_response + + response = api_client.delete_app(appId=appId) + if isinstance(response, dict) and int(response.get("status_code", 500)) >= 400: + return response + + return response except NotFound404Exception: return ( diff --git a/edge_cloud_management_api/controllers/app_instance_helpers.py b/edge_cloud_management_api/controllers/app_instance_helpers.py index a446693..77e2ab4 100644 --- a/edge_cloud_management_api/controllers/app_instance_helpers.py +++ b/edge_cloud_management_api/controllers/app_instance_helpers.py @@ -1,4 +1,17 @@ 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): @@ -54,7 +67,13 @@ def enrich_instance_zone_from_catalog(instance): return instance zone_provider = zone.get("edgeCloudProvider") - stored_zone = get_zone(zone_id, zone_provider) if zone_provider and zone_provider != "unknown" else get_zone(zone_id) + 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 diff --git a/edge_cloud_management_api/controllers/app_partner_orchestration.py b/edge_cloud_management_api/controllers/app_partner_orchestration.py index ccc2a6e..ca25ec2 100644 --- a/edge_cloud_management_api/controllers/app_partner_orchestration.py +++ b/edge_cloud_management_api/controllers/app_partner_orchestration.py @@ -2,6 +2,7 @@ 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 @@ -12,7 +13,7 @@ def resolve_target_zone(srm_client, edge_cloud_zone_id, edge_cloud_provider, zon 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 = srm_client.edge_cloud_zones() + zones = get_local_zones() if isinstance(zones, list): for candidate_zone in zones: if not isinstance(candidate_zone, dict): @@ -22,18 +23,14 @@ def resolve_target_zone(srm_client, edge_cloud_zone_id, edge_cloud_provider, zon 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: - candidate_zone["isLocal"] = "true" - insert_zones([candidate_zone]) zone = candidate_zone 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.setdefault("isLocal", "true") - insert_zones([zone_payload]) - zone = get_zone(edge_cloud_zone_id, edge_cloud_provider) or zone_payload + zone = dict(zone_payload) + zone.setdefault("isLocal", "true") return zone @@ -42,6 +39,7 @@ def cleanup_federated_app(federation_client, feds, app_id, app_provider_id, norm if not feds: return None + cleanup_performed = False for fed in feds: fed_token = fed.get("token") federation_context_id = fed.get("_id") @@ -56,6 +54,7 @@ def cleanup_federated_app(federation_client, feds, app_id, app_provider_id, norm 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 @@ -76,15 +75,24 @@ def cleanup_federated_app(federation_client, feds, app_id, app_provider_id, norm zone_id=zone_id, token=fed_token, ) + elif fed_code not in (404, 422): + return jsonify(fed_instances), fed_code - remove_response, remove_status = federation_client.delete_onboarded_app( + remove_response = federation_client.delete_onboarded_app( federation_context_id, normalize_federated_app_id(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): - return jsonify(remove_response), remove_status + cleanup_performed = True + continue + if remove_status == 404: + continue + return jsonify(remove_response), remove_status + if cleanup_performed: + return "", 204 return None diff --git a/edge_cloud_management_api/controllers/edge_cloud_controller.py b/edge_cloud_management_api/controllers/edge_cloud_controller.py index 349bc2e..12c299d 100644 --- a/edge_cloud_management_api/controllers/edge_cloud_controller.py +++ b/edge_cloud_management_api/controllers/edge_cloud_controller.py @@ -4,20 +4,10 @@ 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 SRMAPIClientFactory -from edge_cloud_management_api.services.storage_service import insert_zones, get_zones +from edge_cloud_management_api.services.storage_service import get_zones from edge_cloud_management_api.services.federation_services import FederationManagerClientFactory -try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - zones = api_client.edge_cloud_zones() - for zone in zones: - zone['isLocal'] = 'true' - insert_zones(zones) -except Exception as e: - logger.error(e.args) - factory = FederationManagerClientFactory() federation_client = factory.create_federation_client() @@ -68,43 +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 = [] - existing_zone_keys = set() - for zone in cached or []: - if not isinstance(zone, dict): - continue - zone_key = (zone.get("edgeCloudProvider"), zone.get("edgeCloudZoneId")) - if zone_key in existing_zone_keys: - continue - merged.append(zone) - existing_zone_keys.add(zone_key) - for zone in get_local_zones(): - zone_key = None - if isinstance(zone, dict): - zone_key = (zone.get("edgeCloudProvider"), zone.get("edgeCloudZoneId")) - if zone_key and zone_key not in existing_zone_keys: - merged.append(zone) - existing_zone_keys.add(zone_key) - 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 @@ -136,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: diff --git a/edge_cloud_management_api/controllers/federation_manager_controller.py b/edge_cloud_management_api/controllers/federation_manager_controller.py index 07ca5dc..7cf8afe 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' @@ -50,6 +53,10 @@ 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 @@ -126,6 +133,10 @@ 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 ) diff --git a/edge_cloud_management_api/models/federation_manager_models.py b/edge_cloud_management_api/models/federation_manager_models.py index 8540639..6e66017 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/specification/openapi.yaml b/edge_cloud_management_api/specification/openapi.yaml index cbfba64..7ff46f3 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/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py index d8e2939..f9f6e93 100644 --- a/tests/unit/controllers/test_app_controllers.py +++ b/tests/unit/controllers/test_app_controllers.py @@ -307,6 +307,46 @@ def test_dedupe_app_instances_prefers_richer_zone_identity(): }] +@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 = { @@ -502,3 +542,60 @@ def test_delete_app_instance_treats_missing_partner_instance_as_already_deleted( 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 = { + "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, + } + + 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, + 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 d624b36..c75ce46 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 bfb3d8f..24a8920 100644 --- a/tests/unit/controllers/test_federation_manager_controller.py +++ b/tests/unit/controllers/test_federation_manager_controller.py @@ -34,8 +34,7 @@ def test_create_federation(mock_federation_client, _mock_get_token, mock_insert_ 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({ @@ -47,49 +46,103 @@ def test_create_federation(mock_federation_client, _mock_get_token, mock_insert_ @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.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.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 @@ -131,6 +184,25 @@ def test_request_zone_synch(mock_federation_client, _mock_get_fed, _mock_get_tok ]) +@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 @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") -- GitLab From a939f762097b7f6ffbbdbdd73f00b3c129e53dbd Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Wed, 29 Apr 2026 13:33:11 +0200 Subject: [PATCH 17/21] fix: return 204 on app delete per CAMARA spec --- .../controllers/app_controllers.py | 104 +++++++++++------- 1 file changed, 66 insertions(+), 38 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 21c8a36..6349004 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -52,6 +52,52 @@ _resolve_federated_app_identity = resolve_federated_app_identity _resolve_federated_app_provider_id = resolve_federated_app_provider_id +def _get_app_provider_for_delete(api_client, app_id): + app_response = api_client.get_app(app_id) + if not isinstance(app_response, dict): + return None + + 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") + return app_provider_id + + +def _cleanup_federated_app_before_local_delete(api_client, app_id): + feds = get_all_feds() + if not feds: + return None + + app_provider_id = _get_app_provider_for_delete(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, + 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) @@ -128,16 +174,18 @@ def get_apps(x_correlator=None): ) -def get_app(appId, x_correlator=None): - """Retrieve the information of an Application""" - try: +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) - return response - - except NotFound404Exception: - return ( + 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, ) @@ -154,37 +202,17 @@ def delete_app(appId, x_correlator=None): try: srm_factory = SRMAPIClientFactory() api_client = srm_factory.create_srm_api_client() - feds = get_all_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") - - if feds: - cleanup_response = cleanup_federated_app( - federation_client=federation_client, - feds=feds, - app_id=appId, - app_provider_id=app_provider_id, - normalize_federated_app_id=_normalize_federated_app_id, - resolve_federated_app_identity=_resolve_federated_app_identity, - ) - if cleanup_response is not None: - cleanup_body, cleanup_status = cleanup_response - if cleanup_status not in (200, 202, 204): - return cleanup_response - - response = api_client.delete_app(appId=appId) - if isinstance(response, dict) and int(response.get("status_code", 500)) >= 400: - return response + 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 - return response - - except NotFound404Exception: - return ( + except NotFound404Exception: + return ( jsonify({"status": 404, "code": "NOT_FOUND", "message": "Resource does not exist"}), 404, ) -- GitLab From 447e9b80c0727a172a28c255f58712829f20cb43 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Wed, 29 Apr 2026 13:51:34 +0200 Subject: [PATCH 18/21] fix: check response status before deleting local federation state --- edge_cloud_management_api/services/federation_services.py | 1 + 1 file changed, 1 insertion(+) diff --git a/edge_cloud_management_api/services/federation_services.py b/edge_cloud_management_api/services/federation_services.py index 0f0a7cb..8c41dd4 100644 --- a/edge_cloud_management_api/services/federation_services.py +++ b/edge_cloud_management_api/services/federation_services.py @@ -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() -- GitLab From 1e2f9d7ea55d04e4f969ab0ff1d58ebdcf23aa2d Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sun, 3 May 2026 12:06:02 +0200 Subject: [PATCH 19/21] fix(federation): preserve Helm artefact metadata --- .../controllers/app_partner_orchestration.py | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_partner_orchestration.py b/edge_cloud_management_api/controllers/app_partner_orchestration.py index ca25ec2..f1e84e5 100644 --- a/edge_cloud_management_api/controllers/app_partner_orchestration.py +++ b/edge_cloud_management_api/controllers/app_partner_orchestration.py @@ -15,9 +15,12 @@ def resolve_target_zone(srm_client, edge_cloud_zone_id, edge_cloud_provider, zon 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: @@ -25,6 +28,8 @@ def resolve_target_zone(srm_client, edge_cloud_zone_id, edge_cloud_provider, zon 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}") @@ -122,6 +127,10 @@ def deploy_to_partner( 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") @@ -168,6 +177,9 @@ def deploy_to_partner( 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({ @@ -200,7 +212,7 @@ def deploy_to_partner( "artefactName": image_name, "artefactVersionInfo": image_tag, "artefactVirtType": "CONTAINER_TYPE", - "artefactDescriptorType": "COMPONENTSPEC", + "artefactDescriptorType": artefact_descriptor_type, "repoType": repo_type, "artefactRepoLocation": {"repoURL": repo_url}, "componentSpec": [{ @@ -301,6 +313,23 @@ def deploy_to_partner( "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, @@ -335,7 +364,7 @@ def deploy_to_partner( "appInstCallbackLink": app_data.get("appInstCallbackLink", ""), "zoneInfo": { "zoneId": zone.get("edgeCloudZoneId"), - "flavourId": zone.get("flavourId", "default"), + "flavourId": zone.get("flavourId") or zone.get("edgeCloudZoneId") or "default", "resPool": res_pool_value, "resourceConsumption": "RESERVED_RES_AVOID", }, -- GitLab From 34c5fc855f935435188a05ca82e24e4cf69880fa Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sun, 3 May 2026 13:52:01 +0200 Subject: [PATCH 20/21] fix(federation): delete federated artefacts on app cleanup --- .../controllers/app_controllers.py | 47 ++++++++---- .../controllers/app_partner_orchestration.py | 32 ++++++-- .../services/federation_services.py | 23 ++++++ .../unit/controllers/test_app_controllers.py | 75 +++++++++++++++++++ 4 files changed, 158 insertions(+), 19 deletions(-) diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py index 6349004..065041a 100644 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ b/edge_cloud_management_api/controllers/app_controllers.py @@ -52,16 +52,24 @@ _resolve_federated_app_identity = resolve_federated_app_identity _resolve_federated_app_provider_id = resolve_federated_app_provider_id -def _get_app_provider_for_delete(api_client, app_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 + 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") - return app_provider_id + 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): @@ -69,13 +77,15 @@ def _cleanup_federated_app_before_local_delete(api_client, app_id): if not feds: return None - app_provider_id = _get_app_provider_for_delete(api_client, app_id) + 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: @@ -118,11 +128,11 @@ def _split_image_reference(image_path): 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 + 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): @@ -301,11 +311,20 @@ def create_app_instance(): # ============================================================ # LOCAL DEPLOYMENT (SRM path) # ============================================================ - logger.info(f"Proceeding with LOCAL deployment for appId={app_id}") - - try: - logger.debug("Sending deployment request to SRM") - response = srm_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( diff --git a/edge_cloud_management_api/controllers/app_partner_orchestration.py b/edge_cloud_management_api/controllers/app_partner_orchestration.py index f1e84e5..3d94ff2 100644 --- a/edge_cloud_management_api/controllers/app_partner_orchestration.py +++ b/edge_cloud_management_api/controllers/app_partner_orchestration.py @@ -40,11 +40,21 @@ def resolve_target_zone(srm_client, edge_cloud_zone_id, edge_cloud_provider, zon return zone -def cleanup_federated_app(federation_client, feds, app_id, app_provider_id, normalize_federated_app_id, resolve_federated_app_identity): +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") @@ -85,14 +95,26 @@ def cleanup_federated_app(federation_client, feds, app_id, app_provider_id, norm remove_response = federation_client.delete_onboarded_app( federation_context_id, - normalize_federated_app_id(app_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): + if remove_status in (200, 202, 204, 404): cleanup_performed = True - continue - if remove_status == 404: + 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 diff --git a/edge_cloud_management_api/services/federation_services.py b/edge_cloud_management_api/services/federation_services.py index 8c41dd4..d23b245 100644 --- a/edge_cloud_management_api/services/federation_services.py +++ b/edge_cloud_management_api/services/federation_services.py @@ -437,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 diff --git a/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py index f9f6e93..f75df58 100644 --- a/tests/unit/controllers/test_app_controllers.py +++ b/tests/unit/controllers/test_app_controllers.py @@ -41,6 +41,39 @@ def test_resolve_federated_app_identity_normalizes_app_and_provider_ids(): 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 @@ -557,6 +590,9 @@ def test_delete_app_runs_federated_cleanup_before_local_delete( mock_client = MagicMock() mock_client.get_app.return_value = { + "appComponentSpecs": [{ + "artefactId": "12345678-1234-1234-9234-123456789abc", + }], "appManifest": { "appProvider": "Local Operator", } @@ -587,6 +623,43 @@ def test_cleanup_federated_app_handles_dict_delete_response(mock_jsonify): "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, @@ -594,6 +667,8 @@ def test_cleanup_federated_app_handles_dict_delete_response(mock_jsonify): 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, ) -- GitLab From a6aea774c73598e9eb77d81cbff34c4e9aff6172 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Sun, 3 May 2026 14:01:59 +0200 Subject: [PATCH 21/21] test(app): assert normalized local deployment payload --- tests/component/controllers/test_app_controllers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/component/controllers/test_app_controllers.py b/tests/component/controllers/test_app_controllers.py index 194ed4b..1ba17c3 100644 --- a/tests/component/controllers/test_app_controllers.py +++ b/tests/component/controllers/test_app_controllers.py @@ -61,6 +61,18 @@ def test_create_app_instance(mock_factory_class, mock_resolve_target_zone, test_ 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 -- GitLab