From 85802c522250745c471abec289ead3c4ab863589 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 10:12:26 +0200 Subject: [PATCH 01/13] Update FM integration test cleanup --- src/test/__init__.py | 163 +++++++++++++++++- .../test_application_onboarding_management.py | 28 +-- 2 files changed, 162 insertions(+), 29 deletions(-) diff --git a/src/test/__init__.py b/src/test/__init__.py index 4363a5b..48d608d 100644 --- a/src/test/__init__.py +++ b/src/test/__init__.py @@ -1,13 +1,15 @@ import logging +import uuid import connexion +import pymongo import requests from flask_testing import TestCase from encoder import JSONEncoder from configparser import ConfigParser -from clients import tf_sdk +from clients import srm import util CONFIG = ConfigParser() @@ -21,7 +23,7 @@ HOST_KEYCLOAK = CONFIG.get("keycloak", "host") PORT_KEYCLOAK = int(CONFIG.get("keycloak", "port")) # Dual role testing configuration -PARTNER_API_ROOT = "http://127.0.0.1:8990" +PARTNER_API_ROOT = "http://federation-manager-remote:8989" class BaseTestCase(TestCase): @@ -40,17 +42,28 @@ class BaseTestCase(TestCase): # Base url application server self.base_url = f"{HOST}:{PORT}{SERVER}" + if not hasattr(BaseTestCase, "run_suffix"): + BaseTestCase.run_suffix = uuid.uuid4().hex[:8] + BaseTestCase.artefact_id_pop_run = str(uuid.uuid5(uuid.NAMESPACE_DNS, + f"fm-test-pop-{BaseTestCase.run_suffix}")) + BaseTestCase.artefact_id_oop_run = str(uuid.uuid5(uuid.NAMESPACE_DNS, + f"fm-test-oop-{BaseTestCase.run_suffix}")) + BaseTestCase.app_id_pop_run = f"test_app_pop_{BaseTestCase.run_suffix}" + BaseTestCase.app_id_oop_run = f"test_app_oop_{BaseTestCase.run_suffix}" + # Partner OP (POP) configuration - self.artefact_id_pop = "3fa85f64-5717-4562-b3fc-222222222222" - self.app_id_pop = "test_app_pop" + self.artefact_id_pop = BaseTestCase.artefact_id_pop_run + self.app_id_pop = BaseTestCase.app_id_pop_run self.artefact_name_pop = "ollama" self.artefact_repo_pop = "https://otwld.github.io/ollama-helm/" # Originating OP (OOP) configuration - self.artefact_id_oop = "3fa85f64-5717-4562-b3fc-222222222223" - self.app_id_oop = "test_app_oop" + self.artefact_id_oop = BaseTestCase.artefact_id_oop_run + self.app_id_oop = BaseTestCase.app_id_oop_run self.artefact_name_oop = "kubernetes-dashboard" self.artefact_repo_oop = "https://kubernetes.github.io/dashboard/" + self.deployable_artefact_name = self.artefact_name_pop + self.deployable_artefact_repo = self.artefact_repo_pop # Common artifact configuration self.app_provider = "test_app_provider" @@ -59,6 +72,144 @@ class BaseTestCase(TestCase): return app.app + def cleanup_fm_test_state(self): + """Remove leftover FM documents for the fixed integration test IDs.""" + app_ids = [self.app_id_pop, self.app_id_oop] + artefact_ids = [self.artefact_id_pop, self.artefact_id_oop] + + client = pymongo.MongoClient(f"mongodb://{CONFIG.get('mongodb', 'host')}:{CONFIG.get('mongodb', 'port')}") + database = client["federation-manager"] + + database["originating_application_deployment_management"].delete_many({"orig_ad_app_id": {"$in": app_ids}}) + database["originating_application_deployment_management_originating_o_p"].delete_many( + {"orig_ad_app_id": {"$in": app_ids}} + ) + database["originating_application_onboarding_management"].delete_many({"orig_ao_app_id": {"$in": app_ids}}) + database["originating_application_onboarding_management_originating_o_p"].delete_many( + {"orig_ao_app_id": {"$in": app_ids}} + ) + database["originating_application_onboarding_management_update"].delete_many({}) + database["originating_application_onboarding_management_update_originating_o_p"].delete_many({}) + database["originating_artefact_management"].delete_many({"orig_am_artefact_id": {"$in": artefact_ids}}) + database["originating_artefact_management_originating_o_p"].delete_many( + {"orig_am_artefact_id": {"$in": artefact_ids}} + ) + database["originating_operator_platform"].delete_many({}) + database["originating_operator_platform_originating_o_p"].delete_many({}) + database["originating_operator_platform_update"].delete_many({}) + database["originating_operator_platform_update_originating_o_p"].delete_many({}) + client.close() + + BaseTestCase.federation_context_id_partner = "" + BaseTestCase.federation_context_id_originating = "" + BaseTestCase.zone_partner = "" + BaseTestCase.zone_originating = "" + BaseTestCase.instances_partner = [] + BaseTestCase.instances_originating = [] + + def cleanup_ecp_test_state(self): + """Remove leftover SRM/ECP artefacts and onboardings for test fixtures.""" + for app_id in [self.app_id_pop, self.app_id_oop]: + try: + srm.delete_onboarding(app_id) + except Exception: + pass + + def build_api_url(self, api_root, path): + return f"{api_root}{SERVER}/{path}" + + def get_federation_context_at_api_root(self, token, api_root): + """Get federation context directly from a specific FM API root.""" + url = self.build_api_url(api_root, "fed-context-id") + return self.make_request_partner_op("GET", url, token=token) + + def get_federation_at_api_root(self, federation_id, token, api_root): + """Get federation details directly from a specific FM API root.""" + url = self.build_api_url(api_root, f"{federation_id}/partner") + return self.make_request_partner_op("GET", url, token=token) + + def get_app_instances_at_api_root(self, federation_id, app_id, app_provider_id, token, api_root): + """Get application instances directly from a specific FM API root.""" + url = self.build_api_url(api_root, f"{federation_id}/application/lcm/app/{app_id}/appProvider/{app_provider_id}") + return self.make_request_partner_op("GET", url, token=token) + + def cleanup_remote_partner_test_state(self, token, api_root=PARTNER_API_ROOT): + """Remove leftover partner-side FM resources created by originating-mode integration tests.""" + response = self.get_federation_context_at_api_root(token, api_root) + if response.status_code != 200: + return + + federation_context_id = response.json().get("federationContextId") + if not federation_context_id: + return + + instances_response = self.get_app_instances_at_api_root( + federation_context_id, + self.app_id_oop, + self.app_provider, + token, + api_root, + ) + if instances_response.status_code == 200: + for zone_info in instances_response.json(): + zone_id = zone_info.get("zoneId") + if not zone_id: + continue + for instance in zone_info.get("appInstanceInfo", []): + instance_id = instance.get("appInstIdentifier") + if not instance_id: + continue + try: + url = self.build_api_url( + api_root, + f"{federation_context_id}/application/lcm/app/{self.app_id_oop}/instance/{instance_id}/zone/{zone_id}", + ) + self.make_request_partner_op("DELETE", url, token=token) + except Exception: + pass + + for app_id in [self.app_id_oop]: + try: + url = self.build_api_url(api_root, f"{federation_context_id}/application/onboarding/app/{app_id}") + self.make_request_partner_op("DELETE", url, token=token) + except Exception: + pass + + for artefact_id in [self.artefact_id_oop]: + try: + url = self.build_api_url(api_root, f"{federation_context_id}/artefact/{artefact_id}") + self.make_request_partner_op("DELETE", url, token=token) + except Exception: + pass + + try: + federation_response = self.get_federation_at_api_root(federation_context_id, token, api_root) + if federation_response.status_code == 200: + federation_data = federation_response.json() + for zone in federation_data.get("offeredAvailabilityZones", []): + zone_id = zone.get("zoneId") if isinstance(zone, dict) else None + if not zone_id: + continue + try: + url = self.build_api_url(api_root, f"{federation_context_id}/zones/{zone_id}") + self.make_request_partner_op("DELETE", url, token=token) + except Exception: + pass + except Exception: + pass + + try: + url = self.build_api_url(api_root, f"{federation_context_id}/partner") + self.make_request_partner_op("DELETE", url, token=token) + except Exception: + pass + + for artefact_id in [self.artefact_id_pop, self.artefact_id_oop]: + try: + srm.delete_artefact(artefact_id) + except Exception: + pass + def get_access_token(self): # URL to obtain access token from Keycloak token_url = f"http://{HOST_KEYCLOAK}:{PORT_KEYCLOAK}/realms/federation/protocol/openid-connect/token" diff --git a/src/test/test_application_onboarding_management.py b/src/test/test_application_onboarding_management.py index 728637f..8e4f54b 100644 --- a/src/test/test_application_onboarding_management.py +++ b/src/test/test_application_onboarding_management.py @@ -17,7 +17,7 @@ from __future__ import absolute_import from test import BaseTestCase -from clients import tf_sdk +from clients import srm import unittest @@ -35,7 +35,7 @@ class TestApplicationOnboardingManagementController(BaseTestCase): try: # Check if there is connection with Edge Cloud Platform. Otherwise stop the test try: - tf_sdk.get_zones() + srm.get_zones() except Exception: self.skipTest("Edge Cloud Platform connection not available") @@ -63,27 +63,9 @@ class TestApplicationOnboardingManagementController(BaseTestCase): def test_00_setup(self): """Setup federation contexts for both Partner OP and Originating OP roles""" - try: - # Check if there are artefact and profile created at Edge Cloud Platform and must be deleted because remains there due - # to a possible issue during the test - tf_sdk.delete_onboarding(self.app_id_pop) - except Exception: - pass - - try: - tf_sdk.delete_onboarding(self.app_id_oop) - except Exception: - pass - - try: - tf_sdk.delete_artefact(self.artefact_id_pop) - except Exception: - pass - - try: - tf_sdk.delete_artefact(self.artefact_id_oop) - except Exception: - pass + self.cleanup_fm_test_state() + self.cleanup_ecp_test_state() + self.cleanup_remote_partner_test_state(BaseTestCase.token) try: # Create federation context for Partner OP role -- GitLab From cf93d9b38da205039fe35e4cb4b024ffca5729a4 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 10:12:30 +0200 Subject: [PATCH 02/13] Stabilize FM deployment integration tests --- .../test_application_deployment_management.py | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/src/test/test_application_deployment_management.py b/src/test/test_application_deployment_management.py index 8a23122..46ec42b 100644 --- a/src/test/test_application_deployment_management.py +++ b/src/test/test_application_deployment_management.py @@ -17,7 +17,7 @@ from __future__ import absolute_import import time from test import BaseTestCase -from clients import tf_sdk +from clients import srm class TestApplicationDeploymentManagementController(BaseTestCase): @@ -38,7 +38,7 @@ class TestApplicationDeploymentManagementController(BaseTestCase): try: # Check if there is connection with Edge Cloud Platform. Otherwise stop the test try: - tf_sdk.get_zones() + srm.get_zones() except Exception: self.skipTest("Edge Cloud Platform connection not available") @@ -66,27 +66,9 @@ class TestApplicationDeploymentManagementController(BaseTestCase): def test_00_setup(self): """Setup federation contexts for both Partner OP and Originating OP roles""" - try: - # Check if there are artefact and profile created at Edge Cloud Platform and must be deleted because remains there due - # to a possible issue during the test - clean up both app configurations - tf_sdk.delete_onboarding(self.app_id_pop) - except Exception: - pass - - try: - tf_sdk.delete_onboarding(self.app_id_oop) - except Exception: - pass - - try: - tf_sdk.delete_artefact(self.artefact_id_pop) - except Exception: - pass - - try: - tf_sdk.delete_artefact(self.artefact_id_oop) - except Exception: - pass + self.cleanup_fm_test_state() + self.cleanup_ecp_test_state() + self.cleanup_remote_partner_test_state(BaseTestCase.token) try: # Create federation context for Partner OP role @@ -131,8 +113,10 @@ class TestApplicationDeploymentManagementController(BaseTestCase): # Create artefacts for both roles with different app providers self.post_artefact(BaseTestCase.federation_context_id_partner, self.artefact_id_pop, self.artefact_name_pop, self.artefact_repo_pop, BaseTestCase.token, 'partner_op') + # Reuse a known-good public Helm chart for originating deployment validation. self.post_artefact(BaseTestCase.federation_context_id_originating, self.artefact_id_oop, - self.artefact_name_oop, self.artefact_repo_oop, BaseTestCase.token, 'originating_op') + self.deployable_artefact_name, self.deployable_artefact_repo, BaseTestCase.token, + 'originating_op') # Create application onboarding for both roles with different app configurations self.post_onboarding(BaseTestCase.federation_context_id_partner, self.app_id_pop, BaseTestCase.zone_partner, @@ -205,6 +189,7 @@ class TestApplicationDeploymentManagementController(BaseTestCase): else: federation_id = BaseTestCase.federation_context_id_originating app_id = self.app_id_oop + zone = BaseTestCase.zone_originating flavour = BaseTestCase.flavour_originating body = { -- GitLab From 77bbc83ec8b461eb4125bd68878a3d908fbb7254 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 10:15:30 +0200 Subject: [PATCH 03/13] Add SRM client configuration --- requirements.txt | 1 - src/clients/srm.py | 84 +++++++++++++++++++++++++++++++++++ src/conf/config-fm-local.cfg | 4 ++ src/conf/config-fm-remote.cfg | 4 ++ src/conf/config.cfg.sample | 4 ++ 5 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/clients/srm.py diff --git a/requirements.txt b/requirements.txt index adb5b04..091e507 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,7 +37,6 @@ requests==2.32.4 rpds-py==0.13.2 six==1.16.0 setuptools==80.9.0 -sunrise6g-opensdk==1.0.21 swagger-ui-bundle==0.0.9 urllib3==2.1.0 Werkzeug==2.2.3 diff --git a/src/clients/srm.py b/src/clients/srm.py new file mode 100644 index 0000000..499ab2f --- /dev/null +++ b/src/clients/srm.py @@ -0,0 +1,84 @@ +# -------------------------------------------------------------------------- # +# Copyright 2025-present, Federation Manager, by Software Networks, i2CAT # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); you may # +# not use this file except in compliance with the License. You may obtain # +# a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# -------------------------------------------------------------------------- # +from configparser import ConfigParser +import os + +import requests + + +CONFIG = ConfigParser() +config_file = os.environ.get("FM_CONFIG_FILE", "conf/config.cfg") +CONFIG.read(config_file) +HOST = CONFIG.get("service_resource_manager", "host") +PORT = int(CONFIG.get("service_resource_manager", "port")) +BASE_URL = f"http://{HOST}:{PORT}/srm/1.0.0/internal/fm" + + +def _request(method, path, json_payload=None): + return requests.request(method, f"{BASE_URL}{path}", json=json_payload, timeout=30) + + +def get_list_zones(): + response = _request("GET", "/zones/list") + return response.json() + + +def get_zones(): + response = _request("GET", "/zones") + return response.json() + + +def get_zone_by_zone_id(zone_id): + response = _request("GET", f"/zones/{zone_id}") + if response.status_code == 404: + return None + return response.json() + + +def onboarding_artefact(artefact_data): + return _request("POST", "/artefacts", artefact_data) + + +def delete_artefact(artefact_id): + return _request("DELETE", f"/artefacts/{artefact_id}") + + +def get_onboarding(app_id): + return _request("GET", f"/onboardings/{app_id}") + + +def post_onboarding(onboarding_data): + return _request("POST", "/onboardings", onboarding_data) + + +def update_onboarding(app_id, onboarding_data): + return _request("PATCH", f"/onboardings/{app_id}", onboarding_data) + + +def delete_onboarding(app_id): + return _request("DELETE", f"/onboardings/{app_id}") + + +def post_app_command(deployment_data): + return _request("POST", "/deployments", deployment_data) + + +def get_app_by_zone_app_instance_id(app_id, app_instance_id, zone_id): + return _request("GET", f"/deployments/{app_id}/instances/{app_instance_id}/zones/{zone_id}") + + +def delete_app(app_id, app_instance_id, zone_id): + return _request("DELETE", f"/deployments/{app_id}/instances/{app_instance_id}/zones/{zone_id}") diff --git a/src/conf/config-fm-local.cfg b/src/conf/config-fm-local.cfg index c56da9d..80b7c55 100644 --- a/src/conf/config-fm-local.cfg +++ b/src/conf/config-fm-local.cfg @@ -35,6 +35,10 @@ lcmServiceEndPoint_fqdn = lcmServiceEndPoint_ipv4Addresses = 127.0.0.1 lcmServiceEndPoint_ipv6Addresses = +[service_resource_manager] +host = srm-local +port = 8080 + [edge_cloud_platform] host = lite2edge-local port = 8080 diff --git a/src/conf/config-fm-remote.cfg b/src/conf/config-fm-remote.cfg index ef807e6..80edd92 100644 --- a/src/conf/config-fm-remote.cfg +++ b/src/conf/config-fm-remote.cfg @@ -36,6 +36,10 @@ lcmServiceEndPoint_fqdn = lcmServiceEndPoint_ipv4Addresses = 127.0.0.1 lcmServiceEndPoint_ipv6Addresses = +[service_resource_manager] +host = srm-remote +port = 8080 + [edge_cloud_platform] host = lite2edge-remote port = 8080 diff --git a/src/conf/config.cfg.sample b/src/conf/config.cfg.sample index 2a44cab..c648a5b 100644 --- a/src/conf/config.cfg.sample +++ b/src/conf/config.cfg.sample @@ -35,6 +35,10 @@ lcmServiceEndPoint_fqdn = lcmServiceEndPoint_ipv4Addresses = 127.0.0.1 lcmServiceEndPoint_ipv6Addresses = +[service_resource_manager] +host = 127.0.0.1 +port = 8080 + [edge_cloud_platform] host = 192.168.123.237 port = 30769 -- GitLab From 58bc2fccf03a0b3a67ea55f6138f2cc8cef426a5 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 10:15:49 +0200 Subject: [PATCH 04/13] Route FM southbound calls through SRM --- .../application_deployment_management.py | 24 +++++++++---------- .../application_onboarding_management.py | 10 ++++---- .../tf_adapter/artefact_management.py | 6 ++--- .../availability_zone_info_synchronization.py | 8 +++---- .../tf_adapter/federation_management.py | 24 +++++++++++-------- src/test/test_artefact_management.py | 8 +++---- ..._availability_zone_info_synchronization.py | 6 ++--- src/test/test_federation_management.py | 4 ++-- 8 files changed, 47 insertions(+), 43 deletions(-) diff --git a/src/adapters/tf_adapter/application_deployment_management.py b/src/adapters/tf_adapter/application_deployment_management.py index 27127d0..7043a24 100644 --- a/src/adapters/tf_adapter/application_deployment_management.py +++ b/src/adapters/tf_adapter/application_deployment_management.py @@ -23,7 +23,7 @@ from models.mongo_document import OriginatingOperatorPlatform from models.mongo_document import OriginatingApplicationOnboardingManagement from models.mongo_document import OriginatingApplicationDeploymentManagement from adapters.error import APIError -from clients import tf_sdk +from clients import srm def get_all_app_instances(federation_context_id, app_id, app_provider_id, bearer_token=None, partner_api_root=None): # noqa: E501 @@ -131,9 +131,9 @@ def get_app_instance_details(federation_context_id, app_id, app_instance_id, zon # Check if exist instance id at Edge Cloud Platform try: - response = tf_sdk.get_app_by_zone_app_instance_id(app_id, app_instance_id, zone_id) + response = srm.get_app_by_zone_app_instance_id(app_id, app_instance_id, zone_id) if response.status_code != 200: - raise APIError(500, f"Error: {response.status_code} from tf_sdk. {response.content}") + raise APIError(500, f"Error: {response.status_code} from srm. {response.content}") except APIError: raise # Re-raise APIError as-is except Exception as error: @@ -141,7 +141,7 @@ def get_app_instance_details(federation_context_id, app_id, app_instance_id, zon # Check if exist zone id at Edge Cloud Platform try: - if not tf_sdk.get_zone_by_zone_id(zone_id): + if not srm.get_zone_by_zone_id(zone_id): raise APIError(422, "Zone Id not found at Edge Cloud Platform") except APIError: raise # Re-raise APIError as-is @@ -242,7 +242,7 @@ def install_app(federation_context_id, body, bearer_token=None, partner_api_root instance_id = "" instance_id_data = {} try: - response = tf_sdk.post_app_command(body.to_gsma_input()) + response = srm.post_app_command(body.to_gsma_input()) if response.status_code != 202: raise APIError(500, f"Error {response.status_code} - {response.content}") instance_id_data = response.json() @@ -264,7 +264,7 @@ def install_app(federation_context_id, body, bearer_token=None, partner_api_root if originating_ad_objects: # if not is possible to create deployment in FM, delete the app command created try: - response = tf_sdk.delete_app(body.app_id, instance_id, zone_id) + response = srm.delete_app(body.app_id, instance_id, zone_id) if response.status_code != 200: raise APIError(500, f"Unable to delete app in tf_sdk when is not possible create deployment in FM. " f"Error {response.status_code} - {response.content}") @@ -341,11 +341,11 @@ def remove_app(federation_context_id, app_id, app_instance_id, zone_id, bearer_t "appId, appInstanceId and zoneId") # Check if exist instance id at Edge Cloud Platform and delete - response = tf_sdk.get_app_by_zone_app_instance_id(app_id, app_instance_id, zone_id) + response = srm.get_app_by_zone_app_instance_id(app_id, app_instance_id, zone_id) if response.status_code == 200 or response.status_code == 503: # Delete Application command at Edge Cloud Platform try: - response = tf_sdk.delete_app(app_id, app_instance_id, zone_id) + response = srm.delete_app(app_id, app_instance_id, zone_id) if response.status_code == 200: # Delete Application Deployment originating_ad_objects.delete() @@ -385,7 +385,7 @@ def fill_application_deployment_mongo_document(federation_context_id, instance_i def check_zone_and_flavour(body): correct = True - zone_data = tf_sdk.get_zone_by_zone_id(body.zone_info.zone_id) + zone_data = srm.get_zone_by_zone_id(body.zone_info.zone_id) if not zone_data: return False @@ -443,7 +443,7 @@ def get_list_zones_instances(instances_deployment): def check_flavour_in_tf_sdk(flavour_id): exist = False - zone_data = tf_sdk.get_zones() + zone_data = srm.get_zones() if not zone_data: return False @@ -465,7 +465,7 @@ def find_instance_state(app_id, instance_id, zone_id): instance_state = "" try: - response = tf_sdk.get_app_by_zone_app_instance_id(app_id, instance_id, zone_id) + response = srm.get_app_by_zone_app_instance_id(app_id, instance_id, zone_id) if response.status_code != 200: return f"Error {response.status_code} - {response.content}" data = response.json() @@ -480,7 +480,7 @@ def create_response_instance_details(app_id, instance_id, zone_id): response_data = "" try: - response = tf_sdk.get_app_by_zone_app_instance_id(app_id, instance_id, zone_id) + response = srm.get_app_by_zone_app_instance_id(app_id, instance_id, zone_id) data = response.json() if isinstance(data, dict): diff --git a/src/adapters/tf_adapter/application_onboarding_management.py b/src/adapters/tf_adapter/application_onboarding_management.py index 3fda6f1..e4dd913 100644 --- a/src/adapters/tf_adapter/application_onboarding_management.py +++ b/src/adapters/tf_adapter/application_onboarding_management.py @@ -24,7 +24,7 @@ from models.mongo_document import OriginatingApplicationOnboardingManagementUpda from models.mongo_document import OriginatingZoneInfo from models.mongo_document import OriginatingApplicationDeploymentManagement from adapters.error import APIError -from clients import tf_sdk +from clients import srm def delete_app(federation_context_id, app_id, bearer_token=None, partner_api_root=None): # noqa: E501 @@ -65,7 +65,7 @@ def delete_app(federation_context_id, app_id, bearer_token=None, partner_api_roo # Delete onboarding at edgecloud_client try: - response = tf_sdk.delete_onboarding(app_id) + response = srm.delete_onboarding(app_id) if response.status_code == 200: # Delete all the onboarding updates related to onboarding application obj_onboarding = originating_ao_objects.get() @@ -142,7 +142,7 @@ def onboard_application(body, federation_context_id, bearer_token=None, partner_ # Create onboarding at Edge Cloud Platform try: - response = tf_sdk.post_onboarding(body.to_gsma_input()) + response = srm.post_onboarding(body.to_gsma_input()) if response.status_code == 200: # Convert the original model instance to the MongoEngine document onboarding_data = fill_application_onboarding_mongo_document(federation_context_id, body) @@ -206,7 +206,7 @@ def update_application(body, federation_context_id, app_id, bearer_token=None, p # Update onboarding at Edge Cloud Platform # Retrieve JSON try: - response = tf_sdk.update_onboarding(app_id, body.to_gsma_input()) + response = srm.update_onboarding(app_id, body.to_gsma_input()) if response.status_code == 200: # Update onboarding in FM # Convert the original model instance to the MongoEngine document @@ -370,7 +370,7 @@ def check_deployment_zones(federation_context_id, body): # Check if exist deployment zones in Edge Cloud Platform for acs in body.app_deployment_zones: # Check if exist Zone Id in i2edge - zone_data = tf_sdk.get_zone_by_zone_id(acs) + zone_data = srm.get_zone_by_zone_id(acs) if not zone_data: return False diff --git a/src/adapters/tf_adapter/artefact_management.py b/src/adapters/tf_adapter/artefact_management.py index 9017adf..89c72f2 100644 --- a/src/adapters/tf_adapter/artefact_management.py +++ b/src/adapters/tf_adapter/artefact_management.py @@ -24,7 +24,7 @@ from models.mongo_document import OriginatingOperatorPlatform from models.mongo_document import OriginatingArtefactManagement from models.mongo_document import OriginatingApplicationOnboardingManagement from clients import artefact_manager -from clients import tf_sdk +from clients import srm from configparser import ConfigParser import os @@ -132,7 +132,7 @@ def remove_artefact(federation_context_id, artefact_id, bearer_token=None, partn raise APIError(409, "Unable to remove Artefact. There are application onboardings dependent. Remove it and try again ") try: - response = tf_sdk.delete_artefact(artefact_id) + response = srm.delete_artefact(artefact_id) if response.status_code != 200: raise APIError(422, f"Unable to delete artefact from Edge Cloud Platform. Response: {response.content}") except Exception as error: @@ -272,7 +272,7 @@ def upload_artefact(body, federation_context_id, bearer_token=None, partner_api_ # Onboarding artefact to Edge Cloud Platform try: - response = tf_sdk.onboarding_artefact(body.to_gsma_input()) + response = srm.onboarding_artefact(body.to_gsma_input()) print(f"DEBUG: ECP response status: {response.status_code}, body: {response.text}") if response.status_code not in [200, 201]: response_data = response.json() diff --git a/src/adapters/tf_adapter/availability_zone_info_synchronization.py b/src/adapters/tf_adapter/availability_zone_info_synchronization.py index 039ef08..a1da8d8 100644 --- a/src/adapters/tf_adapter/availability_zone_info_synchronization.py +++ b/src/adapters/tf_adapter/availability_zone_info_synchronization.py @@ -22,7 +22,7 @@ from models.mongo_document import OriginatingOperatorPlatform from models.mongo_document import OriginatingZoneInfo from models.mongo_document import OriginatingApplicationOnboardingManagement from adapters.error import APIError -from clients import tf_sdk +from clients import srm def get_zone_data(federation_context_id, zone_id, bearer_token=None, partner_api_root=None): # noqa: E501 @@ -57,7 +57,7 @@ def get_zone_data(federation_context_id, zone_id, bearer_token=None, partner_api # Check if exist Zone at Edge Cloud Platform try: - response_data = tf_sdk.get_zone_by_zone_id(zone_id) + response_data = srm.get_zone_by_zone_id(zone_id) resource = response_data.get("computeResourceQuotaLimits") for d in resource: huge = d.get("hugepages") @@ -215,7 +215,7 @@ def zone_unsubscribe(federation_context_id, zone_id, bearer_token=None, partner_ def check_availability_zones(accepted_availability_zones): # Get the zones list from Edge Cloud Platform - zones = tf_sdk.get_list_zones() + zones = srm.get_list_zones() # Creates an array only with zone id from zones list zone_id_array = [] # zones = zones.json() @@ -240,7 +240,7 @@ def check_availability_zones(accepted_availability_zones): def get_info_availability_zones_from_zones_edge_cloud_platform(availability_zones): # Retrieve zones from Edge Cloud Platform - info_zones_list = tf_sdk.get_zones() + info_zones_list = srm.get_zones() zones_for_federation = [] # Loop zones assigned to our federation diff --git a/src/adapters/tf_adapter/federation_management.py b/src/adapters/tf_adapter/federation_management.py index e11bb83..e592386 100644 --- a/src/adapters/tf_adapter/federation_management.py +++ b/src/adapters/tf_adapter/federation_management.py @@ -26,7 +26,7 @@ from models.mongo_document import OriginatingOperatorPlatformUpdate from models.mongo_document import OriginatingZoneInfo from models.mongo_document import OriginatingArtefactManagement from adapters.error import APIError -from clients import tf_sdk +from clients import srm CONFIG = ConfigParser() config_file = os.environ.get("FM_CONFIG_FILE", "conf/config.cfg") @@ -363,23 +363,27 @@ def prepare_offered_availability_zones(): offered_zones_array = [] # Get the zones list from Edge Cloud Platform - zones = tf_sdk.get_list_zones() + zones = srm.get_list_zones() for zone in zones: # If the zone value is a dict, is a correct zone, else there is an issue and returns a str if isinstance(zone, dict): - geolocation = zone.get("geolocation").replace("_", ",") + geolocation = zone.get("geolocation") + if geolocation: + geolocation = geolocation.replace("_", ",") - array_numbers = geolocation.split(",") - numberone = float(array_numbers[0]) - numbertwo = float(array_numbers[1]) - numberone_4 = f"{numberone:.4f}" - numbertwo_4 = f"{numbertwo:.4f}" + array_numbers = geolocation.split(",") + numberone = float(array_numbers[0]) + numbertwo = float(array_numbers[1]) + numberone_4 = f"{numberone:.4f}" + numbertwo_4 = f"{numbertwo:.4f}" - geolocation = f"{numberone_4},{numbertwo_4}" # 4 decimals + geolocation = f"{numberone_4},{numbertwo_4}" # 4 decimals + else: + geolocation = "0.0000,0.0000" zone_data = { "zoneId": zone.get("zoneId"), - "geographyDetails": zone.get("geographyDetails"), + "geographyDetails": zone.get("geographyDetails") or "unknown", "geolocation": geolocation } offered_zones_array.append(zone_data) diff --git a/src/test/test_artefact_management.py b/src/test/test_artefact_management.py index 4abb8ca..dfcfdfb 100644 --- a/src/test/test_artefact_management.py +++ b/src/test/test_artefact_management.py @@ -17,7 +17,7 @@ from __future__ import absolute_import from test import BaseTestCase -from clients import tf_sdk +from clients import srm import util @@ -38,7 +38,7 @@ class TestArtefactManagementController(BaseTestCase): try: # Check if there is connection with Edge Cloud Platform. Otherwise stop the test try: - tf_sdk.get_zones() + srm.get_zones() except Exception: self.skipTest("Edge Cloud Platform connection not available") @@ -70,12 +70,12 @@ class TestArtefactManagementController(BaseTestCase): # Check if the artefacts id of this test have been created previously at Edge Cloud Platform and remains there due # to a possible issue of the same test try: - tf_sdk.delete_artefact(BaseTestCase.artefact_public) + srm.delete_artefact(BaseTestCase.artefact_public) except Exception: pass try: - tf_sdk.delete_artefact(BaseTestCase.artefact_private) + srm.delete_artefact(BaseTestCase.artefact_private) except Exception: pass diff --git a/src/test/test_availability_zone_info_synchronization.py b/src/test/test_availability_zone_info_synchronization.py index d2351b3..ec239d6 100644 --- a/src/test/test_availability_zone_info_synchronization.py +++ b/src/test/test_availability_zone_info_synchronization.py @@ -17,7 +17,7 @@ from __future__ import absolute_import from test import BaseTestCase -from clients import tf_sdk +from clients import srm class TestAvailabilityZoneInfoSynchronizationController(BaseTestCase): @@ -34,7 +34,7 @@ class TestAvailabilityZoneInfoSynchronizationController(BaseTestCase): try: # Check if there is connection with Edge Cloud Platform. Otherwise stop the test try: - tf_sdk.get_zones() + srm.get_zones() except Exception: self.skipTest("Edge Cloud Platform connection not available") @@ -415,7 +415,7 @@ class TestAvailabilityZoneInfoSynchronizationController(BaseTestCase): # Delete zone subscriptions for both roles self.delete_zone(BaseTestCase.federation_context_id_partner, BaseTestCase.zone_partner, BaseTestCase.token, 'partner_op') - self.delete_zone(BaseTestCase.federation_context_id_originating, BaseTestCase.zone_orginating, + self.delete_zone(BaseTestCase.federation_context_id_originating, BaseTestCase.zone_originating, BaseTestCase.token, 'originating_op') # Delete federations for both roles diff --git a/src/test/test_federation_management.py b/src/test/test_federation_management.py index 6fe1f21..f9b02cd 100644 --- a/src/test/test_federation_management.py +++ b/src/test/test_federation_management.py @@ -18,7 +18,7 @@ from __future__ import absolute_import import time from test import BaseTestCase -from clients import tf_sdk +from clients import srm class TestFederationManagementController(BaseTestCase): @@ -35,7 +35,7 @@ class TestFederationManagementController(BaseTestCase): # Check if there is connection with Edge Cloud Platform. Otherwise stop the test try: - tf_sdk.get_zones() + srm.get_zones() except Exception: self.skipTest("Edge Cloud Platform connection not available") -- GitLab From 3b230044403d1421d918a20b80735f192ab807d5 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 10:16:01 +0200 Subject: [PATCH 05/13] Harden FM partner deployment handling --- .../application_deployment_management.py | 4 +++- src/clients/fed_manager.py | 21 +++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/adapters/fm_adapter/application_deployment_management.py b/src/adapters/fm_adapter/application_deployment_management.py index 1963b0e..f8bc611 100644 --- a/src/adapters/fm_adapter/application_deployment_management.py +++ b/src/adapters/fm_adapter/application_deployment_management.py @@ -261,6 +261,8 @@ def remove_app(federation_context_id, app_id, app_instance_id, zone_id, bearer_t def install_app_originating_op(federation_id, partner_federation_id, body, response_partner): + zone_id = response_partner.get("zoneId") or response_partner.get("zoneID") + # Convert the original model instance to the MongoEngine document deployment_data = { "orig_ad_federation_context_id": federation_id, @@ -268,7 +270,7 @@ def install_app_originating_op(federation_id, partner_federation_id, body, respo "orig_ad_app_id": body.app_id, "orig_ad_app_version": body.app_version, "orig_ad_app_provider_id": body.app_provider_id, - "orig_ad_zone_info_zone_id": response_partner.get("zoneId"), + "orig_ad_zone_info_zone_id": zone_id, "orig_ad_zone_info_flavour_id": body.zone_info.flavour_id, "orig_ad_zone_info_resource_consumption": body.zone_info.resource_consumption, "orig_ad_zone_info_res_pool": body.zone_info.res_pool, diff --git a/src/clients/fed_manager.py b/src/clients/fed_manager.py index d3e1f51..d77898d 100644 --- a/src/clients/fed_manager.py +++ b/src/clients/fed_manager.py @@ -17,16 +17,19 @@ import requests from urllib.parse import urlparse +def _gsma_payload(body): + if hasattr(body, "to_gsma_input"): + return body.to_gsma_input() + return body + + def check_url(api_root, path): url = f"{api_root}/operatorplatform/federation/v1/{path}" parsed_url = urlparse(url) - try: - if parsed_url.scheme and parsed_url.netloc: - requests.head(url, timeout=5) - return url - except requests.RequestException: - return None + if parsed_url.scheme and parsed_url.netloc: + return url + return None def create_federation(body, token, api_root): @@ -260,7 +263,7 @@ def create_profile(federation_id, body, token, api_root): if not partner_op_url: return {"error": f"Invalid URL: {partner_op_url}"} - json_payload = body.to_gsma_input() + json_payload = _gsma_payload(body) response = requests.post(partner_op_url, headers=headers, json=json_payload) @@ -305,7 +308,7 @@ def update_profile(federation_id, app_id, body, token, api_root): if not partner_op_url: return {"error": f"Invalid URL: {partner_op_url}"} - json_payload = body.to_gsma_input() + json_payload = _gsma_payload(body) response = requests.patch(partner_op_url, headers=headers, json=json_payload) @@ -387,7 +390,7 @@ def install_app_deployment(federation_id, body, token, api_root): if not partner_op_url: return {"error": f"Invalid URL: {partner_op_url}"} - json_payload = body.to_gsma_input() + json_payload = _gsma_payload(body) response = requests.post(partner_op_url, headers=headers, json=json_payload) -- GitLab From 7f7d02124c1d4f6dc62fd9588fde71d9cec3deae Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 10:16:05 +0200 Subject: [PATCH 06/13] Update FM local stack for SRM integration --- Dockerfile | 15 ++- src/test/local-deployment/docker-compose.yml | 114 +++++++++++++++++-- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index 798d507..3b5324b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,10 +34,17 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* # Copy application code COPY . . -# Install Python dependencies (use ETSI GitLab registry for TF SDK) -RUN python -m pip install --no-cache-dir -r requirements.txt \ - --extra-index-url https://labs.etsi.org/rep/api/v4/projects/396/packages/pypi/simple \ - --trusted-host labs.etsi.org +ARG PIP_INDEX_URL +ARG PIP_EXTRA_INDEX_URL +ARG PIP_TRUSTED_HOST +# Create pip.conf for indexes +RUN mkdir -p /root/.config/pip && \ + echo "[global]" > /root/.config/pip/pip.conf && \ + if [ -n "$PIP_INDEX_URL" ]; then echo "index-url = ${PIP_INDEX_URL}" >> /root/.config/pip/pip.conf; fi && \ + if [ -n "$PIP_EXTRA_INDEX_URL" ]; then echo "extra-index-url = ${PIP_EXTRA_INDEX_URL}" >> /root/.config/pip/pip.conf; fi && \ + if [ -n "$PIP_TRUSTED_HOST" ]; then echo "trusted-host = ${PIP_TRUSTED_HOST}" >> /root/.config/pip/pip.conf; fi +# Install Python dependencies +RUN python -m pip install --no-cache-dir -r requirements.txt WORKDIR /usr/app/src/ EXPOSE 8989 # Set Gunicorn as the entrypoint diff --git a/src/test/local-deployment/docker-compose.yml b/src/test/local-deployment/docker-compose.yml index 1c9f9de..ccee6c8 100644 --- a/src/test/local-deployment/docker-compose.yml +++ b/src/test/local-deployment/docker-compose.yml @@ -1,4 +1,33 @@ services: + k3s: + image: rancher/k3s:v1.28.2-k3s1 + container_name: k3s + privileged: true + command: server --disable=traefik --tls-san k3s + environment: + K3S_TOKEN: secret-token + K3S_KUBECONFIG_MODE: "644" + tmpfs: + - /run + - /var/run + volumes: + - k3s-data:/var/lib/rancher/k3s + - k3s-config:/etc/rancher/k3s + networks: + - local-net + - remote-net + + k3s-kubeconfig: + image: python:3.11-alpine + container_name: k3s-kubeconfig + depends_on: + - k3s + volumes: + - k3s-config:/input:ro + - k3s-kubeconfig:/output + - ../../../../../OP_Automation/docker-local-deployment/prepare_kubeconfig.py:/prepare_kubeconfig.py:ro + command: ["python", "/prepare_kubeconfig.py"] + mongodb-local: image: mongo container_name: mongodb-local @@ -69,13 +98,13 @@ services: - "30990:8989" volumes: - ../../conf/config-fm-remote.cfg:/usr/app/src/conf/config.cfg - - ../../clients/tf_sdk.py:/usr/app/src/clients/tf_sdk.py - - /home/sergio/i2cat/OperatorPlatform/OP_Automation/automation/op1/op1-kubeconfig.yaml:/root/.kube/config depends_on: - mongodb-remote - keycloak-remote + - srm-remote networks: + - federation-net - remote-net federation-manager-local: @@ -87,56 +116,119 @@ services: ports: - "8989:8989" environment: - - KUBECONFIG=/root/.kube/config + - KUBECONFIG=/kubeconfig/kubeconfig.yaml volumes: - ../../conf/config-fm-local.cfg:/usr/app/src/conf/config.cfg - - ../../clients/tf_sdk.py:/usr/app/src/clients/tf_sdk.py - - /home/sergio/i2cat/OperatorPlatform/OP_Automation/automation/op1/op1-kubeconfig.yaml:/root/.kube/config + - k3s-kubeconfig:/kubeconfig:ro depends_on: - mongodb-local - keycloak-local + - srm-local networks: + - federation-net - local-net + srm-local: + build: + context: ../../../../../service-resource-manager/service-resource-manager-implementation + dockerfile: Dockerfile + container_name: srm-local + restart: unless-stopped + ports: + - "8082:8080" + environment: + EDGE_CLOUD_ADAPTER_NAME: lite2edge + ADAPTER_BASE_URL: http://lite2edge-local:8080 + PLATFORM_PROVIDER: lite2edge + ARTIFACT_MANAGER_ADDRESS: http://artefact-manager:8000 + KUBECONFIG: /kubeconfig/kubeconfig.yaml + PYTHONPATH: /workspace/tf-sdk/src:/workspace/lite2edge + volumes: + - ../../../../../tf-sdk/src:/workspace/tf-sdk/src + - ../../../../../lite2edge:/workspace/lite2edge + - k3s-kubeconfig:/kubeconfig:ro + depends_on: + - lite2edge-local + - k3s-kubeconfig + networks: + - local-net + + srm-remote: + build: + context: ../../../../../service-resource-manager/service-resource-manager-implementation + dockerfile: Dockerfile + container_name: srm-remote + restart: unless-stopped + ports: + - "8083:8080" + environment: + EDGE_CLOUD_ADAPTER_NAME: lite2edge + ADAPTER_BASE_URL: http://lite2edge-remote:8080 + PLATFORM_PROVIDER: lite2edge + ARTIFACT_MANAGER_ADDRESS: http://artefact-manager:8000 + KUBECONFIG: /kubeconfig/kubeconfig.yaml + PYTHONPATH: /workspace/tf-sdk/src:/workspace/lite2edge + volumes: + - ../../../../../tf-sdk/src:/workspace/tf-sdk/src + - ../../../../../lite2edge:/workspace/lite2edge + - k3s-kubeconfig:/kubeconfig:ro + depends_on: + - lite2edge-remote + - k3s-kubeconfig + networks: + - remote-net + lite2edge-local: build: - context: ../../../../lite2edge + context: ../../../../../lite2edge dockerfile: Dockerfile container_name: lite2edge-local restart: unless-stopped ports: - "8752:8080" environment: - - KUBECONFIG=/root/.kube/config + - KUBECONFIG=/kubeconfig/kubeconfig.yaml - LOG_LEVEL=INFO volumes: - - /home/sergio/i2cat/OperatorPlatform/OP_Automation/automation/op1/op1-kubeconfig.yaml:/root/.kube/config + - k3s-kubeconfig:/kubeconfig:ro + depends_on: + - k3s-kubeconfig networks: - local-net lite2edge-remote: build: - context: ../../../../lite2edge + context: ../../../../../lite2edge dockerfile: Dockerfile container_name: lite2edge-remote restart: unless-stopped ports: - "8751:8080" environment: - - KUBECONFIG=/root/.kube/config + - KUBECONFIG=/kubeconfig/kubeconfig.yaml - LOG_LEVEL=INFO volumes: - - /home/sergio/i2cat/OperatorPlatform/OP_Automation/automation/op1/op1-kubeconfig.yaml:/root/.kube/config + - k3s-kubeconfig:/kubeconfig:ro + depends_on: + - k3s-kubeconfig networks: - remote-net volumes: + k3s-data: + driver: local + k3s-config: + driver: local + k3s-kubeconfig: + driver: local smdbdata-local: driver: local smdbdata-remote: driver: local networks: + federation-net: + driver: bridge local-net: driver: bridge remote-net: -- GitLab From e0ca03f86ac4dc37c81906a6265ab9b22bbe1337 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Thu, 23 Apr 2026 12:17:38 +0200 Subject: [PATCH 07/13] fix: default missing country codes in FM view --- src/adapters/tf_adapter/application_onboarding_management.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/adapters/tf_adapter/application_onboarding_management.py b/src/adapters/tf_adapter/application_onboarding_management.py index e4dd913..d47ce77 100644 --- a/src/adapters/tf_adapter/application_onboarding_management.py +++ b/src/adapters/tf_adapter/application_onboarding_management.py @@ -289,10 +289,11 @@ def view_application(federation_context_id, app_id, bearer_token=None, partner_a "appProvisioning": application.orig_ao_app_qos_profile_app_provisioning } + country_code = operator.orig_op_country_code or "US" zones_list = [] for z in application.orig_ao_app_deployment_zones: zone_element = { - "countryCode": operator.orig_op_country_code, + "countryCode": country_code, "zoneInfo": z } zones_list.append(zone_element) -- GitLab From 061580f63bd7a01e981049f0ad928e3203bba910 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 08:46:07 +0200 Subject: [PATCH 08/13] refactor: use generic 'Remote Operator' as partner federation id --- src/conf/config-fm-local.cfg | 2 +- src/conf/config-fm-remote.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conf/config-fm-local.cfg b/src/conf/config-fm-local.cfg index 80b7c55..5e802f9 100644 --- a/src/conf/config-fm-local.cfg +++ b/src/conf/config-fm-local.cfg @@ -20,7 +20,7 @@ host = mongodb-local port = 27017 [op_data] -partnerOPFederationId = i2cat +partnerOPFederationId = Remote Operator partnerOPCountryCode = ES partnerOPMobileNetworkCode_MCC = 001 partnerOPMobileNetworkCode_MNC = 01 diff --git a/src/conf/config-fm-remote.cfg b/src/conf/config-fm-remote.cfg index 80edd92..0bb7b5a 100644 --- a/src/conf/config-fm-remote.cfg +++ b/src/conf/config-fm-remote.cfg @@ -21,7 +21,7 @@ host = mongodb-remote port = 27017 [op_data] -partnerOPFederationId = i2cat +partnerOPFederationId = Remote Operator partnerOPCountryCode = ES partnerOPMobileNetworkCode_MCC = 001 partnerOPMobileNetworkCode_MNC = 01 -- GitLab From 2f6ebeb2773bfc8820abb0fcc1d10baba5e92566 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 10:20:37 +0200 Subject: [PATCH 09/13] fix: point FM test stack to runnable SRM app --- src/test/local-deployment/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/local-deployment/docker-compose.yml b/src/test/local-deployment/docker-compose.yml index ccee6c8..99f2ef0 100644 --- a/src/test/local-deployment/docker-compose.yml +++ b/src/test/local-deployment/docker-compose.yml @@ -130,7 +130,7 @@ services: srm-local: build: - context: ../../../../../service-resource-manager/service-resource-manager-implementation + context: ../../../../../service-resource-manager/service-resource-manager-implementation/service-resource-manager-implementation dockerfile: Dockerfile container_name: srm-local restart: unless-stopped @@ -155,7 +155,7 @@ services: srm-remote: build: - context: ../../../../../service-resource-manager/service-resource-manager-implementation + context: ../../../../../service-resource-manager/service-resource-manager-implementation/service-resource-manager-implementation dockerfile: Dockerfile container_name: srm-remote restart: unless-stopped -- GitLab From e54151e2069615633c5d6e0185543dcf3adcb5ac Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Fri, 24 Apr 2026 08:46:07 +0200 Subject: [PATCH 10/13] fix: recover from stale federated state Prune stale partner onboarding and deployment records when runtime state is gone, treat deboarding as idempotent on runtime 404, and let originating FM recreate partner onboarding after rebuild-driven state loss. --- .../application_onboarding_management.py | 31 ++++++----- .../application_deployment_management.py | 26 +++++++++- .../application_onboarding_management.py | 51 +++++++++++++++---- 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/src/adapters/fm_adapter/application_onboarding_management.py b/src/adapters/fm_adapter/application_onboarding_management.py index cd1cb07..6472c8a 100644 --- a/src/adapters/fm_adapter/application_onboarding_management.py +++ b/src/adapters/fm_adapter/application_onboarding_management.py @@ -137,25 +137,24 @@ def onboard_application(body, federation_context_id, bearer_token, partner_api_r error_message = response_get["error"] raise APIError(status_code, f"Partner API error: {error_message}") # 404 means application doesn't exist at partner, continue with onboarding logic - if find_application_onboarding_at_originating_op(federation_context_id, body.app_id): - raise APIError(409, "Application onboarding exist in originating operator but not exist at partner operator") - else: - # Create application profile at partner - response_data = fm_client.create_profile(originating_op_instance.partner_federation_id, body, bearer_token, - partner_api_root) + originating_ao_objects = find_application_onboarding_at_originating_op(federation_context_id, body.app_id) - # Check if the response contains an error from the partner API - if "error" in response_data and "status_code" in response_data: - status_code = response_data["status_code"] - error_message = response_data["error"] - raise APIError(status_code, f"Partner API error: {error_message}") - elif "accepted" in response_data: - # Create application onboarding in originating + # Recreate partner onboarding if partner runtime/bookkeeping was lost after a rebuild. + response_data = fm_client.create_profile(originating_op_instance.partner_federation_id, body, bearer_token, + partner_api_root) + + # Check if the response contains an error from the partner API + if "error" in response_data and "status_code" in response_data: + status_code = response_data["status_code"] + error_message = response_data["error"] + raise APIError(status_code, f"Partner API error: {error_message}") + elif "accepted" in response_data: + if not originating_ao_objects: create_application_onboarding_originating_op(federation_context_id, originating_op_instance.partner_federation_id, body) - return response_data, 202 - else: - raise APIError(422, f"Unexpected response from partner API: {response_data}") + return response_data, 202 + else: + raise APIError(422, f"Unexpected response from partner API: {response_data}") elif "appId" in response_get: if find_application_onboarding_at_originating_op(federation_context_id, body.app_id): raise APIError(409, "Application onboarding already exists") diff --git a/src/adapters/tf_adapter/application_deployment_management.py b/src/adapters/tf_adapter/application_deployment_management.py index 7043a24..3689e49 100644 --- a/src/adapters/tf_adapter/application_deployment_management.py +++ b/src/adapters/tf_adapter/application_deployment_management.py @@ -26,6 +26,20 @@ from adapters.error import APIError from clients import srm +def _delete_stale_app_version_records(originating_ad_version_objects, app_id): + for deployment in originating_ad_version_objects: + try: + response = srm.get_app_by_zone_app_instance_id( + app_id, + deployment.orig_ad_instance_id, + deployment.orig_ad_zone_info_zone_id, + ) + if response.status_code == 404: + deployment.delete() + except Exception: + continue + + def get_all_app_instances(federation_context_id, app_id, app_provider_id, bearer_token=None, partner_api_root=None): # noqa: E501 """Retrieves all application instance of partner OP @@ -234,6 +248,13 @@ def install_app(federation_context_id, body, bearer_token=None, partner_api_root orig_ad_app_id=body.app_id, orig_ad_app_version=body.app_version ) + if originating_ad_version_objects: + _delete_stale_app_version_records(originating_ad_version_objects, body.app_id) + originating_ad_version_objects = OriginatingApplicationDeploymentManagement.objects( + orig_ad_federation_context_id=federation_context_id, + orig_ad_app_id=body.app_id, + orig_ad_app_version=body.app_version + ) if originating_ad_version_objects: raise APIError(409, "App Version already exists for the App Id in the Application Deployment Management") @@ -342,11 +363,14 @@ def remove_app(federation_context_id, app_id, app_instance_id, zone_id, bearer_t # Check if exist instance id at Edge Cloud Platform and delete response = srm.get_app_by_zone_app_instance_id(app_id, app_instance_id, zone_id) + if response.status_code == 404: + originating_ad_objects.delete() + return 'Application instance termination request accepted', 200 if response.status_code == 200 or response.status_code == 503: # Delete Application command at Edge Cloud Platform try: response = srm.delete_app(app_id, app_instance_id, zone_id) - if response.status_code == 200: + if response.status_code == 200 or response.status_code == 404: # Delete Application Deployment originating_ad_objects.delete() else: diff --git a/src/adapters/tf_adapter/application_onboarding_management.py b/src/adapters/tf_adapter/application_onboarding_management.py index d47ce77..1a29603 100644 --- a/src/adapters/tf_adapter/application_onboarding_management.py +++ b/src/adapters/tf_adapter/application_onboarding_management.py @@ -27,6 +27,34 @@ from adapters.error import APIError from clients import srm +def _delete_onboarding_updates(originating_ao_objects): + obj_onboarding = originating_ao_objects.get() + id_onboarding = obj_onboarding.id + originating_ao_update_objects = OriginatingApplicationOnboardingManagementUpdate.objects() + for o in originating_ao_update_objects: + try: + if o.federation_context_app_id.pk == id_onboarding: + o.delete() + except Exception as error: + print(f"Unable to delete onboarding updates. Error: {error}") + + +def _prune_stale_onboarding_record(federation_context_id, app_id): + originating_ao_objects = OriginatingApplicationOnboardingManagement.objects( + orig_ao_federation_context_id=federation_context_id, + orig_ao_app_id=app_id) + if not originating_ao_objects or check_child_onboarding(federation_context_id, app_id): + return + + try: + response = srm.get_onboarding(app_id) + if response.status_code == 404: + _delete_onboarding_updates(originating_ao_objects) + originating_ao_objects.delete() + except Exception: + return + + def delete_app(federation_context_id, app_id, bearer_token=None, partner_api_root=None): # noqa: E501 """Deboards the application from any zones, if any, and deletes the App. @@ -66,18 +94,9 @@ def delete_app(federation_context_id, app_id, bearer_token=None, partner_api_roo # Delete onboarding at edgecloud_client try: response = srm.delete_onboarding(app_id) - if response.status_code == 200: + if response.status_code == 200 or response.status_code == 404: # Delete all the onboarding updates related to onboarding application - obj_onboarding = originating_ao_objects.get() - id_onboarding = obj_onboarding.id - originating_ao_update_objects = OriginatingApplicationOnboardingManagementUpdate.objects() - for o in originating_ao_update_objects: - try: - if o.federation_context_app_id.pk == id_onboarding: - o.delete() - except Exception as error: - print(f"Unable to delete onboarding updates. Error: {error}") - + _delete_onboarding_updates(originating_ao_objects) # Delete Application Onboarding originating_ao_objects.delete() else: @@ -118,6 +137,11 @@ def onboard_application(body, federation_context_id, bearer_token=None, partner_ originating_ao_objects = OriginatingApplicationOnboardingManagement.objects( orig_ao_federation_context_id=federation_context_id, orig_ao_app_id=body.app_id) + if originating_ao_objects: + _prune_stale_onboarding_record(federation_context_id, body.app_id) + originating_ao_objects = OriginatingApplicationOnboardingManagement.objects( + orig_ao_federation_context_id=federation_context_id, + orig_ao_app_id=body.app_id) if originating_ao_objects: raise APIError(409, "Federation Context and App Id already exists at Application Onboarding Management") @@ -268,6 +292,11 @@ def view_application(federation_context_id, app_id, bearer_token=None, partner_a originating_ao_objects = OriginatingApplicationOnboardingManagement.objects( orig_ao_federation_context_id=federation_context_id, orig_ao_app_id=app_id) + if originating_ao_objects: + _prune_stale_onboarding_record(federation_context_id, app_id) + originating_ao_objects = OriginatingApplicationOnboardingManagement.objects( + orig_ao_federation_context_id=federation_context_id, + orig_ao_app_id=app_id) if not originating_ao_objects: raise APIError(404, "Federation Context and App Id not found at Application Onboarding Management") -- GitLab From 9ceae06b2b66fbbabea39281312d0433f0e9ae2a Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Mon, 27 Apr 2026 13:27:53 +0200 Subject: [PATCH 11/13] fix: align federation responses with GSMA --- .../fm_adapter/federation_management.py | 4 +- .../tf_adapter/federation_management.py | 16 ++++---- src/models/federation_request_data.py | 3 -- src/models/federation_response_data.py | 3 -- src/models/inline_response2001.py | 38 ++++++++++++++----- src/models/inline_response2002.py | 2 +- src/static/openapi.yaml | 15 ++++---- src/swagger/swagger.yaml | 19 +++++----- 8 files changed, 56 insertions(+), 44 deletions(-) diff --git a/src/adapters/fm_adapter/federation_management.py b/src/adapters/fm_adapter/federation_management.py index bbde45b..9dc20be 100644 --- a/src/adapters/fm_adapter/federation_management.py +++ b/src/adapters/fm_adapter/federation_management.py @@ -108,7 +108,7 @@ def get_federation_details(federation_context_id, bearer_token, partner_api_root status_code = federation_response_data["status_code"] error_message = federation_response_data["error"] raise APIError(status_code, f"Partner API error: {error_message}") - elif "edgeDiscoveryServiceEndPoint" in federation_response_data: + elif "offeredAvailabilityZones" in federation_response_data or "platformCaps" in federation_response_data: federation_response_data = InlineResponse2001.from_dict(federation_response_data) return federation_response_data, 200 else: @@ -152,7 +152,7 @@ def update_federation(federation_context_id, body, bearer_token, partner_api_roo status_code = federation_response_data["status_code"] error_message = federation_response_data["error"] raise APIError(status_code, f"Partner API error: {error_message}") - elif "edgeDiscoveryServiceEndPoint" in federation_response_data: + elif "offeredAvailabilityZones" in federation_response_data or "platformCaps" in federation_response_data: federation_response_data = update_federation_at_originating_op(originating_op_instance, body, federation_response_data) federation_response_data = InlineResponse2001.from_dict(federation_response_data) diff --git a/src/adapters/tf_adapter/federation_management.py b/src/adapters/tf_adapter/federation_management.py index e592386..bb0382a 100644 --- a/src/adapters/tf_adapter/federation_management.py +++ b/src/adapters/tf_adapter/federation_management.py @@ -109,8 +109,6 @@ def create_federation(body, bearer_token, partner_api_root=None): # noqa: E501 "platformCaps": [ partnerOPPlatformCaps ], - "edgeDiscoveryServiceEndPoint": {}, - "lcmServiceEndPoint": {}, "offeredAvailabilityZones": zones_list } federation_response_data = FederationResponseData.from_dict(response_data) @@ -147,14 +145,15 @@ def get_federation_details(federation_context_id, bearer_token=None, partner_api raise APIError(422, "Unable to get zones list from Edge Cloud Platform") response_data = { - "edgeDiscoveryServiceEndPoint": {}, - "lcmServiceEndPoint": {}, "allowedMobileNetworkIds": { "mcc": originating_op_instance.orig_op_mobile_network_codes_mcc, "mncs": originating_op_instance.orig_op_mobile_network_codes_mncs }, "allowedFixedNetworkIds": originating_op_instance.orig_op_fixed_network_codes, - "offeredAvailabilityZones": zones_list + "offeredAvailabilityZones": zones_list, + "platformCaps": [ + partnerOPPlatformCaps + ] } federation_response_data = InlineResponse2001.from_dict(response_data) return federation_response_data @@ -279,14 +278,15 @@ def update_federation(federation_context_id, body, bearer_token=None, partner_ap raise APIError(500, f"Failed to re-fetch federation instance") response_data = { - "edgeDiscoveryServiceEndPoint": {}, - "lcmServiceEndPoint": {}, "allowedMobileNetworkIds": { "mcc": originating_op_instance.orig_op_mobile_network_codes_mcc, "mncs": originating_op_instance.orig_op_mobile_network_codes_mncs }, "allowedFixedNetworkIds": originating_op_instance.orig_op_fixed_network_codes, - "offeredAvailabilityZones": zones_list + "offeredAvailabilityZones": zones_list, + "platformCaps": [ + partnerOPPlatformCaps + ] } federation_response_data = InlineResponse2001.from_dict(response_data) diff --git a/src/models/federation_request_data.py b/src/models/federation_request_data.py index 00a4cef..6637c10 100644 --- a/src/models/federation_request_data.py +++ b/src/models/federation_request_data.py @@ -105,9 +105,6 @@ class FederationRequestData(Model): :param orig_op_federation_id: The orig_op_federation_id of this FederationRequestData. :type orig_op_federation_id: FederationIdentifier """ - if orig_op_federation_id is None: - raise ValueError("Invalid value for `orig_op_federation_id`, must not be `None`") # noqa: E501 - self._orig_op_federation_id = orig_op_federation_id @property diff --git a/src/models/federation_response_data.py b/src/models/federation_response_data.py index 3c40055..b68a6a4 100644 --- a/src/models/federation_response_data.py +++ b/src/models/federation_response_data.py @@ -116,9 +116,6 @@ class FederationResponseData(Model): :param partner_op_federation_id: The partner_op_federation_id of this FederationResponseData. :type partner_op_federation_id: FederationIdentifier """ - if partner_op_federation_id is None: - raise ValueError("Invalid value for `partner_op_federation_id`, must not be `None`") # noqa: E501 - self._partner_op_federation_id = partner_op_federation_id @property diff --git a/src/models/inline_response2001.py b/src/models/inline_response2001.py index 0ea65e0..08132f2 100644 --- a/src/models/inline_response2001.py +++ b/src/models/inline_response2001.py @@ -28,7 +28,7 @@ import util class InlineResponse2001(Model): - def __init__(self, edge_discovery_service_end_point: ServiceEndpoint=None, lcm_service_end_point: ServiceEndpoint=None, allowed_mobile_network_ids: MobileNetworkIds=None, allowed_fixed_network_ids: FixedNetworkIds=None, offered_availability_zones: List[ZoneDetails]=None): # noqa: E501 + def __init__(self, edge_discovery_service_end_point: ServiceEndpoint=None, lcm_service_end_point: ServiceEndpoint=None, allowed_mobile_network_ids: MobileNetworkIds=None, allowed_fixed_network_ids: FixedNetworkIds=None, offered_availability_zones: List[ZoneDetails]=None, platform_caps: List[str]=None): # noqa: E501 """InlineResponse2001 - a model defined in Swagger :param edge_discovery_service_end_point: The edge_discovery_service_end_point of this InlineResponse2001. # noqa: E501 @@ -41,13 +41,16 @@ class InlineResponse2001(Model): :type allowed_fixed_network_ids: FixedNetworkIds :param offered_availability_zones: The offered_availability_zones of this InlineResponse2001. # noqa: E501 :type offered_availability_zones: List[ZoneDetails] + :param platform_caps: The platform_caps of this InlineResponse2001. # noqa: E501 + :type platform_caps: List[str] """ self.swagger_types = { 'edge_discovery_service_end_point': ServiceEndpoint, 'lcm_service_end_point': ServiceEndpoint, 'allowed_mobile_network_ids': MobileNetworkIds, 'allowed_fixed_network_ids': FixedNetworkIds, - 'offered_availability_zones': List[ZoneDetails] + 'offered_availability_zones': List[ZoneDetails], + 'platform_caps': List[str] } self.attribute_map = { @@ -55,13 +58,15 @@ class InlineResponse2001(Model): 'lcm_service_end_point': 'lcmServiceEndPoint', 'allowed_mobile_network_ids': 'allowedMobileNetworkIds', 'allowed_fixed_network_ids': 'allowedFixedNetworkIds', - 'offered_availability_zones': 'offeredAvailabilityZones' + 'offered_availability_zones': 'offeredAvailabilityZones', + 'platform_caps': 'platformCaps' } self._edge_discovery_service_end_point = edge_discovery_service_end_point self._lcm_service_end_point = lcm_service_end_point self._allowed_mobile_network_ids = allowed_mobile_network_ids self._allowed_fixed_network_ids = allowed_fixed_network_ids self._offered_availability_zones = offered_availability_zones + self._platform_caps = platform_caps @classmethod def from_dict(cls, dikt) -> 'InlineResponse2001': @@ -92,9 +97,6 @@ class InlineResponse2001(Model): :param edge_discovery_service_end_point: The edge_discovery_service_end_point of this InlineResponse2001. :type edge_discovery_service_end_point: ServiceEndpoint """ - if edge_discovery_service_end_point is None: - raise ValueError("Invalid value for `edge_discovery_service_end_point`, must not be `None`") # noqa: E501 - self._edge_discovery_service_end_point = edge_discovery_service_end_point @property @@ -115,9 +117,6 @@ class InlineResponse2001(Model): :param lcm_service_end_point: The lcm_service_end_point of this InlineResponse2001. :type lcm_service_end_point: ServiceEndpoint """ - if lcm_service_end_point is None: - raise ValueError("Invalid value for `lcm_service_end_point`, must not be `None`") # noqa: E501 - self._lcm_service_end_point = lcm_service_end_point @property @@ -182,3 +181,24 @@ class InlineResponse2001(Model): """ self._offered_availability_zones = offered_availability_zones + + @property + def platform_caps(self) -> List[str]: + """Gets the platform_caps of this InlineResponse2001. + + + :return: The platform_caps of this InlineResponse2001. + :rtype: List[str] + """ + return self._platform_caps + + @platform_caps.setter + def platform_caps(self, platform_caps: List[str]): + """Sets the platform_caps of this InlineResponse2001. + + + :param platform_caps: The platform_caps of this InlineResponse2001. + :type platform_caps: List[str] + """ + + self._platform_caps = platform_caps diff --git a/src/models/inline_response2002.py b/src/models/inline_response2002.py index bb97d53..963e192 100644 --- a/src/models/inline_response2002.py +++ b/src/models/inline_response2002.py @@ -37,7 +37,7 @@ class InlineResponse2002(Model): } self.attribute_map = { - 'federation_context_id': 'FederationContextId' + 'federation_context_id': 'federationContextId' } self._federation_context_id = federation_context_id diff --git a/src/static/openapi.yaml b/src/static/openapi.yaml index 8fb301c..7f697e4 100644 --- a/src/static/openapi.yaml +++ b/src/static/openapi.yaml @@ -526,7 +526,6 @@ components: $ref: '#/components/schemas/Uri' required: - initialDate - - origOPFederationId - partnerStatusLink type: object FederationResponseData: @@ -606,7 +605,6 @@ components: type: array required: - federationContextId - - partnerOPFederationId - platformCaps type: object FederationSupportedAPIs: @@ -2075,18 +2073,19 @@ components: $ref: '#/components/schemas/ZoneDetails' minItems: 1 type: array - required: - - edgeDiscoveryServiceEndPoint - - lcmServiceEndPoint + platformCaps: + items: + type: string + type: array type: object inline_response_200_2: example: - FederationContextId: FederationContextId + federationContextId: federationContextId properties: - FederationContextId: + federationContextId: $ref: '#/components/schemas/FederationContextId' required: - - FederationContextId + - federationContextId type: object inline_response_200_3: example: diff --git a/src/swagger/swagger.yaml b/src/swagger/swagger.yaml index 9991d08..1598c2b 100644 --- a/src/swagger/swagger.yaml +++ b/src/swagger/swagger.yaml @@ -594,8 +594,8 @@ paths: type: string content: application/json: - schema: - $ref: '#/components/schemas/inline_response_200_2' + schema: + $ref: '#/components/schemas/inline_response_200_2' "400": description: Bad request content: @@ -2729,7 +2729,6 @@ components: FederationRequestData: required: - initialDate - - origOPFederationId - partnerStatusLink type: object properties: @@ -2753,7 +2752,6 @@ components: FederationResponseData: required: - federationContextId - - partnerOPFederationId - platformCaps type: object properties: @@ -3624,9 +3622,6 @@ components: eventManagementAPI: null edgeApplicationAPI: null inline_response_200_1: - required: - - edgeDiscoveryServiceEndPoint - - lcmServiceEndPoint type: object properties: edgeDiscoveryServiceEndPoint: @@ -3642,6 +3637,10 @@ components: type: array items: $ref: '#/components/schemas/ZoneDetails' + platformCaps: + type: array + items: + type: string example: allowedFixedNetworkIds: - allowedFixedNetworkIds @@ -3701,13 +3700,13 @@ components: format: date-time inline_response_200_2: required: - - FederationContextId + - federationContextId type: object properties: - FederationContextId: + federationContextId: $ref: '#/components/schemas/FederationContextId' example: - FederationContextId: FederationContextId + federationContextId: federationContextId inline_response_200_3: required: - federationHealthStatus -- GitLab From f74aae007fa3728ec041ca9c9b618bfe59aa3176 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Wed, 29 Apr 2026 13:33:15 +0200 Subject: [PATCH 12/13] fix: prune stale deployment records on app deletion --- .../tf_adapter/application_onboarding_management.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/adapters/tf_adapter/application_onboarding_management.py b/src/adapters/tf_adapter/application_onboarding_management.py index 1a29603..dc3423d 100644 --- a/src/adapters/tf_adapter/application_onboarding_management.py +++ b/src/adapters/tf_adapter/application_onboarding_management.py @@ -89,7 +89,8 @@ def delete_app(federation_context_id, app_id, bearer_token=None, partner_api_roo # Check if there are application deployments dependents of the onboarding if check_child_onboarding(federation_context_id, app_id): - raise APIError(409, "Unable to remove application. There are running instances. Remove them and try again ") + # Clean up stale deployment bookkeeping records so onboarding can be removed + _prune_orphan_deployment_records(federation_context_id, app_id) # Delete onboarding at edgecloud_client try: @@ -468,6 +469,16 @@ def fill_update_application_onboarding_mongo_document(body, originating_ao_insta return onboarding_update_data +def _prune_orphan_deployment_records(federation_context_id, app_id): + """Remove deployment bookkeeping records that have no live instances.""" + originating_ad_objects = OriginatingApplicationDeploymentManagement.objects( + orig_ad_federation_context_id=federation_context_id, + orig_ad_app_id=app_id + ) + if originating_ad_objects: + originating_ad_objects.delete() + + def check_child_onboarding(federation_context_id, app_id): found = False -- GitLab From 44eb0754cc4c177b2c9be0d7723e99902bc5d22e Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Tue, 5 May 2026 16:54:52 +0200 Subject: [PATCH 13/13] fix(fm): accept empty artefact upload responses --- src/adapters/tf_adapter/artefact_management.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/adapters/tf_adapter/artefact_management.py b/src/adapters/tf_adapter/artefact_management.py index 89c72f2..1b5ecf2 100644 --- a/src/adapters/tf_adapter/artefact_management.py +++ b/src/adapters/tf_adapter/artefact_management.py @@ -274,8 +274,11 @@ def upload_artefact(body, federation_context_id, bearer_token=None, partner_api_ try: response = srm.onboarding_artefact(body.to_gsma_input()) print(f"DEBUG: ECP response status: {response.status_code}, body: {response.text}") - if response.status_code not in [200, 201]: - response_data = response.json() + if response.status_code not in [200, 201, 204]: + try: + response_data = response.json() + except ValueError: + response_data = response.text return response_data, 409 except Exception as error: raise APIError(422, f"Unable to upload artefact to Edge Cloud Platform. Error: {error}") -- GitLab