Commit 6a16b9df authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

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.
parent afb0fc56
Loading
Loading
Loading
Loading
+93 −696

File changed.

Preview size limit exceeded, changes collapsed.

+165 −0
Original line number Diff line number Diff line
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
+167 −0
Original line number Diff line number Diff line
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
+356 −0

File added.

Preview size limit exceeded, changes collapsed.

+28 −22
Original line number Diff line number Diff line
@@ -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",
        "appZones": [{
            "EdgeCloudZone": {
                "edgeCloudZoneId": "zone-1",
        "kubernetesClusterRef": "cluster-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}
Loading