Commit c72628ff authored by George Papathanail's avatar George Papathanail
Browse files

feat: pin federation-lifecycle relay to GSMA OPG.04 v1.4.0 and partnerOid to paths

parent 84fe0368
Loading
Loading
Loading
Loading
Loading
+11 −10
Original line number Diff line number Diff line
@@ -5,11 +5,9 @@ from open_exposure_gateway.core.config import get_settings

logger = structlog.get_logger(__name__)

# fm/requirements-and-design.md §L.3b says FM exposes "matching internal endpoints"
# under /internal/federation/ without naming them per operation. Mirroring the
# operator-facing suffix 1:1 is the reading agreed on until FM's side is built and
# confirms the actual paths.
_INTERNAL_PREFIX = "/internal/federation"
# OEG's platform-namespace federation routes (/platform/v1/federation/...) mirror
# FM's own /internal/... routes 1:1, so this is a plain prefix swap -- nothing else.
_INTERNAL_PREFIX = "/internal"


class FmUnavailableError(Exception):
@@ -29,14 +27,16 @@ class FmClient:
        content: bytes | None,
        content_type: str | None,
        x_correlator: str | None,
        params: tuple[tuple[str, str], ...] | None = None,
    ) -> httpx.Response:
        """Forward one federation-lifecycle operation to FM, unaltered.

        `path` is the operator-facing suffix after
        ``/operatorplatform/federation/v1`` (e.g. ``/partner``,
        ``/{federationContextId}/zones/{zoneId}``). The request body is forwarded
        as the raw bytes the caller sent -- no parsing, no reshaping -- and the
        caller relays the returned response body and status the same way.
        `path` is the operator-facing suffix after ``/platform/v1/federation``
        (e.g. ``/partners/{partnerOpId}/federations``,
        ``/partners/{partnerOpId}/federations/{federationContextId}/zones/{zoneId}``).
        The request body and query string are forwarded as the caller sent them --
        no parsing, no reshaping -- and the caller relays the returned response
        body and status the same way.
        """
        url = f"{self.base_url}{_INTERNAL_PREFIX}{path}"
        headers = {"X-Correlator": x_correlator} if x_correlator else None
@@ -52,6 +52,7 @@ class FmClient:
                    url=url,
                    content=content,
                    headers=headers,
                    params=params,
                )
        except httpx.TimeoutException as exc:
            log.exception("FM request timed out")
+7843 −0

File added.

Preview size limit exceeded, changes collapsed.

+0 −4609

File deleted.

Preview size limit exceeded, changes collapsed.

+121 −46
Original line number Diff line number Diff line
"""FastAPI routes for the GSMA Federation Manager API (EWBI OPG v1.2.0).

Endpoints defined by ``API_definitions/federation-manager.yaml``. Every handler is a
stateless synchronous relay to FM (ADR-0046, ADR-0048): the raw request body is
forwarded to FM's matching internal endpoint unaltered, and FM's response body and
status are relayed back unaltered. OEG does not reshape the request on this path --
the two POST bodies (``FederationRequestData``, ``ZoneRegistrationRequestData``) are
validated for shape before being forwarded, but the *validated* object is only used
as a gate; the original raw bytes are what's actually sent to FM, so a field OEG's
model doesn't happen to cover is still forwarded unchanged rather than dropped. The
remaining schemas in ``schemas.py`` are referenced here only to document the
response contract in the OpenAPI document (via ``response_model``); FM's response is
never parsed against them.
"""FastAPI routes for the platform-namespace federation-lifecycle relay (ADR-0046, ADR-0048).

