From 496eb1effe925d61851bf7b1ef696ecb888d7b53 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Tue, 8 Sep 2026 16:27:32 +0200 Subject: [PATCH] feat(fm): terminate a federated instance on RemoveApp --- .../adapters/database/transaction_repo.py | 14 ++++ src/federation_manager/api/errors.py | 11 +++ src/federation_manager/api/ewbi/v1/lcm.py | 17 ++++ .../application/deployment.py | 74 +++++++++++++++++ src/federation_manager/domain/errors.py | 6 ++ src/federation_manager/domain/ports.py | 4 + tests/fakes.py | 12 +++ tests/test_install_app.py | 83 ++++++++++++++++++- 8 files changed, 220 insertions(+), 1 deletion(-) diff --git a/src/federation_manager/adapters/database/transaction_repo.py b/src/federation_manager/adapters/database/transaction_repo.py index decf07a..1665cec 100644 --- a/src/federation_manager/adapters/database/transaction_repo.py +++ b/src/federation_manager/adapters/database/transaction_repo.py @@ -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: diff --git a/src/federation_manager/api/errors.py b/src/federation_manager/api/errors.py index 50fc55b..059feba 100644 --- a/src/federation_manager/api/errors.py +++ b/src/federation_manager/api/errors.py @@ -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( diff --git a/src/federation_manager/api/ewbi/v1/lcm.py b/src/federation_manager/api/ewbi/v1/lcm.py index dd45b57..a43e134 100644 --- a/src/federation_manager/api/ewbi/v1/lcm.py +++ b/src/federation_manager/api/ewbi/v1/lcm.py @@ -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) diff --git a/src/federation_manager/application/deployment.py b/src/federation_manager/application/deployment.py index 846f5d1..5aec83c 100644 --- a/src/federation_manager/application/deployment.py +++ b/src/federation_manager/application/deployment.py @@ -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) diff --git a/src/federation_manager/domain/errors.py b/src/federation_manager/domain/errors.py index 0c542a8..4270897 100644 --- a/src/federation_manager/domain/errors.py +++ b/src/federation_manager/domain/errors.py @@ -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") diff --git a/src/federation_manager/domain/ports.py b/src/federation_manager/domain/ports.py index 2bf34a0..c6f9791 100644 --- a/src/federation_manager/domain/ports.py +++ b/src/federation_manager/domain/ports.py @@ -75,6 +75,10 @@ class TransactionRepositoryPort(Protocol): async def find_by_operation_id(self, operation_id: UUID) -> FederationTransaction | None: ... + async def find_by_external_resource_id( + self, partner_id: UUID, api_type: str, external_resource_id: str + ) -> FederationTransaction | None: ... + async def mark_in_progress(self, transaction_id: UUID) -> None: ... async def record_callback(self, transaction_id: UUID, *, status: str) -> None: ... diff --git a/tests/fakes.py b/tests/fakes.py index 7679cc8..c7b983f 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -132,6 +132,18 @@ class InMemoryTransactionRepo: return transaction return None + async def find_by_external_resource_id( + self, partner_id: UUID, api_type: str, external_resource_id: str + ) -> FederationTransaction | None: + for transaction in self.transactions.values(): + if ( + transaction.partner_op_id, + transaction.api_type, + transaction.external_resource_id, + ) == (partner_id, api_type, external_resource_id): + return transaction + return None + async def mark_in_progress(self, transaction_id: UUID) -> None: self.transactions[transaction_id].status = "in_progress" diff --git a/tests/test_install_app.py b/tests/test_install_app.py index 8854c74..597a80f 100644 --- a/tests/test_install_app.py +++ b/tests/test_install_app.py @@ -7,7 +7,7 @@ from uuid import UUID, uuid4 from fastapi import FastAPI from fastapi.testclient import TestClient -from federation_manager.contracts.srm import SUBJECT_DEPLOY +from federation_manager.contracts.srm import SUBJECT_DEPLOY, SUBJECT_TERMINATE from federation_manager.dependencies import ( get_agreement_repo, get_command_publisher, @@ -225,3 +225,84 @@ def test_incomplete_body_is_422() -> None: assert client.post(URL, json=body, headers=_headers()).status_code == 422, missing assert publisher.published == [] + + +def _install(client: TestClient, key: str = "idem-1") -> str: + response = client.post(URL, json=_body(), headers=_headers(key)) + assert response.status_code == 202 + identifier: str = response.json()["appInstIdentifier"] + return identifier + + +def _remove_url(instance_id: str, app_id: str = APP_ID, zone: str = ZONE_ID) -> str: + return ( + f"/operatorplatform/federation/v1/{CONTEXT_ID}/application/lcm" + f"/app/{app_id}/instance/{instance_id}/zone/{zone}" + ) + + +def test_removing_an_instance_publishes_the_terminate_command() -> None: + client, transactions, publisher, partner = _client() + instance_id = _install(client) + + response = client.delete(_remove_url(instance_id), headers=_bearer()) + + assert response.status_code == 200 + subject, command = publisher.published[1] + assert subject == SUBJECT_TERMINATE + assert command["service_instance_id"] == str(UUID(instance_id)) + assert command["service_specification_id"] == str(SPEC_ID) + assert command["source"] == "federation" + assert command["app_provider_id"] == str(partner.id) + + terminate = [t for t in transactions.transactions.values() if t.api_type == "remove-app"][0] + assert terminate.status == "in_progress" + assert terminate.external_resource_id == instance_id + assert terminate.operation_id == UUID(cast(str, command["operation_id"])) + assert terminate.callback_url == CALLBACK + + +def test_removing_twice_does_not_republish() -> None: + client, _, publisher, _ = _client() + instance_id = _install(client) + client.delete(_remove_url(instance_id), headers=_bearer()) + + second = client.delete(_remove_url(instance_id), headers=_bearer()) + + assert second.status_code == 200 + assert len(publisher.published) == 2 + + +def test_removing_an_unknown_instance_is_404() -> None: + client, _, publisher, _ = _client() + _install(client) + + response = client.delete(_remove_url(uuid4().hex), headers=_bearer()) + + assert response.status_code == 404 + assert response.json()["type"] == "urn:oop:ewbi:error:app-instance-unknown" + assert len(publisher.published) == 1 + + +def test_removing_with_a_mismatched_app_or_zone_is_404() -> None: + client, _, publisher, _ = _client() + instance_id = _install(client) + + wrong_app = client.delete(_remove_url(instance_id, app_id="otherApp"), headers=_bearer()) + wrong_zone = client.delete(_remove_url(instance_id, zone=str(uuid4())), headers=_bearer()) + + assert wrong_app.status_code == 404 + assert wrong_zone.status_code == 404 + assert len(publisher.published) == 1 + + +def test_removing_from_an_unknown_context_is_404() -> None: + client, _, publisher, _ = _client() + instance_id = _install(client) + + response = client.delete( + _remove_url(instance_id).replace(CONTEXT_ID, "other-ctx"), headers=_bearer() + ) + + assert response.status_code == 404 + assert len(publisher.published) == 1 -- GitLab