Loading src/federation_manager/adapters/database/transaction_repo.py +14 −0 Original line number Diff line number Diff line Loading @@ -58,6 +58,20 @@ class PostgresTransactionRepo: select(transactions).where(transactions.c.operation_id == operation_id) ) async def find_by_external_resource_id( self, partner_id: UUID, api_type: str, external_resource_id: str ) -> FederationTransaction | None: return await self._one( select(transactions) .where( transactions.c.partner_op_id == partner_id, transactions.c.api_type == api_type, transactions.c.external_resource_id == external_resource_id, ) .order_by(transactions.c.started_at.desc()) .limit(1) ) async def _one(self, stmt: Select[tuple[object, ...]]) -> FederationTransaction | None: row = (await self._session.execute(stmt)).one_or_none() if row is None: Loading src/federation_manager/api/errors.py +11 −0 Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ from federation_manager.contracts.ewbi import InvalidParam, ProblemDetails from federation_manager.domain.errors import ( AgreementExpired, AgreementViolation, AppInstanceUnknown, AuthenticationFailed, FederationAlreadyExists, FederationContextMissing, Loading Loading @@ -229,6 +230,16 @@ def register_exception_handlers(app: FastAPI) -> None: request.url.path, ) @app.exception_handler(AppInstanceUnknown) async def _app_instance_unknown(request: Request, exc: AppInstanceUnknown) -> JSONResponse: return problem( 404, "app-instance-unknown", "Unknown Application Instance", "No application instance with this identifier exists for the calling partner.", request.url.path, ) @app.exception_handler(FederationContextUnknown) async def _context_unknown(request: Request, exc: FederationContextUnknown) -> JSONResponse: return problem( Loading src/federation_manager/api/ewbi/v1/lcm.py +17 −0 Original line number Diff line number Diff line Loading @@ -31,3 +31,20 @@ async def install_app( return InstallAppResponse( zone_id=accepted.zone_id, app_inst_identifier=accepted.app_instance_identifier ) @router.delete( "/{federationContextId}/application/lcm/app/{appId}/instance/{appInstanceId}/zone/{zoneId}", operation_id="RemoveApp", status_code=200, responses=EWBI_ERROR_RESPONSES, ) async def remove_app( federationContextId: FederationContextIdPath, # noqa: N803 - GSMA path template name appId: str, # noqa: N803 - GSMA path template name appInstanceId: str, # noqa: N803 - GSMA path template name zoneId: str, # noqa: N803 - GSMA path template name service: Annotated[InboundDeploymentService, Depends(get_inbound_deployment_service)], token: Annotated[str, Depends(get_bearer_token)], ) -> None: await service.terminate(token, federationContextId, appId, appInstanceId, zoneId) src/federation_manager/application/deployment.py +74 −0 Original line number Diff line number Diff line Loading @@ -10,12 +10,16 @@ from federation_manager.application.authorization import AgreementChecker from federation_manager.contracts.ewbi import InstallAppRequest from federation_manager.contracts.srm import ( SUBJECT_DEPLOY, SUBJECT_TERMINATE, DeployPayloadV1, DeployTargetV1, SrmServiceDeployV1, SrmServiceTerminateV1, TerminatePayloadV1, ) from federation_manager.domain.errors import ( AgreementViolation, AppInstanceUnknown, FederationContextUnknown, IdempotencyKeyReused, ) Loading @@ -27,6 +31,7 @@ from federation_manager.domain.ports import ( ) API_TYPE = "install-app" TERMINATE_API_TYPE = "remove-app" INBOUND = "inbound" Loading Loading @@ -140,6 +145,75 @@ class InboundDeploymentService: zone_id=request.zone_info.zone_id, app_instance_identifier=app_instance_id.hex ) async def terminate( self, token: str, federation_context_id: str, app_id: str, app_instance_id: str, zone_id: str, ) -> None: partner = await self._authenticator.authenticate(token) context = await self._contexts.find_inbound(partner.id, federation_context_id) if context is None or context.is_terminated(): raise FederationContextUnknown(partner.id) deployment = await self._transactions.find_by_external_resource_id( partner.id, API_TYPE, app_instance_id ) if deployment is None or deployment.request_summary.get("app_id") != app_id: raise AppInstanceUnknown(app_instance_id) if deployment.request_summary.get("zone_id") != zone_id: raise AppInstanceUnknown(app_instance_id) already = await self._transactions.find_by_external_resource_id( partner.id, TERMINATE_API_TYPE, app_instance_id ) if already is not None: return operation_id = self._new_id() correlation_id = self._new_id() transaction = FederationTransaction( id=self._new_id(), partner_op_id=partner.id, agreement_id=deployment.agreement_id, federation_context_row_id=context.id, direction=INBOUND, operation_id=operation_id, correlation_id=correlation_id, api_type=TERMINATE_API_TYPE, status="pending", external_resource_id=app_instance_id, callback_url=deployment.callback_url, callback_status="pending" if deployment.callback_url else None, request_summary={ "app_id": app_id, "zone_id": zone_id, "service_specification_id": deployment.request_summary.get( "service_specification_id" ), "federation_context_id": federation_context_id, }, started_at=self._clock(), ) await self._transactions.add(transaction) specification_id = deployment.request_summary.get("service_specification_id") command = SrmServiceTerminateV1( operation_id=operation_id, correlation_id=str(correlation_id), requested_at=self._clock(), app_provider_id=str(partner.id), federation_partner_ref=partner.mcc_mnc, source="federation", service_instance_id=UUID(app_instance_id), service_specification_id=UUID(str(specification_id)) if specification_id else None, terminate=TerminatePayloadV1(), ) await self._publisher.publish(SUBJECT_TERMINATE, command.model_dump(mode="json")) await self._transactions.mark_in_progress(transaction.id) def _fingerprint(request: InstallAppRequest) -> str: canonical = json.dumps(request.model_dump(mode="json", by_alias=True), sort_keys=True) Loading src/federation_manager/domain/errors.py +6 −0 Original line number Diff line number Diff line Loading @@ -91,6 +91,12 @@ class FederationContextUnknown(FederationError): self.partner_id = partner_id class AppInstanceUnknown(FederationError): def __init__(self, app_instance_id: str) -> None: super().__init__(f"no application instance {app_instance_id} for this partner") self.app_instance_id = app_instance_id class IdempotencyKeyReused(FederationError): def __init__(self, idempotency_key: str) -> None: super().__init__(f"idempotency key {idempotency_key} was reused with a different request") Loading Loading
src/federation_manager/adapters/database/transaction_repo.py +14 −0 Original line number Diff line number Diff line Loading @@ -58,6 +58,20 @@ class PostgresTransactionRepo: select(transactions).where(transactions.c.operation_id == operation_id) ) async def find_by_external_resource_id( self, partner_id: UUID, api_type: str, external_resource_id: str ) -> FederationTransaction | None: return await self._one( select(transactions) .where( transactions.c.partner_op_id == partner_id, transactions.c.api_type == api_type, transactions.c.external_resource_id == external_resource_id, ) .order_by(transactions.c.started_at.desc()) .limit(1) ) async def _one(self, stmt: Select[tuple[object, ...]]) -> FederationTransaction | None: row = (await self._session.execute(stmt)).one_or_none() if row is None: Loading
src/federation_manager/api/errors.py +11 −0 Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ from federation_manager.contracts.ewbi import InvalidParam, ProblemDetails from federation_manager.domain.errors import ( AgreementExpired, AgreementViolation, AppInstanceUnknown, AuthenticationFailed, FederationAlreadyExists, FederationContextMissing, Loading Loading @@ -229,6 +230,16 @@ def register_exception_handlers(app: FastAPI) -> None: request.url.path, ) @app.exception_handler(AppInstanceUnknown) async def _app_instance_unknown(request: Request, exc: AppInstanceUnknown) -> JSONResponse: return problem( 404, "app-instance-unknown", "Unknown Application Instance", "No application instance with this identifier exists for the calling partner.", request.url.path, ) @app.exception_handler(FederationContextUnknown) async def _context_unknown(request: Request, exc: FederationContextUnknown) -> JSONResponse: return problem( Loading
src/federation_manager/api/ewbi/v1/lcm.py +17 −0 Original line number Diff line number Diff line Loading @@ -31,3 +31,20 @@ async def install_app( return InstallAppResponse( zone_id=accepted.zone_id, app_inst_identifier=accepted.app_instance_identifier ) @router.delete( "/{federationContextId}/application/lcm/app/{appId}/instance/{appInstanceId}/zone/{zoneId}", operation_id="RemoveApp", status_code=200, responses=EWBI_ERROR_RESPONSES, ) async def remove_app( federationContextId: FederationContextIdPath, # noqa: N803 - GSMA path template name appId: str, # noqa: N803 - GSMA path template name appInstanceId: str, # noqa: N803 - GSMA path template name zoneId: str, # noqa: N803 - GSMA path template name service: Annotated[InboundDeploymentService, Depends(get_inbound_deployment_service)], token: Annotated[str, Depends(get_bearer_token)], ) -> None: await service.terminate(token, federationContextId, appId, appInstanceId, zoneId)
src/federation_manager/application/deployment.py +74 −0 Original line number Diff line number Diff line Loading @@ -10,12 +10,16 @@ from federation_manager.application.authorization import AgreementChecker from federation_manager.contracts.ewbi import InstallAppRequest from federation_manager.contracts.srm import ( SUBJECT_DEPLOY, SUBJECT_TERMINATE, DeployPayloadV1, DeployTargetV1, SrmServiceDeployV1, SrmServiceTerminateV1, TerminatePayloadV1, ) from federation_manager.domain.errors import ( AgreementViolation, AppInstanceUnknown, FederationContextUnknown, IdempotencyKeyReused, ) Loading @@ -27,6 +31,7 @@ from federation_manager.domain.ports import ( ) API_TYPE = "install-app" TERMINATE_API_TYPE = "remove-app" INBOUND = "inbound" Loading Loading @@ -140,6 +145,75 @@ class InboundDeploymentService: zone_id=request.zone_info.zone_id, app_instance_identifier=app_instance_id.hex ) async def terminate( self, token: str, federation_context_id: str, app_id: str, app_instance_id: str, zone_id: str, ) -> None: partner = await self._authenticator.authenticate(token) context = await self._contexts.find_inbound(partner.id, federation_context_id) if context is None or context.is_terminated(): raise FederationContextUnknown(partner.id) deployment = await self._transactions.find_by_external_resource_id( partner.id, API_TYPE, app_instance_id ) if deployment is None or deployment.request_summary.get("app_id") != app_id: raise AppInstanceUnknown(app_instance_id) if deployment.request_summary.get("zone_id") != zone_id: raise AppInstanceUnknown(app_instance_id) already = await self._transactions.find_by_external_resource_id( partner.id, TERMINATE_API_TYPE, app_instance_id ) if already is not None: return operation_id = self._new_id() correlation_id = self._new_id() transaction = FederationTransaction( id=self._new_id(), partner_op_id=partner.id, agreement_id=deployment.agreement_id, federation_context_row_id=context.id, direction=INBOUND, operation_id=operation_id, correlation_id=correlation_id, api_type=TERMINATE_API_TYPE, status="pending", external_resource_id=app_instance_id, callback_url=deployment.callback_url, callback_status="pending" if deployment.callback_url else None, request_summary={ "app_id": app_id, "zone_id": zone_id, "service_specification_id": deployment.request_summary.get( "service_specification_id" ), "federation_context_id": federation_context_id, }, started_at=self._clock(), ) await self._transactions.add(transaction) specification_id = deployment.request_summary.get("service_specification_id") command = SrmServiceTerminateV1( operation_id=operation_id, correlation_id=str(correlation_id), requested_at=self._clock(), app_provider_id=str(partner.id), federation_partner_ref=partner.mcc_mnc, source="federation", service_instance_id=UUID(app_instance_id), service_specification_id=UUID(str(specification_id)) if specification_id else None, terminate=TerminatePayloadV1(), ) await self._publisher.publish(SUBJECT_TERMINATE, command.model_dump(mode="json")) await self._transactions.mark_in_progress(transaction.id) def _fingerprint(request: InstallAppRequest) -> str: canonical = json.dumps(request.model_dump(mode="json", by_alias=True), sort_keys=True) Loading
src/federation_manager/domain/errors.py +6 −0 Original line number Diff line number Diff line Loading @@ -91,6 +91,12 @@ class FederationContextUnknown(FederationError): self.partner_id = partner_id class AppInstanceUnknown(FederationError): def __init__(self, app_instance_id: str) -> None: super().__init__(f"no application instance {app_instance_id} for this partner") self.app_instance_id = app_instance_id class IdempotencyKeyReused(FederationError): def __init__(self, idempotency_key: str) -> None: super().__init__(f"idempotency key {idempotency_key} was reused with a different request") Loading