Bodies are raw GSMA (OPG.04 v6.0 / artifact v1.4.0), forwarded unaltered, but paths
are OEG's own platform namespace rather than the GSMA EWBI paths: GSMA addresses the
callee operator through the URL (operator A calls B's FM directly), which only works
because there is no intermediate hop. Here the call chain is Portal -> OEG -> FM-A,
with FM-A only then making the real EWBI call to FM-B, so the partner has to be named
explicitly -- as ``partnerOpId``, our own registered id for the partner -- on every
route. FM uses it to look up B's token URL, client id and secret from B's
registration, and to reject a ``federationContextId`` that belongs to a different
partner. Every handler is otherwise a stateless synchronous relay to FM: the raw
request body (and query string) is forwarded to FM's matching ``/internal`` endpoint
unaltered, and FM's response body and status are relayed back unaltered. The two POST
bodies (``FederationRequestData``, ``ZoneRegistrationRequestData``) are validated for
shape before being forwarded, but the *validated* object is only used as a gate; the
original raw bytes are what's actually sent to FM, so a field OEG's model doesn't
happen to cover is still forwarded unchanged rather than dropped. The remaining
schemas in ``schemas.py`` are referenced here only to document the response contract
in the OpenAPI document (via ``response_model``); FM's response is never parsed
against them.

This module exposes the ``FederationManagement`` tag plus ``zone_subscribe``,
``get_zone_data`` and ``zone_unsubscribe`` from
``get_zone_data``, ``get_zone_details`` and ``zone_unsubscribe`` from
``AvailabilityZoneInfoSynchronization``. ``update_federation``
(``PATCH /{federationContextId}/partner``) is out of scope.
(``UpdateFederation``) is out of scope.
"""

import json
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Request, Response
from fastapi import APIRouter, Depends, Query, Request, Response
from pydantic import BaseModel, ValidationError

from open_exposure_gateway.adapters.http.fm_client import FmClient, FmUnavailableError
from open_exposure_gateway.api.camara.common import XCorrelatorHeader
from open_exposure_gateway.api.gsma.federation_manager.v1_2_0.schemas import (
from open_exposure_gateway.api.gsma.federation_manager.v1_4_0.schemas import (
    FederationContextId,
    FederationContextIdResponse,
    FederationDetails,
    FederationRequestData,
    FederationResponseData,
    PartnerOpId,
    ProblemDetails,
    ZoneIdentifier,
    ZoneRegisteredData,
@@ -40,16 +48,19 @@ from open_exposure_gateway.api.gsma.federation_manager.v1_2_0.schemas import (
)
from open_exposure_gateway.dependencies import get_fm_client

# GSMA OPG serves the Federation Management API at {apiRoot}/operatorplatform/federation/v1.
BASE_PATH = "/operatorplatform/federation/v1"
# OEG's own platform namespace, mirrored 1:1 at FM's `/internal` (FmClient does a
# prefix swap, nothing else -- see fm_client.py).
BASE_PATH = "/platform/v1/federation"

PARTNER_PREFIX = "/partners/{partnerOpId}/federations"

# Tags are set per route (not on the router) so each endpoint carries only its own
# federation-manager.yaml tag.
# GSMA operation's tag.
router = APIRouter(prefix=BASE_PATH)

FmClientDep = Annotated[FmClient, Depends(get_fm_client)]

# Every Federation Manager error response is an RFC 7807 ProblemDetails (federation-manager.yaml).
# Every Federation Manager error response is an RFC 7807 ProblemDetails.
_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
    400: {"model": ProblemDetails, "description": "Bad request"},
    401: {"model": ProblemDetails, "description": "Unauthorized"},
@@ -94,8 +105,10 @@ async def _relay(
) -> Response:
    """Forward one federation-lifecycle operation to FM and relay its answer unaltered.

    ``path`` is the operator-facing suffix after ``BASE_PATH`` (e.g. ``/partner``),
    which ``FmClient`` maps onto FM's internal endpoint. A body that isn't even
    ``path`` is the operator-facing suffix after ``BASE_PATH`` (e.g.
    ``/partners/{partnerOpId}/federations``), which ``FmClient`` maps onto FM's
    matching internal endpoint. The request's query string is forwarded as-is
    (e.g. ``GetZoneData``'s ``zoneId`` filter). A body that isn't even
    syntactically JSON can't reach FM meaningfully, so that's rejected here
    (ADR-0047: OEG-authored 400); the mirror case on the way back -- FM answering
    with a body it labelled JSON that isn't actually parseable -- is ADR-0047's 502.
@@ -130,6 +143,7 @@ async def _relay(
            content=body or None,
            content_type=request.headers.get("content-type"),
            x_correlator=x_correlator,
            params=tuple(request.query_params.multi_items()),
        )
    except FmUnavailableError as exc:
        return _problem_response(503, "Federation Manager unavailable", str(exc))
@@ -149,7 +163,7 @@ async def _relay(


@router.post(
    "/partner",
    PARTNER_PREFIX,
    tags=["Federation Manager"],
    summary="Create a one-direction federation with a partner operator platform",
    operation_id="create_federation",
@@ -157,15 +171,42 @@ async def _relay(
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def create_federation(
    request: Request, fm_client: FmClientDep, x_correlator: XCorrelatorHeader = None
    partnerOpId: PartnerOpId,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client, "POST", "/partner", request, x_correlator, request_schema=FederationRequestData
        fm_client,
        "POST",
        f"/partners/{partnerOpId}/federations",
        request,
        x_correlator,
        request_schema=FederationRequestData,
    )


@router.get(
    "/{federationContextId}/partner",
    PARTNER_PREFIX,
    tags=["Federation Manager"],
    summary="Retrieve the existing federationContextId with the partner operator platform",
    operation_id="get_federation_context_id",
    response_model=FederationContextIdResponse,
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def get_federation_context_id(
    partnerOpId: PartnerOpId,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client, "GET", f"/partners/{partnerOpId}/federations", request, x_correlator
    )


@router.get(
    f"{PARTNER_PREFIX}/{{federationContextId}}",
    tags=["Federation Manager"],
    summary="Retrieve details about the federation context with the partner OP",
    operation_id="get_federation_details",
@@ -173,16 +214,23 @@ async def create_federation(
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def get_federation_details(
    partnerOpId: PartnerOpId,
    federationContextId: FederationContextId,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(fm_client, "GET", f"/{federationContextId}/partner", request, x_correlator)
    return await _relay(
        fm_client,
        "GET",
        f"/partners/{partnerOpId}/federations/{federationContextId}",
        request,
        x_correlator,
    )


@router.delete(
    "/{federationContextId}/partner",
    f"{PARTNER_PREFIX}/{{federationContextId}}",
    tags=["Federation Manager"],
    summary="Remove an existing federation with the partner OP",
    operation_id="delete_federation_details",
@@ -190,32 +238,48 @@ async def get_federation_details(
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def delete_federation_details(
    partnerOpId: PartnerOpId,
    federationContextId: FederationContextId,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client, "DELETE", f"/{federationContextId}/partner", request, x_correlator
        fm_client,
        "DELETE",
        f"/partners/{partnerOpId}/federations/{federationContextId}",
        request,
        x_correlator,
    )


@router.get(
    "/fed-context-id",
    tags=["Federation Manager"],
    summary="Retrieve the existing federationContextId with the partner operator platform",
    operation_id="get_federation_context_id",
    response_model=FederationContextIdResponse,
    f"{PARTNER_PREFIX}/{{federationContextId}}/zones",
    tags=["Availability Zone Info Synchronization"],
    summary="List the zones the partner OP offers, optionally filtered to one zone",
    operation_id="get_zone_data",
    response_model=ZoneRegisteredData,
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def get_federation_context_id(
    request: Request, fm_client: FmClientDep, x_correlator: XCorrelatorHeader = None
async def get_zone_data(
    partnerOpId: PartnerOpId,
    federationContextId: FederationContextId,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
    zoneId: Annotated[ZoneIdentifier | None, Query()] = None,
) -> Response:
    return await _relay(fm_client, "GET", "/fed-context-id", request, x_correlator)
    return await _relay(
        fm_client,
        "GET",
        f"/partners/{partnerOpId}/federations/{federationContextId}/zones",
        request,
        x_correlator,
    )


@router.post(
    "/{federationContextId}/zones",
    f"{PARTNER_PREFIX}/{{federationContextId}}/zones",
    tags=["Availability Zone Info Synchronization"],
    summary="Subscribe to partner OP availability zones and reserve zone resources",
    operation_id="zone_subscribe",
@@ -223,6 +287,7 @@ async def get_federation_context_id(
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def zone_subscribe(
    partnerOpId: PartnerOpId,
    federationContextId: FederationContextId,
    request: Request,
    fm_client: FmClientDep,
@@ -231,7 +296,7 @@ async def zone_subscribe(
    return await _relay(
        fm_client,
        "POST",
        f"/{federationContextId}/zones",
        f"/partners/{partnerOpId}/federations/{federationContextId}/zones",
        request,
        x_correlator,
        request_schema=ZoneRegistrationRequestData,
@@ -239,17 +304,18 @@ async def zone_subscribe(


@router.get(
    "/{federationContextId}/zones/{zoneId}",
    f"{PARTNER_PREFIX}/{{federationContextId}}/zones/{{zoneId}}",
    tags=["Availability Zone Info Synchronization"],
    summary=(
        "Retrieves details about the computation and network resources that partner "
        "OP has reserved for this zone"
    ),
    operation_id="get_zone_data",
    operation_id="get_zone_details",
    response_model=ZoneRegisteredData,
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def get_zone_data(
async def get_zone_details(
    partnerOpId: PartnerOpId,
    federationContextId: FederationContextId,
    zoneId: ZoneIdentifier,
    request: Request,
@@ -257,12 +323,16 @@ async def get_zone_data(
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client, "GET", f"/{federationContextId}/zones/{zoneId}", request, x_correlator
        fm_client,
        "GET",
        f"/partners/{partnerOpId}/federations/{federationContextId}/zones/{zoneId}",
        request,
        x_correlator,
    )


@router.delete(
    "/{federationContextId}/zones/{zoneId}",
    f"{PARTNER_PREFIX}/{{federationContextId}}/zones/{{zoneId}}",
    tags=["Availability Zone Info Synchronization"],
    summary=(
        "Assert usage of a partner OP zone. Originating OP informs partner OP that "
@@ -273,6 +343,7 @@ async def get_zone_data(
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def zone_unsubscribe(
    partnerOpId: PartnerOpId,
    federationContextId: FederationContextId,
    zoneId: ZoneIdentifier,
    request: Request,
@@ -280,5 +351,9 @@ async def zone_unsubscribe(
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client, "DELETE", f"/{federationContextId}/zones/{zoneId}", request, x_correlator
        fm_client,
        "DELETE",
        f"/partners/{partnerOpId}/federations/{federationContextId}/zones/{zoneId}",
        request,
        x_correlator,
    )
+35 −36
Original line number Diff line number Diff line
"""Pydantic models for the GSMA Federation Manager API (EWBI OPG v1.2.0).

Request/response schemas derived from
``API_definitions/federation-manager.yaml``. Only the ``FederationManagement``
tag is modelled here; the other tags (zones, artefacts, application onboarding
and LCM) are added as they are implemented.

Schema names follow the OpenAPI ``operationId`` intent rather than the generated
``inline_response_*`` / ``*_body`` names: ``FederationDetails`` is the spec's
``inline_response_200_1`` and ``FederationContextIdResponse`` is
``inline_response_200_2``.
"""Pydantic models for the GSMA Federation Manager API (OPG.04 v6.0 / artifact v1.4.0).

Request/response schemas derived from FM's profiled copy of the GSMA artifact,
``API_definitions/OPG.04-v6.0-EWBI-Federation-API-v1.4.0-oop-profile.yaml``.
Only the ``FederationManagement`` and ``AvailabilityZoneInfoSynchronization``
tags are modelled here; the other tags (zones onboarding, artefacts,
application onboarding and LCM) are added as they are implemented.

Schema names follow the OpenAPI ``operationId`` intent rather than the spec's
inline response names: ``FederationDetails`` is the ``GetFederationDetails``/
``UpdateFederation`` response body and ``FederationContextIdResponse`` is
``GetFederationContextId``'s.
"""

from __future__ import annotations
@@ -34,6 +35,11 @@ FederationIdentifier = Annotated[str, StringConstraints(pattern=_ID_PATTERN)]

ZoneIdentifier = Annotated[str, StringConstraints(pattern=_ID_PATTERN)]

PartnerOpId = Annotated[str, StringConstraints(pattern=_ID_PATTERN)]
"""Our own registered id for the partner operator platform (not part of GSMA -- this
is the platform-namespace path param that says which partner an EWBI call is for,
since the GSMA body itself never names the callee)."""

CountryCode = Annotated[str, StringConstraints(pattern=r"^[A-Z]{2}$")]
"""ISO 3166-1 Alpha-2 country code of the partner operator."""

@@ -95,16 +101,6 @@ class MobileNetworkIds(BaseModel):
    mncs: Optional[Annotated[list[Mnc], Field(min_length=1)]] = None


class CallbackCredentials(BaseModel):
    """OAuth2 client-credentials used by the partner OP to authenticate callbacks."""

    model_config = ConfigDict(extra="forbid")

    tokenUrl: Uri
    clientId: str
    clientSecret: str


class ServiceEndpoint(BaseModel):
    """Reachability information for the edge-discovery or LCM service of an OP.

@@ -147,38 +143,40 @@ class ProblemDetails(BaseModel):


class FederationRequestData(BaseModel):
    """Body of ``POST /partner`` -- the Originating OP's federation create request."""
    """Body of ``CreateFederation`` -- the Originating OP's federation create request.

    v1.4.0 requires only ``initialDate`` and ``partnerStatusLink``;
    ``partnerCallbackCredentials`` no longer exists as a field.
    """

    model_config = ConfigDict(extra="forbid")

    origOPFederationId: FederationIdentifier
    initialDate: datetime
    partnerStatusLink: Uri
    origOPFederationId: Optional[FederationIdentifier] = None
    origOPCountryCode: Optional[CountryCode] = None
    origOPMobileNetworkCodes: Optional[MobileNetworkIds] = None
    origOPFixedNetworkCodes: Optional[FixedNetworkIds] = None
    partnerCallbackCredentials: Optional[CallbackCredentials] = None


class FederationResponseData(BaseModel):
    """``200`` body of ``POST /partner`` -- the partner OP's federation context."""
    """``200`` body of ``CreateFederation`` -- the partner OP's federation context."""

    partnerOPFederationId: FederationIdentifier
    federationContextId: FederationContextId
    platformCaps: list[PlatformCapability]
    partnerOPFederationId: Optional[FederationIdentifier] = None
    partnerOPCountryCode: Optional[CountryCode] = None
    edgeDiscoveryServiceEndPoint: Optional[ServiceEndpoint] = None
    lcmServiceEndPoint: Optional[ServiceEndpoint] = None
    partnerOPMobileNetworkCodes: Optional[MobileNetworkIds] = None
    partnerOPFixedNetworkCodes: Optional[FixedNetworkIds] = None
    offeredAvailabilityZones: Optional[Annotated[list[ZoneDetails], Field(min_length=1)]] = None
    federationExpiryDate: Optional[datetime] = None
    federationRenewalDate: Optional[datetime] = None


class FederationDetails(BaseModel):
    """``200`` body of ``GET`` / ``PATCH`` ``/{federationContextId}/partner``.

    The spec's ``inline_response_200_1``.
    """
    """``200`` body of ``GetFederationDetails`` / ``UpdateFederation``."""

    edgeDiscoveryServiceEndPoint: ServiceEndpoint
    lcmServiceEndPoint: ServiceEndpoint
@@ -188,7 +186,7 @@ class FederationDetails(BaseModel):


class FederationContextIdResponse(BaseModel):
    """``200`` body of ``GET /fed-context-id``. The spec's ``inline_response_200_2``.
    """``200`` body of ``GetFederationContextId``.

    The wire field name is ``FederationContextId`` (capitalised) per the spec.
    """
@@ -198,9 +196,10 @@ class FederationContextIdResponse(BaseModel):

# --- AvailabilityZoneInfoSynchronization --------------------------------------
#
# Schemas for ``POST /{federationContextId}/zones`` (``zone_subscribe``): the
# Originating OP subscribes to a set of the partner OP's availability zones and
# the partner OP reserves compute/network resources for them.
# Schemas for ``ZoneSubscribe``: the Originating OP subscribes to a set of the
# partner OP's availability zones and the partner OP reserves compute/network
# resources for them. ``ZoneRegisteredData`` is also the response of
# ``GetZoneData`` (list) and ``GetZoneDetails`` (single zone).

FlavourId = str
Vcpu = Annotated[str, StringConstraints(pattern=r"^\d+((\.\d{1,3})|(m))?$")]
@@ -350,7 +349,7 @@ class ZoneRegisteredData(BaseModel):


class ZoneRegistrationRequestData(BaseModel):
    """Body of ``POST /{federationContextId}/zones``."""
    """Body of ``ZoneSubscribe``."""

    model_config = ConfigDict(extra="forbid")

@@ -359,6 +358,6 @@ class ZoneRegistrationRequestData(BaseModel):


class ZoneRegistrationResponseData(BaseModel):
    """``200`` body of ``POST /{federationContextId}/zones``."""
    """``200`` body of ``ZoneSubscribe``."""

    acceptedZoneResourceInfo: Annotated[list[ZoneRegisteredData], Field(min_length=1)]
Loading