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

feat: add post /{federationcontextid}/zones endpoint

parent 834497fa
Loading
Loading
Loading
Loading
Loading
+30 −5
Original line number Diff line number Diff line
@@ -2,10 +2,11 @@

Endpoints defined by ``API_definitions/federation-manager.yaml``.

This module currently exposes the ``FederationManagement`` tag only, and every
handler is a surface stub that raises :class:`NotImplementedException` (HTTP 501).
The request/response contract (path, schema, error codes) is live and visible in
the OpenAPI document; the fulfilment logic is added in a later step.
This module currently exposes the ``FederationManagement`` tag plus
``zone_subscribe`` from ``AvailabilityZoneInfoSynchronization``, and every handler
is a surface stub that raises :class:`NotImplementedException` (HTTP 501). The
request/response contract (path, schema, error codes) is live and visible in the
OpenAPI document; the fulfilment logic is added in a later step.
"""

from typing import Any
@@ -20,13 +21,17 @@ from open_exposure_gateway.api.gsma.federation_manager.v1_2_0.schemas import (
    FederationRequestData,
    FederationResponseData,
    ProblemDetails,
    ZoneRegistrationRequestData,
    ZoneRegistrationResponseData,
)
from open_exposure_gateway.core.exceptions import NotImplementedException

# GSMA OPG serves the Federation Management API at {apiRoot}/operatorplatform/federation/v1.
BASE_PATH = "/operatorplatform/federation/v1"

router = APIRouter(prefix=BASE_PATH, tags=["Federation Manager"])
# Tags are set per route (not on the router) so each endpoint carries only its own
# federation-manager.yaml tag.
router = APIRouter(prefix=BASE_PATH)

# Every Federation Manager error response is an RFC 7807 ProblemDetails (federation-manager.yaml).
_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
@@ -51,6 +56,7 @@ _NOT_IMPLEMENTED = "Federation Manager is not implemented in this release"

@router.post(
    "/partner",
    tags=["Federation Manager"],
    summary="Create a one-direction federation with a partner operator platform",
    operation_id="create_federation",
    response_model=FederationResponseData,
@@ -63,6 +69,7 @@ async def create_federation(request: FederationRequestData) -> Any:

@router.get(
    "/{federationContextId}/partner",
    tags=["Federation Manager"],
    summary="Retrieve details about the federation context with the partner OP",
    operation_id="get_federation_details",
    response_model=FederationDetails,
@@ -75,6 +82,7 @@ async def get_federation_details(federationContextId: FederationContextId) -> An

@router.patch(
    "/{federationContextId}/partner",
    tags=["Federation Manager"],
    summary="Update the parameters associated with an existing federation",
    operation_id="update_federation",
    response_model=FederationDetails,
@@ -89,6 +97,7 @@ async def update_federation(

@router.delete(
    "/{federationContextId}/partner",
    tags=["Federation Manager"],
    summary="Remove an existing federation with the partner OP",
    operation_id="delete_federation_details",
    status_code=200,
@@ -100,6 +109,7 @@ async def delete_federation_details(federationContextId: FederationContextId) ->

@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,
@@ -108,3 +118,18 @@ async def delete_federation_details(federationContextId: FederationContextId) ->
)
async def get_federation_context_id() -> Any:
    raise NotImplementedException(message=_NOT_IMPLEMENTED)


@router.post(
    "/{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,
    response_model_exclude_none=True,
    responses=_responses(400, 401, 404, 409, 422, 500, 501, 503, 520),
)
async def zone_subscribe(
    federationContextId: FederationContextId, request: ZoneRegistrationRequestData
) -> Any:
    raise NotImplementedException(message=_NOT_IMPLEMENTED)
+168 −0
Original line number Diff line number Diff line
@@ -223,3 +223,171 @@ class FederationPatchRequest(BaseModel):
    removeMobileNetworkIds: Optional[MobileNetworkIds] = None
    addFixedNetworkIds: Optional[FixedNetworkIds] = None
    removeFixedNetworkIds: Optional[FixedNetworkIds] = None


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

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 ``POST /{federationContextId}/zones``."""

    model_config = ConfigDict(extra="forbid")

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


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

    acceptedZoneResourceInfo: Annotated[list[ZoneRegisteredData], Field(min_length=1)]
+4 −0
Original line number Diff line number Diff line
@@ -166,6 +166,10 @@ openapi_tags = [
        "name": "Federation Manager",
        "description": "GSMA OPG Federation Management (EWBI) -- partner federation lifecycle",
    },
    {
        "name": "Availability Zone Info Synchronization",
        "description": "GSMA OPG availability-zone subscription and resource reservation",
    },
    {
        "name": "Platform",
        "description": "Platform-specific endpoints (health, readiness probes)",
+19 −0
Original line number Diff line number Diff line
@@ -14,6 +14,7 @@ from pydantic import ValidationError
from open_exposure_gateway.api.gsma.federation_manager.v1_2_0.router import BASE_PATH
from open_exposure_gateway.api.gsma.federation_manager.v1_2_0.schemas import (
    FederationRequestData,
    ZoneRegistrationRequestData,
)
from open_exposure_gateway.main import app

@@ -30,6 +31,10 @@ _VALID_PATCH = {
    "operationType": "ADD_CODES",
    "modificationDate": "2026-09-04T12:00:00Z",
}
_VALID_ZONE_SUBSCRIBE = {
    "acceptedAvailabilityZones": ["zone-a"],
    "availZoneNotifLink": "https://orig-op.example.com/zone-notif",
}

_ENDPOINTS = [
    ("post", f"{BASE_PATH}/partner", _VALID_CREATE),
@@ -37,6 +42,7 @@ _ENDPOINTS = [
    ("patch", f"{BASE_PATH}/{_CTX}/partner", _VALID_PATCH),
    ("delete", f"{BASE_PATH}/{_CTX}/partner", None),
    ("get", f"{BASE_PATH}/fed-context-id", None),
    ("post", f"{BASE_PATH}/{_CTX}/zones", _VALID_ZONE_SUBSCRIBE),
]

_OPERATION_IDS = {
@@ -45,6 +51,7 @@ _OPERATION_IDS = {
    "update_federation",
    "delete_federation_details",
    "get_federation_context_id",
    "zone_subscribe",
}


@@ -75,3 +82,15 @@ def test_federation_request_data_requires_partner_status_link() -> None:
    payload = {k: v for k, v in _VALID_CREATE.items() if k != "partnerStatusLink"}
    with pytest.raises(ValidationError):
        FederationRequestData.model_validate(payload)


def test_zone_registration_request_data_round_trips() -> None:
    model = ZoneRegistrationRequestData.model_validate(_VALID_ZONE_SUBSCRIBE)
    assert model.acceptedAvailabilityZones == ["zone-a"]


def test_zone_registration_request_data_rejects_empty_zone_list() -> None:
    with pytest.raises(ValidationError):
        ZoneRegistrationRequestData.model_validate(
            {"acceptedAvailabilityZones": [], "availZoneNotifLink": "https://x.example.com"}
        )