Commit 6740ce23 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

feat(fm): store the zones a partner offers (#23)

Nothing recorded partner zones, so SRM had no way to tell a federated zone
from an unknown one, and an outbound InstallApp had no zone id to send.

Adds shared_resource_catalogues (migration 0004) and fills it from the
offeredAvailabilityZones a partner returns on CreateFederation. Each zone
gets a UUID derived from the partner and the partner's own zone id, so a
re-sync is a plain replace that keeps ids stable, and a restored fm_db
keeps the ids OEG and App Providers hold.

SRM reads them through GET /internal/federated-zones and
/internal/federated-zones/{zone_id}, which answers a GSMA problem+json 404
when no partner offers the zone. The partner's opaque zone id stays inside
FM.

INBOUND and OUTBOUND now live once in the domain instead of being
redefined in five application modules.
parent e99fbb5a
Loading
Loading
Loading
Loading
Loading
+85 −0
Original line number Diff line number Diff line
from typing import Any
from uuid import UUID

from sqlalchemy import Select, delete, insert, select
from sqlalchemy.ext.asyncio import AsyncSession

from federation_manager.adapters.database.tables import shared_resource_catalogues as catalogues
from federation_manager.domain.models import INBOUND, FederatedZone
from federation_manager.domain.zones import EDGE_ZONE


class PostgresCatalogueRepo:
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def replace_partner_zones(self, partner_id: UUID, zones: list[FederatedZone]) -> None:
        """Make the stored catalogue match the zone list the partner just sent.

        The partner always offers its whole catalogue, so the rows are replaced rather than
        merged: zones it dropped disappear, and because zone_id is derived the ids of the
        zones it kept do not change.
        """
        await self._session.execute(
            delete(catalogues).where(
                catalogues.c.partner_op_id == partner_id,
                catalogues.c.resource_type == EDGE_ZONE,
            )
        )
        if zones:
            await self._session.execute(
                insert(catalogues),
                [
                    {
                        "partner_op_id": partner_id,
                        "direction": INBOUND,
                        "resource_type": EDGE_ZONE,
                        "resource_data": _data(zone),
                    }
                    for zone in zones
                ],
            )
        await self._session.commit()

    async def find_zone(self, zone_id: UUID) -> FederatedZone | None:
        return await self._one(
            select(catalogues).where(
                catalogues.c.resource_type == EDGE_ZONE,
                catalogues.c.resource_data["zone_id"].astext == str(zone_id),
            )
        )

    async def list_zones(self) -> list[FederatedZone]:
        stmt = (
            select(catalogues)
            .where(catalogues.c.resource_type == EDGE_ZONE)
            .order_by(catalogues.c.created_at)
        )
        rows = (await self._session.execute(stmt)).all()
        return [_to_zone(row) for row in rows]

    async def _one(self, stmt: Select[tuple[object, ...]]) -> FederatedZone | None:
        row = (await self._session.execute(stmt)).one_or_none()
        return None if row is None else _to_zone(row)


def _data(zone: FederatedZone) -> dict[str, Any]:
    return {
        "zone_id": str(zone.zone_id),
        "external_zone_id": zone.external_zone_id,
        "geography_details": zone.geography_details,
        "geolocation": zone.geolocation,
        "supported_apis": list(zone.supported_apis),
    }


def _to_zone(row: Any) -> FederatedZone:
    data = row.resource_data
    return FederatedZone(
        zone_id=UUID(data["zone_id"]),
        partner_op_id=row.partner_op_id,
        external_zone_id=data["external_zone_id"],
        geography_details=data.get("geography_details"),
        geolocation=data.get("geolocation"),
        supported_apis=tuple(data.get("supported_apis") or ()),
    )
+31 −0
Original line number Diff line number Diff line
@@ -153,6 +153,37 @@ routing_rules = Table(
    ),
)

