Loading src/open_exposure_gateway/api/gsma/federation_manager/v1_2_0/router.py +30 −5 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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]] = { Loading @@ -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, Loading @@ -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, Loading @@ -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, Loading @@ -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, Loading @@ -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, Loading @@ -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) src/open_exposure_gateway/api/gsma/federation_manager/v1_2_0/schemas.py +168 −0 Original line number Diff line number Diff line Loading @@ -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)] src/open_exposure_gateway/main.py +4 −0 Original line number Diff line number Diff line Loading @@ -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)", Loading tests/unit/test_federation_manager_endpoints.py +19 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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), Loading @@ -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 = { Loading @@ -45,6 +51,7 @@ _OPERATION_IDS = { "update_federation", "delete_federation_details", "get_federation_context_id", "zone_subscribe", } Loading Loading @@ -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"} ) Loading
src/open_exposure_gateway/api/gsma/federation_manager/v1_2_0/router.py +30 −5 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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]] = { Loading @@ -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, Loading @@ -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, Loading @@ -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, Loading @@ -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, Loading @@ -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, Loading @@ -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)
src/open_exposure_gateway/api/gsma/federation_manager/v1_2_0/schemas.py +168 −0 Original line number Diff line number Diff line Loading @@ -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)]
src/open_exposure_gateway/main.py +4 −0 Original line number Diff line number Diff line Loading @@ -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)", Loading
tests/unit/test_federation_manager_endpoints.py +19 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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), Loading @@ -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 = { Loading @@ -45,6 +51,7 @@ _OPERATION_IDS = { "update_federation", "delete_federation_details", "get_federation_context_id", "zone_subscribe", } Loading Loading @@ -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"} )