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