# direction follows the resource, not the federation: a zone a partner offers us is `inbound`.
shared_resource_catalogues = Table(
    "shared_resource_catalogues",
    metadata,
    Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
    Column("partner_op_id", PGUUID(as_uuid=True), ForeignKey("partner_ops.id"), nullable=False),
    Column("direction", String(10), nullable=False),
    Column("resource_type", String(50), nullable=False),
    Column("resource_data", JSONB, nullable=False),
    Column("synced_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
    Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
    Column(
        "updated_at",
        DateTime(timezone=True),
        nullable=False,
        server_default=func.now(),
        onupdate=func.now(),
    ),
    Index("idx_shared_catalogue_partner_direction", "partner_op_id", "direction"),
    Index("idx_shared_catalogue_resource_type", "resource_type"),
    # one row per federated zone: zone_id is derived, so re-syncing is an upsert on this key
    Index(
        "uq_shared_catalogue_zone",
        text("(resource_data ->> 'zone_id')"),
        unique=True,
        postgresql_where=text("resource_type = 'edge_zone'"),
    ),
    CheckConstraint(_one_of("direction", *DIRECTIONS), name="ck_shared_catalogue_direction"),
)


federation_transactions = Table(
    "federation_transactions",
    metadata,
+11 −0
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ from federation_manager.domain.errors import (
    SessionUnknown,
    SrmQueryFailed,
    UnsupportedServiceApi,
    ZoneNotFederated,
    problem_type,
)

@@ -347,3 +348,13 @@ def register_exception_handlers(app: FastAPI) -> None:
            str(exc),
            request.url.path,
        )

    @app.exception_handler(ZoneNotFederated)
    async def _zone_not_federated(request: Request, exc: ZoneNotFederated) -> JSONResponse:
        return problem(
            404,
            "zone-not-federated",
            "Zone Not Federated",
            "No federation partner offers this zone.",
            request.url.path,
        )
+46 −0
Original line number Diff line number Diff line
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends
from pydantic import BaseModel

from federation_manager.dependencies import get_catalogue_repo
from federation_manager.domain.errors import ZoneNotFederated
from federation_manager.domain.models import FederatedZone
from federation_manager.domain.ports import CatalogueRepositoryPort

# SRM reads these to tell a partner's zone from an unknown one, and to list partner zones.
router = APIRouter(prefix="/internal/federated-zones", tags=["internal-federated-zones"])

Catalogue = Annotated[CatalogueRepositoryPort, Depends(get_catalogue_repo)]


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

    @classmethod
    def of(cls, zone: FederatedZone) -> "FederatedZoneView":
        return cls(
            zone_id=zone.zone_id,
            partner_op_id=zone.partner_op_id,
            supported_apis=list(zone.supported_apis),
            geography_details=zone.geography_details,
            geolocation=zone.geolocation,
        )


@router.get("", response_model=list[FederatedZoneView])
async def list_federated_zones(catalogue: Catalogue) -> list[FederatedZoneView]:
    return [FederatedZoneView.of(zone) for zone in await catalogue.list_zones()]


@router.get("/{zone_id}", response_model=FederatedZoneView)
async def get_federated_zone(zone_id: UUID, catalogue: Catalogue) -> FederatedZoneView:
    zone = await catalogue.find_zone(zone_id)
    if zone is None:
        raise ZoneNotFederated(zone_id)
    return FederatedZoneView.of(zone)
+2 −2
Original line number Diff line number Diff line
from federation_manager.application.federation import OUTBOUND, TERMINATED
from federation_manager.application.federation import TERMINATED
from federation_manager.contracts.ewbi import PartnerStatusEvent
from federation_manager.core.logging import get_logger
from federation_manager.domain.errors import FederationContextUnknown
from federation_manager.domain.models import FederationContext, PartnerOP
from federation_manager.domain.models import OUTBOUND, FederationContext, PartnerOP
from federation_manager.domain.ports import FederationContextRepositoryPort

logger = get_logger(__name__)
Loading