From d1abcad57ff14c173a8382b335f597de3519a5f8 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Tue, 15 Sep 2026 17:03:50 +0200 Subject: [PATCH 1/3] Add internal endpoints for operator-triggered federation lifecycle --- .../database/federation_context_repo.py | 14 + .../adapters/http/ewbi_client.py | 23 +- src/federation_manager/api/errors.py | 41 ++- .../api/internal/partner_federations.py | 94 +++++ .../application/federation.py | 77 +++- .../application/partner_federations.py | 151 ++++++++ src/federation_manager/contracts/ewbi.py | 82 +++++ src/federation_manager/core/config.py | 1 + src/federation_manager/dependencies.py | 44 ++- src/federation_manager/domain/errors.py | 23 +- src/federation_manager/domain/ewbi.py | 21 +- src/federation_manager/domain/ports.py | 10 + src/federation_manager/main.py | 4 + tests/fakes.py | 27 +- tests/test_ewbi_client.py | 26 ++ tests/test_partner_federations.py | 337 ++++++++++++++++++ 16 files changed, 941 insertions(+), 34 deletions(-) create mode 100644 src/federation_manager/api/internal/partner_federations.py create mode 100644 src/federation_manager/application/partner_federations.py create mode 100644 tests/test_partner_federations.py diff --git a/src/federation_manager/adapters/database/federation_context_repo.py b/src/federation_manager/adapters/database/federation_context_repo.py index 3fba6b5..4d42a9a 100644 --- a/src/federation_manager/adapters/database/federation_context_repo.py +++ b/src/federation_manager/adapters/database/federation_context_repo.py @@ -46,6 +46,20 @@ class PostgresFederationContextRepo: ) ) + async def find_outbound( + self, partner_id: UUID, federation_context_id: str + ) -> FederationContext | None: + return await self._one( + select(contexts) + .where( + contexts.c.partner_op_id == partner_id, + contexts.c.direction == "outbound", + contexts.c.federation_context_id == federation_context_id, + ) + .order_by(contexts.c.created_at.desc()) + .limit(1) + ) + async def add(self, context: FederationContext) -> None: await self._session.execute( insert(contexts).values( diff --git a/src/federation_manager/adapters/http/ewbi_client.py b/src/federation_manager/adapters/http/ewbi_client.py index 9f4632a..e350fd0 100644 --- a/src/federation_manager/adapters/http/ewbi_client.py +++ b/src/federation_manager/adapters/http/ewbi_client.py @@ -20,14 +20,35 @@ class HttpxEwbiClient: self._allow_insecure = allow_insecure async def post(self, partner: PartnerOP, path: str, payload: dict[str, object]) -> EwbiResponse: + return await self._send(partner, "POST", path, payload=payload) + + async def get( + self, partner: PartnerOP, path: str, params: dict[str, str] | None = None + ) -> EwbiResponse: + return await self._send(partner, "GET", path, params=params) + + async def delete(self, partner: PartnerOP, path: str) -> EwbiResponse: + return await self._send(partner, "DELETE", path) + + async def _send( + self, + partner: PartnerOP, + method: str, + path: str, + *, + payload: dict[str, object] | None = None, + params: dict[str, str] | None = None, + ) -> EwbiResponse: url = self._url(partner, path) token = await self._token_provider.token_for(partner, scope="fed-mgmt") try: - response = await self._client.post( + response = await self._client.request( + method, url, headers={"Accept": "application/json", "Authorization": f"Bearer {token}"}, json=payload, + params=params, ) except httpx.HTTPError: raise PartnerRequestFailed(partner.id) from None diff --git a/src/federation_manager/api/errors.py b/src/federation_manager/api/errors.py index e2f0c9e..01648a9 100644 --- a/src/federation_manager/api/errors.py +++ b/src/federation_manager/api/errors.py @@ -1,6 +1,6 @@ from typing import Any -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse from pydantic.json_schema import models_json_schema @@ -13,11 +13,14 @@ from federation_manager.domain.errors import ( FederationAlreadyExists, FederationContextMissing, FederationContextUnknown, + FederationIdentityMismatch, IdempotencyKeyReused, NetworkQueryNotApplicable, NoRouteMatched, PartnerEndpointConfigurationError, PartnerNotActive, + PartnerNotRegistered, + PartnerRejectedRequest, PartnerRequestFailed, PartnerResponseInvalid, PartnerTokenConfigurationError, @@ -177,6 +180,42 @@ def register_exception_handlers(app: FastAPI) -> None: request.url.path, ) + @app.exception_handler(PartnerRejectedRequest) + async def _partner_rejected(request: Request, exc: PartnerRejectedRequest) -> Response: + if not 400 <= exc.status_code < 600: + return problem( + 502, + "partner-response-invalid", + "Invalid Partner Response", + "The partner operator's response did not match the OPG.04 contract.", + request.url.path, + ) + if exc.body is None: + return Response(status_code=exc.status_code) + return JSONResponse( + exc.body, status_code=exc.status_code, media_type="application/problem+json" + ) + + @app.exception_handler(FederationIdentityMismatch) + async def _identity_mismatch(request: Request, exc: FederationIdentityMismatch) -> JSONResponse: + return problem( + 400, + "federation-identity-mismatch", + "Request Does Not Match This Operator", + f"{exc.field} must be omitted or match this operator's own value.", + request.url.path, + ) + + @app.exception_handler(PartnerNotRegistered) + async def _partner_not_registered(request: Request, exc: PartnerNotRegistered) -> JSONResponse: + return problem( + 404, + "partner-not-registered", + "Partner Not Registered", + "No partner operator is registered with this identifier.", + request.url.path, + ) + @app.exception_handler(FederationAlreadyExists) async def _federation_exists(request: Request, exc: FederationAlreadyExists) -> JSONResponse: return problem( diff --git a/src/federation_manager/api/internal/partner_federations.py b/src/federation_manager/api/internal/partner_federations.py new file mode 100644 index 0000000..3c2cc39 --- /dev/null +++ b/src/federation_manager/api/internal/partner_federations.py @@ -0,0 +1,94 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response +from fastapi.responses import JSONResponse + +from federation_manager.application.partner_federations import PartnerFederationService +from federation_manager.contracts.ewbi import ( + FederationContextIdResponse, + FederationDetails, + FederationRequestData, + FederationResponseData, + ZoneRegisteredData, + ZoneRegistrationRequestData, + ZoneRegistrationResponseData, +) +from federation_manager.dependencies import get_partner_federation_service + +router = APIRouter( + prefix="/internal/partners/{partner_op_id}/federations", + tags=["internal-partner-federations"], +) + +Service = Annotated[PartnerFederationService, Depends(get_partner_federation_service)] + + +@router.post("", responses={200: {"model": FederationResponseData}}) +async def create_federation( + partner_op_id: UUID, body: FederationRequestData, service: Service +) -> JSONResponse: + return JSONResponse(await service.create(partner_op_id, body)) + + +@router.get("", response_model=FederationContextIdResponse) +async def get_federation_context_id( + partner_op_id: UUID, service: Service +) -> FederationContextIdResponse: + return FederationContextIdResponse( + federation_context_id=await service.context_id(partner_op_id) + ) + + +@router.get("/{federation_context_id}", responses={200: {"model": FederationDetails}}) +async def get_federation_details( + partner_op_id: UUID, federation_context_id: str, service: Service +) -> JSONResponse: + return JSONResponse(await service.details(partner_op_id, federation_context_id)) + + +@router.delete("/{federation_context_id}") +async def delete_federation( + partner_op_id: UUID, federation_context_id: str, service: Service +) -> Response: + await service.delete(partner_op_id, federation_context_id) + return Response(status_code=200) + + +@router.get("/{federation_context_id}/zones", responses={200: {"model": ZoneRegisteredData}}) +async def get_zone_data( + partner_op_id: UUID, + federation_context_id: str, + service: Service, + zone_id: Annotated[str | None, Query(alias="zoneId")] = None, +) -> JSONResponse: + return JSONResponse(await service.zone_data(partner_op_id, federation_context_id, zone_id)) + + +@router.post( + "/{federation_context_id}/zones", responses={200: {"model": ZoneRegistrationResponseData}} +) +async def subscribe_zones( + partner_op_id: UUID, + federation_context_id: str, + body: ZoneRegistrationRequestData, + service: Service, +) -> JSONResponse: + return JSONResponse(await service.subscribe_zones(partner_op_id, federation_context_id, body)) + + +@router.get( + "/{federation_context_id}/zones/{zone_id}", responses={200: {"model": ZoneRegisteredData}} +) +async def get_zone_details( + partner_op_id: UUID, federation_context_id: str, zone_id: str, service: Service +) -> JSONResponse: + return JSONResponse(await service.zone_details(partner_op_id, federation_context_id, zone_id)) + + +@router.delete("/{federation_context_id}/zones/{zone_id}") +async def unsubscribe_zone( + partner_op_id: UUID, federation_context_id: str, zone_id: str, service: Service +) -> Response: + await service.unsubscribe_zone(partner_op_id, federation_context_id, zone_id) + return Response(status_code=200) diff --git a/src/federation_manager/application/federation.py b/src/federation_manager/application/federation.py index d6889bf..9d22b74 100644 --- a/src/federation_manager/application/federation.py +++ b/src/federation_manager/application/federation.py @@ -9,6 +9,7 @@ from federation_manager.contracts.ewbi import ( FederationRequestData, FederationResponseData, MobileNetworkIds, + ZoneRegistrationRequestData, ) from federation_manager.core.logging import get_logger from federation_manager.domain.errors import ( @@ -16,6 +17,7 @@ from federation_manager.domain.errors import ( FederationContextUnknown, FederationError, FederationEstablishmentFailed, + FederationIdentityMismatch, PartnerNotActive, PartnerResponseInvalid, ) @@ -47,6 +49,49 @@ class LocalOperator: mncs: tuple[str, ...] partner_status_link: str platform_caps: tuple[str, ...] = ("serviceAPIs",) + avail_zone_notif_link: str | None = None + + def federation_request(self, initial_date: datetime) -> FederationRequestData: + return FederationRequestData( + initial_date=initial_date, + partner_status_link=self.partner_status_link, + orig_op_federation_id=self.federation_id, + orig_op_country_code=self.country_code, + orig_op_mobile_network_codes=MobileNetworkIds(mcc=self.mcc, mncs=list(self.mncs)), + ) + + def complete_federation_request(self, request: FederationRequestData) -> FederationRequestData: + _require_ours("origOPFederationId", request.orig_op_federation_id, self.federation_id) + _require_ours("origOPCountryCode", request.orig_op_country_code, self.country_code) + _require_ours("partnerStatusLink", request.partner_status_link, self.partner_status_link) + codes = request.orig_op_mobile_network_codes + # Federating only some of our networks is fine; naming one we don't operate is not. + if codes is not None and (codes.mcc != self.mcc or not set(codes.mncs) <= set(self.mncs)): + raise FederationIdentityMismatch("origOPMobileNetworkCodes") + # This operator has no fixed network codes configured, so any claimed one isn't ours. + if request.orig_op_fixed_network_codes: + raise FederationIdentityMismatch("origOPFixedNetworkCodes") + return request.model_copy( + update={ + "orig_op_federation_id": self.federation_id, + "orig_op_country_code": self.country_code, + "orig_op_mobile_network_codes": codes + or MobileNetworkIds(mcc=self.mcc, mncs=list(self.mncs)), + } + ) + + def complete_zone_registration( + self, request: ZoneRegistrationRequestData + ) -> ZoneRegistrationRequestData: + _require_ours( + "availZoneNotifLink", request.avail_zone_notif_link, self.avail_zone_notif_link + ) + return request.model_copy(update={"avail_zone_notif_link": self.avail_zone_notif_link}) + + +def _require_ours(field: str, value: str | None, ours: str | None) -> None: + if value is not None and value != ours: + raise FederationIdentityMismatch(field) class FederationEstablishmentService: @@ -73,23 +118,31 @@ class FederationEstablishmentService: existing = await self._contexts.find_active_outbound(partner.id) if existing is not None: return existing - - request = FederationRequestData( - initial_date=self._clock(), - partner_status_link=self._local.partner_status_link, - orig_op_federation_id=self._local.federation_id, - orig_op_country_code=self._local.country_code, - orig_op_mobile_network_codes=MobileNetworkIds( - mcc=self._local.mcc, mncs=list(self._local.mncs) - ), + context, _ = await self._request_federation( + partner, self._local.federation_request(self._clock()) ) + return context + + async def create( + self, partner: PartnerOP, request: FederationRequestData + ) -> tuple[FederationContext, object]: + completed = self._local.complete_federation_request(request) + if not partner.is_active(): + raise PartnerNotActive(partner.status) + if await self._contexts.find_active_outbound(partner.id) is not None: + raise FederationAlreadyExists(partner.id) + return await self._request_federation(partner, completed) + + async def _request_federation( + self, partner: PartnerOP, request: FederationRequestData + ) -> tuple[FederationContext, object]: response = await self._ewbi_client.post( partner, CREATE_FEDERATION_PATH, request.model_dump(mode="json", by_alias=True, exclude_none=True), ) if not response.is_success(): - raise FederationEstablishmentFailed(partner.id, response.status_code) + raise FederationEstablishmentFailed(partner.id, response.status_code, response.body) try: accepted = FederationResponseData.model_validate(response.body) except ValidationError: @@ -102,10 +155,10 @@ class FederationEstablishmentService: federation_context_id=accepted.federation_context_id, status=AVAILABLE, created_at=self._clock(), - status_callback_url=self._local.partner_status_link, + status_callback_url=request.partner_status_link, ) await self._contexts.add(context) - return context + return context, response.body async def establish_missing(self) -> list[FederationContext]: established: list[FederationContext] = [] diff --git a/src/federation_manager/application/partner_federations.py b/src/federation_manager/application/partner_federations.py new file mode 100644 index 0000000..f901839 --- /dev/null +++ b/src/federation_manager/application/partner_federations.py @@ -0,0 +1,151 @@ +from uuid import UUID + +from pydantic import BaseModel, ValidationError + +from federation_manager.application.federation import ( + TERMINATED, + FederationEstablishmentService, + LocalOperator, +) +from federation_manager.contracts.ewbi import ( + FederationDetails, + FederationRequestData, + ZoneRegisteredData, + ZoneRegistrationRequestData, + ZoneRegistrationResponseData, +) +from federation_manager.domain.errors import ( + FederationContextUnknown, + PartnerNotRegistered, + PartnerRejectedRequest, + PartnerResponseInvalid, +) +from federation_manager.domain.ewbi import federation_path, zone_path, zones_path +from federation_manager.domain.models import EwbiResponse, FederationContext, PartnerOP +from federation_manager.domain.ports import ( + EwbiClientPort, + FederationContextRepositoryPort, + PartnerRepositoryPort, +) + +# OPG.04 Table 9: 404 means the partner no longer has it, 409 that it is already terminating it. +_ALREADY_GONE = frozenset({404, 409}) + + +class PartnerFederationService: + def __init__( + self, + partners: PartnerRepositoryPort, + contexts: FederationContextRepositoryPort, + ewbi_client: EwbiClientPort, + establishment: FederationEstablishmentService, + local: LocalOperator, + ) -> None: + self._partners = partners + self._contexts = contexts + self._ewbi_client = ewbi_client + self._establishment = establishment + self._local = local + + async def create(self, partner_op_id: UUID, request: FederationRequestData) -> object: + partner = await self._partner(partner_op_id) + _, body = await self._establishment.create(partner, request) + return body + + async def context_id(self, partner_op_id: UUID) -> str: + partner = await self._partner(partner_op_id) + context = await self._contexts.find_active_outbound(partner.id) + if context is None: + raise FederationContextUnknown(partner.id) + return context.federation_context_id + + async def details(self, partner_op_id: UUID, federation_context_id: str) -> object: + partner, context = await self._outbound(partner_op_id, federation_context_id) + response = await self._ewbi_client.get( + partner, federation_path(context.federation_context_id) + ) + # OPG.04 ยง2.2.1.10: a partner that no longer knows the context has terminated it. + if response.status_code == 404: + await self._contexts.set_status(context.id, TERMINATED) + return _accepted(partner, response, FederationDetails) + + async def delete(self, partner_op_id: UUID, federation_context_id: str) -> None: + partner, context = await self._outbound(partner_op_id, federation_context_id) + response = await self._ewbi_client.delete( + partner, federation_path(context.federation_context_id) + ) + if response.is_success() or response.status_code in _ALREADY_GONE: + await self._contexts.set_status(context.id, TERMINATED) + _accepted(partner, response) + + async def zone_data( + self, partner_op_id: UUID, federation_context_id: str, zone_id: str | None + ) -> object: + partner, context = await self._outbound(partner_op_id, federation_context_id) + response = await self._ewbi_client.get( + partner, + zones_path(context.federation_context_id), + {"zoneId": zone_id} if zone_id is not None else None, + ) + return _accepted(partner, response, ZoneRegisteredData) + + async def subscribe_zones( + self, + partner_op_id: UUID, + federation_context_id: str, + request: ZoneRegistrationRequestData, + ) -> object: + registration = self._local.complete_zone_registration(request) + partner, context = await self._outbound(partner_op_id, federation_context_id) + response = await self._ewbi_client.post( + partner, + zones_path(context.federation_context_id), + registration.model_dump(mode="json", by_alias=True, exclude_none=True), + ) + return _accepted(partner, response, ZoneRegistrationResponseData) + + async def zone_details( + self, partner_op_id: UUID, federation_context_id: str, zone_id: str + ) -> object: + partner, context = await self._outbound(partner_op_id, federation_context_id) + response = await self._ewbi_client.get( + partner, zone_path(context.federation_context_id, zone_id) + ) + return _accepted(partner, response, ZoneRegisteredData) + + async def unsubscribe_zone( + self, partner_op_id: UUID, federation_context_id: str, zone_id: str + ) -> None: + partner, context = await self._outbound(partner_op_id, federation_context_id) + response = await self._ewbi_client.delete( + partner, zone_path(context.federation_context_id, zone_id) + ) + _accepted(partner, response) + + async def _partner(self, partner_op_id: UUID) -> PartnerOP: + partner = await self._partners.find_by_id(partner_op_id) + if partner is None: + raise PartnerNotRegistered(partner_op_id) + return partner + + async def _outbound( + self, partner_op_id: UUID, federation_context_id: str + ) -> tuple[PartnerOP, FederationContext]: + partner = await self._partner(partner_op_id) + context = await self._contexts.find_outbound(partner.id, federation_context_id) + if context is None or context.is_terminated(): + raise FederationContextUnknown(partner.id) + return partner, context + + +def _accepted( + partner: PartnerOP, response: EwbiResponse, contract: type[BaseModel] | None = None +) -> object: + if not response.is_success(): + raise PartnerRejectedRequest(partner.id, response.status_code, response.body) + if contract is not None: + try: + contract.model_validate(response.body) + except ValidationError: + raise PartnerResponseInvalid(partner.id) from None + return response.body diff --git a/src/federation_manager/contracts/ewbi.py b/src/federation_manager/contracts/ewbi.py index a1d4eb6..c37724f 100644 --- a/src/federation_manager/contracts/ewbi.py +++ b/src/federation_manager/contracts/ewbi.py @@ -115,6 +115,11 @@ class FederationRequestData(BaseModel): validation_alias="origOPMobileNetworkCodes", serialization_alias="origOPMobileNetworkCodes", ) + orig_op_fixed_network_codes: list[str] | None = Field( + default=None, + validation_alias="origOPFixedNetworkCodes", + serialization_alias="origOPFixedNetworkCodes", + ) class FederationResponseData(BaseModel): @@ -148,6 +153,83 @@ class FederationResponseData(BaseModel): ) +class FederationContextIdResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + federation_context_id: str = Field( + validation_alias="FederationContextId", serialization_alias="FederationContextId" + ) + + +class ZoneDetails(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + zone_id: str = Field(validation_alias="zoneId", serialization_alias="zoneId") + geography_details: str = Field( + validation_alias="geographyDetails", serialization_alias="geographyDetails" + ) + + +class FederationDetails(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + offered_availability_zones: list[ZoneDetails] | None = Field( + default=None, + min_length=1, + validation_alias="offeredAvailabilityZones", + serialization_alias="offeredAvailabilityZones", + ) + platform_caps: list[str] | None = Field( + default=None, validation_alias="platformCaps", serialization_alias="platformCaps" + ) + + +class ZoneRegistrationRequestData(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + accepted_availability_zones: list[str] = Field( + min_length=1, + validation_alias="acceptedAvailabilityZones", + serialization_alias="acceptedAvailabilityZones", + ) + avail_zone_notif_link: str | None = Field( + default=None, + validation_alias="availZoneNotifLink", + serialization_alias="availZoneNotifLink", + ) + + +class ZoneRegisteredData(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + zone_id: str = Field(validation_alias="zoneId", serialization_alias="zoneId") + reserved_compute_resources: list[dict[str, Any]] = Field( + min_length=1, + validation_alias="reservedComputeResources", + serialization_alias="reservedComputeResources", + ) + compute_resource_quota_limits: list[dict[str, Any]] = Field( + min_length=1, + validation_alias="computeResourceQuotaLimits", + serialization_alias="computeResourceQuotaLimits", + ) + flavours_supported: list[dict[str, Any]] = Field( + min_length=1, + validation_alias="flavoursSupported", + serialization_alias="flavoursSupported", + ) + + +class ZoneRegistrationResponseData(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + accepted_zone_resource_info: list[ZoneRegisteredData] = Field( + min_length=1, + validation_alias="acceptedZoneResourceInfo", + serialization_alias="acceptedZoneResourceInfo", + ) + + FederationStatus = Literal["FAILED", "TEMPORARY_FAILURE", "AVAILABLE", "LOCKED", "NOT_AVAILABLE"] diff --git a/src/federation_manager/core/config.py b/src/federation_manager/core/config.py index 45990d9..0425ce8 100644 --- a/src/federation_manager/core/config.py +++ b/src/federation_manager/core/config.py @@ -20,6 +20,7 @@ class Settings(BaseSettings): mcc: str = "214" mncs: tuple[str, ...] = ("07",) partner_status_link: str = "https://localhost/operatorplatform/federation/v1/partner-status" + avail_zone_notif_link: str | None = None platform_caps: tuple[str, ...] = ("serviceAPIs",) # Local stacks have no TLS. Never enable outside development. allow_insecure_partner_endpoints: bool = False diff --git a/src/federation_manager/dependencies.py b/src/federation_manager/dependencies.py index 46c47b1..c37dcb7 100644 --- a/src/federation_manager/dependencies.py +++ b/src/federation_manager/dependencies.py @@ -15,8 +15,13 @@ from federation_manager.application.authentication import PartnerAuthenticator from federation_manager.application.authorization import AgreementChecker from federation_manager.application.capabilities import InboundCapabilityService from federation_manager.application.deployment import InboundDeploymentService -from federation_manager.application.federation import InboundFederationService, LocalOperator +from federation_manager.application.federation import ( + FederationEstablishmentService, + InboundFederationService, + LocalOperator, +) from federation_manager.application.outbound import OutboundFederationService +from federation_manager.application.partner_federations import PartnerFederationService from federation_manager.application.queries import InboundQueryService from federation_manager.core.config import get_settings from federation_manager.domain.ports import ( @@ -110,21 +115,34 @@ def get_outbound_federation_service( ) +def get_local_operator() -> LocalOperator: + settings = get_settings() + return LocalOperator( + federation_id=settings.federation_id, + country_code=settings.country_code, + mcc=settings.mcc, + mncs=settings.mncs, + partner_status_link=settings.partner_status_link, + platform_caps=settings.platform_caps, + avail_zone_notif_link=settings.avail_zone_notif_link, + ) + + def get_inbound_federation_service( context_repo: Annotated[FederationContextRepositoryPort, Depends(get_federation_context_repo)], + local: Annotated[LocalOperator, Depends(get_local_operator)], ) -> InboundFederationService: - settings = get_settings() - return InboundFederationService( - context_repo, - LocalOperator( - federation_id=settings.federation_id, - country_code=settings.country_code, - mcc=settings.mcc, - mncs=settings.mncs, - partner_status_link=settings.partner_status_link, - platform_caps=settings.platform_caps, - ), - ) + return InboundFederationService(context_repo, local) + + +def get_partner_federation_service( + partner_repo: Annotated[PartnerRepositoryPort, Depends(get_partner_repo)], + context_repo: Annotated[FederationContextRepositoryPort, Depends(get_federation_context_repo)], + ewbi_client: Annotated[EwbiClientPort, Depends(get_ewbi_client)], + local: Annotated[LocalOperator, Depends(get_local_operator)], +) -> PartnerFederationService: + establishment = FederationEstablishmentService(partner_repo, context_repo, ewbi_client, local) + return PartnerFederationService(partner_repo, context_repo, ewbi_client, establishment, local) def get_databus_health(request: Request) -> DataBusHealthPort: diff --git a/src/federation_manager/domain/errors.py b/src/federation_manager/domain/errors.py index ac21036..4de1b04 100644 --- a/src/federation_manager/domain/errors.py +++ b/src/federation_manager/domain/errors.py @@ -66,11 +66,28 @@ class FederationContextMissing(FederationError): self.partner_id = partner_id -class FederationEstablishmentFailed(FederationError): - def __init__(self, partner_id: UUID, status_code: int) -> None: - super().__init__(f"partner {partner_id} rejected federation setup with {status_code}") +class PartnerRejectedRequest(FederationError): + def __init__(self, partner_id: UUID, status_code: int, body: object | None = None) -> None: + super().__init__(f"partner {partner_id} rejected the request with {status_code}") self.partner_id = partner_id self.status_code = status_code + self.body = body + + +class FederationEstablishmentFailed(PartnerRejectedRequest): + pass + + +class FederationIdentityMismatch(FederationError): + def __init__(self, field: str) -> None: + super().__init__(f"{field} does not match this operator") + self.field = field + + +class PartnerNotRegistered(FederationError): + def __init__(self, partner_id: UUID) -> None: + super().__init__(f"no partner registered with id {partner_id}") + self.partner_id = partner_id class PartnerResponseInvalid(FederationError): diff --git a/src/federation_manager/domain/ewbi.py b/src/federation_manager/domain/ewbi.py index 1b9d6f7..51d7d4f 100644 --- a/src/federation_manager/domain/ewbi.py +++ b/src/federation_manager/domain/ewbi.py @@ -23,5 +23,22 @@ def service_api_name(api_type: str) -> str: def api_forwarding_path(federation_context_id: str, service_api: str) -> str: - # The context id is issued by the partner; encode it so it cannot escape its path segment. - return f"{EWBI_BASE_PATH}/{quote(federation_context_id, safe='')}/apiservice/{service_api}" + return f"{EWBI_BASE_PATH}/{_segment(federation_context_id)}/apiservice/{service_api}" + + +def federation_path(federation_context_id: str) -> str: + return f"{EWBI_BASE_PATH}/{_segment(federation_context_id)}/partner" + + +def zones_path(federation_context_id: str) -> str: + return f"{EWBI_BASE_PATH}/{_segment(federation_context_id)}/zones" + + +def zone_path(federation_context_id: str, zone_id: str) -> str: + return f"{zones_path(federation_context_id)}/{_segment(zone_id)}" + + +def _segment(value: str) -> str: + # Context and zone ids come from partners and callers; encode them so they cannot escape + # their path segment. + return quote(value, safe="") diff --git a/src/federation_manager/domain/ports.py b/src/federation_manager/domain/ports.py index 0a79619..5f0d43b 100644 --- a/src/federation_manager/domain/ports.py +++ b/src/federation_manager/domain/ports.py @@ -35,6 +35,12 @@ class EwbiClientPort(Protocol): self, partner: PartnerOP, path: str, payload: dict[str, object] ) -> EwbiResponse: ... + async def get( + self, partner: PartnerOP, path: str, params: dict[str, str] | None = None + ) -> EwbiResponse: ... + + async def delete(self, partner: PartnerOP, path: str) -> EwbiResponse: ... + class SrmQueryPort(Protocol): async def query_location(self, request: LocationQueryRequestV1) -> LocationQueryResponseV1: ... @@ -55,6 +61,10 @@ class FederationContextRepositoryPort(Protocol): self, partner_id: UUID, federation_context_id: str ) -> FederationContext | None: ... + async def find_outbound( + self, partner_id: UUID, federation_context_id: str + ) -> FederationContext | None: ... + async def find_active_inbound(self, partner_id: UUID) -> FederationContext | None: ... async def add(self, context: FederationContext) -> None: ... diff --git a/src/federation_manager/main.py b/src/federation_manager/main.py index 129059e..48589c1 100644 --- a/src/federation_manager/main.py +++ b/src/federation_manager/main.py @@ -33,6 +33,9 @@ from federation_manager.api.ewbi.v1.lcm import router as ewbi_lcm_router from federation_manager.api.ewbi.v1.management import router as ewbi_management_router from federation_manager.api.ewbi.v1.service import router as ewbi_service_router from federation_manager.api.internal.federation import router as internal_federation_router +from federation_manager.api.internal.partner_federations import ( + router as internal_partner_federations_router, +) from federation_manager.api.platform.health import router as health_router from federation_manager.application.events import OperationCompletedConsumer from federation_manager.application.federation import ( @@ -142,6 +145,7 @@ def create_app(lifespan: Lifespan[FastAPI] | None = None) -> FastAPI: app.include_router(ewbi_lcm_router) app.include_router(ewbi_service_router) app.include_router(internal_federation_router) + app.include_router(internal_partner_federations_router) return app diff --git a/tests/fakes.py b/tests/fakes.py index c7b983f..1aa54a4 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -89,10 +89,20 @@ class InMemoryFederationContextRepo: async def find_inbound( self, partner_id: UUID, federation_context_id: str ) -> FederationContext | None: - for context in self.contexts: + return self._find(partner_id, "inbound", federation_context_id) + + async def find_outbound( + self, partner_id: UUID, federation_context_id: str + ) -> FederationContext | None: + return self._find(partner_id, "outbound", federation_context_id) + + def _find( + self, partner_id: UUID, direction: str, federation_context_id: str + ) -> FederationContext | None: + for context in reversed(self.contexts): if ( context.partner_op_id == partner_id - and context.direction == "inbound" + and context.direction == direction and context.federation_context_id == federation_context_id ): return context @@ -219,9 +229,22 @@ class FakeEwbiClient: self._response = response or EwbiResponse(status_code=200, body={"ok": True}) self._error = error self.calls: list[tuple[UUID, str, dict[str, object]]] = [] + self.requests: list[tuple[str, str, object]] = [] async def post(self, partner: PartnerOP, path: str, payload: dict[str, object]) -> EwbiResponse: self.calls.append((partner.id, path, payload)) + return self._answer("POST", path, payload) + + async def get( + self, partner: PartnerOP, path: str, params: dict[str, str] | None = None + ) -> EwbiResponse: + return self._answer("GET", path, params) + + async def delete(self, partner: PartnerOP, path: str) -> EwbiResponse: + return self._answer("DELETE", path, None) + + def _answer(self, method: str, path: str, data: object) -> EwbiResponse: + self.requests.append((method, path, data)) if self._error is not None: raise self._error return self._response diff --git a/tests/test_ewbi_client.py b/tests/test_ewbi_client.py index 76b8134..24a39aa 100644 --- a/tests/test_ewbi_client.py +++ b/tests/test_ewbi_client.py @@ -77,6 +77,32 @@ async def test_posts_payload_with_bearer_token_and_only_standard_headers() -> No assert token_provider.calls == [(partner.id, "fed-mgmt")] +async def test_get_sends_query_parameters_and_delete_sends_no_body() -> None: + seen: list[tuple[str, str, bytes]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.method, str(request.url), request.content)) + assert request.headers["Authorization"] == "Bearer partner-access-token" + return httpx.Response(200) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + client = HttpxEwbiClient(http_client, FakeTokenProvider()) + fetched = await client.get( + _partner(), "/operatorplatform/federation/v1/ctx-1/zones", {"zoneId": "zone-1"} + ) + deleted = await client.delete(_partner(), "/operatorplatform/federation/v1/ctx-1/partner") + + assert (fetched.body, deleted.body) == (None, None) + assert seen == [ + ( + "GET", + "https://partner.example/operatorplatform/federation/v1/ctx-1/zones?zoneId=zone-1", + b"", + ), + ("DELETE", "https://partner.example/operatorplatform/federation/v1/ctx-1/partner", b""), + ] + + async def test_returns_partner_http_error_as_response() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(403, json={"type": "agreement-violation"}) diff --git a/tests/test_partner_federations.py b/tests/test_partner_federations.py new file mode 100644 index 0000000..a9fb43e --- /dev/null +++ b/tests/test_partner_federations.py @@ -0,0 +1,337 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import replace +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from federation_manager.application.federation import LocalOperator +from federation_manager.dependencies import ( + get_ewbi_client, + get_federation_context_repo, + get_local_operator, + get_partner_repo, +) +from federation_manager.domain.models import EwbiResponse, FederationContext, PartnerOP +from federation_manager.main import create_app +from tests.fakes import FakeEwbiClient, InMemoryFederationContextRepo, InMemoryPartnerRepo + +STATUS_LINK = "https://us.example/operatorplatform/federation/v1/partner-status" +ZONE_LINK = "https://us.example/operatorplatform/federation/v1/zone-notifications" +LOCAL = LocalOperator( + federation_id="oop-i2cat", + country_code="ES", + mcc="214", + mncs=("07", "01"), + partner_status_link=STATUS_LINK, + avail_zone_notif_link=ZONE_LINK, +) +CONTEXT_ID = "fed-ctx-partner-a" +PROBLEM = {"title": "Unprocessable Entity", "cause": "MANDATORY_PARAMS_MISSING"} +ZONE = { + "zoneId": "zone-madrid-1", + "reservedComputeResources": [{"cpuArchType": "ISA_X86_64", "numCPU": 4, "memory": 8192}], + "computeResourceQuotaLimits": [{"cpuArchType": "ISA_X86_64", "numCPU": 8, "memory": 16384}], + "flavoursSupported": [{"flavourId": "small", "cpuArchType": "ISA_X86_64"}], +} + + +@asynccontextmanager +async def _no_infra(app: FastAPI) -> AsyncIterator[None]: + yield + + +def _partner() -> PartnerOP: + return PartnerOP( + id=uuid4(), + mcc_mnc="208-01", + oauth2_client_id="partner-a", + status="active", + base_url="https://partner-a.example", + ) + + +def _context(partner: PartnerOP, status: str = "available") -> FederationContext: + return FederationContext( + id=uuid4(), + partner_op_id=partner.id, + direction="outbound", + federation_context_id=CONTEXT_ID, + status=status, + created_at=datetime(2026, 9, 1, tzinfo=timezone.utc), + ) + + +class Harness: + def __init__(self, response: EwbiResponse | None = None, *, federated: bool = True) -> None: + self.partner = _partner() + self.contexts = InMemoryFederationContextRepo([_context(self.partner)] if federated else []) + self.ewbi = FakeEwbiClient(response or EwbiResponse(200, None)) + app = create_app(lifespan=_no_infra) + app.dependency_overrides[get_partner_repo] = lambda: InMemoryPartnerRepo([self.partner]) + app.dependency_overrides[get_federation_context_repo] = lambda: self.contexts + app.dependency_overrides[get_ewbi_client] = lambda: self.ewbi + app.dependency_overrides[get_local_operator] = lambda: LOCAL + self.client = TestClient(app) + + @property + def base(self) -> str: + return f"/internal/partners/{self.partner.id}/federations" + + def context_status(self) -> str: + return self.contexts.contexts[0].status + + +def _accepted() -> dict[str, Any]: + return { + "federationContextId": CONTEXT_ID, + "platformCaps": ["serviceAPIs"], + "edgeDiscoveryServiceEndPoint": {"port": 443, "fqdn": "discovery.partner-a.example"}, + } + + +def _problem_type(response: Any) -> str: + return str(response.json()["type"]).rsplit(":", 1)[-1] + + +def test_create_fills_our_identity_and_returns_the_partner_answer_unchanged() -> None: + h = Harness(EwbiResponse(200, _accepted()), federated=False) + + response = h.client.post( + h.base, + json={ + "initialDate": "2026-09-15T10:00:00Z", + "partnerStatusLink": STATUS_LINK, + "origOPMobileNetworkCodes": {"mcc": "214", "mncs": ["07"]}, + }, + ) + + assert response.status_code == 200 + assert response.json() == _accepted() + assert h.ewbi.requests == [ + ( + "POST", + "/operatorplatform/federation/v1/partner", + { + "initialDate": "2026-09-15T10:00:00Z", + "partnerStatusLink": STATUS_LINK, + "origOPFederationId": "oop-i2cat", + "origOPCountryCode": "ES", + "origOPMobileNetworkCodes": {"mcc": "214", "mncs": ["07"]}, + }, + ) + ] + [context] = h.contexts.contexts + assert (context.direction, context.federation_context_id, context.status) == ( + "outbound", + CONTEXT_ID, + "available", + ) + assert context.status_callback_url == STATUS_LINK + + +@pytest.mark.parametrize( + "claim", + [ + {"origOPFederationId": "someone-else"}, + {"origOPCountryCode": "FR"}, + {"origOPMobileNetworkCodes": {"mcc": "208", "mncs": ["07"]}}, + {"origOPMobileNetworkCodes": {"mcc": "214", "mncs": ["07", "99"]}}, + {"origOPFixedNetworkCodes": ["33100"]}, + {"partnerStatusLink": "https://attacker.example/status"}, + ], +) +def test_create_rejects_a_request_that_speaks_for_another_operator(claim: dict[str, Any]) -> None: + h = Harness(federated=False) + + response = h.client.post( + h.base, + json={"initialDate": "2026-09-15T10:00:00Z", "partnerStatusLink": STATUS_LINK, **claim}, + ) + + assert response.status_code == 400 + assert _problem_type(response) == "federation-identity-mismatch" + assert h.ewbi.requests == [] + + +def test_create_while_already_federated_is_a_conflict() -> None: + h = Harness() + + response = h.client.post( + h.base, json={"initialDate": "2026-09-15T10:00:00Z", "partnerStatusLink": STATUS_LINK} + ) + + assert response.status_code == 409 + assert _problem_type(response) == "federation-exists" + assert h.ewbi.requests == [] + + +def test_partner_rejection_is_relayed_unchanged_and_nothing_is_stored() -> None: + h = Harness(EwbiResponse(422, PROBLEM), federated=False) + + response = h.client.post( + h.base, json={"initialDate": "2026-09-15T10:00:00Z", "partnerStatusLink": STATUS_LINK} + ) + + assert response.status_code == 422 + assert response.headers["content-type"] == "application/problem+json" + assert response.json() == PROBLEM + assert h.contexts.contexts == [] + + +def test_a_partner_answer_outside_the_contract_is_a_bad_gateway() -> None: + h = Harness(EwbiResponse(200, {"platformCaps": ["serviceAPIs"]}), federated=False) + + response = h.client.post( + h.base, json={"initialDate": "2026-09-15T10:00:00Z", "partnerStatusLink": STATUS_LINK} + ) + + assert response.status_code == 502 + assert _problem_type(response) == "partner-response-invalid" + assert h.contexts.contexts == [] + + +def test_unregistered_partner_is_not_found() -> None: + h = Harness() + + response = h.client.get(f"/internal/partners/{uuid4()}/federations") + + assert response.status_code == 404 + assert _problem_type(response) == "partner-not-registered" + + +def test_context_id_is_answered_from_our_own_records() -> None: + h = Harness() + + response = h.client.get(h.base) + + assert response.status_code == 200 + assert response.json() == {"FederationContextId": CONTEXT_ID} + assert h.ewbi.requests == [] + + +def test_context_id_without_a_federation_is_not_found() -> None: + h = Harness(federated=False) + + response = h.client.get(h.base) + + assert response.status_code == 404 + assert _problem_type(response) == "federation-context-unknown" + + +def test_details_are_fetched_from_the_partner() -> None: + details = {"platformCaps": ["serviceAPIs"], "lcmServiceEndPoint": {"port": 8443}} + h = Harness(EwbiResponse(200, details)) + + response = h.client.get(f"{h.base}/{CONTEXT_ID}") + + assert response.status_code == 200 + assert response.json() == details + assert h.ewbi.requests == [ + ("GET", f"/operatorplatform/federation/v1/{CONTEXT_ID}/partner", None) + ] + + +def test_a_context_that_belongs_to_another_partner_is_not_found() -> None: + h = Harness() + h.contexts.contexts[0] = replace(h.contexts.contexts[0], partner_op_id=uuid4()) + + response = h.client.get(f"{h.base}/{CONTEXT_ID}") + + assert response.status_code == 404 + assert _problem_type(response) == "federation-context-unknown" + assert h.ewbi.requests == [] + + +def test_a_partner_that_no_longer_knows_the_context_has_terminated_it() -> None: + h = Harness(EwbiResponse(404, PROBLEM)) + + response = h.client.get(f"{h.base}/{CONTEXT_ID}") + + assert response.status_code == 404 + assert h.context_status() == "terminated" + assert h.client.get(h.base).status_code == 404 + + +@pytest.mark.parametrize( + ("partner_status", "local_status"), + [(200, "terminated"), (404, "terminated"), (409, "terminated"), (500, "available")], +) +def test_delete_terminates_locally_only_once_the_partner_has_let_go( + partner_status: int, local_status: str +) -> None: + h = Harness(EwbiResponse(partner_status, PROBLEM if partner_status >= 400 else None)) + + response = h.client.delete(f"{h.base}/{CONTEXT_ID}") + + assert response.status_code == partner_status + assert h.ewbi.requests == [ + ("DELETE", f"/operatorplatform/federation/v1/{CONTEXT_ID}/partner", None) + ] + assert h.context_status() == local_status + + +def test_zone_data_forwards_the_zone_filter() -> None: + h = Harness(EwbiResponse(200, ZONE)) + + response = h.client.get(f"{h.base}/{CONTEXT_ID}/zones", params={"zoneId": "zone-madrid-1"}) + + assert response.status_code == 200 + assert response.json() == ZONE + assert h.ewbi.requests == [ + ("GET", f"/operatorplatform/federation/v1/{CONTEXT_ID}/zones", {"zoneId": "zone-madrid-1"}) + ] + + +def test_zone_subscription_carries_our_notification_link() -> None: + accepted = {"acceptedZoneResourceInfo": [ZONE]} + h = Harness(EwbiResponse(200, accepted)) + + response = h.client.post( + f"{h.base}/{CONTEXT_ID}/zones", json={"acceptedAvailabilityZones": ["zone-madrid-1"]} + ) + foreign = h.client.post( + f"{h.base}/{CONTEXT_ID}/zones", + json={ + "acceptedAvailabilityZones": ["zone-madrid-1"], + "availZoneNotifLink": "https://attacker.example/zones", + }, + ) + + assert response.status_code == 200 + assert response.json() == accepted + assert h.ewbi.requests == [ + ( + "POST", + f"/operatorplatform/federation/v1/{CONTEXT_ID}/zones", + {"acceptedAvailabilityZones": ["zone-madrid-1"], "availZoneNotifLink": ZONE_LINK}, + ) + ] + assert foreign.status_code == 400 + assert _problem_type(foreign) == "federation-identity-mismatch" + + +def test_zone_details_and_unsubscribe_address_the_zone_by_its_encoded_id() -> None: + h = Harness(EwbiResponse(200, ZONE)) + + details = h.client.get(f"{h.base}/{CONTEXT_ID}/zones/zone madrid") + removed = h.client.delete(f"{h.base}/{CONTEXT_ID}/zones/zone-madrid-1") + + assert (details.status_code, removed.status_code) == (200, 200) + assert [request[:2] for request in h.ewbi.requests] == [ + ("GET", f"/operatorplatform/federation/v1/{CONTEXT_ID}/zones/zone%20madrid"), + ("DELETE", f"/operatorplatform/federation/v1/{CONTEXT_ID}/zones/zone-madrid-1"), + ] + + +def test_an_unknown_zone_does_not_end_the_federation() -> None: + h = Harness(EwbiResponse(404, PROBLEM)) + + response = h.client.delete(f"{h.base}/{CONTEXT_ID}/zones/zone-madrid-1") + + assert response.status_code == 404 + assert h.context_status() == "available" -- GitLab From d8d447f7d5af45268026f4a9e9b4c98739d6c81b Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Tue, 22 Sep 2026 07:02:15 +0200 Subject: [PATCH 2/3] feat(fm): replace create_all with alembic migrations --- alembic.ini | 40 ++++ pyproject.toml | 6 + .../adapters/database/core.py | 56 ++++- src/federation_manager/main.py | 4 +- src/federation_manager/migrations/env.py | 79 +++++++ .../migrations/script.py.mako | 25 ++ .../versions/0001_baseline_fm_db.py | 217 ++++++++++++++++++ .../test_federation_context_repo.py | 4 +- tests/integration/test_fm_srm_loop.py | 4 +- tests/integration/test_outbound_repos.py | 6 +- tests/integration/test_partner_repo.py | 6 +- .../integration/test_two_stack_federation.py | 4 +- 12 files changed, 434 insertions(+), 17 deletions(-) create mode 100644 alembic.ini create mode 100644 src/federation_manager/migrations/env.py create mode 100644 src/federation_manager/migrations/script.py.mako create mode 100644 src/federation_manager/migrations/versions/0001_baseline_fm_db.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..d550569 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,40 @@ +# Alembic configuration. The database URL is not set here: env.py reads it from +# Settings.postgres_url so migrations and the app never disagree about the target. +[alembic] +script_location = src/federation_manager/migrations +prepend_sys_path = src +version_path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/pyproject.toml b/pyproject.toml index 68a8346..0f7b49e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "Federation Manager (OOP Release 2.0)" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "alembic>=1.13", "asyncpg>=0.30", "fastapi[standard]>=0.115", "httpx>=0.27", @@ -34,6 +35,11 @@ dev = [ where = ["src"] include = ["federation_manager*"] +[tool.setuptools.package-data] +# Migrations ship with the package so the container image, which copies only src/, +# can run them at startup. +federation_manager = ["migrations/*.py", "migrations/*.mako", "migrations/versions/*.py"] + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/src/federation_manager/adapters/database/core.py b/src/federation_manager/adapters/database/core.py index 752a3a3..d354fae 100644 --- a/src/federation_manager/adapters/database/core.py +++ b/src/federation_manager/adapters/database/core.py @@ -1,3 +1,10 @@ +from pathlib import Path + +import structlog +from alembic import command +from alembic.config import Config +from sqlalchemy import inspect +from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -5,7 +12,9 @@ from sqlalchemy.ext.asyncio import ( create_async_engine, ) -from federation_manager.adapters.database.tables import metadata +logger: structlog.BoundLogger = structlog.get_logger(__name__) + +BASELINE_REVISION = "0001_baseline" def build_engine(url: str, echo: bool = False) -> AsyncEngine: @@ -16,6 +25,47 @@ def build_session_maker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession] return async_sessionmaker(engine, expire_on_commit=False) -async def create_schema(engine: AsyncEngine) -> None: +def _alembic_config() -> Config: + """Config built in code, not from alembic.ini. + + The migrations ship inside the package, so they are present in the container + image, which copies only src/. alembic.ini exists for the developer CLI. + """ + migrations = Path(__file__).resolve().parents[2] / "migrations" + + config = Config() + config.set_main_option("script_location", str(migrations)) + + return config + + +def _needs_baseline_stamp(connection: Connection) -> bool: + """True for a database built by the old create_all path. + + Those have the tables but no alembic_version, so an upgrade would try to create + what is already there. Stamping adopts them once; afterwards this is never true. + """ + tables = set(inspect(connection).get_table_names()) + + return "alembic_version" not in tables and "partner_ops" in tables + + +def _upgrade(connection: Connection) -> None: + config = _alembic_config() + config.attributes["connection"] = connection + + if _needs_baseline_stamp(connection): + logger.warning("adopting_pre_alembic_database", stamped_as=BASELINE_REVISION) + command.stamp(config, BASELINE_REVISION) + + command.upgrade(config, "head") + + +async def run_migrations(engine: AsyncEngine) -> None: + """Bring fm_db to head. + + Replaces metadata.create_all, which only ever created missing tables and so could + not add a column to a database that already existed. + """ async with engine.begin() as conn: - await conn.run_sync(metadata.create_all) + await conn.run_sync(_upgrade) diff --git a/src/federation_manager/main.py b/src/federation_manager/main.py index 48589c1..f148a38 100644 --- a/src/federation_manager/main.py +++ b/src/federation_manager/main.py @@ -10,7 +10,7 @@ from federation_manager import __version__ from federation_manager.adapters.database.core import ( build_engine, build_session_maker, - create_schema, + run_migrations, ) from federation_manager.adapters.database.federation_context_repo import ( PostgresFederationContextRepo, @@ -54,7 +54,7 @@ async def default_lifespan(app: FastAPI) -> AsyncIterator[None]: settings = get_settings() configure_logging(settings.log_level) engine = build_engine(settings.postgres_url, echo=settings.postgres_echo) - await create_schema(engine) + await run_migrations(engine) app.state.session_maker = build_session_maker(engine) app.state.jwt_validator = KeycloakJwtValidator(settings.keycloak_issuer) publisher = NatsCommandPublisher(settings.nats_url) diff --git a/src/federation_manager/migrations/env.py b/src/federation_manager/migrations/env.py new file mode 100644 index 0000000..bc92b57 --- /dev/null +++ b/src/federation_manager/migrations/env.py @@ -0,0 +1,79 @@ +"""Alembic environment. + +The URL comes from Settings, not alembic.ini, so a migration run can never target a +different database than the application. +""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from sqlalchemy.pool import NullPool + +from federation_manager.adapters.database.tables import metadata +from federation_manager.core.config import get_settings + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = metadata + +config.set_main_option("sqlalchemy.url", get_settings().postgres_url) + + +def _configure(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + # Without this, autogenerate produces no diff for a column whose type or + # nullability changed, which is most of what we will be doing. + compare_type=True, + compare_server_default=True, + ) + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def _run(connection: Connection) -> None: + _configure(connection) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + engine = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=NullPool, + ) + + async with engine.connect() as connection: + await connection.run_sync(_run) + + await engine.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +elif (connection := config.attributes.get("connection")) is not None: + # Called in-process by the app, which already holds a connection. + _run(connection) +else: + asyncio.run(run_migrations_online()) diff --git a/src/federation_manager/migrations/script.py.mako b/src/federation_manager/migrations/script.py.mako new file mode 100644 index 0000000..e848d9e --- /dev/null +++ b/src/federation_manager/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/src/federation_manager/migrations/versions/0001_baseline_fm_db.py b/src/federation_manager/migrations/versions/0001_baseline_fm_db.py new file mode 100644 index 0000000..55f5def --- /dev/null +++ b/src/federation_manager/migrations/versions/0001_baseline_fm_db.py @@ -0,0 +1,217 @@ +"""Baseline fm_db schema + +Captures the schema that metadata.create_all produced up to this point, so existing +databases can be stamped rather than rebuilt. + +Revision ID: 0001_baseline +Revises: +Create Date: 2026-09-21 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +revision: str = "0001_baseline" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "partner_ops", + sa.Column( + "id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid() + ), + sa.Column("mcc_mnc", sa.String(10), nullable=False, unique=True), + sa.Column("oauth2_client_id", sa.String(255), nullable=False, unique=True), + sa.Column("base_url", sa.Text, nullable=False), + sa.Column("our_client_id", sa.String(255)), + sa.Column("our_client_secret_ref", sa.Text), + sa.Column("token_endpoint", sa.Text), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + ) + op.create_index("idx_partner_ops_oauth2_client_id", "partner_ops", ["oauth2_client_id"]) + op.create_index("idx_partner_ops_status", "partner_ops", ["status"]) + + op.create_table( + "federation_agreements", + sa.Column( + "id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid() + ), + sa.Column( + "partner_op_id", + PGUUID(as_uuid=True), + sa.ForeignKey("partner_ops.id"), + nullable=False, + ), + sa.Column("permitted_api_types", JSONB, nullable=False), + sa.Column( + "permitted_zone_ids", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb") + ), + sa.Column( + "service_spec_mappings", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb") + ), + sa.Column("usage_limits", JSONB, server_default=sa.text("'{}'::jsonb")), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("valid_until", sa.DateTime(timezone=True)), + sa.Column("status", sa.String(20), nullable=False, server_default="draft"), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + ) + op.create_index("idx_federation_agreements_partner", "federation_agreements", ["partner_op_id"]) + op.create_index( + "idx_federation_agreements_status_validity", + "federation_agreements", + ["status", "valid_from", "valid_until"], + ) + + op.create_table( + "federation_contexts", + sa.Column( + "id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid() + ), + sa.Column( + "partner_op_id", + PGUUID(as_uuid=True), + sa.ForeignKey("partner_ops.id"), + nullable=False, + ), + sa.Column("agreement_id", PGUUID(as_uuid=True), sa.ForeignKey("federation_agreements.id")), + sa.Column("direction", sa.String(10), nullable=False), + sa.Column("federation_context_id", sa.String(255), nullable=False), + sa.Column("status_callback_url", sa.Text), + sa.Column("status", sa.String(20), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.UniqueConstraint( + "partner_op_id", + "direction", + "federation_context_id", + name="uq_federation_contexts_partner_direction_id", + ), + ) + + op.create_table( + "routing_rules", + sa.Column( + "id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid() + ), + sa.Column( + "partner_op_id", + PGUUID(as_uuid=True), + sa.ForeignKey("partner_ops.id"), + nullable=False, + ), + sa.Column("identifier_type", sa.String(20), nullable=False), + sa.Column("value_range", sa.String(50), nullable=False), + sa.Column("priority", sa.Integer, nullable=False, server_default="100"), + sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.UniqueConstraint( + "identifier_type", "value_range", "priority", name="uq_routing_rules_rule" + ), + ) + op.create_index( + "idx_routing_rules_type_value", "routing_rules", ["identifier_type", "value_range"] + ) + op.create_index("idx_routing_rules_partner", "routing_rules", ["partner_op_id"]) + op.create_index( + "idx_routing_rules_active", + "routing_rules", + ["is_active"], + postgresql_where=sa.text("is_active = true"), + ) + + op.create_table( + "federation_transactions", + sa.Column( + "id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid() + ), + sa.Column( + "partner_op_id", + PGUUID(as_uuid=True), + sa.ForeignKey("partner_ops.id"), + nullable=False, + ), + sa.Column("agreement_id", PGUUID(as_uuid=True), sa.ForeignKey("federation_agreements.id")), + sa.Column( + "federation_context_row_id", + PGUUID(as_uuid=True), + sa.ForeignKey("federation_contexts.id"), + ), + sa.Column("direction", sa.String(10), nullable=False), + sa.Column("federation_operation_id", PGUUID(as_uuid=True)), + sa.Column("operation_id", PGUUID(as_uuid=True)), + sa.Column("correlation_id", PGUUID(as_uuid=True)), + sa.Column("external_txn_id", sa.String(255)), + sa.Column("idempotency_key", sa.String(255)), + sa.Column("request_fingerprint", sa.CHAR(64)), + sa.Column("external_resource_id", sa.String(255)), + sa.Column("callback_url", sa.Text), + sa.Column("callback_status", sa.String(20)), + sa.Column("callback_attempts", sa.Integer, nullable=False, server_default="0"), + sa.Column("api_type", sa.String(100), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("request_summary", JSONB, nullable=False), + sa.Column("response_summary", JSONB), + sa.Column("error_detail", JSONB), + sa.Column( + "started_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + ) + op.create_index("idx_fed_tx_partner", "federation_transactions", ["partner_op_id"]) + op.create_index( + "uq_fed_tx_federation_operation_id", + "federation_transactions", + ["federation_operation_id"], + unique=True, + postgresql_where=sa.text("federation_operation_id IS NOT NULL"), + ) + op.create_index("idx_fed_tx_context", "federation_transactions", ["federation_context_row_id"]) + op.create_index("idx_fed_tx_operation", "federation_transactions", ["operation_id"]) + op.create_index("idx_fed_tx_status", "federation_transactions", ["status"]) + op.create_index("idx_fed_tx_started", "federation_transactions", ["started_at"]) + op.create_index("idx_fed_tx_external_txn", "federation_transactions", ["external_txn_id"]) + op.create_index( + "uq_fed_tx_idempotency_key", + "federation_transactions", + ["partner_op_id", "api_type", "idempotency_key"], + unique=True, + postgresql_where=sa.text("idempotency_key IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_table("federation_transactions") + op.drop_table("routing_rules") + op.drop_table("federation_contexts") + op.drop_table("federation_agreements") + op.drop_table("partner_ops") diff --git a/tests/integration/test_federation_context_repo.py b/tests/integration/test_federation_context_repo.py index 858f183..e810c3f 100644 --- a/tests/integration/test_federation_context_repo.py +++ b/tests/integration/test_federation_context_repo.py @@ -8,7 +8,7 @@ from sqlalchemy import delete, insert, select from federation_manager.adapters.database.core import ( build_engine, build_session_maker, - create_schema, + run_migrations, ) from federation_manager.adapters.database.federation_context_repo import ( PostgresFederationContextRepo, @@ -31,7 +31,7 @@ URL = os.getenv("FM_POSTGRES_URL", "postgresql+asyncpg://fm:fm@localhost:5433/fm async def test_context_repo_round_trips_through_postgres() -> None: engine = build_engine(URL) - await create_schema(engine) + await run_migrations(engine) session_maker = build_session_maker(engine) partner_id, other_id = uuid4(), uuid4() diff --git a/tests/integration/test_fm_srm_loop.py b/tests/integration/test_fm_srm_loop.py index f6c3fe1..48c0d94 100644 --- a/tests/integration/test_fm_srm_loop.py +++ b/tests/integration/test_fm_srm_loop.py @@ -17,7 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from federation_manager.adapters.database.core import ( build_engine, build_session_maker, - create_schema, + run_migrations, ) from federation_manager.adapters.database.tables import ( federation_agreements, @@ -94,7 +94,7 @@ def _token() -> str: async def _seed(specification_id: UUID, partner_id: UUID, zone_id: UUID) -> None: engine = build_engine(FM_DB) - await create_schema(engine) + await run_migrations(engine) async with build_session_maker(engine)() as session: # oauth2_client_id is unique: drop anything an interrupted run left behind stale = ( diff --git a/tests/integration/test_outbound_repos.py b/tests/integration/test_outbound_repos.py index a506187..6c49391 100644 --- a/tests/integration/test_outbound_repos.py +++ b/tests/integration/test_outbound_repos.py @@ -10,7 +10,7 @@ from federation_manager.adapters.database.agreement_repo import PostgresAgreemen from federation_manager.adapters.database.core import ( build_engine, build_session_maker, - create_schema, + run_migrations, ) from federation_manager.adapters.database.partner_repo import PostgresPartnerRepo from federation_manager.adapters.database.routing_repo import PostgresRoutingRuleRepo @@ -52,7 +52,7 @@ async def _insert_partner(session: object, partner_id: object) -> str: async def test_outbound_repos_round_trip_through_postgres() -> None: engine = build_engine(URL) - await create_schema(engine) + await run_migrations(engine) session_maker = build_session_maker(engine) partner_id, other_partner_id = uuid4(), uuid4() @@ -214,7 +214,7 @@ async def test_outbound_repos_round_trip_through_postgres() -> None: async def test_idempotency_key_is_unique_per_partner_and_api_type() -> None: engine = build_engine(URL) - await create_schema(engine) + await run_migrations(engine) session_maker = build_session_maker(engine) partner_id = uuid4() diff --git a/tests/integration/test_partner_repo.py b/tests/integration/test_partner_repo.py index 7d36e65..e92a476 100644 --- a/tests/integration/test_partner_repo.py +++ b/tests/integration/test_partner_repo.py @@ -7,7 +7,7 @@ from sqlalchemy import delete, insert from federation_manager.adapters.database.core import ( build_engine, build_session_maker, - create_schema, + run_migrations, ) from federation_manager.adapters.database.partner_repo import PostgresPartnerRepo from federation_manager.adapters.database.tables import partner_ops @@ -29,7 +29,7 @@ def _validator(client_id: str) -> FakeJwtValidator: async def test_repo_reads_partner_from_postgres() -> None: engine = build_engine(URL) - await create_schema(engine) + await run_migrations(engine) session_maker = build_session_maker(engine) client_id = f"partner-{uuid4().hex[:8]}" @@ -74,7 +74,7 @@ async def test_repo_reads_partner_from_postgres() -> None: async def test_suspended_partner_rejected_against_postgres() -> None: engine = build_engine(URL) - await create_schema(engine) + await run_migrations(engine) session_maker = build_session_maker(engine) client_id = f"partner-{uuid4().hex[:8]}" diff --git a/tests/integration/test_two_stack_federation.py b/tests/integration/test_two_stack_federation.py index 755b5f1..47253e8 100644 --- a/tests/integration/test_two_stack_federation.py +++ b/tests/integration/test_two_stack_federation.py @@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from federation_manager.adapters.database.core import ( build_engine, build_session_maker, - create_schema, + run_migrations, ) from federation_manager.adapters.database.federation_context_repo import ( PostgresFederationContextRepo, @@ -175,7 +175,7 @@ def stacks(tmp_path: Path) -> Iterator[Stacks]: ), ): engine = build_engine(f"{PG_ROOT}/{database}") - await create_schema(engine) + await run_migrations(engine) async with build_session_maker(engine)() as session: await session.execute(insert(partner_ops).values(**values)) await session.commit() -- GitLab From 446d9b9d88fe740c99573eb0f5383defe0e90ff6 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Tue, 22 Sep 2026 07:06:07 +0200 Subject: [PATCH 3/3] lock alembic --- uv.lock | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/uv.lock b/uv.lock index 2f3ec50..eedd7a7 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/15/504337e302d070bd2ff2f39418bb17d2ad73c662c403a4f37cb4e5c5b237/agent_detector-2.0.0-py3-none-any.whl", hash = "sha256:22c6a1c9c23894a12f55aea625f060b8d3330fba774de72d02d5442bb580417f", size = 9352, upload-time = "2026-09-02T14:19:50.971Z" }, ] +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -623,6 +637,7 @@ name = "federation-manager" version = "2.0.0" source = { editable = "." } dependencies = [ + { name = "alembic" }, { name = "asyncpg" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, @@ -646,6 +661,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.13" }, { name = "asyncpg", specifier = ">=0.30" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, @@ -767,6 +783,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/f5/7af1dd74b9608a85274414fc423a7162844492b198ece3fe31f4e1f88237/grimp-3.17-cp313-cp313-win32.whl", hash = "sha256:34369739ed293accbb674494ae932351516b316e8e85d1d333d4c51796af846a", size = 1874244, upload-time = "2026-09-04T11:29:21.693Z" }, { url = "https://files.pythonhosted.org/packages/8b/d4/af497adddfcf11f9bd4ab273625a49022bf5ba4f5fc389589f4e6edc67b9/grimp-3.17-cp313-cp313-win_amd64.whl", hash = "sha256:d21b859c50418bdb48b403a84450650fa7aab3102f3212d00d3c21c76a982167", size = 1998225, upload-time = "2026-09-04T11:29:10.5Z" }, { url = "https://files.pythonhosted.org/packages/e2/2a/07fd537997281049af2390fc8641de5ae063ae4972cb6fcec85873674595/grimp-3.17-cp313-cp313-win_arm64.whl", hash = "sha256:206cd9f7c757b97934f48ba2b8ec6d649725fd6314d6b182dd327b19b4f35707", size = 1922071, upload-time = "2026-09-04T11:28:58.34Z" }, + { url = "https://files.pythonhosted.org/packages/39/e0/e11ea5aeb62534c9706e17952d142aa844ce5659e2d66b4b8ba9377407d6/grimp-3.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0100ff6f08067c8eb8a77dd1433af5e173aea8dbf6a6b76f5372a3b49f2e0a43", size = 2161696, upload-time = "2026-09-11T10:36:49.104Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d6/ddf82f4428d8bdc54ced0db244cbf5d8e5638490c3ed6a8b9f5db55dfa51/grimp-3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:67ada40a5c1cf4321183c0b0cd0393658ccd04b845caed002ac81dc2fdef828e", size = 2107301, upload-time = "2026-09-11T10:36:46.459Z" }, { url = "https://files.pythonhosted.org/packages/86/1a/1dd0d9d2751e8078547e1cb7131e13d9624a2ea60700d926b3d624c15825/grimp-3.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa385915a4c17948bfb25f77509cbbfb33795f321d1c5ca7cc602e34ed58e99", size = 2278433, upload-time = "2026-09-04T11:27:03.075Z" }, { url = "https://files.pythonhosted.org/packages/58/71/3768fd253e7455a83d668193055691c82d3a4d434047116c572a27d2bd39/grimp-3.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8322a435b5ee97ff3db823df36cbefea569faec505cc0ca66223c27b4b44cca6", size = 2221152, upload-time = "2026-09-04T11:27:12.642Z" }, { url = "https://files.pythonhosted.org/packages/b4/4f/907a130e87554d57be14c2a7cedeb818d0ef519064877297d19636735f6b/grimp-3.17-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb953c21beb6ac85a59b5b54128be7fdddd89396d34b3c5705cf59829a85b431", size = 2371546, upload-time = "2026-09-04T11:27:41.195Z" }, @@ -1028,6 +1046,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" -- GitLab