Refactor/fm

Summary

Implements OEG's side of the 7 operator-triggered outbound federation-lifecycle operations (ADR-0046 / ADR-0047 / ADR-0048) as a stateless, synchronous HTTP proxy to FM:

POST /partner, GET/DELETE /{federationContextId}/partner, GET /fed-context-id, POST /{federationContextId}/zones, GET/DELETE /{federationContextId}/zones/{zoneId}.

OEG forwards the raw request body to FM byte-for-byte and relays FM's response (status, body, content-type) back unaltered. The two POST bodies (POST /partner, POST /{federationContextId}/zones) are validated for shape against the GSMA schema before being forwarded — a request missing required fields is rejected with 400 and FM is never called — but the validated object is only a gate: what's actually sent to FM is always the caller's original bytes, not a rebuilt payload, so a field outside OEG's model still reaches FM unchanged. FM's response is never parsed against a schema, only checked for basic JSON well-formedness (→ 502 if unusable), and FM being unreachable → 503.

Needs your confirmation: FM-side internal endpoint paths

fm/requirements-and-design.md §L.3b says FM exposes "matching internal endpoints... under /internal/federation/" but doesn't name the per-operation paths.

In the absence of that table, I implemented OEG's client (fm_client.py) to mirror the operator-facing path 1:1 under that prefix. This is currently an assumption, not a confirmed contract — please confirm or correct it before this merges:

  • POST /partner -> POST /internal/federation/partner
  • GET /{federationContextId}/partner -> GET /internal/federation/{federationContextId}/partner
  • DELETE /{federationContextId}/partner -> DELETE /internal/federation/{federationContextId}/partner
  • GET /fed-context-id -> GET /internal/federation/fed-context-id
  • POST /{federationContextId}/zones -> POST /internal/federation/{federationContextId}/zones
  • GET /{federationContextId}/zones/{zoneId} -> GET /internal/federation/{federationContextId}/zones/{zoneId}
  • DELETE /{federationContextId}/zones/{zoneId} -> DELETE /internal/federation/{federationContextId}/zones/{zoneId}

