Commit 496eb1ef authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

feat(fm): terminate a federated instance on RemoveApp

parent ba923a19
Loading
Loading
Loading
Loading
Loading
+14 −0
Original line number Diff line number Diff line
@@ -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:
+11 −0
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ from federation_manager.contracts.ewbi import InvalidParam, ProblemDetails
from federation_manager.domain.errors import (
    AgreementExpired,
    AgreementViolation,
    AppInstanceUnknown,
    AuthenticationFailed,
    FederationAlreadyExists,
    FederationContextMissing,
@@ -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(
+17 −0
Original line number Diff line number Diff line
@@ -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)
+74 −0
Original line number Diff line number Diff line
@@ -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,
)
@@ -27,6 +31,7 @@ from federation_manager.domain.ports import (
)

API_TYPE = "install-app"
TERMINATE_API_TYPE = "remove-app"
INBOUND = "inbound"


@@ -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)
+6 −0
Original line number Diff line number Diff line
@@ -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