Commit 8acb4a04 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

feat(fm): list our own zones, and answer relayed agreement calls as ProblemDetails

OEG now relays /internal/agreements to the portal, so those routes join the
proxied set: a bad body comes back as a 400 ProblemDetails that OEG can pass
on, not FastAPI's 422.

GET /internal/local-zones lists the zones an agreement can offer: SRM's zones
minus the ones a partner offers us. Zone offers already filtered those out;
both now share local_zones().

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent f8e94efc
Loading
Loading
Loading
Loading
Loading
+2 −1
Changes for src/federation_manager/api/errors.py: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -119,7 +119,8 @@ def problem(
# and turns anything else into its own 502, so on these routes FM maps its EWBI-side 403/502
# onto the OPG.04 v1.4.0 set (_PROBLEM_STATUSES); `type` still says which failure it was.
_PROXIED_PATH = re.compile(
    r"^/internal/(identity|partners(/[^/]+)?|partners/[^/]+/federations(/.*)?)$"
    r"^/internal/(identity|local-zones|agreements(/[^/]+)?|partners(/[^/]+)?"
    r"|partners/[^/]+/federations(/.*)?)$"
)

# Partner rejections that describe the operator's own request and are relayed as they are.
+29 −0
Changes for src/federation_manager/api/internal/local_zones.py: 29 added lines, 0 removed lines.
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.application.zone_offer import local_zones
from federation_manager.dependencies import get_catalogue_repo, get_srm_client
from federation_manager.domain.ports import CatalogueRepositoryPort, SrmQueryPort

# What an operator can put in an agreement: our zones, never ones a partner offers us.
router = APIRouter(prefix="/internal/local-zones", tags=["internal-local-zones"])

Srm = Annotated[SrmQueryPort, Depends(get_srm_client)]
Catalogue = Annotated[CatalogueRepositoryPort, Depends(get_catalogue_repo)]


class LocalZoneView(BaseModel):
    id: UUID
    name: str
    region: str | None


@router.get("", response_model=list[LocalZoneView])
async def list_local_zones(srm: Srm, catalogue: Catalogue) -> list[LocalZoneView]:
    return [
        LocalZoneView(id=zone.id, name=zone.name, region=zone.region)
        for zone in await local_zones(srm, catalogue)
    ]
+9 −4
Changes for src/federation_manager/application/zone_offer.py: 9 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -10,14 +10,19 @@ from federation_manager.domain.ports import CatalogueRepositoryPort, SrmQueryPor
logger = get_logger(__name__)


async def local_zones(srm: SrmQueryPort, catalogue: CatalogueRepositoryPort) -> list[LocalZone]:
    """Our own zones: SRM lists partner zones too, and those are never passed on."""
    return [zone for zone in await srm.list_zones() if await catalogue.find_zone(zone.id) is None]


async def offered_zones(
    agreement: Agreement, srm: SrmQueryPort, catalogue: CatalogueRepositoryPort
) -> dict[UUID, tuple[LocalZone, ZoneTerms]]:
    # A zone another partner offered us is never passed on, and one without valid GSMA
    # terms is left out: every zone operation must answer with those terms.
    # A zone without valid GSMA terms is left out: every zone operation must answer
    # with those terms.
    offered: dict[UUID, tuple[LocalZone, ZoneTerms]] = {}
    for zone in await srm.list_zones():
        if not agreement.permits_zone(zone.id) or await catalogue.find_zone(zone.id) is not None:
    for zone in await local_zones(srm, catalogue):
        if not agreement.permits_zone(zone.id):
            continue
        terms = _terms(agreement, zone.id)
        if terms is not None:
+2 −0
Changes for src/federation_manager/main.py: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -41,6 +41,7 @@ from federation_manager.api.ewbi.v1.zones import router as ewbi_zones_router
from federation_manager.api.internal.agreements import router as internal_agreements_router
from federation_manager.api.internal.federation import router as internal_federation_router
from federation_manager.api.internal.identity import router as internal_identity_router
from federation_manager.api.internal.local_zones import router as internal_local_zones_router
from federation_manager.api.internal.partner_federations import (
    router as internal_partner_federations_router,
)
@@ -235,6 +236,7 @@ def create_app(lifespan: Lifespan[FastAPI] | None = None) -> FastAPI:
    app.include_router(internal_agreements_router)
    app.include_router(internal_federation_router)
    app.include_router(internal_identity_router)
    app.include_router(internal_local_zones_router)
    app.include_router(internal_partner_federations_router)
    app.include_router(internal_partners_router)
    app.include_router(internal_zones_router)
+4 −2
Changes for tests/test_internal_agreements.py: 4 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -139,7 +139,9 @@ def test_unknown_partner_is_404(client: TestClient) -> None:
def test_invalid_body_is_rejected(client: TestClient, overrides: dict[str, Any]) -> None:
    response = client.post(AGREEMENTS, json=_body(**overrides))

    assert response.status_code == 422, response.text
    # OEG relays this route, so a bad body is a ProblemDetails it can pass on
    assert response.status_code == 400, response.text
    assert response.headers["content-type"].startswith("application/problem+json")


def test_validity_must_end_after_it_starts(client: TestClient) -> None:
@@ -179,7 +181,7 @@ def test_patch_rejects_other_changes(client: TestClient, body: dict[str, Any]) -

    response = client.patch(f"{AGREEMENTS}/{created['id']}", json=body)

    assert response.status_code == 422, response.text
    assert response.status_code == 400, response.text


def test_patch_cannot_end_validity_before_it_starts(client: TestClient) -> None:
Loading