Commit 82278995 authored by George Papathanail's avatar George Papathanail
Browse files

fix: emit operation row and status-change callback when DELETE has nothing to terminate

parent ddd2ec5b
Loading
Loading
Loading
Loading
+27 −1
Original line number Diff line number Diff line
@@ -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)
+74 −2
Original line number Diff line number Diff line
@@ -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,
@@ -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,