Commit 8e77c3ad authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

feat(fm): offer our own zones to a partner that federates with us (#23)

parent 6740ce23
Loading
Loading
Loading
Loading
Loading
+25 −0
Changes for src/federation_manager/adapters/http/srm_client.py: 25 added lines, 0 removed lines.
Original line number Diff line number Diff line
from uuid import UUID

import httpx

from federation_manager.contracts.srm import LocationQueryRequestV1, LocationQueryResponseV1
from federation_manager.domain.errors import NetworkQueryNotApplicable, SrmQueryFailed
from federation_manager.domain.models import LocalZone

LOCATION_PATH = "/internal/network-queries/location"
ZONES_PATH = "/internal/zones"


class HttpxSrmClient:
@@ -23,3 +27,24 @@ class HttpxSrmClient:
            return LocationQueryResponseV1.model_validate(response.json())
        except (httpx.HTTPError, ValueError) as error:
            raise SrmQueryFailed(str(error)[:200]) from None

    async def list_zones(self) -> list[LocalZone]:
        try:
            response = await self._client.get(
                f"{self._base_url}{ZONES_PATH}", params={"state": "active"}
            )
            response.raise_for_status()
            return [_local_zone(zone) for zone in response.json()]
        except (httpx.HTTPError, ValueError, KeyError, TypeError) as error:
            raise SrmQueryFailed(str(error)[:200]) from None


def _local_zone(zone: dict[str, object]) -> LocalZone:
    metadata = zone.get("metadata")
    location = metadata.get("location") if isinstance(metadata, dict) else None
    region = location.get("region") if isinstance(location, dict) else None
    return LocalZone(
        id=UUID(str(zone["id"])),
        name=str(zone["name"]),
        region=str(region) if region is not None else None,
    )
+38 −3
Changes for src/federation_manager/application/federation.py: 38 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@ from federation_manager.contracts.ewbi import (
    FederationRequestData,
    FederationResponseData,
    MobileNetworkIds,
    ZoneDetails,
    ZoneRegistrationRequestData,
)
from federation_manager.core.logging import get_logger
@@ -21,6 +22,7 @@ from federation_manager.domain.errors import (
    FederationIdentityMismatch,
    PartnerNotActive,
    PartnerResponseInvalid,
    SrmQueryFailed,
)
from federation_manager.domain.ewbi import CREATE_FEDERATION_PATH
from federation_manager.domain.models import (
@@ -28,13 +30,16 @@ from federation_manager.domain.models import (
    OUTBOUND,
    FederatedZone,
    FederationContext,
    LocalZone,
    PartnerOP,
)
from federation_manager.domain.ports import (
    AgreementRepositoryPort,
    CatalogueRepositoryPort,
    EwbiClientPort,
    FederationContextRepositoryPort,
    PartnerRepositoryPort,
    SrmQueryPort,
)
from federation_manager.domain.zones import federated_zone_id

@@ -173,7 +178,6 @@ class FederationEstablishmentService:
    async def _store_offered_zones(
        self, partner: PartnerOP, accepted: FederationResponseData
    ) -> None:
        """Record the zones the partner offered, so SRM can place work on them later."""
        if self._catalogue is None:
            return
        zones = []
@@ -218,6 +222,9 @@ class InboundFederationService:
        self,
        contexts: FederationContextRepositoryPort,
        local: LocalOperator,
        agreements: AgreementRepositoryPort,
        srm: SrmQueryPort,
        catalogue: CatalogueRepositoryPort,
        *,
        clock: Callable[[], datetime] = _utcnow,
        id_factory: Callable[[], UUID] = uuid4,
@@ -225,6 +232,9 @@ class InboundFederationService:
    ) -> None:
        self._contexts = contexts
        self._local = local
        self._agreements = agreements
        self._srm = srm
        self._catalogue = catalogue
        self._clock = clock
        self._new_id = id_factory
        self._new_context_id = context_id_factory
@@ -245,17 +255,20 @@ class InboundFederationService:
            status_callback_url=request.partner_status_link,
        )
        await self._contexts.add(context)
        offered = await self._offered_zones(partner)
        return context, FederationResponseData(
            federation_context_id=context.federation_context_id,
            platform_caps=list(self._local.platform_caps),
            partner_op_federation_id=self._local.federation_id,
            offered_availability_zones=[zone.model_dump(by_alias=True) for zone in offered] or None,
        )

    async def details(self, partner: PartnerOP, federation_context_id: str) -> FederationDetails:
        await self._live_inbound(partner, federation_context_id)
        # OPG.04 asks for offered zones and our edge-discovery/LCM endpoints too; FM has no
        # zone catalogue or endpoint configuration yet, so those stay absent rather than empty.
        # OPG.04 also asks for our edge-discovery and LCM endpoints; FM has no endpoint
        # configuration yet, so those stay absent rather than empty.
        return FederationDetails(
            offered_availability_zones=await self._offered_zones(partner) or None,
            platform_caps=list(self._local.platform_caps),
            allowed_mobile_network_ids=MobileNetworkIds(
                mcc=self._local.mcc, mncs=list(self._local.mncs)
@@ -266,6 +279,24 @@ class InboundFederationService:
        context = await self._live_inbound(partner, federation_context_id)
        await self._contexts.set_status(context.id, TERMINATED)

    async def _offered_zones(self, partner: PartnerOP) -> list[ZoneDetails]:
        agreement = await self._agreements.find_active_for_partner(partner.id)
        if agreement is None:
            return []
        try:
            zones = await self._srm.list_zones()
        except SrmQueryFailed as error:
            # the partner can still federate; it sees our zones on the next GetFederationDetails
            logger.warning(
                "offered_zones_unavailable", partner_op_id=str(partner.id), error=error.detail
            )
            return []
        return [
            _zone_details(zone)
            for zone in zones
            if agreement.permits_zone(zone.id) and await self._catalogue.find_zone(zone.id) is None
        ]

    async def _live_inbound(
        self, partner: PartnerOP, federation_context_id: str
    ) -> FederationContext:
@@ -273,3 +304,7 @@ class InboundFederationService:
        if context is None or context.is_terminated():
            raise FederationContextUnknown(partner.id)
        return context


def _zone_details(zone: LocalZone) -> ZoneDetails:
    return ZoneDetails(zone_id=str(zone.id), geography_details=zone.region or zone.name)
+9 −6
Changes for src/federation_manager/dependencies.py: 9 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -138,11 +138,19 @@ def get_local_operator() -> LocalOperator:
    )


def get_srm_client(request: Request) -> SrmQueryPort:
    client: SrmQueryPort = request.app.state.srm_client
    return client


def get_inbound_federation_service(
    context_repo: Annotated[FederationContextRepositoryPort, Depends(get_federation_context_repo)],
    local: Annotated[LocalOperator, Depends(get_local_operator)],
    agreement_repo: Annotated[AgreementRepositoryPort, Depends(get_agreement_repo)],
    srm_client: Annotated[SrmQueryPort, Depends(get_srm_client)],
    catalogue: Annotated[CatalogueRepositoryPort, Depends(get_catalogue_repo)],
) -> InboundFederationService:
    return InboundFederationService(context_repo, local)
    return InboundFederationService(context_repo, local, agreement_repo, srm_client, catalogue)


def get_partner_federation_service(
@@ -196,11 +204,6 @@ def get_inbound_deployment_service(
    )


def get_srm_client(request: Request) -> SrmQueryPort:
    client: SrmQueryPort = request.app.state.srm_client
    return client


def get_inbound_query_service(
    authenticator: Annotated[PartnerAuthenticator, Depends(get_partner_authenticator)],
    context_repo: Annotated[FederationContextRepositoryPort, Depends(get_federation_context_repo)],
+7 −0
Changes for src/federation_manager/domain/models.py: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -6,6 +6,13 @@ INBOUND = "inbound"
OUTBOUND = "outbound"


@dataclass(frozen=True)
class LocalZone:
    id: UUID
    name: str
    region: str | None = None


@dataclass
class PartnerOP:
    id: UUID
+3 −0
Changes for src/federation_manager/domain/ports.py: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -9,6 +9,7 @@ from federation_manager.domain.models import (
    FederatedZone,
    FederationContext,
    FederationTransaction,
    LocalZone,
    PartnerOP,
    RoutingRule,
    ValidatedClaims,
@@ -62,6 +63,8 @@ class EwbiClientPort(Protocol):
class SrmQueryPort(Protocol):
    async def query_location(self, request: LocationQueryRequestV1) -> LocationQueryResponseV1: ...

    async def list_zones(self) -> list[LocalZone]: ...


class CallbackClientPort(Protocol):
    async def deliver(self, partner: PartnerOP, url: str, payload: dict[str, object]) -> bool: ...
Loading