Commit 95b8be32 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

fix(fm-client): read FM's federated zone format, list zones with no partner zones

get_zone and list_zones validated FM's answer straight into Zone, which
needs ref, name, kind and state, while FM returns zone_id, partner_op_id
and supported_apis. Every partner zone raised a ValidationError, so
has_zone threw and deploys never reached FM. The client now reads FM's
format and maps it to a Zone with kind federation_business, and marks
provider as "federation" so a partner zone doesn't claim to be ours.

ListZonesUseCase only assigned filtered_fm_zones when FM returned zones,
so an empty answer raised UnboundLocalError and GET /internal/zones
returned 500.
parent cf3784d9
Loading
Loading
Loading
Loading
Loading
+29 −5
Original line number Diff line number Diff line
@@ -5,10 +5,12 @@ from uuid import UUID

import httpx
import structlog
from pydantic import BaseModel

from srm.adapters.errors import DownstreamServiceException, NotFoundException
from srm.config import FederationManagerSettings
from srm.domain.models.runtime_inventory.models import ServiceInstance
from srm.domain.models.topology.enums import ZoneKind, ZoneState
from srm.domain.models.topology.models import Zone
from srm.domain.ports.databus.publisher import DataBusPublisher
from srm.domain.ports.federation_manager.ports import FederationManagerPort
@@ -22,6 +24,31 @@ _LIST_SERVICE_INSTANCES_ENDPOINT = "/internal/federated-service-instances"
logger: structlog.BoundLogger = structlog.getLogger(__name__)


class FederatedZone(BaseModel):
    zone_id: UUID
    partner_op_id: UUID
    supported_apis: list[str] = []
    geography_details: str | None = None
    geolocation: str | None = None

    def to_zone(self) -> Zone:
        metadata: dict[str, Any] = {
            "provider": "federation",
            "partner_op_id": str(self.partner_op_id),
            "supported_apis": self.supported_apis,
        }
        if self.geolocation is not None:
            metadata["geolocation"] = self.geolocation
        return Zone(
            id=self.zone_id,
            ref=f"federated:{self.zone_id}",
            name=self.geography_details or str(self.zone_id),
            kind=ZoneKind.FEDERATION_BUSINESS,
            state=ZoneState.ACTIVE,
            metadata=metadata,
        )


class FederationManager(FederationManagerPort):
    def __init__(self, settings: FederationManagerSettings, publisher: DataBusPublisher):
        self._settings = settings
@@ -105,16 +132,13 @@ class FederationManager(FederationManagerPort):
    async def get_zone(self, zone_id: UUID) -> Zone | None:
        try:
            res = await self._request("GET", str.format(_GET_ZONE_ENDPOINT, zone_id=zone_id))
            return Zone.model_validate(res)
            return FederatedZone.model_validate(res).to_zone()
        except NotFoundException:
            return None

    async def list_zones(self) -> list[Zone]:
        res = await self._request("GET", _LIST_ZONES_ENDPOINT)
        zones = []
        for obj in res:
            zones.append(Zone.model_validate(obj))
        return zones
        return [FederatedZone.model_validate(obj).to_zone() for obj in res]

    async def list_full_service_instances(self) -> list[ServiceInstance]:
        res = await self._request("GET", _LIST_SERVICE_INSTANCES_ENDPOINT)
+1 −6
Original line number Diff line number Diff line
@@ -22,12 +22,7 @@ class ListZonesUseCase:
            region=command.region,
        )
        federated_zones = await self._fm_client.list_zones()
        if len(federated_zones) > 0:
            filtered_fm_zones = filter(
                lambda zone: self.filter_zones(zone, command), federated_zones
            )

        return local_zones + list(filtered_fm_zones)
        return local_zones + [zone for zone in federated_zones if self.filter_zones(zone, command)]

    def filter_zones(self, zone: Zone, command: ListZonesCommand) -> bool:
        if command.region is None and command.state is None:
+54 −0
Original line number Diff line number Diff line
from unittest.mock import AsyncMock
from uuid import UUID

import pytest

from srm.adapters.errors import NotFoundException
from srm.adapters.federation_manager.fm_client import FederationManager
from srm.config import FederationManagerSettings
from srm.domain.models.topology import ZoneKind, ZoneState

ZONE_ID = UUID("16bba4bc-c337-5970-b1f6-e11a542be6c7")
PARTNER_ID = UUID("3f6c1d7e-9a52-4b08-b6d2-2e4f8a913c55")
FM_ZONE = {
    "zone_id": str(ZONE_ID),
    "partner_op_id": str(PARTNER_ID),
    "supported_apis": ["edge-application-management"],
    "geography_details": "Madrid",
    "geolocation": None,
}


def _client(response: object) -> FederationManager:
    client = FederationManager(
        FederationManagerSettings(base_url="http://fm", timeout=1.0), AsyncMock()
    )
    client._request = AsyncMock(side_effect=response)  # type: ignore[method-assign]
    return client


async def test_get_zone_reads_the_federated_zone_fm_returns() -> None:
    zone = await _client([FM_ZONE]).get_zone(ZONE_ID)

    assert zone is not None
    assert zone.id == ZONE_ID
    assert zone.name == "Madrid"
    assert zone.kind == ZoneKind.FEDERATION_BUSINESS
    assert zone.state == ZoneState.ACTIVE
    assert zone.metadata["partner_op_id"] == str(PARTNER_ID)


async def test_has_zone_is_false_when_no_partner_offers_it() -> None:
    assert await _client(NotFoundException()).has_zone(ZONE_ID) is False


async def test_list_zones_reads_every_federated_zone() -> None:
    zones = await _client([[FM_ZONE]]).list_zones()

    assert [zone.id for zone in zones] == [ZONE_ID]


@pytest.mark.parametrize("payload", [{"zone_id": str(ZONE_ID)}, {"id": str(ZONE_ID)}])
async def test_get_zone_rejects_a_malformed_answer(payload: dict[str, str]) -> None:
    with pytest.raises(ValueError):
        await _client([payload]).get_zone(ZONE_ID)
+12 −0
Original line number Diff line number Diff line
@@ -29,3 +29,15 @@ async def test_execute_forwards_filters_to_the_repo_and_returns_its_result() ->
        *zones.list_resource_zones.return_value,
        *fm_client.list_zones.return_value,
    ]


async def test_execute_returns_local_zones_when_no_partner_offers_any() -> None:
    zones = AsyncMock()
    zones.list_resource_zones.return_value = [_zone(ref="zone-local", name="Local Zone")]
    fm_client = AsyncMock()
    fm_client.list_zones.return_value = []
    use_case = ListZonesUseCase(zones, fm_client)

    result = await use_case.execute(ListZonesCommand())

    assert result == zones.list_resource_zones.return_value