Please also confirm this /internal/federation/* prefix is a separate surface from FM's public /operatorplatform/federation/v1/* EWBI path. Per ADR-0046 the two are meant to be distinct — different auth (platform service credential vs. partner fed-mgmt token) and /internal/* is never exposed through Nginx/Ingress. If FM's actual implementation instead reuses the public EWBI path for this, fm_client.py's base path needs to change to match.

Other things FM's side needs to support, per the ADRs

  • Auth: OEG will eventually present a platform service credential (OAuth2 client-credentials, ADR-0042) on this call. Not implemented yet on OEG's side — this MR ships with nothing attached on the outbound call, so FM shouldn't hard-gate on it until that lands too.
  • Byte-for-byte on the body FM actually receives: OEG only checks shape on the two POST bodies as a pre-flight gate; the bytes FM receives are always the operator's original payload, never something OEG rebuilt.
  • Errors: FM's error responses should be application/problem+json ProblemDetails (ADR-0047) — OEG relays whatever FM sends unaltered.
  • Secrets: FederationRequestData.partnerCallbackCredentials.clientSecret passes through this call. OEG doesn't log or persist request/response bodies on this path — please confirm FM doesn't either, beyond what §S.4 requires.

Out of scope for this MR

  • update_federation (PATCH /{federationContextId}/partner) — intentionally excluded per current requirements.
  • Operator-role auth gate and the OEG→FM platform service credential itself.
  • Reconciling federation-manager.yaml (currently v1.2.0, ~20 ops) against the "OPG.04 v6.0 / OpenAPI v1.4.0 / 74 operations" language in FM's own docs (ADR-0048 open item).

Test plan

Automated (in this MR)

  • tests/unit/test_fm_client.py — FmClient.relay transport behavior (method/URL/body/headers forwarded correctly; FM error responses passed through untouched; timeout/connection failure raise FmUnavailableError)
  • tests/unit/test_federation_manager_endpoints.py — router-level relay contract, via a fake FmClient (no real network):
    • All 7 endpoints forward the correct method/path/body/X-Correlator to FM and relay a successful FM response byte-for-byte
    • FM error response (e.g. 409) relayed unaltered, status/body/content-type preserved
    • FM unreachable → 503 ProblemDetails
    • Malformed (non-JSON) request body → 400, FM never called
    • Well-formed JSON but wrong shape (POST /partner, POST /{federationContextId}/zones) → 400, FM never called
    • Valid-shape request body is still forwarded as the original raw bytes (validation is a gate, not a reshape)
    • Unparseable FM response body → 502
    • OpenAPI exposes all 7 operationIds
  • ruff check clean
  • mypy clean (71 source files)
  • Full unit suite passes (580 tests)

Manual / pending FM's side

  • End-to-end call against a real FM instance once /internal/federation/* exists, to confirm the path mapping listed above
  • Confirm FM returns application/problem+json ProblemDetails for its own errors, per ADR-0047
  • Confirm FM doesn't log/persist partnerCallbackCredentials.clientSecret from the request/response body

Merge request reports

Loading
+65 −0
Changes for src/open_exposure_gateway/adapters/http/fm_client.py: 65 added lines, 0 removed lines.
Original line number Diff line number Diff line
import httpx
import structlog

from open_exposure_gateway.core.config import get_settings

logger = structlog.get_logger(__name__)

# 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):
    """FM did not answer the internal call (connection failure or timeout)."""


class FmClient:
    def __init__(self) -> None:
        settings = get_settings()
        self.base_url = str(settings.fm_settings.base_url).rstrip("/")
        self.timeout = settings.fm_settings.timeout

    async def relay(
        self,
        method: str,
        path: str,
        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 ``/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
        if content_type:
            headers = {**(headers or {}), "Content-Type": content_type}

        log = logger.bind(method=method, url=url, x_correlator=x_correlator)

        try:
            async with httpx.AsyncClient(timeout=self.timeout) as client:
                return await client.request(
                    method=method,
                    url=url,
                    content=content,
                    headers=headers,
                    params=params,
                )
        except httpx.TimeoutException as exc:
            log.exception("FM request timed out")
            raise FmUnavailableError("FM request timed out") from exc
        except httpx.ConnectError as exc:
            log.exception("FM connection failed")
            raise FmUnavailableError("Could not connect to FM") from exc
        except httpx.RequestError as exc:
            log.exception("FM request error")
            raise FmUnavailableError("FM request failed") from exc
+7843 −0

File added.

Preview size limit exceeded, changes collapsed.

+359 −0
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/router.py: 359 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""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``, ``get_zone_details`` and ``zone_unsubscribe`` from
``AvailabilityZoneInfoSynchronization``. ``update_federation``
(``UpdateFederation``) is out of scope.
"""

import json
from typing import Annotated, Any

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_4_0.schemas import (
    FederationContextId,
    FederationContextIdResponse,
    FederationDetails,
    FederationRequestData,
    FederationResponseData,
    PartnerOpId,
    ProblemDetails,
    ZoneIdentifier,
    ZoneRegisteredData,
    ZoneRegistrationRequestData,
    ZoneRegistrationResponseData,
)
from open_exposure_gateway.dependencies import get_fm_client

# 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
# 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.
_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
    400: {"model": ProblemDetails, "description": "Bad request"},
    401: {"model": ProblemDetails, "description": "Unauthorized"},
    404: {"model": ProblemDetails, "description": "Not found"},
    409: {"model": ProblemDetails, "description": "Conflict"},
    422: {"model": ProblemDetails, "description": "Unprocessable entity"},
    500: {"model": ProblemDetails, "description": "Internal server error"},
    502: {"model": ProblemDetails, "description": "Bad gateway"},
    503: {"model": ProblemDetails, "description": "Service unavailable"},
    520: {"model": ProblemDetails, "description": "Unknown error"},
}


def _responses(*codes: int) -> dict[int | str, dict[str, Any]]:
    return {code: _ERROR_RESPONSES[code] for code in codes}


def _problem_response(status_code: int, title: str, detail: str) -> Response:
    problem = ProblemDetails(title=title, detail=detail)
    return Response(
        content=problem.model_dump_json(exclude_none=True),
        status_code=status_code,
        media_type="application/problem+json",
    )


def _is_valid_json(content: bytes) -> bool:
    try:
        json.loads(content)
    except ValueError:
        return False
    return True


async def _relay(
    fm_client: FmClient,
    method: str,
    path: str,
    request: Request,
    x_correlator: str | None,
    request_schema: type[BaseModel] | None = None,
) -> Response:
    """Forward one federation-lifecycle operation to FM and relay its answer unaltered.

    ``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.

    When ``request_schema`` is given, the parsed body is also validated against it
    as a shape check before being forwarded. The validated object itself is
    discarded afterwards -- what's sent to FM is always the original raw bytes, so
    this is a gate, not a reshape.
    """
    body = await request.body()
    if body:
        try:
            parsed = json.loads(body)
        except ValueError:
            return _problem_response(
                400, "Malformed request body", "Request body is not valid JSON"
            )
        if request_schema is not None:
            try:
                request_schema.model_validate(parsed)
            except ValidationError as exc:
                return _problem_response(
                    400,
                    "Request body does not match the expected schema",
                    str(exc.errors())[:512],
                )

    try:
        fm_response = await fm_client.relay(
            method,
            path,
            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))

    if fm_response.content and not _is_valid_json(fm_response.content):
        return _problem_response(
            502,
            "Federation Manager returned an unusable response",
            "Response body is not valid JSON",
        )

    return Response(
        content=fm_response.content,
        status_code=fm_response.status_code,
        media_type=fm_response.headers.get("content-type"),
    )


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


@router.get(
    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",
    response_model=FederationDetails,
    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"/partners/{partnerOpId}/federations/{federationContextId}",
        request,
        x_correlator,
    )


@router.delete(
    f"{PARTNER_PREFIX}/{{federationContextId}}",
    tags=["Federation Manager"],
    summary="Remove an existing federation with the partner OP",
    operation_id="delete_federation_details",
    status_code=200,
    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"/partners/{partnerOpId}/federations/{federationContextId}",
        request,
        x_correlator,
    )


@router.get(
    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_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",
        f"/partners/{partnerOpId}/federations/{federationContextId}/zones",
        request,
        x_correlator,
    )


@router.post(
    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",
    response_model=ZoneRegistrationResponseData,
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def zone_subscribe(
    partnerOpId: PartnerOpId,
    federationContextId: FederationContextId,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client,
        "POST",
        f"/partners/{partnerOpId}/federations/{federationContextId}/zones",
        request,
        x_correlator,
        request_schema=ZoneRegistrationRequestData,
    )


@router.get(
    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_details",
    response_model=ZoneRegisteredData,
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def get_zone_details(
    partnerOpId: PartnerOpId,
    federationContextId: FederationContextId,
    zoneId: ZoneIdentifier,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client,
        "GET",
        f"/partners/{partnerOpId}/federations/{federationContextId}/zones/{zoneId}",
        request,
        x_correlator,
    )


@router.delete(
    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 "
        "it will no longer access the specified zone"
    ),
    operation_id="zone_unsubscribe",
    status_code=200,
    responses=_responses(400, 401, 404, 409, 422, 500, 502, 503, 520),
)
async def zone_unsubscribe(
    partnerOpId: PartnerOpId,
    federationContextId: FederationContextId,
    zoneId: ZoneIdentifier,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client,
        "DELETE",
        f"/partners/{partnerOpId}/federations/{federationContextId}/zones/{zoneId}",
        request,
        x_correlator,
    )
+363 −0
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/schemas.py: 363 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""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

from datetime import datetime
from enum import StrEnum
from typing import Annotated, Optional

from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator

# --- Scalar types (OpenAPI: string/integer with pattern or format) -------------

# "^[A-Za-z0-9][A-Za-z0-9-]*$" is shared by FederationContextId, FederationIdentifier
# and ZoneIdentifier in the spec.
_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9-]*$"

FederationContextId = Annotated[str, StringConstraints(pattern=_ID_PATTERN)]
"""Allocated by the partner OP when a federation context is created; echoed by the
Originating OP on every subsequent request."""

FederationIdentifier = Annotated[str, StringConstraints(pattern=_ID_PATTERN)]
"""Globally unique identifier of an operator platform, in the MEC federation context."""

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."""

Mcc = Annotated[str, StringConstraints(pattern=r"^\d{3}$")]
Mnc = Annotated[str, StringConstraints(pattern=r"^\d{2,3}$")]

GeoLocation = Annotated[
    str,
    StringConstraints(
        pattern=r"^([-+]?)([\d]{1,2})((((\.)([\d]{1,4}))?(,)))(([-+]?)([\d]{1,3})((\.)([\d]{1,4}))?)$"
    ),
]
"""``Latitude,Longitude`` as a decimal fraction, up to 4-digit precision."""

Ipv4Addr = Annotated[
    str,
    StringConstraints(
        pattern=r"^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}"
        r"([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$"
    ),
]
Ipv6Addr = Annotated[
    str,
    StringConstraints(
        pattern=r"^((:|(0?|([1-9a-f][0-9a-f]{0,3}))):)"
        r"((0?|([1-9a-f][0-9a-f]{0,3})):){0,6}(:|(0?|([1-9a-f][0-9a-f]{0,3})))$"
    ),
]

Fqdn = str
Uri = str
Port = Annotated[int, Field(ge=0)]

# A non-empty list of fixed-line network identifiers.
FixedNetworkIds = Annotated[list[str], Field(min_length=1)]


# --- Enumerations -------------------------------------------------------------


class PlatformCapability(StrEnum):
    """Capabilities an operator platform advertises to a federation partner."""

    HOME_ROUTING = "homeRouting"
    ANCHORING = "Anchoring"
    SERVICE_APIS = "serviceAPIs"
    FAULT_MGMT = "faultMgmt"
    EVENT_MGMT = "eventMgmt"
    RESOURCE_MONITOR = "resourceMonitor"


# --- Object schemas --------------------------------------------------------------


class MobileNetworkIds(BaseModel):
    """MCC plus the set of MNCs associated with an operator platform's mobile network."""

    mcc: Optional[Mcc] = None
    mncs: Optional[Annotated[list[Mnc], Field(min_length=1)]] = None


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

    The spec requires ``port`` plus at least one of ``fqdn`` / ``ipv4Addresses`` /
    ``ipv6Addresses``.
    """

    port: Port
    fqdn: Optional[Fqdn] = None
    ipv4Addresses: Optional[Annotated[list[Ipv4Addr], Field(min_length=1)]] = None
    ipv6Addresses: Optional[Annotated[list[Ipv6Addr], Field(min_length=1)]] = None

    @model_validator(mode="after")
    def _at_least_one_address(self) -> ServiceEndpoint:
        if not (self.fqdn or self.ipv4Addresses or self.ipv6Addresses):
            raise ValueError("one of fqdn, ipv4Addresses or ipv6Addresses is required")
        return self


class ZoneDetails(BaseModel):
    """An availability zone an OP offers to the developers of a federation partner."""

    zoneId: ZoneIdentifier
    geolocation: GeoLocation
    geographyDetails: str


class InvalidParam(BaseModel):
    param: str
    reason: Optional[str] = None


class ProblemDetails(BaseModel):
    """RFC 7807 error body used by every Federation Manager error response."""

    title: Optional[str] = None
    detail: Optional[str] = None
    cause: Optional[str] = None
    invalidParams: Optional[Annotated[list[InvalidParam], Field(min_length=1)]] = None


class FederationRequestData(BaseModel):
    """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")

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


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

    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 ``GetFederationDetails`` / ``UpdateFederation``."""

    edgeDiscoveryServiceEndPoint: ServiceEndpoint
    lcmServiceEndPoint: ServiceEndpoint
    allowedMobileNetworkIds: Optional[MobileNetworkIds] = None
    allowedFixedNetworkIds: Optional[FixedNetworkIds] = None
    offeredAvailabilityZones: Optional[Annotated[list[ZoneDetails], Field(min_length=1)]] = None


class FederationContextIdResponse(BaseModel):
    """``200`` body of ``GetFederationContextId``.

    The wire field name is ``FederationContextId`` (capitalised) per the spec.
    """

    FederationContextId: FederationContextId


# --- AvailabilityZoneInfoSynchronization --------------------------------------
#
# 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))?$")]
"""vCPU count in whole, decimal (up to millivcpu) or millivcpu (``500m``) form."""


class ComputeCpuArchType(StrEnum):
    """CPU ISA as used inside ``ComputeResourceInfo`` (narrower than ``CpuArchType``)."""

    ISA_X86_64 = "ISA_X86_64"
    ISA_ARM_64 = "ISA_ARM_64"


class CpuArchType(StrEnum):
    """CPU ISA as used inside ``Flavour``."""

    ISA_X86 = "ISA_X86"
    ISA_X86_64 = "ISA_X86_64"
    ISA_ARM_64 = "ISA_ARM_64"


class GpuVendorType(StrEnum):
    NVIDIA = "GPU_PROVIDER_NVIDIA"
    AMD = "GPU_PROVIDER_AMD"


class HugePageSize(StrEnum):
    SIZE_2MB = "2MB"
    SIZE_4MB = "4MB"
    SIZE_1GB = "1GB"


class OsArchitecture(StrEnum):
    X86_64 = "x86_64"
    X86 = "x86"


class OsDistribution(StrEnum):
    RHEL = "RHEL"
    UBUNTU = "UBUNTU"
    COREOS = "COREOS"
    FEDORA = "FEDORA"
    WINDOWS = "WINDOWS"
    OTHER = "OTHER"


class OsVersion(StrEnum):
    UBUNTU_2204_LTS = "OS_VERSION_UBUNTU_2204_LTS"
    RHEL_8 = "OS_VERSION_RHEL_8"
    RHEL_7 = "OS_VERSION_RHEL_7"
    DEBIAN_11 = "OS_VERSION_DEBIAN_11"
    COREOS_STABLE = "OS_VERSION_COREOS_STABLE"
    MS_WINDOWS_2012_R2 = "OS_MS_WINDOWS_2012_R2"
    OTHER = "OTHER"


class OsLicense(StrEnum):
    FREE = "OS_LICENSE_TYPE_FREE"
    ON_DEMAND = "OS_LICENSE_TYPE_ON_DEMAND"
    NOT_SPECIFIED = "NOT_SPECIFIED"


class GpuInfo(BaseModel):
    gpuVendorType: GpuVendorType
    gpuModeName: str
    gpuMemory: int
    numGPU: int


class HugePage(BaseModel):
    pageSize: HugePageSize
    number: int


class OSType(BaseModel):
    architecture: OsArchitecture
    distribution: OsDistribution
    version: OsVersion
    license: OsLicense


class ComputeResourceInfo(BaseModel):
    cpuArchType: ComputeCpuArchType
    numCPU: Vcpu
    memory: int
    diskStorage: Optional[int] = None
    gpu: Optional[list[GpuInfo]] = None
    vpu: Optional[int] = None
    fpga: Optional[int] = None
    hugepages: Optional[list[HugePage]] = None
    cpuExclusivity: Optional[bool] = None


class Flavour(BaseModel):
    flavourId: FlavourId
    cpuArchType: CpuArchType
    supportedOSTypes: Annotated[list[OSType], Field(min_length=1)]
    numCPU: int
    memorySize: int
    storageSize: int
    gpu: Optional[list[GpuInfo]] = None
    fpga: Optional[int] = None
    vpu: Optional[int] = None
    hugepages: Optional[list[HugePage]] = None
    cpuExclusivity: Optional[bool] = None


class ZoneNetworkResources(BaseModel):
    """The spec's ``ZoneRegisteredData_networkResources``."""

    egressBandWidth: int
    dedicatedNIC: int
    supportSriov: bool
    supportDPDK: bool


class LatencyRange(BaseModel):
    minLatency: Optional[Annotated[int, Field(ge=1)]] = None
    maxLatency: Optional[int] = None


class JitterRange(BaseModel):
    minJitter: Optional[Annotated[int, Field(ge=1)]] = None
    maxJitter: Optional[int] = None


class ThroughputRange(BaseModel):
    minThroughput: Optional[Annotated[int, Field(ge=1)]] = None
    maxThroughput: Optional[int] = None


class ZoneServiceLevelObjectives(BaseModel):
    """The spec's ``ZoneRegisteredData_zoneServiceLevelObjsInfo``."""

    latencyRanges: LatencyRange
    jitterRanges: JitterRange
    throughputRanges: ThroughputRange


class ZoneRegisteredData(BaseModel):
    zoneId: ZoneIdentifier
    reservedComputeResources: Annotated[list[ComputeResourceInfo], Field(min_length=1)]
    computeResourceQuotaLimits: Annotated[list[ComputeResourceInfo], Field(min_length=1)]
    flavoursSupported: Annotated[list[Flavour], Field(min_length=1)]
    networkResources: Optional[ZoneNetworkResources] = None
    zoneServiceLevelObjsInfo: Optional[ZoneServiceLevelObjectives] = None


class ZoneRegistrationRequestData(BaseModel):
    """Body of ``ZoneSubscribe``."""

    model_config = ConfigDict(extra="forbid")

    acceptedAvailabilityZones: Annotated[list[ZoneIdentifier], Field(min_length=1)]
    availZoneNotifLink: Uri


class ZoneRegistrationResponseData(BaseModel):
    """``200`` body of ``ZoneSubscribe``."""

    acceptedZoneResourceInfo: Annotated[list[ZoneRegisteredData], Field(min_length=1)]
+6 −0
Changes for src/open_exposure_gateway/core/config.py: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -69,6 +69,11 @@ class LocationRetrievalSettings(BaseModel):
        return self.service_specification_id == DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID


class FmSettings(BaseModel):
    base_url: HttpUrl = HttpUrl("http://localhost:8082")
    timeout: float = 10.0


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
@@ -91,6 +96,7 @@ class Settings(BaseSettings):
    callback_settings: CallbackSettings = CallbackSettings()
    qod_settings: QodSettings = QodSettings()
    location_retrieval_settings: LocationRetrievalSettings = LocationRetrievalSettings()
    fm_settings: FmSettings = FmSettings()


@lru_cache
Loading
Loading