Loading src/open_exposure_gateway/application/services/edge_application_management_service.py +27 −1 Original line number Diff line number Diff line Loading @@ -796,10 +796,36 @@ class EdgeApplicationManagementService: if not live_instances: # Nothing to hand to SRM, so no completion will ever arrive to # finalise the aggregate -- settle it here rather than leave the row # stuck at terminating. # stuck at terminating. Still record a terminal operation row and # fire onAppDeploymentStatusChange, so subscribers hear the # deployment reach TERMINATED exactly as they would when a real SRM # completion drives the normal teardown path. operation_id, correlation_id, _ = self._new_operation_metadata(x_correlator) settled_at = datetime.now(timezone.utc) await self._operation_repo.save( Operation( operation_id=operation_id, correlation_id=correlation_id, tenant_id=tenant_id, app_provider_id=app_provider_id, operation_type=OperationType.TERMINATE, status=OperationStatus.COMPLETED, subject=Subject.TASK_TERMINATE, app_registration_id=app_deployment.app_registration_id, completed_at=settled_at, metadata={ "app_deployment_id": str(app_deployment_id), "app_instance_ids": [ str(instance.app_instance_id) for instance in app_instances ], }, ) ) await self._app_deployment_repo.save( app_deployment.model_copy(update={"state": AppDeploymentState.TERMINATED}) ) if app_instances: await self._deliver_callbacks(operation_id, settled_at.isoformat(), app_instances) return operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) Loading tests/unit/test_eam_service.py +74 −2 Original line number Diff line number Diff line Loading @@ -2015,7 +2015,7 @@ class TestDeleteAppDeployment: assert untouched is not None assert untouched.state == AppInstanceState.TERMINATED async def test_no_live_instances_settles_deployment_without_publishing( async def test_no_live_instances_settles_deployment_and_records_operation( self, service: EdgeApplicationManagementService, publisher: AsyncMock, Loading @@ -2034,12 +2034,84 @@ class TestDeleteAppDeployment: await service.delete_app_deployment( app_deployment_id=DEPLOYMENT_ID, tenant_id="tenant-1", app_provider_id="p" ) # Nothing is handed to SRM, but the teardown still leaves a terminal # TERMINATE operation row so this branch is auditable like every other. publisher.publish.assert_not_called() assert operation_repo.rows == {} (operation,) = list(operation_repo.rows.values()) assert operation.operation_type == OperationType.TERMINATE assert operation.status == OperationStatus.COMPLETED assert operation.completed_at is not None assert operation.metadata["app_deployment_id"] == str(DEPLOYMENT_ID) assert set(operation.metadata["app_instance_ids"]) == { str(self.INSTANCE_A), str(self.INSTANCE_B), } deployment = await app_deployment_repo.get_by_id(DEPLOYMENT_ID) assert deployment is not None assert deployment.state == AppDeploymentState.TERMINATED async def test_no_live_instances_still_fires_deployment_status_change_callback( self, service: EdgeApplicationManagementService, app_registration_repo: FakeAppRegistrationRepository, app_deployment_repo: FakeAppDeploymentRepository, app_instance_repo: FakeAppInstanceRepository, callback_registration_repo: FakeCallbackRegistrationRepository, callback_delivery_port: FakeCallbackDeliveryPort, callback_delivery_repo: FakeCallbackDeliveryRepository, ) -> None: """onAppDeploymentStatusChange subscribers hear the deployment reach its terminal state even when there is nothing left for SRM to tear down.""" await app_registration_repo.save( AppRegistration( app_registration_id=APP_REGISTRATION_ID, app_id=APP_ID, tenant_id="tenant-1", name="myvideoapp", version="1.0.0", package_type=PackageType.HELM, status=AppRegistrationStatus.REGISTERED, ) ) await self._seed_deployment( app_deployment_repo, app_instance_repo, instance_states={ self.INSTANCE_A: AppInstanceState.TERMINATED, self.INSTANCE_B: AppInstanceState.TERMINATED, }, ) # The subscription was registered against the original DEPLOY operation. await callback_registration_repo.save( CallbackRegistration( id=uuid4(), operation_id=self.DEPLOY_OPERATION_ID, tenant_id="tenant-1", api_family="edge-application-management", sink="https://client.example.com/callback", event_types=[ "org.camaraproject.edge-application-management.v0.app-deployment-status-change" ], is_active=True, ) ) await service.delete_app_deployment( app_deployment_id=DEPLOYMENT_ID, tenant_id="tenant-1", app_provider_id="p" ) assert len(callback_delivery_port.delivered) == 1 sink, events = callback_delivery_port.delivered[0] assert sink == "https://client.example.com/callback" assert isinstance(events, list) assert all(isinstance(e, AppDeploymentStatusChangeCloudEvent) for e in events) assert {e.data.appInstanceId for e in events} == {self.INSTANCE_A, self.INSTANCE_B} assert all(e.data.appDeploymentId == DEPLOYMENT_ID for e in events) assert all(e.data.appId == APP_ID for e in events) # CAMARA's AppInstanceStatus has no `terminated`; it surfaces as `unknown`. assert all(e.data.status == "unknown" for e in events) assert len(list(callback_delivery_repo.rows.values())) == 1 async def test_raises_when_operation_repo_unavailable( self, srm_client: AsyncMock, Loading Loading
src/open_exposure_gateway/application/services/edge_application_management_service.py +27 −1 Original line number Diff line number Diff line Loading @@ -796,10 +796,36 @@ class EdgeApplicationManagementService: if not live_instances: # Nothing to hand to SRM, so no completion will ever arrive to # finalise the aggregate -- settle it here rather than leave the row # stuck at terminating. # stuck at terminating. Still record a terminal operation row and # fire onAppDeploymentStatusChange, so subscribers hear the # deployment reach TERMINATED exactly as they would when a real SRM # completion drives the normal teardown path. operation_id, correlation_id, _ = self._new_operation_metadata(x_correlator) settled_at = datetime.now(timezone.utc) await self._operation_repo.save( Operation( operation_id=operation_id, correlation_id=correlation_id, tenant_id=tenant_id, app_provider_id=app_provider_id, operation_type=OperationType.TERMINATE, status=OperationStatus.COMPLETED, subject=Subject.TASK_TERMINATE, app_registration_id=app_deployment.app_registration_id, completed_at=settled_at, metadata={ "app_deployment_id": str(app_deployment_id), "app_instance_ids": [ str(instance.app_instance_id) for instance in app_instances ], }, ) ) await self._app_deployment_repo.save( app_deployment.model_copy(update={"state": AppDeploymentState.TERMINATED}) ) if app_instances: await self._deliver_callbacks(operation_id, settled_at.isoformat(), app_instances) return operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) Loading
tests/unit/test_eam_service.py +74 −2 Original line number Diff line number Diff line Loading @@ -2015,7 +2015,7 @@ class TestDeleteAppDeployment: assert untouched is not None assert untouched.state == AppInstanceState.TERMINATED async def test_no_live_instances_settles_deployment_without_publishing( async def test_no_live_instances_settles_deployment_and_records_operation( self, service: EdgeApplicationManagementService, publisher: AsyncMock, Loading @@ -2034,12 +2034,84 @@ class TestDeleteAppDeployment: await service.delete_app_deployment( app_deployment_id=DEPLOYMENT_ID, tenant_id="tenant-1", app_provider_id="p" ) # Nothing is handed to SRM, but the teardown still leaves a terminal # TERMINATE operation row so this branch is auditable like every other. publisher.publish.assert_not_called() assert operation_repo.rows == {} (operation,) = list(operation_repo.rows.values()) assert operation.operation_type == OperationType.TERMINATE assert operation.status == OperationStatus.COMPLETED assert operation.completed_at is not None assert operation.metadata["app_deployment_id"] == str(DEPLOYMENT_ID) assert set(operation.metadata["app_instance_ids"]) == { str(self.INSTANCE_A), str(self.INSTANCE_B), } deployment = await app_deployment_repo.get_by_id(DEPLOYMENT_ID) assert deployment is not None assert deployment.state == AppDeploymentState.TERMINATED async def test_no_live_instances_still_fires_deployment_status_change_callback( self, service: EdgeApplicationManagementService, app_registration_repo: FakeAppRegistrationRepository, app_deployment_repo: FakeAppDeploymentRepository, app_instance_repo: FakeAppInstanceRepository, callback_registration_repo: FakeCallbackRegistrationRepository, callback_delivery_port: FakeCallbackDeliveryPort, callback_delivery_repo: FakeCallbackDeliveryRepository, ) -> None: """onAppDeploymentStatusChange subscribers hear the deployment reach its terminal state even when there is nothing left for SRM to tear down.""" await app_registration_repo.save( AppRegistration( app_registration_id=APP_REGISTRATION_ID, app_id=APP_ID, tenant_id="tenant-1", name="myvideoapp", version="1.0.0", package_type=PackageType.HELM, status=AppRegistrationStatus.REGISTERED, ) ) await self._seed_deployment( app_deployment_repo, app_instance_repo, instance_states={ self.INSTANCE_A: AppInstanceState.TERMINATED, self.INSTANCE_B: AppInstanceState.TERMINATED, }, ) # The subscription was registered against the original DEPLOY operation. await callback_registration_repo.save( CallbackRegistration( id=uuid4(), operation_id=self.DEPLOY_OPERATION_ID, tenant_id="tenant-1", api_family="edge-application-management", sink="https://client.example.com/callback", event_types=[ "org.camaraproject.edge-application-management.v0.app-deployment-status-change" ], is_active=True, ) ) await service.delete_app_deployment( app_deployment_id=DEPLOYMENT_ID, tenant_id="tenant-1", app_provider_id="p" ) assert len(callback_delivery_port.delivered) == 1 sink, events = callback_delivery_port.delivered[0] assert sink == "https://client.example.com/callback" assert isinstance(events, list) assert all(isinstance(e, AppDeploymentStatusChangeCloudEvent) for e in events) assert {e.data.appInstanceId for e in events} == {self.INSTANCE_A, self.INSTANCE_B} assert all(e.data.appDeploymentId == DEPLOYMENT_ID for e in events) assert all(e.data.appId == APP_ID for e in events) # CAMARA's AppInstanceStatus has no `terminated`; it surfaces as `unknown`. assert all(e.data.status == "unknown" for e in events) assert len(list(callback_delivery_repo.rows.values())) == 1 async def test_raises_when_operation_repo_unavailable( self, srm_client: AsyncMock, Loading