Loading src/federation_manager/adapters/http/srm_client.py +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: Loading @@ -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, ) src/federation_manager/application/federation.py +38 −3 Changes for src/federation_manager/application/federation.py: 38 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -10,6 +10,7 @@ from federation_manager.contracts.ewbi import ( FederationRequestData, FederationResponseData, MobileNetworkIds, ZoneDetails, ZoneRegistrationRequestData, ) from federation_manager.core.logging import get_logger Loading @@ -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 ( Loading @@ -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 Loading Loading @@ -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 = [] Loading Loading @@ -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, Loading @@ -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 Loading @@ -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) Loading @@ -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: Loading @@ -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) src/federation_manager/dependencies.py +9 −6 Changes for src/federation_manager/dependencies.py: 9 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -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( Loading Loading @@ -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)], Loading src/federation_manager/domain/models.py +7 −0 Changes for src/federation_manager/domain/models.py: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading src/federation_manager/domain/ports.py +3 −0 Changes for src/federation_manager/domain/ports.py: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ from federation_manager.domain.models import ( FederatedZone, FederationContext, FederationTransaction, LocalZone, PartnerOP, RoutingRule, ValidatedClaims, Loading Loading @@ -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 Loading
src/federation_manager/adapters/http/srm_client.py +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: Loading @@ -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, )
src/federation_manager/application/federation.py +38 −3 Changes for src/federation_manager/application/federation.py: 38 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -10,6 +10,7 @@ from federation_manager.contracts.ewbi import ( FederationRequestData, FederationResponseData, MobileNetworkIds, ZoneDetails, ZoneRegistrationRequestData, ) from federation_manager.core.logging import get_logger Loading @@ -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 ( Loading @@ -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 Loading Loading @@ -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 = [] Loading Loading @@ -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, Loading @@ -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 Loading @@ -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) Loading @@ -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: Loading @@ -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)
src/federation_manager/dependencies.py +9 −6 Changes for src/federation_manager/dependencies.py: 9 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -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( Loading Loading @@ -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)], Loading
src/federation_manager/domain/models.py +7 −0 Changes for src/federation_manager/domain/models.py: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
src/federation_manager/domain/ports.py +3 −0 Changes for src/federation_manager/domain/ports.py: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ from federation_manager.domain.models import ( FederatedZone, FederationContext, FederationTransaction, LocalZone, PartnerOP, RoutingRule, ValidatedClaims, Loading Loading @@ -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