From 6a65ab28a334ab0cbeb56892a0608c8995dbdb5a Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 09:58:19 +0300 Subject: [PATCH 01/36] feat(eam): resolve appId to app_registrations before instantiation --- .../edge_application_management_service.py | 7 ++++ tests/unit/test_eam_flows.py | 28 +++++++++++++ tests/unit/test_eam_service.py | 40 ++++++++++++++++++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 780f1a3..0f351ae 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -28,6 +28,7 @@ from open_exposure_gateway.application.mappers.edge_application_mapper import ( build_terminate_instance_command, ) from open_exposure_gateway.core.exceptions import ( + BadRequestException, ConflictException, DownstreamServiceException, NotImplementedException, @@ -192,6 +193,12 @@ class EdgeApplicationManagementService: app_provider_id: str, x_correlator: Optional[str] = None, ) -> AppInstanceInfo: + if self._app_registration_repo is None: + raise RuntimeError("AppRegistrationRepository is not available") + app_registration = await self._app_registration_repo.get_by_app_id(request.appId) + if app_registration is None: + raise BadRequestException(message=f"App {request.appId} is not registered") + operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) app_instance_id = uuid4() translation = build_app_deployment_translation( diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 59d6521..d01eda3 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -13,6 +13,7 @@ from typing import Any from unittest.mock import AsyncMock from uuid import uuid4 +import pytest from fastapi.testclient import TestClient from open_exposure_gateway.api.camara.edge_application_management.vwip.router import ( @@ -64,7 +65,20 @@ def _manifest_body(num_cpu: float | int) -> dict[str, Any]: } +def _register_app(api_client: TestClient, app_id: Any = APP_ID) -> None: + """Registers app_id via the real POST /apps flow, so create_app_instance's + appId -> app_registrations resolution finds it.""" + response = api_client.post( + f"{EAM_BASE}/apps", json={**_manifest_body(num_cpu=2), "appId": str(app_id)} + ) + assert response.status_code == 201 + + class TestCreateAppInstanceFlow: + @pytest.fixture(autouse=True) + def _registered_app(self, api_client: TestClient) -> None: + _register_app(api_client) + def test_returns_202_with_location_header( self, api_client: TestClient, live_srm: FakeSRMClient ) -> None: @@ -148,7 +162,21 @@ class TestCreateAppInstanceFlow: assert instances[0]["status"] == "ready" +class TestCreateAppInstanceUnregisteredAppFlow: + """No autouse app registration here — this class exists to prove the + unregistered-appId path specifically.""" + + def test_returns_400_when_app_was_never_registered(self, api_client: TestClient) -> None: + response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + assert response.status_code == 400 + assert response.json()["code"] == "INVALID_ARGUMENT" + + class TestDeleteAppInstanceFlow: + @pytest.fixture(autouse=True) + def _registered_app(self, api_client: TestClient) -> None: + _register_app(api_client) + def test_returns_202_and_srm_terminates_the_instance( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient ) -> None: diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 0eb707f..a6f1e2b 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -16,6 +16,7 @@ from open_exposure_gateway.application.services.edge_application_management_serv EdgeApplicationManagementService, ) from open_exposure_gateway.core.exceptions import ( + BadRequestException, ConflictException, DownstreamServiceException, NotImplementedException, @@ -410,6 +411,22 @@ class TestCreateAppInstance: edgeCloudZoneId=ZONE_ID, ) + @pytest.fixture(autouse=True) + async def _registered_app(self, app_registration_repo: FakeAppRegistrationRepository) -> None: + # create_app_instance now resolves appId -> app_registrations before + # anything else, so every test in this class needs the app to exist. + await app_registration_repo.save( + AppRegistration( + app_registration_id=uuid4(), + app_id=APP_ID, + tenant_id="tenant-1", + name="myvideoapp", + version="1.0.0", + package_type=PackageType.HELM, + status=AppRegistrationStatus.REGISTERED, + ) + ) + async def test_publishes_deploy_command( self, service: EdgeApplicationManagementService, publisher: AsyncMock ) -> None: @@ -434,8 +451,12 @@ class TestCreateAppInstance: assert result.appId == APP_ID assert result.edgeCloudZoneId == ZONE_ID - async def test_raises_when_publisher_unavailable(self, srm_client: AsyncMock) -> None: - service = EdgeApplicationManagementService(srm_client=srm_client, publisher=None) + async def test_raises_when_publisher_unavailable( + self, srm_client: AsyncMock, app_registration_repo: FakeAppRegistrationRepository + ) -> None: + service = EdgeApplicationManagementService( + srm_client=srm_client, publisher=None, app_registration_repo=app_registration_repo + ) with pytest.raises(RuntimeError, match="DataBus publisher is not available"): await service.create_app_instance( request=self._make_request(), tenant_id="t", app_provider_id="p" @@ -451,6 +472,21 @@ class TestCreateAppInstance: ) +class TestCreateAppInstanceUnregisteredApp: + async def test_raises_bad_request_when_app_not_registered( + self, service: EdgeApplicationManagementService + ) -> None: + request = CreateAppInstanceRequest( + name="myapp_inst", + appId=APP_ID, + edgeCloudZoneId=ZONE_ID, + ) + with pytest.raises(BadRequestException, match=str(APP_ID)): + await service.create_app_instance( + request=request, tenant_id="tenant-1", app_provider_id="provider-1" + ) + + class TestGetAppInstances: async def test_returns_mapped_instances( self, service: EdgeApplicationManagementService, srm_client: AsyncMock -- GitLab From 392cbcde1a0994c2fe521466c8fc6ccc96da2739 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 10:24:39 +0300 Subject: [PATCH 02/36] test(eam): register app befor /appinstances-dependent tests --- pyproject.toml | 1 + tests/unit/conftest.py | 47 ++++++++++++++++++++++++++++++++- tests/unit/test_eam_contract.py | 9 +++++++ tests/unit/test_eam_flows.py | 18 ++++--------- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e60f91..e06e922 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ python_version = "3.12" cache_dir = ".cache/mypy" strict = true ignore_missing_imports = true +mypy_path = "src" [[tool.mypy.overrides]] module = "tests.conformance.*" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 78b989a..f8e82fd 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,10 +1,13 @@ import json as _json -from collections.abc import Generator +from collections.abc import Callable, Generator from typing import Any import pytest from fastapi.testclient import TestClient +from open_exposure_gateway.api.camara.edge_application_management.vwip.router import ( + BASE_PATH as EAM_BASE, +) from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) @@ -89,6 +92,48 @@ def qod_service(fake_srm: FakeSRMClient) -> QualityOnDemandService: return QualityOnDemandService(srm_client=fake_srm) +@pytest.fixture() +def register_app(api_client: TestClient) -> Callable[[Any], None]: + """Registers an app via a real POST /apps call, for tests that exercise + /appinstances or /deployments and need appId -> app_registrations to + resolve. Returns a callable so each test picks its own appId.""" + + def _register(app_id: Any) -> None: + response = api_client.post( + f"{EAM_BASE}/apps", + json={ + "appId": str(app_id), + "name": "myvideoapp", + "appProvider": "acme_provider", + "version": "1.0.0", + "packageType": "HELM", + "appRepo": { + "type": "PUBLICREPO", + "imagePath": "oci://registry.example.com/app:1.0", + }, + "requiredResources": { + "infraKind": "kubernetes", + "applicationResources": { + "cpuPool": { + "numCPU": 2, + "memory": 4096, + "topology": { + "minNumberOfNodes": 1, + "minNodeCpu": 1, + "minNodeMemory": 1024, + }, + } + }, + "isStandalone": False, + }, + "componentSpec": [], + }, + ) + assert response.status_code == 201 + + return _register + + @pytest.fixture() def api_client( eam_service: EdgeApplicationManagementService, diff --git a/tests/unit/test_eam_contract.py b/tests/unit/test_eam_contract.py index ee5ecee..f45362e 100644 --- a/tests/unit/test_eam_contract.py +++ b/tests/unit/test_eam_contract.py @@ -6,6 +6,7 @@ code (or the fakes, where noted) until the test passes; do not weaken the test. """ +from collections.abc import Callable from datetime import datetime from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -60,6 +61,10 @@ def _assert_envelope(payload: dict[str, Any]) -> None: class TestCommandEnvelope: + @pytest.fixture(autouse=True) + def _registered_app(self, register_app: Callable[[Any], None]) -> None: + register_app(APP_ID) + def test_deploy_and_terminate_carry_the_full_envelope( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient ) -> None: @@ -74,6 +79,10 @@ class TestCommandEnvelope: class TestTerminateCommandConformance: + @pytest.fixture(autouse=True) + def _registered_app(self, register_app: Callable[[Any], None]) -> None: + register_app(APP_ID) + def test_terminate_carries_required_terminate_object( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient ) -> None: diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index d01eda3..6d842e5 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -9,6 +9,7 @@ Tests in this module assert *intended* behavior. A failing test here means a real hole in the flow, not a broken test. """ +from collections.abc import Callable from typing import Any from unittest.mock import AsyncMock from uuid import uuid4 @@ -65,19 +66,10 @@ def _manifest_body(num_cpu: float | int) -> dict[str, Any]: } -def _register_app(api_client: TestClient, app_id: Any = APP_ID) -> None: - """Registers app_id via the real POST /apps flow, so create_app_instance's - appId -> app_registrations resolution finds it.""" - response = api_client.post( - f"{EAM_BASE}/apps", json={**_manifest_body(num_cpu=2), "appId": str(app_id)} - ) - assert response.status_code == 201 - - class TestCreateAppInstanceFlow: @pytest.fixture(autouse=True) - def _registered_app(self, api_client: TestClient) -> None: - _register_app(api_client) + def _registered_app(self, register_app: Callable[[Any], None]) -> None: + register_app(APP_ID) def test_returns_202_with_location_header( self, api_client: TestClient, live_srm: FakeSRMClient @@ -174,8 +166,8 @@ class TestCreateAppInstanceUnregisteredAppFlow: class TestDeleteAppInstanceFlow: @pytest.fixture(autouse=True) - def _registered_app(self, api_client: TestClient) -> None: - _register_app(api_client) + def _registered_app(self, register_app: Callable[[Any], None]) -> None: + register_app(APP_ID) def test_returns_202_and_srm_terminates_the_instance( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient -- GitLab From cd08664574f7f6815952380397920b48a4679abe Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 10:28:22 +0300 Subject: [PATCH 03/36] feat(eam): wire Operation/AppInstance/CallbackRegistration repos --- .../edge_application_management_service.py | 9 +++++ src/open_exposure_gateway/dependencies.py | 38 ++++++++++++++++++- tests/unit/conftest.py | 24 ++++++++++++ tests/unit/test_eam_service.py | 28 +++++++++++++- 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 0f351ae..d4c9bce 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -43,6 +43,9 @@ from open_exposure_gateway.domain.models import ( AppRegistrationStatus, PackageType, ) +from open_exposure_gateway.ports.database.callbacks import CallbackRegistrationRepository +from open_exposure_gateway.ports.database.instances import AppInstanceRepository +from open_exposure_gateway.ports.database.operations import OperationRepository from open_exposure_gateway.ports.database.registration import AppRegistrationRepository from open_exposure_gateway.ports.databus_port import DataBusPort from open_exposure_gateway.ports.srm_port import SRMClientPort @@ -62,10 +65,16 @@ class EdgeApplicationManagementService: srm_client: SRMClientPort, publisher: DataBusPort | None = None, app_registration_repo: AppRegistrationRepository | None = None, + operation_repo: OperationRepository | None = None, + app_instance_repo: AppInstanceRepository | None = None, + callback_registration_repo: CallbackRegistrationRepository | None = None, ) -> None: self.srm_client = srm_client self._publisher = publisher self._app_registration_repo = app_registration_repo + self._operation_repo = operation_repo + self._app_instance_repo = app_instance_repo + self._callback_registration_repo = callback_registration_repo async def get_edge_cloud_zones( self, diff --git a/src/open_exposure_gateway/dependencies.py b/src/open_exposure_gateway/dependencies.py index e5cb275..f826c19 100644 --- a/src/open_exposure_gateway/dependencies.py +++ b/src/open_exposure_gateway/dependencies.py @@ -6,9 +6,18 @@ from fastapi import Depends, Request from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from open_exposure_gateway.adapters.database.repos.app_instances import ( + SqlAppInstanceRepository, +) from open_exposure_gateway.adapters.database.repos.app_registrations import ( SqlAppRegistrationRepository, ) +from open_exposure_gateway.adapters.database.repos.callback_registrations import ( + SqlCallbackRegistrationRepository, +) +from open_exposure_gateway.adapters.database.repos.operations import ( + SqlOperationRepository, +) from open_exposure_gateway.api.camara.common import XCorrelatorHeader from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, @@ -17,6 +26,9 @@ from open_exposure_gateway.application.services.quality_on_demand_service import QualityOnDemandService, ) from open_exposure_gateway.core.state import AppState +from open_exposure_gateway.ports.database.callbacks import CallbackRegistrationRepository +from open_exposure_gateway.ports.database.instances import AppInstanceRepository +from open_exposure_gateway.ports.database.operations import OperationRepository from open_exposure_gateway.ports.database.registration import AppRegistrationRepository from open_exposure_gateway.ports.databus_port import DataBusPort from open_exposure_gateway.ports.srm_port import SRMClientPort @@ -87,12 +99,36 @@ def get_app_registration_repo(session: SessionDep) -> AppRegistrationRepository: return SqlAppRegistrationRepository(session) +def get_operation_repo(session: SessionDep) -> OperationRepository: + return SqlOperationRepository(session) + + +def get_app_instance_repo(session: SessionDep) -> AppInstanceRepository: + return SqlAppInstanceRepository(session) + + +def get_callback_registration_repo(session: SessionDep) -> CallbackRegistrationRepository: + return SqlCallbackRegistrationRepository(session) + + def get_edge_app_service( srm: SRMClientPort = Depends(get_client), publisher: DataBusPort = Depends(get_publisher), app_registration_repo: AppRegistrationRepository = Depends(get_app_registration_repo), + operation_repo: OperationRepository = Depends(get_operation_repo), + app_instance_repo: AppInstanceRepository = Depends(get_app_instance_repo), + callback_registration_repo: CallbackRegistrationRepository = Depends( + get_callback_registration_repo + ), ) -> EdgeApplicationManagementService: - return EdgeApplicationManagementService(srm, publisher, app_registration_repo) + return EdgeApplicationManagementService( + srm, + publisher, + app_registration_repo, + operation_repo, + app_instance_repo, + callback_registration_repo, + ) def get_qod_service( diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index f8e82fd..ad10075 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -22,8 +22,11 @@ from open_exposure_gateway.dependencies import ( ) from open_exposure_gateway.main import app from tests.unit.fakes import ( + FakeAppInstanceRepository, FakeAppRegistrationRepository, + FakeCallbackRegistrationRepository, FakeDataBus, + FakeOperationRepository, FakeSRMClient, wire_operation_consumer, wire_srm_worker, @@ -74,16 +77,37 @@ def app_registration_repo() -> FakeAppRegistrationRepository: return FakeAppRegistrationRepository() +@pytest.fixture() +def operation_repo() -> FakeOperationRepository: + return FakeOperationRepository() + + +@pytest.fixture() +def app_instance_repo() -> FakeAppInstanceRepository: + return FakeAppInstanceRepository() + + +@pytest.fixture() +def callback_registration_repo() -> FakeCallbackRegistrationRepository: + return FakeCallbackRegistrationRepository() + + @pytest.fixture() def eam_service( fake_srm: FakeSRMClient, fake_bus: FakeDataBus, app_registration_repo: FakeAppRegistrationRepository, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, ) -> EdgeApplicationManagementService: return EdgeApplicationManagementService( srm_client=fake_srm, publisher=fake_bus, app_registration_repo=app_registration_repo, + operation_repo=operation_repo, + app_instance_repo=app_instance_repo, + callback_registration_repo=callback_registration_repo, ) diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index a6f1e2b..6ffa45d 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -42,7 +42,12 @@ from open_exposure_gateway.domain.models import ( AppRegistrationStatus, PackageType, ) -from tests.unit.fakes import FakeAppRegistrationRepository +from tests.unit.fakes import ( + FakeAppInstanceRepository, + FakeAppRegistrationRepository, + FakeCallbackRegistrationRepository, + FakeOperationRepository, +) APP_ID = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") ZONE_ID = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") @@ -147,16 +152,37 @@ def app_registration_repo() -> FakeAppRegistrationRepository: return FakeAppRegistrationRepository() +@pytest.fixture() +def operation_repo() -> FakeOperationRepository: + return FakeOperationRepository() + + +@pytest.fixture() +def app_instance_repo() -> FakeAppInstanceRepository: + return FakeAppInstanceRepository() + + +@pytest.fixture() +def callback_registration_repo() -> FakeCallbackRegistrationRepository: + return FakeCallbackRegistrationRepository() + + @pytest.fixture() def service( srm_client: AsyncMock, publisher: AsyncMock, app_registration_repo: FakeAppRegistrationRepository, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, ) -> EdgeApplicationManagementService: return EdgeApplicationManagementService( srm_client=srm_client, publisher=publisher, app_registration_repo=app_registration_repo, + operation_repo=operation_repo, + app_instance_repo=app_instance_repo, + callback_registration_repo=callback_registration_repo, ) -- GitLab From 75dd3a6bf9c0f931f3fc93736a52c0f13a04f5e3 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 10:36:42 +0300 Subject: [PATCH 04/36] fix: update gitlab-ci --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 60a86e5..4a0d4de 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,6 +23,7 @@ stages: variables: UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv" + GIT_STRATEGY: clone type: stage: type -- GitLab From 6a8b3f32a997dfa41d00dc3cc333c72ae8b69092 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 10:50:57 +0300 Subject: [PATCH 05/36] feat: persist operations + app_instances before publish --- .../edge_application_management_service.py | 35 +++++++++ tests/unit/test_eam_flows.py | 25 ++++++- tests/unit/test_eam_service.py | 73 ++++++++++++++++++- 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index d4c9bce..6bf7368 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -39,8 +39,13 @@ from open_exposure_gateway.domain.edge_application_management import ( Subject, ) from open_exposure_gateway.domain.models import ( + AppInstance, + AppInstanceState, AppRegistration, AppRegistrationStatus, + Operation, + OperationStatus, + OperationType, PackageType, ) from open_exposure_gateway.ports.database.callbacks import CallbackRegistrationRepository @@ -204,6 +209,8 @@ class EdgeApplicationManagementService: ) -> AppInstanceInfo: if self._app_registration_repo is None: raise RuntimeError("AppRegistrationRepository is not available") + if self._operation_repo is None or self._app_instance_repo is None: + raise RuntimeError("Operation/AppInstance repositories are not available") app_registration = await self._app_registration_repo.get_by_app_id(request.appId) if app_registration is None: raise BadRequestException(message=f"App {request.appId} is not registered") @@ -218,6 +225,34 @@ class EdgeApplicationManagementService: app_provider_id=app_provider_id, correlation_id=correlation_id, ) + + 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.DEPLOY, + status=OperationStatus.PENDING, + subject=Subject.TASK_DEPLOY, + app_registration_id=app_registration.app_registration_id, + metadata={ + "name": translation.name, + "resource_zone_id": translation.resource_zone_id, + "compute_domain_id": translation.compute_domain_id, + }, + ) + ) + await self._app_instance_repo.save( + AppInstance( + app_instance_id=app_instance_id, + operation_id=operation_id, + app_registration_id=app_registration.app_registration_id, + edge_cloud_zone_id=request.edgeCloudZoneId, + state=AppInstanceState.INSTANTIATING, + ) + ) + _command = build_deploy_command(translation, requested_at) await self._publish( Subject.TASK_DEPLOY, diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 6d842e5..415ac7b 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -12,7 +12,7 @@ real hole in the flow, not a broken test. from collections.abc import Callable from typing import Any from unittest.mock import AsyncMock -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from fastapi.testclient import TestClient @@ -27,9 +27,12 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMTerminateCommand, Subject, ) +from open_exposure_gateway.domain.models import AppInstanceState, OperationStatus from tests.unit.fakes import ( + FakeAppInstanceRepository, FakeDataBus, FakeMsg, + FakeOperationRepository, FakeSRMClient, wire_operation_consumer, ) @@ -93,6 +96,26 @@ class TestCreateAppInstanceFlow: assert body["status"] == "instantiating" assert "appInstanceId" in body + def test_persists_operation_and_app_instance_rows( + self, + api_client: TestClient, + live_srm: FakeSRMClient, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """Proves the DI wiring end-to-end: the router/service must reach the + real repositories, not just the constructor accepting them.""" + response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + instance_id = response.json()["appInstanceId"] + + operations = list(operation_repo.rows.values()) + assert len(operations) == 1 + assert operations[0].status == OperationStatus.PENDING + + stored_instance = app_instance_repo.rows.get(UUID(instance_id)) + assert stored_instance is not None + assert stored_instance.state == AppInstanceState.INSTANTIATING + def test_srm_receives_a_valid_deploy_command( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient ) -> None: diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 6ffa45d..f430210 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -38,8 +38,11 @@ from open_exposure_gateway.domain.edge_application_management import ( Subject, ) from open_exposure_gateway.domain.models import ( + AppInstanceState, AppRegistration, AppRegistrationStatus, + OperationStatus, + OperationType, PackageType, ) from tests.unit.fakes import ( @@ -477,11 +480,77 @@ class TestCreateAppInstance: assert result.appId == APP_ID assert result.edgeCloudZoneId == ZONE_ID + async def test_persists_pending_operation_row( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + ) -> None: + await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + operations = list(operation_repo.rows.values()) + assert len(operations) == 1 + operation = operations[0] + assert operation.status == OperationStatus.PENDING + assert operation.operation_type == OperationType.DEPLOY + assert operation.subject == Subject.TASK_DEPLOY + assert operation.tenant_id == "tenant-1" + assert operation.app_provider_id == "provider-1" + registered = await app_registration_repo.get_by_app_id(APP_ID) + assert registered is not None + assert operation.app_registration_id == registered.app_registration_id + assert operation.metadata["name"] == "myapp_inst" + assert operation.metadata["resource_zone_id"] == str(ZONE_ID) + + async def test_persists_instantiating_app_instance_row( + self, + service: EdgeApplicationManagementService, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + result = await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + stored = await app_instance_repo.get_by_id(result.appInstanceId) + assert stored is not None + assert stored.state == AppInstanceState.INSTANTIATING + assert stored.edge_cloud_zone_id == ZONE_ID + + async def test_raises_when_operation_repo_unavailable( + self, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + service = EdgeApplicationManagementService( + srm_client=srm_client, + publisher=AsyncMock(), + app_registration_repo=app_registration_repo, + operation_repo=None, + app_instance_repo=app_instance_repo, + ) + with pytest.raises(RuntimeError, match="Operation/AppInstance repositories"): + await service.create_app_instance( + request=self._make_request(), tenant_id="t", app_provider_id="p" + ) + async def test_raises_when_publisher_unavailable( - self, srm_client: AsyncMock, app_registration_repo: FakeAppRegistrationRepository + self, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, ) -> None: service = EdgeApplicationManagementService( - srm_client=srm_client, publisher=None, app_registration_repo=app_registration_repo + srm_client=srm_client, + publisher=None, + app_registration_repo=app_registration_repo, + operation_repo=operation_repo, + app_instance_repo=app_instance_repo, ) with pytest.raises(RuntimeError, match="DataBus publisher is not available"): await service.create_app_instance( -- GitLab From 3f9075baff19c7af6d3e5ded080e02988f3932b5 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 11:07:13 +0300 Subject: [PATCH 06/36] feat: Idempotency-key header and idempotent replay --- .../adapters/database/repos/app_instances.py | 5 ++ .../api/camara/common.py | 5 ++ .../vwip/router.py | 3 + .../edge_application_management_service.py | 26 ++++++ .../ports/database/instances.py | 4 + tests/unit/fakes.py | 6 ++ tests/unit/test_eam_endpoints.py | 1 + tests/unit/test_eam_flows.py | 14 ++++ tests/unit/test_eam_service.py | 81 +++++++++++++++++++ 9 files changed, 145 insertions(+) diff --git a/src/open_exposure_gateway/adapters/database/repos/app_instances.py b/src/open_exposure_gateway/adapters/database/repos/app_instances.py index 13ba07a..b79e10a 100644 --- a/src/open_exposure_gateway/adapters/database/repos/app_instances.py +++ b/src/open_exposure_gateway/adapters/database/repos/app_instances.py @@ -18,6 +18,11 @@ class SqlAppInstanceRepository(AppInstanceRepository): row = await self._session.scalar(stmt) return AppInstanceMapper.to_domain(row) if row is not None else None + async def get_by_operation_id(self, operation_id: UUID) -> AppInstance | None: + stmt = select(AppInstanceRow).where(AppInstanceRow.operation_id == operation_id) + row = await self._session.scalar(stmt) + return AppInstanceMapper.to_domain(row) if row is not None else None + async def save(self, app_instance: AppInstance) -> AppInstance: merged = await self._session.merge(AppInstanceMapper.to_row(app_instance)) await self._session.flush() diff --git a/src/open_exposure_gateway/api/camara/common.py b/src/open_exposure_gateway/api/camara/common.py index 4ea32b2..4b6fef0 100644 --- a/src/open_exposure_gateway/api/camara/common.py +++ b/src/open_exposure_gateway/api/camara/common.py @@ -10,3 +10,8 @@ XCorrelatorHeader = Annotated[ Optional[str], Header(alias="x-correlator", max_length=256, pattern=X_CORRELATOR_PATTERN_STR), ] + +IdempotencyKeyHeader = Annotated[ + Optional[str], + Header(alias="Idempotency-Key", max_length=128), +] diff --git a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py index 0b2911f..23146c4 100644 --- a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py +++ b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py @@ -3,6 +3,7 @@ from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, Query, Request, Response, status +from open_exposure_gateway.api.camara.common import IdempotencyKeyHeader from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( AppInstanceInfo, AppManifest, @@ -170,12 +171,14 @@ async def create_app_instance( caller: Caller, http_request: Request, response: Response, + idempotency_key: IdempotencyKeyHeader = None, ) -> Any: instance = await service.create_app_instance( request=request, tenant_id=caller.tenant_id, app_provider_id=caller.app_provider_id, x_correlator=caller.x_correlator, + idempotency_key=idempotency_key, ) # Absolute URI per the spec ("Contains the URI of the newly created application."); # base_url reflects the scheme/host the caller actually used to reach OEG. diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 6bf7368..220e157 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -206,11 +206,35 @@ class EdgeApplicationManagementService: tenant_id: str, app_provider_id: str, x_correlator: Optional[str] = None, + idempotency_key: Optional[str] = None, ) -> AppInstanceInfo: if self._app_registration_repo is None: raise RuntimeError("AppRegistrationRepository is not available") if self._operation_repo is None or self._app_instance_repo is None: raise RuntimeError("Operation/AppInstance repositories are not available") + + if idempotency_key is not None: + existing_operation = await self._operation_repo.get_by_idempotency_key( + tenant_id, idempotency_key + ) + if existing_operation is not None: + existing_instance = await self._app_instance_repo.get_by_operation_id( + existing_operation.operation_id + ) + if existing_instance is None: + raise RuntimeError( + "operations row found for idempotency_key but its app_instances " + "row is missing" + ) + return AppInstanceInfo( + appInstanceId=existing_instance.app_instance_id, + name=request.name, + appId=request.appId, + appProvider=app_provider_id, + status=AppInstanceStatus(existing_instance.state), + edgeCloudZoneId=request.edgeCloudZoneId, + ) + app_registration = await self._app_registration_repo.get_by_app_id(request.appId) if app_registration is None: raise BadRequestException(message=f"App {request.appId} is not registered") @@ -224,6 +248,7 @@ class EdgeApplicationManagementService: tenant_id=tenant_id, app_provider_id=app_provider_id, correlation_id=correlation_id, + idempotency_key=idempotency_key, ) await self._operation_repo.save( @@ -235,6 +260,7 @@ class EdgeApplicationManagementService: operation_type=OperationType.DEPLOY, status=OperationStatus.PENDING, subject=Subject.TASK_DEPLOY, + idempotency_key=idempotency_key, app_registration_id=app_registration.app_registration_id, metadata={ "name": translation.name, diff --git a/src/open_exposure_gateway/ports/database/instances.py b/src/open_exposure_gateway/ports/database/instances.py index 118b572..653ce6f 100644 --- a/src/open_exposure_gateway/ports/database/instances.py +++ b/src/open_exposure_gateway/ports/database/instances.py @@ -11,6 +11,10 @@ class AppInstanceRepository(ABC): async def get_by_id(self, app_instance_id: UUID) -> AppInstance | None: pass + @abstractmethod + async def get_by_operation_id(self, operation_id: UUID) -> AppInstance | None: + pass + @abstractmethod async def save(self, app_instance: AppInstance) -> AppInstance: pass diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 3ca2375..8b54b50 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -315,6 +315,12 @@ class FakeAppInstanceRepository(AppInstanceRepository): found = self.rows.get(app_instance_id) return found.model_copy(deep=True) if found is not None else None + async def get_by_operation_id(self, operation_id: UUID) -> AppInstance | None: + for row in self.rows.values(): + if row.operation_id == operation_id: + return row.model_copy(deep=True) + return None + async def save(self, app_instance: AppInstance) -> AppInstance: stored = app_instance.model_copy(deep=True) self.rows[stored.app_instance_id] = stored diff --git a/tests/unit/test_eam_endpoints.py b/tests/unit/test_eam_endpoints.py index 4477850..1ece8e1 100644 --- a/tests/unit/test_eam_endpoints.py +++ b/tests/unit/test_eam_endpoints.py @@ -151,6 +151,7 @@ class TestCreateAppInstance: tenant_id=ANY, app_provider_id=ANY, x_correlator=ANY, + idempotency_key=ANY, ) def test_location_header_set(self, client: TestClient) -> None: diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 415ac7b..7b86be0 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -150,6 +150,20 @@ class TestCreateAppInstanceFlow: deploys = [p for s, p in fake_bus.published if s == Subject.TASK_DEPLOY] assert len({d["operation_id"] for d in deploys}) == 2 + def test_repeated_idempotency_key_returns_same_instance_and_does_not_republish( + self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient + ) -> None: + headers = {"Idempotency-Key": "client-retry-1"} + first = api_client.post( + f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY, headers=headers + ) + second = api_client.post( + f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY, headers=headers + ) + assert first.json()["appInstanceId"] == second.json()["appInstanceId"] + deploys = [p for s, p in fake_bus.published if s == Subject.TASK_DEPLOY] + assert len(deploys) == 1 + def test_publish_failure_maps_to_503_envelope( self, api_client: TestClient, fake_bus: FakeDataBus ) -> None: diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index f430210..e14282e 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -520,6 +520,87 @@ class TestCreateAppInstance: assert stored.state == AppInstanceState.INSTANTIATING assert stored.edge_cloud_zone_id == ZONE_ID + async def test_retried_request_with_same_key_does_not_republish( + self, service: EdgeApplicationManagementService, publisher: AsyncMock + ) -> None: + first = await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="retry-key-1", + ) + second = await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="retry-key-1", + ) + assert second.appInstanceId == first.appInstanceId + publisher.publish.assert_called_once() + + async def test_retried_request_does_not_duplicate_operation_row( + self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository + ) -> None: + for _ in range(2): + await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="retry-key-1", + ) + assert len(operation_repo.rows) == 1 + + async def test_replay_reflects_current_instance_status( + self, + service: EdgeApplicationManagementService, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + first = await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="retry-key-1", + ) + stored = await app_instance_repo.get_by_id(first.appInstanceId) + assert stored is not None + await app_instance_repo.save(stored.model_copy(update={"state": AppInstanceState.READY})) + + second = await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="retry-key-1", + ) + assert second.status == AppInstanceStatus.READY + + async def test_different_keys_create_separate_operations( + self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository + ) -> None: + await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="key-a", + ) + await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="key-b", + ) + assert len(operation_repo.rows) == 2 + + async def test_absent_key_never_dedupes( + self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository + ) -> None: + for _ in range(2): + await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + assert len(operation_repo.rows) == 2 + async def test_raises_when_operation_repo_unavailable( self, srm_client: AsyncMock, -- GitLab From 94f6f3d6f3f43015454492b38acbd0cbaf8be9a1 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 11:17:38 +0300 Subject: [PATCH 07/36] feat: presist callback_registrations from subscriptionRequest --- .../edge_application_management_service.py | 22 +++++ tests/unit/test_eam_flows.py | 21 ++++ tests/unit/test_eam_service.py | 98 +++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 220e157..4b6ec63 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -43,6 +43,7 @@ from open_exposure_gateway.domain.models import ( AppInstanceState, AppRegistration, AppRegistrationStatus, + CallbackRegistration, Operation, OperationStatus, OperationType, @@ -279,6 +280,27 @@ class EdgeApplicationManagementService: ) ) + if request.subscriptionRequest is not None: + if self._callback_registration_repo is None: + raise RuntimeError("CallbackRegistrationRepository is not available") + subscription = request.subscriptionRequest + await self._callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation_id, + tenant_id=tenant_id, + api_family="edge-application-management", + sink=subscription.sink, + event_types=subscription.types, + sink_credential_ref=f"secret://oeg/{operation_id}/sink-credential" + if subscription.sinkCredential is not None + else None, + expires_at=subscription.config.subscriptionExpireTime + if subscription.config + else None, + ) + ) + _command = build_deploy_command(translation, requested_at) await self._publish( Subject.TASK_DEPLOY, diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 7b86be0..1d783b4 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -30,6 +30,7 @@ from open_exposure_gateway.domain.edge_application_management import ( from open_exposure_gateway.domain.models import AppInstanceState, OperationStatus from tests.unit.fakes import ( FakeAppInstanceRepository, + FakeCallbackRegistrationRepository, FakeDataBus, FakeMsg, FakeOperationRepository, @@ -116,6 +117,26 @@ class TestCreateAppInstanceFlow: assert stored_instance is not None assert stored_instance.state == AppInstanceState.INSTANTIATING + def test_persists_callback_registration_when_subscription_request_present( + self, + api_client: TestClient, + live_srm: FakeSRMClient, + callback_registration_repo: FakeCallbackRegistrationRepository, + ) -> None: + body = { + **CREATE_INSTANCE_BODY, + "subscriptionRequest": { + "sink": "https://client.example.com/callback", + "types": [ + "org.camaraproject.edge-application-management.v0.app-instance-status-change" + ], + }, + } + api_client.post(f"{EAM_BASE}/appinstances", json=body) + + (callback,) = list(callback_registration_repo.rows.values()) + assert callback.sink == "https://client.example.com/callback" + def test_srm_receives_a_valid_deploy_command( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient ) -> None: diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index e14282e..8b68721 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from unittest.mock import AsyncMock from uuid import UUID, uuid4 @@ -10,6 +11,8 @@ from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas i AppRepo, CreateAppInstanceRequest, KubernetesResources, + SubscriptionConfig, + SubscriptionRequest, VmResources, ) from open_exposure_gateway.application.services.edge_application_management_service import ( @@ -601,6 +604,101 @@ class TestCreateAppInstance: ) assert len(operation_repo.rows) == 2 + def _make_request_with_subscription( + self, with_credential: bool = False, expires_at: datetime | None = None + ) -> CreateAppInstanceRequest: + return CreateAppInstanceRequest( + name="myapp_inst", + appId=APP_ID, + edgeCloudZoneId=ZONE_ID, + subscriptionRequest=SubscriptionRequest( + sink="https://client.example.com/callback", + sinkCredential={"credentialType": "ACCESSTOKEN", "accessToken": "raw-token"} + if with_credential + else None, + types=[ + "org.camaraproject.edge-application-management.v0.app-instance-status-change" + ], + config=SubscriptionConfig(subscriptionExpireTime=expires_at) + if expires_at + else None, + ), + ) + + async def test_persists_callback_registration_when_subscription_present( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + ) -> None: + expires_at = datetime(2027, 1, 17, 13, 18, 23, tzinfo=timezone.utc) + await service.create_app_instance( + request=self._make_request_with_subscription(expires_at=expires_at), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + (operation,) = list(operation_repo.rows.values()) + callback = await callback_registration_repo.get_by_operation_id(operation.operation_id) + assert callback is not None + assert callback.tenant_id == "tenant-1" + assert callback.api_family == "edge-application-management" + assert callback.sink == "https://client.example.com/callback" + assert callback.event_types == [ + "org.camaraproject.edge-application-management.v0.app-instance-status-change" + ] + assert callback.is_active is True + assert callback.expires_at == expires_at + assert callback.sink_credential_ref is None + + async def test_sink_credential_is_stored_as_secret_reference_not_raw( + self, + service: EdgeApplicationManagementService, + callback_registration_repo: FakeCallbackRegistrationRepository, + ) -> None: + await service.create_app_instance( + request=self._make_request_with_subscription(with_credential=True), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + (callback,) = list(callback_registration_repo.rows.values()) + assert callback.sink_credential_ref is not None + assert callback.sink_credential_ref.startswith("secret://") + assert "raw-token" not in callback.sink_credential_ref + + async def test_no_callback_registration_when_subscription_absent( + self, + service: EdgeApplicationManagementService, + callback_registration_repo: FakeCallbackRegistrationRepository, + ) -> None: + await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + assert callback_registration_repo.rows == {} + + async def test_raises_when_callback_registration_repo_unavailable( + self, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + service = EdgeApplicationManagementService( + srm_client=srm_client, + publisher=AsyncMock(), + app_registration_repo=app_registration_repo, + operation_repo=operation_repo, + app_instance_repo=app_instance_repo, + callback_registration_repo=None, + ) + with pytest.raises(RuntimeError, match="CallbackRegistrationRepository"): + await service.create_app_instance( + request=self._make_request_with_subscription(), + tenant_id="t", + app_provider_id="p", + ) + async def test_raises_when_operation_repo_unavailable( self, srm_client: AsyncMock, -- GitLab From 9d950016fd32dd1755daa5adb4051371e0eb79a3 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 12:02:36 +0300 Subject: [PATCH 08/36] fix(tests): wire repos into conformance harness accept 400 for unregistered appId --- tests/conformance/conftest.py | 11 ++++++++++- tests/conformance/test_eam_conformance.py | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py index 322427e..ecb7625 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -25,7 +25,11 @@ from open_exposure_gateway.dependencies import ( from open_exposure_gateway.domain.edge_application_management import ResourceZone from tests.conformance.harness import app from tests.unit.fakes import ( + FakeAppInstanceRepository, + FakeAppRegistrationRepository, + FakeCallbackRegistrationRepository, FakeDataBus, + FakeOperationRepository, FakeSRMClient, wire_operation_consumer, wire_srm_worker, @@ -50,7 +54,12 @@ def service_overrides() -> Generator[None, None, None]: wire_operation_consumer(bus) wire_srm_worker(bus, srm) app.dependency_overrides[get_edge_app_service] = lambda: EdgeApplicationManagementService( - srm_client=srm, publisher=bus + srm_client=srm, + publisher=bus, + app_registration_repo=FakeAppRegistrationRepository(), + operation_repo=FakeOperationRepository(), + app_instance_repo=FakeAppInstanceRepository(), + callback_registration_repo=FakeCallbackRegistrationRepository(), ) app.dependency_overrides[get_qod_service] = lambda: QualityOnDemandService(srm_client=srm) app.dependency_overrides[get_publisher] = lambda: bus diff --git a/tests/conformance/test_eam_conformance.py b/tests/conformance/test_eam_conformance.py index 6e55abc..5caaeac 100644 --- a/tests/conformance/test_eam_conformance.py +++ b/tests/conformance/test_eam_conformance.py @@ -41,6 +41,12 @@ schema.config.generation.update(max_examples=10, no_shrink=True) # not_a_server_error check flags any 5xx by default regardless of the spec # (see schemathesis/schemathesis#2539). schema.config.checks.not_a_server_error.expected_statuses.append("501") +# 400 for a schema-valid but unregistered appId is a deliberate choice to stay +# within createAppInstance's literally-documented response codes (400/401/403/ +# 409/500/501/503 — no 404 listed), even though CAMARA's own Generic400 vs +# Generic404 semantics would suggest 404. Schemathesis's positive_data_acceptance +# check otherwise flags this as rejecting schema-compliant data. +schema.config.checks.positive_data_acceptance.expected_statuses.append("400") @schema.parametrize() -- GitLab From e51075f366637a655528d0852bf9b6de0434c2f7 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 12:17:47 +0300 Subject: [PATCH 09/36] feat(eam): handle event.srm.operation.completed, update operations row --- .../adapters/databus/nats_adapter.py | 24 ++- .../edge_application_management_service.py | 46 ++++- src/open_exposure_gateway/main.py | 33 +++- tests/conformance/conftest.py | 5 +- tests/integration/test_nats_consumer.py | 21 ++- tests/unit/conftest.py | 8 +- tests/unit/fakes.py | 17 +- tests/unit/test_eam_contract.py | 4 +- tests/unit/test_eam_flows.py | 15 +- tests/unit/test_eam_service.py | 162 ++++++++++++++++++ tests/unit/test_nats_adapter.py | 53 +++++- 11 files changed, 358 insertions(+), 30 deletions(-) diff --git a/src/open_exposure_gateway/adapters/databus/nats_adapter.py b/src/open_exposure_gateway/adapters/databus/nats_adapter.py index d85bda3..51ce781 100644 --- a/src/open_exposure_gateway/adapters/databus/nats_adapter.py +++ b/src/open_exposure_gateway/adapters/databus/nats_adapter.py @@ -1,12 +1,15 @@ import json +from collections.abc import Awaitable, Callable from typing import Any, Protocol import nats import structlog from nats.aio.client import Client from nats.aio.subscription import Subscription +from pydantic import ValidationError from open_exposure_gateway.core.config import NatsSettings +from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted from open_exposure_gateway.ports.databus_port import DataBusPort logger: structlog.BoundLogger = structlog.get_logger(__name__) @@ -71,9 +74,15 @@ class _Msg(Protocol): class NatsOperationConsumer: - def __init__(self, client: Client, subject: str) -> None: + def __init__( + self, + client: Client, + subject: str, + handler: Callable[[SRMOperationCompleted], Awaitable[None]], + ) -> None: self._client = client self._subject = subject + self._handler = handler self._subscription: Subscription | None = None async def start(self) -> None: @@ -90,6 +99,13 @@ class NatsOperationConsumer: logger.warning("invalid_json", subject=msg.subject) return - # TODO: parse operation.completed payload, update oeg_db operation record to - # COMPLETED or FAILED, and trigger webhook callback if registered - logger.info("operation_completed_received", subject=msg.subject, payload=raw) + try: + event = SRMOperationCompleted.model_validate(raw) + except ValidationError as exc: + logger.warning("invalid_operation_completed_event", subject=msg.subject, error=str(exc)) + return + + try: + await self._handler(event) + except Exception: + logger.exception("operation_completed_handler_failed", operation_id=event.operation_id) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 4b6ec63..a5dc52b 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import Optional +from typing import Any, Optional from uuid import UUID, uuid4 import structlog @@ -36,6 +36,7 @@ from open_exposure_gateway.core.exceptions import ( from open_exposure_gateway.domain.edge_application_management import ( ResourceZone, SRMCatalogPayload, + SRMOperationCompleted, Subject, ) from open_exposure_gateway.domain.models import ( @@ -64,6 +65,12 @@ logger: structlog.BoundLogger = structlog.get_logger(__name__) # catalog or left to fail at deploy time (ADR-0009). _SUPPORTED_PACKAGE_TYPES = frozenset({"CONTAINER", "HELM"}) +_OPERATION_COMPLETION_STATUS_MAP: dict[str, OperationStatus] = { + "completed": OperationStatus.COMPLETED, + "partially_completed": OperationStatus.PARTIALLY_COMPLETED, + "failed": OperationStatus.FAILED, +} + class EdgeApplicationManagementService: def __init__( @@ -347,3 +354,40 @@ class EdgeApplicationManagementService: command, "Failed to publish app instance termination command", ) + + async def handle_completed(self, event: SRMOperationCompleted) -> None: + if self._operation_repo is None: + raise RuntimeError("OperationRepository is not available") + + operation_id = UUID(event.operation_id) + operation = await self._operation_repo.get_by_id(operation_id) + if operation is None: + logger.warning( + "operation_completed_for_unknown_operation", operation_id=event.operation_id + ) + return + + status = _OPERATION_COMPLETION_STATUS_MAP[event.status] + result: Optional[dict[str, Any]] = None + if status != OperationStatus.FAILED: + result = { + "instances": [ + { + "app_instance_id": instance.service_instance_id, + "service_instance_id": instance.service_instance_id, + "zone_id": instance.zone_id, + "status": instance.status, + } + for instance in event.instances + ] + } + + updated = operation.model_copy( + update={ + "status": status, + "result": result, + "error": event.error, + "completed_at": datetime.fromisoformat(event.completed_at), + } + ) + await self._operation_repo.save(updated) diff --git a/src/open_exposure_gateway/main.py b/src/open_exposure_gateway/main.py index 23a9f42..3f08f66 100644 --- a/src/open_exposure_gateway/main.py +++ b/src/open_exposure_gateway/main.py @@ -4,12 +4,14 @@ from typing import Optional import structlog from fastapi import FastAPI, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from starlette.types import Lifespan from open_exposure_gateway.adapters.database.core import ( build_engine_and_session_maker, schema_initialization, ) +from open_exposure_gateway.adapters.database.repos.operations import SqlOperationRepository from open_exposure_gateway.adapters.databus.nats_adapter import ( NatsMessagePublisher, NatsOperationConsumer, @@ -26,9 +28,37 @@ from open_exposure_gateway.api.error_handlers import ( x_correlator_header, ) from open_exposure_gateway.api.platform.health import router as health_router +from open_exposure_gateway.application.services.edge_application_management_service import ( + EdgeApplicationManagementService, +) from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.logging import configure_logging -from open_exposure_gateway.domain.edge_application_management import Subject +from open_exposure_gateway.domain.edge_application_management import ( + SRMOperationCompleted, + Subject, +) +from open_exposure_gateway.ports.srm_port import SRMClientPort + + +def _build_operation_completed_handler( + session_maker: async_sessionmaker[AsyncSession], + srm_client: SRMClientPort, +) -> Callable[[SRMOperationCompleted], Awaitable[None]]: + async def handle(event: SRMOperationCompleted) -> None: + async with session_maker() as session: + try: + service = EdgeApplicationManagementService( + srm_client=srm_client, + operation_repo=SqlOperationRepository(session), + ) + await service.handle_completed(event) + await session.commit() + except Exception: + await session.rollback() + raise + + return handle + openapi_tags = [ { @@ -94,6 +124,7 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: consumer = NatsOperationConsumer( client=publisher.client, subject=Subject.OPERATION_COMPLETED, + handler=_build_operation_completed_handler(session_maker, srm_client), ) await consumer.start() logger.info("NATS consumer started", subject=Subject.OPERATION_COMPLETED) diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py index ecb7625..01adcbb 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -51,13 +51,14 @@ def service_overrides() -> Generator[None, None, None]: provider="conformance-provider", ) ) - wire_operation_consumer(bus) + operation_repo = FakeOperationRepository() + wire_operation_consumer(bus, operation_repo) wire_srm_worker(bus, srm) app.dependency_overrides[get_edge_app_service] = lambda: EdgeApplicationManagementService( srm_client=srm, publisher=bus, app_registration_repo=FakeAppRegistrationRepository(), - operation_repo=FakeOperationRepository(), + operation_repo=operation_repo, app_instance_repo=FakeAppInstanceRepository(), callback_registration_repo=FakeCallbackRegistrationRepository(), ) diff --git a/tests/integration/test_nats_consumer.py b/tests/integration/test_nats_consumer.py index ed65aaf..5fcd185 100644 --- a/tests/integration/test_nats_consumer.py +++ b/tests/integration/test_nats_consumer.py @@ -1,6 +1,7 @@ import asyncio import json from typing import Any +from unittest.mock import AsyncMock import nats from nats.aio.client import Client @@ -9,14 +10,18 @@ from open_exposure_gateway.adapters.databus.nats_adapter import NatsOperationCon async def test_consumer_subscribes_to_subject(nats_client: Client) -> None: - consumer = NatsOperationConsumer(client=nats_client, subject="operation.completed") + consumer = NatsOperationConsumer( + client=nats_client, subject="operation.completed", handler=AsyncMock() + ) await consumer.start() assert consumer._subscription is not None async def test_consumer_invokes_handler_when_message_arrives(nats_client: Client) -> None: invoked = asyncio.Event() - consumer = NatsOperationConsumer(client=nats_client, subject="operation.completed") + consumer = NatsOperationConsumer( + client=nats_client, subject="operation.completed", handler=AsyncMock() + ) original = consumer._handle_message @@ -34,7 +39,9 @@ async def test_consumer_invokes_handler_when_message_arrives(nats_client: Client async def test_consumer_decodes_json_payload(nats_client: Client) -> None: decoded: list[Any] = [] ready = asyncio.Event() - consumer = NatsOperationConsumer(client=nats_client, subject="operation.completed") + consumer = NatsOperationConsumer( + client=nats_client, subject="operation.completed", handler=AsyncMock() + ) original = consumer._handle_message @@ -55,7 +62,9 @@ async def test_consumer_decodes_json_payload(nats_client: Client) -> None: async def test_consumer_handles_invalid_json_gracefully(nats_client: Client) -> None: handled = asyncio.Event() - consumer = NatsOperationConsumer(client=nats_client, subject="operation.completed") + consumer = NatsOperationConsumer( + client=nats_client, subject="operation.completed", handler=AsyncMock() + ) original = consumer._handle_message @@ -73,7 +82,9 @@ async def test_consumer_handles_invalid_json_gracefully(nats_client: Client) -> async def test_consumer_unsubscribes_cleanly(nats_url: str) -> None: client = await nats.connect(nats_url) try: - consumer = NatsOperationConsumer(client=client, subject="operation.completed") + consumer = NatsOperationConsumer( + client=client, subject="operation.completed", handler=AsyncMock() + ) await consumer.start() assert consumer._subscription is not None diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index ad10075..a551bee 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -65,9 +65,13 @@ def fake_srm() -> FakeSRMClient: @pytest.fixture() -def live_srm(fake_bus: FakeDataBus, fake_srm: FakeSRMClient) -> FakeSRMClient: +def live_srm( + fake_bus: FakeDataBus, + fake_srm: FakeSRMClient, + operation_repo: FakeOperationRepository, +) -> FakeSRMClient: """Fake SRM with its async side running: consumes commands, publishes completions.""" - wire_operation_consumer(fake_bus) + wire_operation_consumer(fake_bus, operation_repo) wire_srm_worker(fake_bus, fake_srm) return fake_srm diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 8b54b50..87104db 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -31,6 +31,9 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( QoDSessionResponse, QosStatus, ) +from open_exposure_gateway.application.services.edge_application_management_service import ( + EdgeApplicationManagementService, +) from open_exposure_gateway.core.exceptions import NotFoundException from open_exposure_gateway.domain.edge_application_management import ( ResourceZone, @@ -237,8 +240,18 @@ def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None: bus.subscribe(Subject.TASK_TERMINATE, on_terminate) -def wire_operation_consumer(bus: FakeDataBus) -> NatsOperationConsumer: - consumer = NatsOperationConsumer(client=AsyncMock(), subject=Subject.OPERATION_COMPLETED) +def wire_operation_consumer( + bus: FakeDataBus, operation_repo: OperationRepository +) -> NatsOperationConsumer: + """Wires OEG's real completion handler (EdgeApplicationManagementService.handle_completed) + behind the fake bus, backed by operation_repo -- pass the same instance used to build + the service under test so a completion event updates the row the test can see.""" + service = EdgeApplicationManagementService( + srm_client=AsyncMock(), operation_repo=operation_repo + ) + consumer = NatsOperationConsumer( + client=AsyncMock(), subject=Subject.OPERATION_COMPLETED, handler=service.handle_completed + ) async def deliver(payload: dict[str, Any]) -> None: await consumer._handle_message( diff --git a/tests/unit/test_eam_contract.py b/tests/unit/test_eam_contract.py index f45362e..bacbc57 100644 --- a/tests/unit/test_eam_contract.py +++ b/tests/unit/test_eam_contract.py @@ -246,7 +246,9 @@ class TestEventConsumptionDelivery: jetstream.subscribe = AsyncMock() client.jetstream.return_value = jetstream - consumer = NatsOperationConsumer(client=client, subject=str(Subject.OPERATION_COMPLETED)) + consumer = NatsOperationConsumer( + client=client, subject=str(Subject.OPERATION_COMPLETED), handler=AsyncMock() + ) await consumer.start() client.subscribe.assert_not_awaited() diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 1d783b4..b855563 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -100,12 +100,13 @@ class TestCreateAppInstanceFlow: def test_persists_operation_and_app_instance_rows( self, api_client: TestClient, - live_srm: FakeSRMClient, operation_repo: FakeOperationRepository, app_instance_repo: FakeAppInstanceRepository, ) -> None: """Proves the DI wiring end-to-end: the router/service must reach the - real repositories, not just the constructor accepting them.""" + real repositories, not just the constructor accepting them. Uses plain + api_client (not live_srm) so nothing auto-completes the operation -- + this test is about the PENDING write, not the completion path.""" response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) instance_id = response.json()["appInstanceId"] @@ -471,16 +472,18 @@ class TestEdgeCloudZonesFlow: class TestOperationCompletedHandling: - async def test_consumer_survives_malformed_event(self, fake_bus: FakeDataBus) -> None: - consumer = wire_operation_consumer(fake_bus) + async def test_consumer_survives_malformed_event( + self, fake_bus: FakeDataBus, operation_repo: FakeOperationRepository + ) -> None: + consumer = wire_operation_consumer(fake_bus, operation_repo) await consumer._handle_message( FakeMsg(data=b"not-json", subject=str(Subject.OPERATION_COMPLETED)) ) async def test_consumer_survives_event_missing_required_fields( - self, fake_bus: FakeDataBus + self, fake_bus: FakeDataBus, operation_repo: FakeOperationRepository ) -> None: - consumer = wire_operation_consumer(fake_bus) + consumer = wire_operation_consumer(fake_bus, operation_repo) await consumer._handle_message( FakeMsg(data=b'{"status": "completed"}', subject=str(Subject.OPERATION_COMPLETED)) ) diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 8b68721..21f1662 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -29,10 +29,12 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMCapabilityRequirement, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, + SRMCompletedInstance, SRMComputeIntent, SRMComputeResources, SRMDeploymentUnit, SRMDeploymentUnitMetadata, + SRMOperationCompleted, SRMRepoMetadata, SRMServiceInstance, SRMServiceSpecDescriptor, @@ -44,6 +46,7 @@ from open_exposure_gateway.domain.models import ( AppInstanceState, AppRegistration, AppRegistrationStatus, + Operation, OperationStatus, OperationType, PackageType, @@ -809,3 +812,162 @@ class TestDeleteAppInstance: publisher.publish.side_effect = Exception("NATS down") with pytest.raises(DownstreamServiceException, match="termination"): await service.delete_app_instance(app_instance_id=INSTANCE_ID, app_provider_id="p") + + +class TestHandleCompleted: + OPERATION_ID = UUID("dddddddd-dddd-dddd-dddd-dddddddddddd") + + async def _seed_pending_operation(self, operation_repo: FakeOperationRepository) -> None: + await operation_repo.save( + Operation( + operation_id=self.OPERATION_ID, + correlation_id="corr-1", + tenant_id="tenant-1", + app_provider_id="provider-1", + operation_type=OperationType.DEPLOY, + status=OperationStatus.PENDING, + subject=Subject.TASK_DEPLOY, + ) + ) + + async def test_updates_operation_to_completed_with_result( + self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository + ) -> None: + await self._seed_pending_operation(operation_repo) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated = await operation_repo.get_by_id(self.OPERATION_ID) + assert updated is not None + assert updated.status == OperationStatus.COMPLETED + assert updated.error is None + assert updated.completed_at == datetime(2026, 7, 4, 10, 2, 35, tzinfo=timezone.utc) + assert updated.result == { + "instances": [ + { + "app_instance_id": str(INSTANCE_ID), + "service_instance_id": str(INSTANCE_ID), + "zone_id": str(ZONE_ID), + "status": "completed", + } + ] + } + + async def test_updates_operation_to_failed_with_error( + self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository + ) -> None: + await self._seed_pending_operation(operation_repo) + error = { + "type": "https://etsi.org/sdg/oop/problems/zone-capacity-exceeded", + "title": "Zone Capacity Exceeded", + "status": 503, + "detail": "The requested edge zone does not have sufficient compute capacity.", + } + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="failed", + instances=[], + error=error, + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated = await operation_repo.get_by_id(self.OPERATION_ID) + assert updated is not None + assert updated.status == OperationStatus.FAILED + assert updated.result is None + assert updated.error == error + + async def test_updates_operation_to_partially_completed( + self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository + ) -> None: + await self._seed_pending_operation(operation_repo) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="partially_completed", + instances=[ + SRMCompletedInstance( + service_instance_id="9a3f1c22-0000-4000-8000-00000000000a", + zone_id="642f6105-7015-4af1-a4d1-e1ecb8437abc", + status="completed", + ), + SRMCompletedInstance( + service_instance_id="9a3f1c22-0000-4000-8000-00000000000b", + zone_id="123e4567-e89b-12d3-a456-426614174000", + status="failed", + error={ + "type": "about:blank", + "title": "Zone Capacity Exceeded", + "status": 503, + "detail": "no capacity", + }, + ), + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated = await operation_repo.get_by_id(self.OPERATION_ID) + assert updated is not None + assert updated.status == OperationStatus.PARTIALLY_COMPLETED + assert updated.error is None + assert updated.result is not None + assert len(updated.result["instances"]) == 2 + + async def test_unknown_operation_id_does_not_raise( + self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository + ) -> None: + """A completion event for an operation_id we have no row for (e.g. from + a different OEG instance, or test noise) must be logged and skipped, + not crash the consumer.""" + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(uuid4()), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + assert operation_repo.rows == {} + + async def test_raises_when_operation_repo_unavailable(self, srm_client: AsyncMock) -> None: + service = EdgeApplicationManagementService(srm_client=srm_client, operation_repo=None) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(uuid4()), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + with pytest.raises(RuntimeError, match="OperationRepository is not available"): + await service.handle_completed(event) diff --git a/tests/unit/test_nats_adapter.py b/tests/unit/test_nats_adapter.py index e66efbf..b6355e8 100644 --- a/tests/unit/test_nats_adapter.py +++ b/tests/unit/test_nats_adapter.py @@ -1,5 +1,6 @@ import json from dataclasses import dataclass, field +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -130,27 +131,67 @@ class Msg: subject: str = field(default="operation.completed") -def make_consumer() -> NatsOperationConsumer: - return NatsOperationConsumer( +def make_consumer() -> tuple[NatsOperationConsumer, AsyncMock]: + handler = AsyncMock() + consumer = NatsOperationConsumer( client=AsyncMock(), subject="operation.completed", + handler=handler, ) + return consumer, handler + + +def _valid_completed_payload() -> dict[str, Any]: + return { + "schema_version": "1.0", + "operation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "completed", + "instances": [ + {"service_instance_id": "abc-123", "zone_id": "zone-1", "status": "completed"} + ], + "correlation_id": "corr-1", + "completed_at": "2026-07-03T12:00:00+00:00", + } async def test_invalid_json_is_ignored() -> None: - consumer = make_consumer() + consumer, handler = make_consumer() await consumer._handle_message(Msg(data=b"not json")) + handler.assert_not_awaited() + async def test_unicode_decode_error_is_ignored() -> None: - consumer = make_consumer() + consumer, handler = make_consumer() await consumer._handle_message(Msg(data=b"\xff\xfe")) + handler.assert_not_awaited() -async def test_valid_message_is_handled() -> None: - consumer = make_consumer() + +async def test_schema_invalid_message_is_ignored() -> None: + consumer, handler = make_consumer() payload = {"status": "completed", "app_instance_id": "abc-123"} await consumer._handle_message(Msg(data=json.dumps(payload).encode())) + + handler.assert_not_awaited() + + +async def test_valid_message_is_handled() -> None: + consumer, handler = make_consumer() + + await consumer._handle_message(Msg(data=json.dumps(_valid_completed_payload()).encode())) + + handler.assert_awaited_once() + assert handler.await_args is not None + (event,) = handler.await_args.args + assert event.operation_id == "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + +async def test_handler_exception_is_caught() -> None: + consumer, handler = make_consumer() + handler.side_effect = RuntimeError("db down") + + await consumer._handle_message(Msg(data=json.dumps(_valid_completed_payload()).encode())) -- GitLab From 7082a5264b209965af3496f0eaa2a28a1bc2910f Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 12:30:21 +0300 Subject: [PATCH 10/36] feat: update app_instances rows on operation completion --- .../edge_application_management_service.py | 33 ++++ tests/conformance/conftest.py | 5 +- tests/unit/conftest.py | 3 +- tests/unit/fakes.py | 13 +- tests/unit/test_eam_flows.py | 35 +++- tests/unit/test_eam_service.py | 162 ++++++++++++++++++ 6 files changed, 240 insertions(+), 11 deletions(-) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index a5dc52b..6d42982 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -71,6 +71,11 @@ _OPERATION_COMPLETION_STATUS_MAP: dict[str, OperationStatus] = { "failed": OperationStatus.FAILED, } +_APP_INSTANCE_COMPLETION_STATE_MAP: dict[str, AppInstanceState] = { + "completed": AppInstanceState.READY, + "failed": AppInstanceState.FAILED, +} + class EdgeApplicationManagementService: def __init__( @@ -358,6 +363,8 @@ class EdgeApplicationManagementService: async def handle_completed(self, event: SRMOperationCompleted) -> None: if self._operation_repo is None: raise RuntimeError("OperationRepository is not available") + if self._app_instance_repo is None: + raise RuntimeError("AppInstanceRepository is not available") operation_id = UUID(event.operation_id) operation = await self._operation_repo.get_by_id(operation_id) @@ -391,3 +398,29 @@ class EdgeApplicationManagementService: } ) await self._operation_repo.save(updated) + + if event.instances: + for instance in event.instances: + app_instance_id = UUID(instance.service_instance_id) + app_instance = await self._app_instance_repo.get_by_id(app_instance_id) + if app_instance is None: + logger.warning( + "app_instance_completed_for_unknown_instance", + app_instance_id=instance.service_instance_id, + ) + continue + await self._app_instance_repo.save( + app_instance.model_copy( + update={"state": _APP_INSTANCE_COMPLETION_STATE_MAP[instance.status]} + ) + ) + elif status == OperationStatus.FAILED: + # Total failure carries no instances[] entries to match against. + # POST /appinstances always creates exactly one app_instances row + # per operation (ADR-0005), so fall back to that link rather than + # leaving the pre-created row stuck at instantiating forever. + app_instance = await self._app_instance_repo.get_by_operation_id(operation_id) + if app_instance is not None: + await self._app_instance_repo.save( + app_instance.model_copy(update={"state": AppInstanceState.FAILED}) + ) diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py index 01adcbb..92df242 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -52,14 +52,15 @@ def service_overrides() -> Generator[None, None, None]: ) ) operation_repo = FakeOperationRepository() - wire_operation_consumer(bus, operation_repo) + app_instance_repo = FakeAppInstanceRepository() + wire_operation_consumer(bus, operation_repo, app_instance_repo) wire_srm_worker(bus, srm) app.dependency_overrides[get_edge_app_service] = lambda: EdgeApplicationManagementService( srm_client=srm, publisher=bus, app_registration_repo=FakeAppRegistrationRepository(), operation_repo=operation_repo, - app_instance_repo=FakeAppInstanceRepository(), + app_instance_repo=app_instance_repo, callback_registration_repo=FakeCallbackRegistrationRepository(), ) app.dependency_overrides[get_qod_service] = lambda: QualityOnDemandService(srm_client=srm) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index a551bee..2778330 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -69,9 +69,10 @@ def live_srm( fake_bus: FakeDataBus, fake_srm: FakeSRMClient, operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, ) -> FakeSRMClient: """Fake SRM with its async side running: consumes commands, publishes completions.""" - wire_operation_consumer(fake_bus, operation_repo) + wire_operation_consumer(fake_bus, operation_repo, app_instance_repo) wire_srm_worker(fake_bus, fake_srm) return fake_srm diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 87104db..9b23ae9 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -241,13 +241,18 @@ def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None: def wire_operation_consumer( - bus: FakeDataBus, operation_repo: OperationRepository + bus: FakeDataBus, + operation_repo: OperationRepository, + app_instance_repo: AppInstanceRepository, ) -> NatsOperationConsumer: """Wires OEG's real completion handler (EdgeApplicationManagementService.handle_completed) - behind the fake bus, backed by operation_repo -- pass the same instance used to build - the service under test so a completion event updates the row the test can see.""" + behind the fake bus, backed by operation_repo/app_instance_repo -- pass the same instances + used to build the service under test so a completion event updates the rows the test can + see.""" service = EdgeApplicationManagementService( - srm_client=AsyncMock(), operation_repo=operation_repo + srm_client=AsyncMock(), + operation_repo=operation_repo, + app_instance_repo=app_instance_repo, ) consumer = NatsOperationConsumer( client=AsyncMock(), subject=Subject.OPERATION_COMPLETED, handler=service.handle_completed diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index b855563..256f16f 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -212,6 +212,27 @@ class TestCreateAppInstanceFlow: assert len(instances) == 1 assert instances[0]["status"] == "ready" + def test_operations_and_app_instances_rows_reach_terminal_state_after_completion( + self, + api_client: TestClient, + live_srm: FakeSRMClient, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """Proves the UC7 completion path end-to-end through real DI wiring -- + not just GET /appinstances (which proxies SRM live), but OEG's own + operations/app_instances rows, which is what a future retry or an + internal read would actually see.""" + response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + instance_id = UUID(response.json()["appInstanceId"]) + + (operation,) = list(operation_repo.rows.values()) + assert operation.status == OperationStatus.COMPLETED + + stored_instance = app_instance_repo.rows.get(instance_id) + assert stored_instance is not None + assert stored_instance.state == AppInstanceState.READY + class TestCreateAppInstanceUnregisteredAppFlow: """No autouse app registration here — this class exists to prove the @@ -473,17 +494,23 @@ class TestEdgeCloudZonesFlow: class TestOperationCompletedHandling: async def test_consumer_survives_malformed_event( - self, fake_bus: FakeDataBus, operation_repo: FakeOperationRepository + self, + fake_bus: FakeDataBus, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, ) -> None: - consumer = wire_operation_consumer(fake_bus, operation_repo) + consumer = wire_operation_consumer(fake_bus, operation_repo, app_instance_repo) await consumer._handle_message( FakeMsg(data=b"not-json", subject=str(Subject.OPERATION_COMPLETED)) ) async def test_consumer_survives_event_missing_required_fields( - self, fake_bus: FakeDataBus, operation_repo: FakeOperationRepository + self, + fake_bus: FakeDataBus, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, ) -> None: - consumer = wire_operation_consumer(fake_bus, operation_repo) + consumer = wire_operation_consumer(fake_bus, operation_repo, app_instance_repo) await consumer._handle_message( FakeMsg(data=b'{"status": "completed"}', subject=str(Subject.OPERATION_COMPLETED)) ) diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 21f1662..7defedf 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -43,6 +43,7 @@ from open_exposure_gateway.domain.edge_application_management import ( Subject, ) from open_exposure_gateway.domain.models import ( + AppInstance, AppInstanceState, AppRegistration, AppRegistrationStatus, @@ -830,6 +831,19 @@ class TestHandleCompleted: ) ) + async def _seed_instantiating_app_instance( + self, app_instance_repo: FakeAppInstanceRepository, app_instance_id: UUID + ) -> None: + await app_instance_repo.save( + AppInstance( + app_instance_id=app_instance_id, + operation_id=self.OPERATION_ID, + app_registration_id=uuid4(), + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.INSTANTIATING, + ) + ) + async def test_updates_operation_to_completed_with_result( self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository ) -> None: @@ -971,3 +985,151 @@ class TestHandleCompleted: ) with pytest.raises(RuntimeError, match="OperationRepository is not available"): await service.handle_completed(event) + + async def test_raises_when_app_instance_repo_unavailable( + self, srm_client: AsyncMock, operation_repo: FakeOperationRepository + ) -> None: + service = EdgeApplicationManagementService( + srm_client=srm_client, operation_repo=operation_repo, app_instance_repo=None + ) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(uuid4()), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + with pytest.raises(RuntimeError, match="AppInstanceRepository is not available"): + await service.handle_completed(event) + + async def test_updates_app_instance_to_ready_on_success( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + await self._seed_pending_operation(operation_repo) + await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated is not None + assert updated.state == AppInstanceState.READY + + async def test_partially_completed_updates_each_instance_independently( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + ready_id = UUID("9a3f1c22-0000-4000-8000-00000000000a") + failed_id = UUID("9a3f1c22-0000-4000-8000-00000000000b") + await self._seed_pending_operation(operation_repo) + await self._seed_instantiating_app_instance(app_instance_repo, ready_id) + await self._seed_instantiating_app_instance(app_instance_repo, failed_id) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="partially_completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(ready_id), zone_id=str(ZONE_ID), status="completed" + ), + SRMCompletedInstance( + service_instance_id=str(failed_id), + zone_id=str(ZONE_ID), + status="failed", + error={ + "type": "about:blank", + "title": "Zone Capacity Exceeded", + "status": 503, + "detail": "no capacity", + }, + ), + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + ready_instance = await app_instance_repo.get_by_id(ready_id) + failed_instance = await app_instance_repo.get_by_id(failed_id) + assert ready_instance is not None + assert ready_instance.state == AppInstanceState.READY + assert failed_instance is not None + assert failed_instance.state == AppInstanceState.FAILED + + async def test_total_failure_with_no_instances_falls_back_to_operation_id( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """A total failure (status=failed) carries no instances[] entries to + match by service_instance_id -- the pre-created app_instances row must + still flip to failed instead of staying instantiating forever.""" + await self._seed_pending_operation(operation_repo) + await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="failed", + instances=[], + error={ + "type": "https://etsi.org/sdg/oop/problems/zone-capacity-exceeded", + "title": "Zone Capacity Exceeded", + "status": 503, + "detail": "no capacity", + }, + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated is not None + assert updated.state == AppInstanceState.FAILED + + async def test_unknown_app_instance_id_is_skipped_not_raised( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + await self._seed_pending_operation(operation_repo) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(uuid4()), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) # must not raise + + assert app_instance_repo.rows == {} -- GitLab From cccc232cd5804235fb550c9481cf2e627b132e83 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 13:17:39 +0300 Subject: [PATCH 11/36] feat: deliver cloudevent webhook callbacks on completion --- .../adapters/http/callback_client.py | 24 ++ .../mappers/edge_application_mapper.py | 28 +- .../edge_application_management_service.py | 73 ++++- src/open_exposure_gateway/core/config.py | 5 + .../domain/edge_application_management.py | 22 ++ src/open_exposure_gateway/main.py | 20 +- .../ports/callback_delivery_port.py | 9 + tests/unit/conftest.py | 50 ++- tests/unit/fakes.py | 25 +- tests/unit/test_eam_flows.py | 29 ++ tests/unit/test_eam_mapper.py | 58 ++++ tests/unit/test_eam_service.py | 306 ++++++++++++++++++ 12 files changed, 628 insertions(+), 21 deletions(-) create mode 100644 src/open_exposure_gateway/adapters/http/callback_client.py create mode 100644 src/open_exposure_gateway/ports/callback_delivery_port.py diff --git a/src/open_exposure_gateway/adapters/http/callback_client.py b/src/open_exposure_gateway/adapters/http/callback_client.py new file mode 100644 index 0000000..aeed2f2 --- /dev/null +++ b/src/open_exposure_gateway/adapters/http/callback_client.py @@ -0,0 +1,24 @@ +import httpx +import structlog + +from open_exposure_gateway.core.config import get_settings +from open_exposure_gateway.domain.edge_application_management import ( + AppInstanceStatusChangeCloudEvent, +) + +logger = structlog.get_logger(__name__) + + +class HttpCallbackClient: + def __init__(self) -> None: + settings = get_settings() + self.timeout = settings.callback_settings.timeout + + async def deliver(self, sink: str, event: AppInstanceStatusChangeCloudEvent) -> None: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + sink, + content=event.model_dump_json(), + headers={"Content-Type": "application/cloudevents+json"}, + ) + response.raise_for_status() diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index ae936ef..5cc89f1 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -1,7 +1,7 @@ import re from collections import defaultdict from typing import Any, Optional -from uuid import UUID +from uuid import UUID, uuid4 from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( AccessEndpoint, @@ -30,6 +30,8 @@ from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas i ) from open_exposure_gateway.domain.edge_application_management import ( AppDeploymentTranslation, + AppInstanceStatusChangeCloudEvent, + AppInstanceStatusChangeData, ApplicationResources, AppRegistrationTranslation, AppRepo, @@ -60,6 +62,7 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMTerminatePayload, SRMTopologyConstraints, ) +from open_exposure_gateway.domain.models import AppInstance _PACKAGE_TYPE_TO_RUNTIME_KIND: dict[str, str] = { "HELM": "helm", @@ -78,6 +81,11 @@ _RUNTIME_KIND_TO_PACKAGE_TYPE: dict[str, str] = { v: k for k, v in _PACKAGE_TYPE_TO_RUNTIME_KIND.items() } +# Placeholder pending a configured public NBI base URL (no such setting exists +# yet, and handle_completed runs from a NATS message, not an HTTP request, so +# there's no live request to derive it from). +_CALLBACK_EVENT_SOURCE = "https://api.example.com/edge-application-management/v1" + _VISIBILITY_REVERSE_MAP: dict[str, str] = {v: k for k, v in _VISIBILITY_MAP.items()} _SRM_STATE_TO_APP_INSTANCE_STATUS: dict[str, AppInstanceStatus] = { @@ -613,3 +621,21 @@ def build_terminate_instance_command( service_instance_id=str(app_instance_id), terminate=SRMTerminatePayload(), ) + + +def build_app_instance_status_change_event( + app_instance: AppInstance, + app_id: UUID, + occurred_at: str, +) -> AppInstanceStatusChangeCloudEvent: + return AppInstanceStatusChangeCloudEvent( + id=uuid4(), + source=_CALLBACK_EVENT_SOURCE, + time=occurred_at, + data=AppInstanceStatusChangeData( + appInstanceId=app_instance.app_instance_id, + appId=app_id, + edgeCloudZoneId=app_instance.edge_cloud_zone_id, + status=app_instance.state.value, + ), + ) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 6d42982..e0ed729 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -19,6 +19,7 @@ from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas i from open_exposure_gateway.application.mappers.edge_application_mapper import ( build_app_deployment_translation, build_app_instance_info, + build_app_instance_status_change_event, build_app_manifest, build_app_registration_translation, build_catalog_payload, @@ -44,13 +45,18 @@ from open_exposure_gateway.domain.models import ( AppInstanceState, AppRegistration, AppRegistrationStatus, + CallbackDelivery, CallbackRegistration, Operation, OperationStatus, OperationType, PackageType, ) -from open_exposure_gateway.ports.database.callbacks import CallbackRegistrationRepository +from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort +from open_exposure_gateway.ports.database.callbacks import ( + CallbackDeliveryRepository, + CallbackRegistrationRepository, +) from open_exposure_gateway.ports.database.instances import AppInstanceRepository from open_exposure_gateway.ports.database.operations import OperationRepository from open_exposure_gateway.ports.database.registration import AppRegistrationRepository @@ -86,6 +92,8 @@ class EdgeApplicationManagementService: operation_repo: OperationRepository | None = None, app_instance_repo: AppInstanceRepository | None = None, callback_registration_repo: CallbackRegistrationRepository | None = None, + callback_delivery_port: CallbackDeliveryPort | None = None, + callback_delivery_repo: CallbackDeliveryRepository | None = None, ) -> None: self.srm_client = srm_client self._publisher = publisher @@ -93,6 +101,8 @@ class EdgeApplicationManagementService: self._operation_repo = operation_repo self._app_instance_repo = app_instance_repo self._callback_registration_repo = callback_registration_repo + self._callback_delivery_port = callback_delivery_port + self._callback_delivery_repo = callback_delivery_repo async def get_edge_cloud_zones( self, @@ -399,6 +409,8 @@ class EdgeApplicationManagementService: ) await self._operation_repo.save(updated) + updated_instances: list[AppInstance] = [] + if event.instances: for instance in event.instances: app_instance_id = UUID(instance.service_instance_id) @@ -409,11 +421,12 @@ class EdgeApplicationManagementService: app_instance_id=instance.service_instance_id, ) continue - await self._app_instance_repo.save( + saved = await self._app_instance_repo.save( app_instance.model_copy( update={"state": _APP_INSTANCE_COMPLETION_STATE_MAP[instance.status]} ) ) + updated_instances.append(saved) elif status == OperationStatus.FAILED: # Total failure carries no instances[] entries to match against. # POST /appinstances always creates exactly one app_instances row @@ -421,6 +434,60 @@ class EdgeApplicationManagementService: # leaving the pre-created row stuck at instantiating forever. app_instance = await self._app_instance_repo.get_by_operation_id(operation_id) if app_instance is not None: - await self._app_instance_repo.save( + saved = await self._app_instance_repo.save( app_instance.model_copy(update={"state": AppInstanceState.FAILED}) ) + updated_instances.append(saved) + + if updated_instances: + await self._deliver_callbacks(operation_id, event.completed_at, updated_instances) + + async def _deliver_callbacks( + self, + operation_id: UUID, + occurred_at: str, + app_instances: list[AppInstance], + ) -> None: + if self._callback_registration_repo is None: + return + registration = await self._callback_registration_repo.get_by_operation_id(operation_id) + if registration is None or not registration.is_active: + return + if self._callback_delivery_port is None or self._callback_delivery_repo is None: + raise RuntimeError("CallbackDeliveryPort/CallbackDeliveryRepository is not available") + + for app_instance in app_instances: + app_id = await self._resolve_app_id(app_instance.app_registration_id) + if app_id is None: + logger.warning( + "callback_skipped_unresolvable_app_id", + app_instance_id=str(app_instance.app_instance_id), + ) + continue + + cloud_event = build_app_instance_status_change_event(app_instance, app_id, occurred_at) + last_error: Optional[str] = None + try: + await self._callback_delivery_port.deliver(registration.sink, cloud_event) + state = "delivered" + except Exception as exc: + state = "failed" + last_error = str(exc) + logger.warning("callback_delivery_failed", sink=registration.sink, error=str(exc)) + + await self._callback_delivery_repo.save( + CallbackDelivery( + id=uuid4(), + callback_registration_id=registration.id, + operation_id=operation_id, + attempt=1, + state=state, + last_error=last_error, + ) + ) + + async def _resolve_app_id(self, app_registration_id: UUID) -> Optional[UUID]: + if self._app_registration_repo is None: + return None + app_registration = await self._app_registration_repo.get_by_id(app_registration_id) + return app_registration.app_id if app_registration else None diff --git a/src/open_exposure_gateway/core/config.py b/src/open_exposure_gateway/core/config.py index 5bca7d6..4335083 100644 --- a/src/open_exposure_gateway/core/config.py +++ b/src/open_exposure_gateway/core/config.py @@ -21,6 +21,10 @@ class NatsSettings(BaseModel): max_reconnect_attempts: int = 3 +class CallbackSettings(BaseModel): + timeout: float = 10.0 + + class ObservabilitySettings(BaseModel): log_level: str = "INFO" @@ -43,6 +47,7 @@ class Settings(BaseSettings): postgresql_settings: PostgreSQLSettings = PostgreSQLSettings() nats_settings: NatsSettings = NatsSettings() observability_settings: ObservabilitySettings = ObservabilitySettings() + callback_settings: CallbackSettings = CallbackSettings() @lru_cache diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index ca5348f..e1f9fb9 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -318,3 +318,25 @@ class SRMServiceInstance(BaseModel): resource_zone_id: str | None = None name: str | None = None capability_instances: list[SRMCapabilityInstanceSummary] = [] + + +class AppInstanceStatusChangeData(BaseModel): + appInstanceId: UUID + appId: UUID + edgeCloudZoneId: UUID + status: str + + +class AppInstanceStatusChangeCloudEvent(BaseModel): + # CloudEvents v1.0 attributes, per the vendored spec's onAppInstanceStatusChange + # callback (ADR-0008). The referenced ../common/CAMARA_event_common.yaml + # defining the shared CloudEvent schema is not itself vendored into this repo; + # this shape is transcribed from the worked example in + # architecture/oeg/app-instance-flow.md instead. + id: UUID + source: str + specversion: str = "1.0" + type: str = "org.camaraproject.edge-application-management.v0.app-instance-status-change" + time: str + datacontenttype: str = "application/json" + data: AppInstanceStatusChangeData diff --git a/src/open_exposure_gateway/main.py b/src/open_exposure_gateway/main.py index 3f08f66..fb4967d 100644 --- a/src/open_exposure_gateway/main.py +++ b/src/open_exposure_gateway/main.py @@ -11,11 +11,22 @@ from open_exposure_gateway.adapters.database.core import ( build_engine_and_session_maker, schema_initialization, ) +from open_exposure_gateway.adapters.database.repos.app_instances import SqlAppInstanceRepository +from open_exposure_gateway.adapters.database.repos.app_registrations import ( + SqlAppRegistrationRepository, +) +from open_exposure_gateway.adapters.database.repos.callback_deliveries import ( + SqlCallbackDeliveryRepository, +) +from open_exposure_gateway.adapters.database.repos.callback_registrations import ( + SqlCallbackRegistrationRepository, +) from open_exposure_gateway.adapters.database.repos.operations import SqlOperationRepository from open_exposure_gateway.adapters.databus.nats_adapter import ( NatsMessagePublisher, NatsOperationConsumer, ) +from open_exposure_gateway.adapters.http.callback_client import HttpCallbackClient from open_exposure_gateway.adapters.http.srm_client import SRMClient from open_exposure_gateway.api.camara.edge_application_management.vwip.router import ( router as edge_application_management_router, @@ -37,19 +48,26 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMOperationCompleted, Subject, ) +from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort def _build_operation_completed_handler( session_maker: async_sessionmaker[AsyncSession], srm_client: SRMClientPort, + callback_delivery_port: CallbackDeliveryPort, ) -> Callable[[SRMOperationCompleted], Awaitable[None]]: async def handle(event: SRMOperationCompleted) -> None: async with session_maker() as session: try: service = EdgeApplicationManagementService( srm_client=srm_client, + app_registration_repo=SqlAppRegistrationRepository(session), operation_repo=SqlOperationRepository(session), + app_instance_repo=SqlAppInstanceRepository(session), + callback_registration_repo=SqlCallbackRegistrationRepository(session), + callback_delivery_port=callback_delivery_port, + callback_delivery_repo=SqlCallbackDeliveryRepository(session), ) await service.handle_completed(event) await session.commit() @@ -124,7 +142,7 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: consumer = NatsOperationConsumer( client=publisher.client, subject=Subject.OPERATION_COMPLETED, - handler=_build_operation_completed_handler(session_maker, srm_client), + handler=_build_operation_completed_handler(session_maker, srm_client, HttpCallbackClient()), ) await consumer.start() logger.info("NATS consumer started", subject=Subject.OPERATION_COMPLETED) diff --git a/src/open_exposure_gateway/ports/callback_delivery_port.py b/src/open_exposure_gateway/ports/callback_delivery_port.py new file mode 100644 index 0000000..67d7841 --- /dev/null +++ b/src/open_exposure_gateway/ports/callback_delivery_port.py @@ -0,0 +1,9 @@ +from typing import Protocol + +from open_exposure_gateway.domain.edge_application_management import ( + AppInstanceStatusChangeCloudEvent, +) + + +class CallbackDeliveryPort(Protocol): + async def deliver(self, sink: str, event: AppInstanceStatusChangeCloudEvent) -> None: ... diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 2778330..075f847 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -24,6 +24,8 @@ from open_exposure_gateway.main import app from tests.unit.fakes import ( FakeAppInstanceRepository, FakeAppRegistrationRepository, + FakeCallbackDeliveryPort, + FakeCallbackDeliveryRepository, FakeCallbackRegistrationRepository, FakeDataBus, FakeOperationRepository, @@ -64,19 +66,6 @@ def fake_srm() -> FakeSRMClient: return FakeSRMClient() -@pytest.fixture() -def live_srm( - fake_bus: FakeDataBus, - fake_srm: FakeSRMClient, - operation_repo: FakeOperationRepository, - app_instance_repo: FakeAppInstanceRepository, -) -> FakeSRMClient: - """Fake SRM with its async side running: consumes commands, publishes completions.""" - wire_operation_consumer(fake_bus, operation_repo, app_instance_repo) - wire_srm_worker(fake_bus, fake_srm) - return fake_srm - - @pytest.fixture() def app_registration_repo() -> FakeAppRegistrationRepository: return FakeAppRegistrationRepository() @@ -97,6 +86,41 @@ def callback_registration_repo() -> FakeCallbackRegistrationRepository: return FakeCallbackRegistrationRepository() +@pytest.fixture() +def callback_delivery_port() -> FakeCallbackDeliveryPort: + return FakeCallbackDeliveryPort() + + +@pytest.fixture() +def callback_delivery_repo() -> FakeCallbackDeliveryRepository: + return FakeCallbackDeliveryRepository() + + +@pytest.fixture() +def live_srm( + fake_bus: FakeDataBus, + fake_srm: FakeSRMClient, + app_registration_repo: FakeAppRegistrationRepository, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + callback_delivery_repo: FakeCallbackDeliveryRepository, +) -> FakeSRMClient: + """Fake SRM with its async side running: consumes commands, publishes completions.""" + wire_operation_consumer( + fake_bus, + operation_repo, + app_instance_repo, + app_registration_repo=app_registration_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_port=callback_delivery_port, + callback_delivery_repo=callback_delivery_repo, + ) + wire_srm_worker(fake_bus, fake_srm) + return fake_srm + + @pytest.fixture() def eam_service( fake_srm: FakeSRMClient, diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 9b23ae9..d8c7260 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -36,6 +36,7 @@ from open_exposure_gateway.application.services.edge_application_management_serv ) from open_exposure_gateway.core.exceptions import NotFoundException from open_exposure_gateway.domain.edge_application_management import ( + AppInstanceStatusChangeCloudEvent, ResourceZone, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, @@ -49,6 +50,7 @@ from open_exposure_gateway.domain.models import ( CallbackRegistration, Operation, ) +from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, CallbackRegistrationRepository, @@ -244,15 +246,24 @@ def wire_operation_consumer( bus: FakeDataBus, operation_repo: OperationRepository, app_instance_repo: AppInstanceRepository, + app_registration_repo: AppRegistrationRepository | None = None, + callback_registration_repo: CallbackRegistrationRepository | None = None, + callback_delivery_port: CallbackDeliveryPort | None = None, + callback_delivery_repo: CallbackDeliveryRepository | None = None, ) -> NatsOperationConsumer: """Wires OEG's real completion handler (EdgeApplicationManagementService.handle_completed) - behind the fake bus, backed by operation_repo/app_instance_repo -- pass the same instances - used to build the service under test so a completion event updates the rows the test can - see.""" + behind the fake bus -- pass the same repo/port instances used to build the service under + test so a completion event updates the rows (and, for callback tests, deliveries) the test + can see. app_registration_repo/callback_* are optional: only needed by tests exercising + webhook delivery.""" service = EdgeApplicationManagementService( srm_client=AsyncMock(), + app_registration_repo=app_registration_repo, operation_repo=operation_repo, app_instance_repo=app_instance_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_port=callback_delivery_port, + callback_delivery_repo=callback_delivery_repo, ) consumer = NatsOperationConsumer( client=AsyncMock(), subject=Subject.OPERATION_COMPLETED, handler=service.handle_completed @@ -378,3 +389,11 @@ class FakeCallbackDeliveryRepository(CallbackDeliveryRepository): stored = callback_delivery.model_copy(deep=True) self.rows[stored.id] = stored return stored.model_copy(deep=True) + + +class FakeCallbackDeliveryPort: + def __init__(self) -> None: + self.delivered: list[tuple[str, AppInstanceStatusChangeCloudEvent]] = [] + + async def deliver(self, sink: str, event: AppInstanceStatusChangeCloudEvent) -> None: + self.delivered.append((sink, event)) diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 256f16f..2fae3a4 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -30,6 +30,7 @@ from open_exposure_gateway.domain.edge_application_management import ( from open_exposure_gateway.domain.models import AppInstanceState, OperationStatus from tests.unit.fakes import ( FakeAppInstanceRepository, + FakeCallbackDeliveryPort, FakeCallbackRegistrationRepository, FakeDataBus, FakeMsg, @@ -138,6 +139,34 @@ class TestCreateAppInstanceFlow: (callback,) = list(callback_registration_repo.rows.values()) assert callback.sink == "https://client.example.com/callback" + def test_delivers_callback_after_srm_completes( + self, + api_client: TestClient, + live_srm: FakeSRMClient, + callback_delivery_port: FakeCallbackDeliveryPort, + ) -> None: + """End-to-end through the real DI wiring: create with a + subscriptionRequest, let live_srm complete it synchronously, and + confirm the webhook actually gets called -- not just that the + callback_registrations row was saved.""" + body = { + **CREATE_INSTANCE_BODY, + "subscriptionRequest": { + "sink": "https://client.example.com/callback", + "types": [ + "org.camaraproject.edge-application-management.v0.app-instance-status-change" + ], + }, + } + response = api_client.post(f"{EAM_BASE}/appinstances", json=body) + instance_id = response.json()["appInstanceId"] + + assert len(callback_delivery_port.delivered) == 1 + sink, cloud_event = callback_delivery_port.delivered[0] + assert sink == "https://client.example.com/callback" + assert str(cloud_event.data.appInstanceId) == instance_id + assert cloud_event.data.status == "ready" + def test_srm_receives_a_valid_deploy_command( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient ) -> None: diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index 87c7c65..b6f3ce7 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -17,6 +17,7 @@ from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas i from open_exposure_gateway.application.mappers.edge_application_mapper import ( build_app_deployment_translation, build_app_instance_info, + build_app_instance_status_change_event, build_app_manifest, build_app_registration_translation, build_catalog_payload, @@ -43,6 +44,7 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMServiceSpecEntry, SRMTopologyConstraints, ) +from open_exposure_gateway.domain.models import AppInstance, AppInstanceState APP_ID = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") ZONE_ID = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") @@ -636,3 +638,59 @@ class TestBuildDeployCommand: assert cmd.source == "nbi_camara" # object, not null, per srm/interface-contract.md §B.2 — fixed in v1. assert cmd.deploy.placement_constraints == {} + + +class TestBuildAppInstanceStatusChangeEvent: + def _make_app_instance(self, state: AppInstanceState) -> AppInstance: + return AppInstance( + app_instance_id=INSTANCE_ID, + operation_id=OP_ID, + app_registration_id=UUID("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), + edge_cloud_zone_id=ZONE_ID, + state=state, + ) + + def test_builds_cloudevents_v1_envelope(self) -> None: + event = build_app_instance_status_change_event( + self._make_app_instance(AppInstanceState.READY), + APP_ID, + "2026-07-04T10:02:35+00:00", + ) + assert event.specversion == "1.0" + assert event.type == ( + "org.camaraproject.edge-application-management.v0.app-instance-status-change" + ) + assert event.datacontenttype == "application/json" + assert event.time == "2026-07-04T10:02:35+00:00" + + def test_maps_data_fields(self) -> None: + event = build_app_instance_status_change_event( + self._make_app_instance(AppInstanceState.READY), + APP_ID, + "2026-07-04T10:02:35+00:00", + ) + assert event.data.appInstanceId == INSTANCE_ID + assert event.data.appId == APP_ID + assert event.data.edgeCloudZoneId == ZONE_ID + assert event.data.status == "ready" + + def test_failed_state_maps_to_failed_status(self) -> None: + event = build_app_instance_status_change_event( + self._make_app_instance(AppInstanceState.FAILED), + APP_ID, + "2026-07-04T10:02:35+00:00", + ) + assert event.data.status == "failed" + + def test_each_call_gets_a_distinct_event_id(self) -> None: + first = build_app_instance_status_change_event( + self._make_app_instance(AppInstanceState.READY), + APP_ID, + "2026-07-04T10:02:35+00:00", + ) + second = build_app_instance_status_change_event( + self._make_app_instance(AppInstanceState.READY), + APP_ID, + "2026-07-04T10:02:35+00:00", + ) + assert first.id != second.id diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 7defedf..d63b16b 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -47,6 +47,7 @@ from open_exposure_gateway.domain.models import ( AppInstanceState, AppRegistration, AppRegistrationStatus, + CallbackRegistration, Operation, OperationStatus, OperationType, @@ -55,6 +56,8 @@ from open_exposure_gateway.domain.models import ( from tests.unit.fakes import ( FakeAppInstanceRepository, FakeAppRegistrationRepository, + FakeCallbackDeliveryPort, + FakeCallbackDeliveryRepository, FakeCallbackRegistrationRepository, FakeOperationRepository, ) @@ -177,6 +180,16 @@ def callback_registration_repo() -> FakeCallbackRegistrationRepository: return FakeCallbackRegistrationRepository() +@pytest.fixture() +def callback_delivery_port() -> FakeCallbackDeliveryPort: + return FakeCallbackDeliveryPort() + + +@pytest.fixture() +def callback_delivery_repo() -> FakeCallbackDeliveryRepository: + return FakeCallbackDeliveryRepository() + + @pytest.fixture() def service( srm_client: AsyncMock, @@ -185,6 +198,8 @@ def service( operation_repo: FakeOperationRepository, app_instance_repo: FakeAppInstanceRepository, callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + callback_delivery_repo: FakeCallbackDeliveryRepository, ) -> EdgeApplicationManagementService: return EdgeApplicationManagementService( srm_client=srm_client, @@ -192,6 +207,8 @@ def service( app_registration_repo=app_registration_repo, operation_repo=operation_repo, app_instance_repo=app_instance_repo, + callback_delivery_port=callback_delivery_port, + callback_delivery_repo=callback_delivery_repo, callback_registration_repo=callback_registration_repo, ) @@ -844,6 +861,56 @@ class TestHandleCompleted: ) ) + async def _seed_app_instance_with_registration( + self, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + app_instance_id: UUID, + ) -> UUID: + """Like _seed_instantiating_app_instance, but with a resolvable + app_registrations row -- needed for tests where handle_completed must + resolve appId for the callback CloudEvent.""" + app_id = uuid4() + app_registration_id = uuid4() + 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 app_instance_repo.save( + AppInstance( + app_instance_id=app_instance_id, + operation_id=self.OPERATION_ID, + app_registration_id=app_registration_id, + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.INSTANTIATING, + ) + ) + return app_id + + async def _seed_active_callback_registration( + self, callback_registration_repo: FakeCallbackRegistrationRepository + ) -> CallbackRegistration: + return await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=self.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-instance-status-change" + ], + is_active=True, + ) + ) + async def test_updates_operation_to_completed_with_result( self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository ) -> None: @@ -1133,3 +1200,242 @@ class TestHandleCompleted: await service.handle_completed(event) # must not raise assert app_instance_repo.rows == {} + + async def test_delivers_callback_when_registration_exists( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + callback_delivery_repo: FakeCallbackDeliveryRepository, + ) -> None: + await self._seed_pending_operation(operation_repo) + app_id = await self._seed_app_instance_with_registration( + app_registration_repo, app_instance_repo, INSTANCE_ID + ) + registration = await self._seed_active_callback_registration(callback_registration_repo) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + assert len(callback_delivery_port.delivered) == 1 + sink, cloud_event = callback_delivery_port.delivered[0] + assert sink == registration.sink + assert cloud_event.data.appInstanceId == INSTANCE_ID + assert cloud_event.data.appId == app_id + assert cloud_event.data.edgeCloudZoneId == ZONE_ID + assert cloud_event.data.status == "ready" + + deliveries = list(callback_delivery_repo.rows.values()) + assert len(deliveries) == 1 + assert deliveries[0].state == "delivered" + assert deliveries[0].last_error is None + assert deliveries[0].callback_registration_id == registration.id + + async def test_no_callback_when_no_registration( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + ) -> None: + await self._seed_pending_operation(operation_repo) + await self._seed_app_instance_with_registration( + app_registration_repo, app_instance_repo, INSTANCE_ID + ) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + assert callback_delivery_port.delivered == [] + + async def test_no_callback_when_registration_inactive( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + ) -> None: + await self._seed_pending_operation(operation_repo) + await self._seed_app_instance_with_registration( + app_registration_repo, app_instance_repo, INSTANCE_ID + ) + registration = await self._seed_active_callback_registration(callback_registration_repo) + await callback_registration_repo.save(registration.model_copy(update={"is_active": False})) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + assert callback_delivery_port.delivered == [] + + async def test_records_failed_delivery_and_still_persists_other_updates( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + callback_delivery_repo: FakeCallbackDeliveryRepository, + ) -> None: + """A webhook failure must not roll back the operations/app_instances + updates that already happened in the same handle_completed call.""" + await self._seed_pending_operation(operation_repo) + await self._seed_app_instance_with_registration( + app_registration_repo, app_instance_repo, INSTANCE_ID + ) + await self._seed_active_callback_registration(callback_registration_repo) + callback_delivery_port.deliver = AsyncMock( # type: ignore[method-assign] + side_effect=RuntimeError("client server down") + ) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) # must not raise + + deliveries = list(callback_delivery_repo.rows.values()) + assert len(deliveries) == 1 + assert deliveries[0].state == "failed" + assert deliveries[0].last_error == "client server down" + + updated_operation = await operation_repo.get_by_id(self.OPERATION_ID) + assert updated_operation is not None + assert updated_operation.status == OperationStatus.COMPLETED + updated_instance = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated_instance is not None + assert updated_instance.state == AppInstanceState.READY + + async def test_raises_when_callback_delivery_port_unavailable( + self, + srm_client: AsyncMock, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + ) -> None: + await self._seed_pending_operation(operation_repo) + await self._seed_app_instance_with_registration( + app_registration_repo, app_instance_repo, INSTANCE_ID + ) + await self._seed_active_callback_registration(callback_registration_repo) + service = EdgeApplicationManagementService( + srm_client=srm_client, + app_registration_repo=app_registration_repo, + operation_repo=operation_repo, + app_instance_repo=app_instance_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_port=None, + callback_delivery_repo=None, + ) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + with pytest.raises(RuntimeError, match="CallbackDeliveryPort/CallbackDeliveryRepository"): + await service.handle_completed(event) + + async def test_multiple_instances_each_get_a_delivery( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + ) -> None: + ready_id = UUID("9a3f1c22-0000-4000-8000-00000000000a") + failed_id = UUID("9a3f1c22-0000-4000-8000-00000000000b") + await self._seed_pending_operation(operation_repo) + await self._seed_app_instance_with_registration( + app_registration_repo, app_instance_repo, ready_id + ) + await self._seed_app_instance_with_registration( + app_registration_repo, app_instance_repo, failed_id + ) + await self._seed_active_callback_registration(callback_registration_repo) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="partially_completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(ready_id), zone_id=str(ZONE_ID), status="completed" + ), + SRMCompletedInstance( + service_instance_id=str(failed_id), + zone_id=str(ZONE_ID), + status="failed", + error={ + "type": "about:blank", + "title": "Zone Capacity Exceeded", + "status": 503, + "detail": "no capacity", + }, + ), + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + assert len(callback_delivery_port.delivered) == 2 + statuses = {cloud_event.data.status for _, cloud_event in callback_delivery_port.delivered} + assert statuses == {"ready", "failed"} -- GitLab From f224df619a29028fff9968c8b5e672dcc80a18f0 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 13:49:47 +0300 Subject: [PATCH 12/36] feat: persist operations+app_instances before terminate publish --- .../vwip/router.py | 1 + .../edge_application_management_service.py | 33 ++++++- tests/unit/test_eam_flows.py | 34 ++++++- tests/unit/test_eam_service.py | 88 +++++++++++++++++-- 4 files changed, 149 insertions(+), 7 deletions(-) diff --git a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py index 23146c4..f7a9825 100644 --- a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py +++ b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py @@ -224,6 +224,7 @@ async def delete_app_instance( ) -> Response: await service.delete_app_instance( app_instance_id=appInstanceId, + tenant_id=caller.tenant_id, app_provider_id=caller.app_provider_id, x_correlator=caller.x_correlator, ) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index e0ed729..9654d51 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -32,6 +32,7 @@ from open_exposure_gateway.core.exceptions import ( BadRequestException, ConflictException, DownstreamServiceException, + NotFoundException, NotImplementedException, ) from open_exposure_gateway.domain.edge_application_management import ( @@ -354,8 +355,21 @@ class EdgeApplicationManagementService: return [build_app_instance_info(i) for i in instances] async def delete_app_instance( - self, app_instance_id: UUID, app_provider_id: str, x_correlator: Optional[str] = None + self, + app_instance_id: UUID, + tenant_id: str, + app_provider_id: str, + x_correlator: Optional[str] = None, ) -> None: + if self._operation_repo is None: + raise RuntimeError("OperationRepository is not available") + if self._app_instance_repo is None: + raise RuntimeError("AppInstanceRepository is not available") + + app_instance = await self._app_instance_repo.get_by_id(app_instance_id) + if app_instance is None: + raise NotFoundException(message=f"App instance {app_instance_id} not found") + operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) command = build_terminate_instance_command( app_instance_id=app_instance_id, @@ -364,6 +378,23 @@ class EdgeApplicationManagementService: correlation_id=correlation_id, requested_at=requested_at, ) + + 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.PENDING, + subject=Subject.TASK_TERMINATE, + app_registration_id=app_instance.app_registration_id, + ) + ) + await self._app_instance_repo.save( + app_instance.model_copy(update={"state": AppInstanceState.TERMINATING}) + ) + await self._publish( Subject.TASK_TERMINATE, command, diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 2fae3a4..cb6c857 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -27,7 +27,7 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMTerminateCommand, Subject, ) -from open_exposure_gateway.domain.models import AppInstanceState, OperationStatus +from open_exposure_gateway.domain.models import AppInstanceState, OperationStatus, OperationType from tests.unit.fakes import ( FakeAppInstanceRepository, FakeCallbackDeliveryPort, @@ -294,6 +294,38 @@ class TestDeleteAppInstanceFlow: assert command.service_specification_id is None assert srm_id not in live_srm.instances + def test_returns_404_for_unknown_instance(self, api_client: TestClient) -> None: + response = api_client.delete(f"{EAM_BASE}/appinstances/{uuid4()}") + assert response.status_code == 404 + + def test_persists_pending_terminate_operation_and_terminating_state( + self, + api_client: TestClient, + fake_bus: FakeDataBus, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """Uses plain api_client (not live_srm) so nothing auto-completes the + terminate -- this test is about the request-side PENDING/terminating + writes, not the completion path.""" + create_response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + instance_id = create_response.json()["appInstanceId"] + + response = api_client.delete(f"{EAM_BASE}/appinstances/{instance_id}") + + assert response.status_code == 202 + terminate_operations = [ + op + for op in operation_repo.rows.values() + if op.operation_type == OperationType.TERMINATE + ] + assert len(terminate_operations) == 1 + assert terminate_operations[0].status == OperationStatus.PENDING + + updated_instance = app_instance_repo.rows.get(UUID(instance_id)) + assert updated_instance is not None + assert updated_instance.state == AppInstanceState.TERMINATING + class TestSubmitAppFlow: def test_returns_201_and_registers_catalog_entry_in_srm( diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index d63b16b..f921765 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -22,6 +22,7 @@ from open_exposure_gateway.core.exceptions import ( BadRequestException, ConflictException, DownstreamServiceException, + NotFoundException, NotImplementedException, ) from open_exposure_gateway.domain.edge_application_management import ( @@ -806,11 +807,26 @@ class TestGetAppInstances: class TestDeleteAppInstance: + @pytest.fixture(autouse=True) + async def _seeded_app_instance(self, app_instance_repo: FakeAppInstanceRepository) -> None: + await app_instance_repo.save( + AppInstance( + app_instance_id=INSTANCE_ID, + operation_id=uuid4(), + app_registration_id=uuid4(), + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.READY, + ) + ) + async def test_publishes_terminate_command( self, service: EdgeApplicationManagementService, publisher: AsyncMock ) -> None: await service.delete_app_instance( - app_instance_id=INSTANCE_ID, app_provider_id="VideoAppsCo", x_correlator="corr-1" + app_instance_id=INSTANCE_ID, + tenant_id="tenant-1", + app_provider_id="VideoAppsCo", + x_correlator="corr-1", ) publisher.publish.assert_called_once() subject, payload = publisher.publish.call_args.args @@ -819,17 +835,79 @@ class TestDeleteAppInstance: assert payload["app_provider_id"] == "VideoAppsCo" assert payload["correlation_id"] == "corr-1" - async def test_raises_when_publisher_unavailable(self, srm_client: AsyncMock) -> None: - service = EdgeApplicationManagementService(srm_client=srm_client, publisher=None) + async def test_persists_pending_operation_and_terminating_state( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + await service.delete_app_instance( + app_instance_id=INSTANCE_ID, tenant_id="tenant-1", app_provider_id="VideoAppsCo" + ) + (operation,) = list(operation_repo.rows.values()) + assert operation.status == OperationStatus.PENDING + assert operation.operation_type == OperationType.TERMINATE + assert operation.subject == Subject.TASK_TERMINATE + + updated = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated is not None + assert updated.state == AppInstanceState.TERMINATING + + async def test_raises_not_found_for_unknown_instance( + self, service: EdgeApplicationManagementService + ) -> None: + with pytest.raises(NotFoundException): + await service.delete_app_instance( + app_instance_id=uuid4(), tenant_id="tenant-1", app_provider_id="p" + ) + + async def test_raises_when_publisher_unavailable( + self, + srm_client: AsyncMock, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + service = EdgeApplicationManagementService( + srm_client=srm_client, + publisher=None, + operation_repo=operation_repo, + app_instance_repo=app_instance_repo, + ) with pytest.raises(RuntimeError, match="DataBus publisher is not available"): - await service.delete_app_instance(app_instance_id=INSTANCE_ID, app_provider_id="p") + await service.delete_app_instance( + app_instance_id=INSTANCE_ID, tenant_id="tenant-1", app_provider_id="p" + ) + + async def test_raises_when_operation_repo_unavailable( + self, srm_client: AsyncMock, app_instance_repo: FakeAppInstanceRepository + ) -> None: + service = EdgeApplicationManagementService( + srm_client=srm_client, operation_repo=None, app_instance_repo=app_instance_repo + ) + with pytest.raises(RuntimeError, match="OperationRepository is not available"): + await service.delete_app_instance( + app_instance_id=INSTANCE_ID, tenant_id="tenant-1", app_provider_id="p" + ) + + async def test_raises_when_app_instance_repo_unavailable( + self, srm_client: AsyncMock, operation_repo: FakeOperationRepository + ) -> None: + service = EdgeApplicationManagementService( + srm_client=srm_client, operation_repo=operation_repo, app_instance_repo=None + ) + with pytest.raises(RuntimeError, match="AppInstanceRepository is not available"): + await service.delete_app_instance( + app_instance_id=INSTANCE_ID, tenant_id="tenant-1", app_provider_id="p" + ) async def test_wraps_publish_error( self, service: EdgeApplicationManagementService, publisher: AsyncMock ) -> None: publisher.publish.side_effect = Exception("NATS down") with pytest.raises(DownstreamServiceException, match="termination"): - await service.delete_app_instance(app_instance_id=INSTANCE_ID, app_provider_id="p") + await service.delete_app_instance( + app_instance_id=INSTANCE_ID, tenant_id="tenant-1", app_provider_id="p" + ) class TestHandleCompleted: -- GitLab From 5d17547ad6d78d1f83825ca10b535c49427dc9f8 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 17 Jul 2026 14:22:33 +0300 Subject: [PATCH 13/36] fix: delet ap_instances_ row on successful termination --- .../adapters/database/repos/app_instances.py | 6 +- .../edge_application_management_service.py | 7 ++ .../ports/database/instances.py | 4 + tests/unit/fakes.py | 3 + tests/unit/test_eam_flows.py | 17 +++ tests/unit/test_eam_service.py | 109 +++++++++++++++++- 6 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/open_exposure_gateway/adapters/database/repos/app_instances.py b/src/open_exposure_gateway/adapters/database/repos/app_instances.py index b79e10a..8e81dae 100644 --- a/src/open_exposure_gateway/adapters/database/repos/app_instances.py +++ b/src/open_exposure_gateway/adapters/database/repos/app_instances.py @@ -1,6 +1,6 @@ from uuid import UUID -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from open_exposure_gateway.adapters.database.mappers import AppInstanceMapper @@ -30,3 +30,7 @@ class SqlAppInstanceRepository(AppInstanceRepository): if saved is None: raise RuntimeError("Saved app instance could not be reloaded") return saved + + async def delete(self, app_instance_id: UUID) -> None: + stmt = delete(AppInstanceRow).where(AppInstanceRow.app_instance_id == app_instance_id) + await self._session.execute(stmt) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 9654d51..4c8818f 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -440,6 +440,7 @@ class EdgeApplicationManagementService: ) await self._operation_repo.save(updated) + is_terminate = operation.operation_type == OperationType.TERMINATE updated_instances: list[AppInstance] = [] if event.instances: @@ -452,6 +453,12 @@ class EdgeApplicationManagementService: app_instance_id=instance.service_instance_id, ) continue + if is_terminate and instance.status == "completed": + # No terminal "terminated" state exists on app_instances.state + # (only instantiating/ready/failed/terminating) -- once SRM + # confirms teardown, the row's job is done. + await self._app_instance_repo.delete(app_instance_id) + continue saved = await self._app_instance_repo.save( app_instance.model_copy( update={"state": _APP_INSTANCE_COMPLETION_STATE_MAP[instance.status]} diff --git a/src/open_exposure_gateway/ports/database/instances.py b/src/open_exposure_gateway/ports/database/instances.py index 653ce6f..e96cc51 100644 --- a/src/open_exposure_gateway/ports/database/instances.py +++ b/src/open_exposure_gateway/ports/database/instances.py @@ -18,3 +18,7 @@ class AppInstanceRepository(ABC): @abstractmethod async def save(self, app_instance: AppInstance) -> AppInstance: pass + + @abstractmethod + async def delete(self, app_instance_id: UUID) -> None: + pass diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index d8c7260..ee6b912 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -355,6 +355,9 @@ class FakeAppInstanceRepository(AppInstanceRepository): self.rows[stored.app_instance_id] = stored return stored.model_copy(deep=True) + async def delete(self, app_instance_id: UUID) -> None: + self.rows.pop(app_instance_id, None) + class FakeCallbackRegistrationRepository(CallbackRegistrationRepository): def __init__(self) -> None: diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index cb6c857..ac11700 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -326,6 +326,23 @@ class TestDeleteAppInstanceFlow: assert updated_instance is not None assert updated_instance.state == AppInstanceState.TERMINATING + def test_app_instance_row_removed_after_srm_confirms_termination( + self, + api_client: TestClient, + live_srm: FakeSRMClient, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """End-to-end through the real DI wiring: live_srm completes the + terminate synchronously, and the app_instances row should be gone -- + not flipped to ready, which is what the deploy-completion path does.""" + create_response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + instance_id = create_response.json()["appInstanceId"] + + response = api_client.delete(f"{EAM_BASE}/appinstances/{instance_id}") + + assert response.status_code == 202 + assert app_instance_repo.rows.get(UUID(instance_id)) is None + class TestSubmitAppFlow: def test_returns_201_and_registers_catalog_entry_in_srm( diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index f921765..e6913e4 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -913,16 +913,25 @@ class TestDeleteAppInstance: class TestHandleCompleted: OPERATION_ID = UUID("dddddddd-dddd-dddd-dddd-dddddddddddd") - async def _seed_pending_operation(self, operation_repo: FakeOperationRepository) -> None: + async def _seed_pending_operation( + self, + operation_repo: FakeOperationRepository, + operation_type: OperationType = OperationType.DEPLOY, + ) -> None: + subject = ( + Subject.TASK_TERMINATE + if operation_type == OperationType.TERMINATE + else Subject.TASK_DEPLOY + ) await operation_repo.save( Operation( operation_id=self.OPERATION_ID, correlation_id="corr-1", tenant_id="tenant-1", app_provider_id="provider-1", - operation_type=OperationType.DEPLOY, + operation_type=operation_type, status=OperationStatus.PENDING, - subject=Subject.TASK_DEPLOY, + subject=subject, ) ) @@ -1279,6 +1288,100 @@ class TestHandleCompleted: assert app_instance_repo.rows == {} + async def test_terminate_completion_deletes_app_instance_row( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """No terminal 'terminated' state exists on app_instances.state -- once + SRM confirms teardown, the row should be gone, not mapped to READY + (which is what the deploy-completion path would otherwise do).""" + await self._seed_pending_operation(operation_repo, operation_type=OperationType.TERMINATE) + await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + assert await app_instance_repo.get_by_id(INSTANCE_ID) is None + + async def test_terminate_completion_failure_marks_failed_not_deleted( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """A failed termination doesn't mean the instance is gone -- it stays + visible as failed rather than being deleted.""" + await self._seed_pending_operation(operation_repo, operation_type=OperationType.TERMINATE) + await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="partially_completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), + zone_id=str(ZONE_ID), + status="failed", + error={ + "type": "about:blank", + "title": "Termination Failed", + "status": 503, + "detail": "backend unreachable", + }, + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated is not None + assert updated.state == AppInstanceState.FAILED + + async def test_terminate_total_failure_fallback_marks_failed_not_deleted( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + await self._seed_pending_operation(operation_repo, operation_type=OperationType.TERMINATE) + await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="failed", + instances=[], + error={ + "type": "about:blank", + "title": "Termination Failed", + "status": 503, + "detail": "backend unreachable", + }, + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated is not None + assert updated.state == AppInstanceState.FAILED + async def test_delivers_callback_when_registration_exists( self, service: EdgeApplicationManagementService, -- GitLab From 3e81fd1b9a252945b224c3aed809c0f6cd8d04c4 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 09:38:31 +0300 Subject: [PATCH 14/36] fix: relax SRM catalago GET response fileds to optional --- .../mappers/edge_application_mapper.py | 2 + .../domain/edge_application_management.py | 10 ++-- tests/unit/test_eam_contract.py | 59 +++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index 5cc89f1..14791b9 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -135,6 +135,8 @@ def build_edge_cloud_zone(srm_zone: ResourceZone) -> EdgeCloudZone: def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: spec = catalog.service_specification unit = catalog.service_deployment_units[0] + if unit.artifact_ref is None: + raise ValueError(f"deployment unit {unit.ref!r} has no artifact_ref") try: app_id: Optional[UUID] = UUID(spec.ref) diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index e1f9fb9..bf63a32 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -150,7 +150,7 @@ class SRMComputeIntent(BaseModel): class SRMServiceSpecDescriptor(BaseModel): - artifact_type: str + artifact_type: str | None = None source_api: str = "edge-application-management" @@ -180,16 +180,18 @@ class SRMDeploymentUnit(BaseModel): ref: str name: str runtime_kind: str - artifact_ref: str + artifact_ref: str | None = None metadata: SRMDeploymentUnitMetadata | None = None resource_requirements: SRMComputeIntent class SRMCapabilityRequirement(BaseModel): ref: str - deployment_unit_ref: str + # Not present on SRM's GET response (ServiceCapabilityRequirementResponseSchema + # drops it); only meaningful on the POST request we build ourselves. + deployment_unit_ref: str | None = None capability_kind: str - domain_kind: str + domain_kind: str | None = None is_required: bool = True diff --git a/tests/unit/test_eam_contract.py b/tests/unit/test_eam_contract.py index bacbc57..ac530b8 100644 --- a/tests/unit/test_eam_contract.py +++ b/tests/unit/test_eam_contract.py @@ -281,3 +281,62 @@ class TestInternalHttpPaths: await client.get_resource_zones() assert calls == [("GET", "/internal/zones")] + + async def test_get_app_parses_srm_catalog_read_shape(self) -> None: + """SRM's GET /internal/catalog/service-specifications/{id} response + (ServiceCapabilityRequirementResponseSchema) never carries + deployment_unit_ref or domain_kind, and artifact_ref is nullable — parsing + must not require fields SRM's own response schema doesn't send.""" + client = SRMClient.__new__(SRMClient) + client.base_url = "http://srm:8081" + client.timeout = 1.0 + + srm_response = { + "service_specification": { + "id": str(APP_ID), + "app_provider_id": "VideoAppsCo", + "ref": str(APP_ID), + "name": "myvideoapp", + "version": "1.0.0", + "descriptor": {}, + "metadata": {}, + }, + "service_deployment_units": [ + { + "ref": "main-runtime", + "name": "Main Runtime", + "runtime_kind": "helm", + "artifact_ref": None, + "resource_requirements": {}, + "parameters_schema": {}, + "metadata": {}, + } + ], + "service_capability_requirements": [ + { + "ref": "require-workload-deployment", + "capability_kind": "deploy_workload", + "domain_kind": None, + "is_required": True, + "selector": {}, + "policy": {}, + "metadata": {}, + } + ], + } + + async def record( + method: str, + path: str, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + return srm_response + + client._request = record # type: ignore[method-assign] + catalog = await client.get_app(APP_ID) + + assert catalog.service_deployment_units[0].artifact_ref is None + assert catalog.service_capability_requirements[0].deployment_unit_ref is None + assert catalog.service_capability_requirements[0].domain_kind is None -- GitLab From fe9b3d12356dae39c76702c44400dcb40ff116f0 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 09:46:39 +0300 Subject: [PATCH 15/36] fix: sync local app registration on delete, protect get_app against malformed catalog entries --- .../database/repos/app_registrations.py | 6 +- .../edge_application_management_service.py | 12 +++- .../ports/database/registration.py | 4 ++ tests/unit/fakes.py | 5 ++ tests/unit/test_eam_service.py | 55 +++++++++++++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/open_exposure_gateway/adapters/database/repos/app_registrations.py b/src/open_exposure_gateway/adapters/database/repos/app_registrations.py index d557056..e605ddb 100644 --- a/src/open_exposure_gateway/adapters/database/repos/app_registrations.py +++ b/src/open_exposure_gateway/adapters/database/repos/app_registrations.py @@ -1,6 +1,6 @@ from uuid import UUID -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -41,3 +41,7 @@ class SqlAppRegistrationRepository(AppRegistrationRepository): if saved is None: raise RuntimeError("Saved app registration could not be reloaded") return saved + + async def delete_by_app_id(self, app_id: UUID) -> None: + stmt = delete(AppRegistrationRow).where(AppRegistrationRow.app_id == app_id) + await self._session.execute(stmt) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 4c8818f..11f97f6 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -148,7 +148,14 @@ class EdgeApplicationManagementService: self, app_id: UUID, x_correlator: Optional[str] = None ) -> AppManifestEnvelope: catalog = await self.srm_client.get_app(app_id=app_id, x_correlator=x_correlator) - return AppManifestEnvelope(appManifest=build_app_manifest(catalog)) + try: + manifest = build_app_manifest(catalog) + except (ValueError, TypeError, IndexError) as exc: + raise DownstreamServiceException( + message="SRM returned a malformed service specification", + details=str(exc), + ) from exc + return AppManifestEnvelope(appManifest=manifest) async def submit_app( self, @@ -223,6 +230,9 @@ class EdgeApplicationManagementService: x_correlator: Optional[str] = None, ) -> None: await self.srm_client.delete_app(app_id=app_id, x_correlator=x_correlator) + if self._app_registration_repo is None: + raise RuntimeError("AppRegistrationRepository is not available") + await self._app_registration_repo.delete_by_app_id(app_id) async def create_app_instance( self, diff --git a/src/open_exposure_gateway/ports/database/registration.py b/src/open_exposure_gateway/ports/database/registration.py index 33b55c8..0596ea2 100644 --- a/src/open_exposure_gateway/ports/database/registration.py +++ b/src/open_exposure_gateway/ports/database/registration.py @@ -18,3 +18,7 @@ class AppRegistrationRepository(ABC): @abstractmethod async def save(self, app_registration: AppRegistration) -> AppRegistration: pass + + @abstractmethod + async def delete_by_app_id(self, app_id: UUID) -> None: + pass diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index ee6b912..5c363c2 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -303,6 +303,11 @@ class FakeAppRegistrationRepository(AppRegistrationRepository): self.rows[stored.app_registration_id] = stored return stored.model_copy(deep=True) + async def delete_by_app_id(self, app_id: UUID) -> None: + for registration_id, row in list(self.rows.items()): + if row.app_id == app_id: + del self.rows[registration_id] + class FakeOperationRepository(OperationRepository): def __init__(self) -> None: diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index e6913e4..f1cb4f5 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -276,6 +276,16 @@ class TestGetApp: await service.get_app(app_id=APP_ID, x_correlator="corr-1") srm_client.get_app.assert_called_once_with(app_id=APP_ID, x_correlator="corr-1") + async def test_raises_downstream_exception_for_unmappable_catalog_entry( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + catalog = _make_srm_catalog() + catalog.service_deployment_units = [] + srm_client.get_app.return_value = catalog + + with pytest.raises(DownstreamServiceException): + await service.get_app(app_id=APP_ID) + class TestSubmitApp: async def test_returns_submitted_app_with_id( @@ -456,6 +466,51 @@ class TestDeleteApp: ) srm_client.delete_app.assert_called_once_with(app_id=APP_ID, x_correlator="corr-1") + async def test_removes_local_app_registration( + self, + service: EdgeApplicationManagementService, + app_registration_repo: FakeAppRegistrationRepository, + ) -> None: + await app_registration_repo.save( + AppRegistration( + app_registration_id=uuid4(), + app_id=APP_ID, + tenant_id="tenant-1", + name="myvideoapp", + version="1.0.0", + package_type=PackageType.HELM, + status=AppRegistrationStatus.REGISTERED, + ) + ) + + await service.delete_app(app_id=APP_ID, app_provider_id="VideoAppsCo") + + assert await app_registration_repo.get_by_app_id(APP_ID) is None + + async def test_does_not_delete_local_registration_when_srm_call_fails( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + ) -> None: + await app_registration_repo.save( + AppRegistration( + app_registration_id=uuid4(), + app_id=APP_ID, + tenant_id="tenant-1", + name="myvideoapp", + version="1.0.0", + package_type=PackageType.HELM, + status=AppRegistrationStatus.REGISTERED, + ) + ) + srm_client.delete_app.side_effect = NotFoundException(message="not found") + + with pytest.raises(NotFoundException): + await service.delete_app(app_id=APP_ID, app_provider_id="VideoAppsCo") + + assert await app_registration_repo.get_by_app_id(APP_ID) is not None + class TestCreateAppInstance: def _make_request(self) -> CreateAppInstanceRequest: -- GitLab From 63aa09506c64234c05ebc0da2f1157f266fec42a Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 27 Jul 2026 16:39:51 +0300 Subject: [PATCH 16/36] fix: align ResourceZone with SRM's latest zone model --- .../adapters/http/srm_client.py | 2 +- .../mappers/edge_application_mapper.py | 9 ++++--- .../domain/edge_application_management.py | 14 +++++++---- tests/conformance/conftest.py | 11 +++++--- tests/unit/test_eam_contract.py | 25 +++++++++++++++++++ tests/unit/test_eam_flows.py | 17 +++++++++---- tests/unit/test_eam_mapper.py | 21 ++++++++++------ tests/unit/test_eam_service.py | 7 +++--- 8 files changed, 76 insertions(+), 30 deletions(-) diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index bafe2f9..c48928b 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -110,7 +110,7 @@ class SRMClient: if region is not None: params["region"] = region if status is not None: - params["status"] = status + params["state"] = status headers = {} if x_correlator: diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index 14791b9..b507fcc 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -119,16 +119,17 @@ def _parse_storage_mb(value: str) -> int: def build_edge_cloud_zone(srm_zone: ResourceZone) -> EdgeCloudZone: try: - status = EdgeCloudZoneStatus(srm_zone.status) + status = EdgeCloudZoneStatus(srm_zone.state) except ValueError: status = EdgeCloudZoneStatus.UNKNOWN + location = srm_zone.metadata.location return EdgeCloudZone( - edgeCloudZoneId=UUID(srm_zone.resource_zone_id), + edgeCloudZoneId=UUID(srm_zone.id), edgeCloudZoneName=srm_zone.name, edgeCloudZoneStatus=status, - edgeCloudProvider=srm_zone.provider, - edgeCloudRegion=srm_zone.location.region if srm_zone.location else None, + edgeCloudProvider=srm_zone.metadata.provider, + edgeCloudRegion=location.region if location else None, ) diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index bf63a32..d0c2344 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -96,17 +96,21 @@ class AppDeploymentTranslation(BaseModel): class ResourceZoneLocation(BaseModel): region: str | None = None - country: str | None = None + geo: Any | None = None -class ResourceZone(BaseModel): - resource_zone_id: str - name: str - status: str +class ResourceZoneMetadata(BaseModel): provider: str location: ResourceZoneLocation | None = None +class ResourceZone(BaseModel): + id: str + name: str + state: str + metadata: ResourceZoneMetadata + + class SRMAccelerator(BaseModel): type: str units: int diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py index 92df242..8350ee6 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -22,7 +22,10 @@ from open_exposure_gateway.dependencies import ( get_publisher, get_qod_service, ) -from open_exposure_gateway.domain.edge_application_management import ResourceZone +from open_exposure_gateway.domain.edge_application_management import ( + ResourceZone, + ResourceZoneMetadata, +) from tests.conformance.harness import app from tests.unit.fakes import ( FakeAppInstanceRepository, @@ -45,10 +48,10 @@ def service_overrides() -> Generator[None, None, None]: # otherwise, which no real provider would ever be. srm.zones.append( ResourceZone( - resource_zone_id=str(uuid4()), + id=str(uuid4()), name="conformance-zone", - status="active", - provider="conformance-provider", + state="active", + metadata=ResourceZoneMetadata(provider="conformance-provider"), ) ) operation_repo = FakeOperationRepository() diff --git a/tests/unit/test_eam_contract.py b/tests/unit/test_eam_contract.py index ac530b8..816ab28 100644 --- a/tests/unit/test_eam_contract.py +++ b/tests/unit/test_eam_contract.py @@ -282,6 +282,31 @@ class TestInternalHttpPaths: assert calls == [("GET", "/internal/zones")] + async def test_zone_status_filter_uses_state_query_param(self) -> None: + """The zone column is `state`, not `status` (srm/persistence-model.md); + the CAMARA-facing `status` filter must be forwarded as `state` or SRM + silently ignores it and returns unfiltered results.""" + client = SRMClient.__new__(SRMClient) + client.base_url = "http://srm:8081" + client.timeout = 1.0 + recorded_params: dict[str, Any] | None = None + + async def record( + method: str, + path: str, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + nonlocal recorded_params + recorded_params = params + return [] + + client._request = record # type: ignore[method-assign] + await client.get_resource_zones(region="athens", status="active") + + assert recorded_params == {"region": "athens", "state": "active"} + async def test_get_app_parses_srm_catalog_read_shape(self) -> None: """SRM's GET /internal/catalog/service-specifications/{id} response (ServiceCapabilityRequirementResponseSchema) never carries diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index ac11700..86db9c1 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -23,6 +23,7 @@ from open_exposure_gateway.api.camara.edge_application_management.vwip.router im from open_exposure_gateway.core.exceptions import DownstreamServiceException from open_exposure_gateway.domain.edge_application_management import ( ResourceZone, + ResourceZoneMetadata, SRMDeployCommand, SRMTerminateCommand, Subject, @@ -510,10 +511,10 @@ class TestEdgeCloudZonesFlow: ) -> None: fake_srm.zones.append( ResourceZone( - resource_zone_id=str(ZONE_ID), + id=str(ZONE_ID), name="berlin-edge-1", - status="active", - provider="acme", + state="active", + metadata=ResourceZoneMetadata(provider="acme"), ) ) response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") @@ -531,12 +532,18 @@ class TestEdgeCloudZonesFlow: still be returned.""" fake_srm.zones.append( ResourceZone( - resource_zone_id=str(ZONE_ID), name="good-zone", status="active", provider="acme" + id=str(ZONE_ID), + name="good-zone", + state="active", + metadata=ResourceZoneMetadata(provider="acme"), ) ) fake_srm.zones.append( ResourceZone( - resource_zone_id="zone-west-1", name="bad-zone", status="active", provider="acme" + id="zone-west-1", + name="bad-zone", + state="active", + metadata=ResourceZoneMetadata(provider="acme"), ) ) response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index b6f3ce7..ddcdbdc 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -27,6 +27,7 @@ from open_exposure_gateway.application.mappers.edge_application_mapper import ( from open_exposure_gateway.domain.edge_application_management import ( ResourceZone, ResourceZoneLocation, + ResourceZoneMetadata, SRMAccelerator, SRMCapabilityEndpoint, SRMCapabilityInstanceSummary, @@ -154,11 +155,13 @@ def _make_helm_manifest( class TestBuildEdgeCloudZone: def test_maps_all_fields(self) -> None: zone = ResourceZone( - resource_zone_id=str(ZONE_ID), + id=str(ZONE_ID), name="berlin-edge-1", - status="active", - provider="acme", - location=ResourceZoneLocation(region="eu-central-1"), + state="active", + metadata=ResourceZoneMetadata( + provider="acme", + location=ResourceZoneLocation(region="eu-central-1"), + ), ) result = build_edge_cloud_zone(zone) assert result.edgeCloudZoneId == ZONE_ID @@ -168,16 +171,18 @@ class TestBuildEdgeCloudZone: def test_unknown_status_falls_back(self) -> None: zone = ResourceZone( - resource_zone_id=str(ZONE_ID), + id=str(ZONE_ID), name="z", - status="maintenance", - provider="p", + state="maintenance", + metadata=ResourceZoneMetadata(provider="p"), ) result = build_edge_cloud_zone(zone) assert result.edgeCloudZoneStatus == EdgeCloudZoneStatus.UNKNOWN def test_no_location_gives_none_region(self) -> None: - zone = ResourceZone(resource_zone_id=str(ZONE_ID), name="z", status="active", provider="p") + zone = ResourceZone( + id=str(ZONE_ID), name="z", state="active", metadata=ResourceZoneMetadata(provider="p") + ) result = build_edge_cloud_zone(zone) assert result.edgeCloudRegion is None diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index f1cb4f5..662964c 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -27,6 +27,7 @@ from open_exposure_gateway.core.exceptions import ( ) from open_exposure_gateway.domain.edge_application_management import ( ResourceZone, + ResourceZoneMetadata, SRMCapabilityRequirement, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, @@ -220,10 +221,10 @@ class TestGetEdgeCloudZones: ) -> None: srm_client.get_resource_zones.return_value = [ ResourceZone( - resource_zone_id=str(ZONE_ID), + id=str(ZONE_ID), name="berlin-edge-1", - status="active", - provider="acme", + state="active", + metadata=ResourceZoneMetadata(provider="acme"), ) ] result = await service.get_edge_cloud_zones() -- GitLab From a601ff4417cc592c5745522c3dcd5cbb4d923134 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Wed, 29 Jul 2026 10:27:25 +0300 Subject: [PATCH 17/36] fix: block DELETE /apps/{appId} while running instances exist --- .../adapters/database/repos/app_instances.py | 7 ++ .../vwip/router.py | 104 +++++++++++++----- .../edge_application_management_service.py | 20 +++- .../ports/database/instances.py | 4 + tests/unit/fakes.py | 3 + tests/unit/test_eam_service.py | 42 ++++++- 6 files changed, 148 insertions(+), 32 deletions(-) diff --git a/src/open_exposure_gateway/adapters/database/repos/app_instances.py b/src/open_exposure_gateway/adapters/database/repos/app_instances.py index 8e81dae..c44d528 100644 --- a/src/open_exposure_gateway/adapters/database/repos/app_instances.py +++ b/src/open_exposure_gateway/adapters/database/repos/app_instances.py @@ -23,6 +23,13 @@ class SqlAppInstanceRepository(AppInstanceRepository): row = await self._session.scalar(stmt) return AppInstanceMapper.to_domain(row) if row is not None else None + async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: + stmt = select(AppInstanceRow.app_instance_id).where( + AppInstanceRow.app_registration_id == app_registration_id + ) + row = await self._session.scalar(stmt.limit(1)) + return row is not None + async def save(self, app_instance: AppInstance) -> AppInstance: merged = await self._session.merge(AppInstanceMapper.to_row(app_instance)) await self._session.flush() diff --git a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py index f7a9825..346f10b 100644 --- a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py +++ b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py @@ -17,12 +17,14 @@ from open_exposure_gateway.application.services.edge_application_management_serv EdgeApplicationManagementService, ) from open_exposure_gateway.core.exceptions import ( + AbortedException, + AlreadyExistsException, BadRequestException, - ConflictException, DownstreamServiceException, ForbiddenException, NotFoundException, NotImplementedException, + OEGException, UnauthorizedException, ) from open_exposure_gateway.dependencies import ( @@ -41,26 +43,28 @@ router = APIRouter(prefix=BASE_PATH) EdgeAppService = Annotated[EdgeApplicationManagementService, Depends(get_edge_app_service)] Caller = Annotated[CallerContext, Depends(get_caller_context)] -# OpenAPI response docs derived from core.exceptions' status_code/message -# defaults, so codes aren't re-listed. 500 has no dedicated exception class -# (it's the unhandled-exception catch-all in error_handlers.py). -_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { - exc_cls().status_code: {"model": ErrorInfo, "description": exc_cls().message} - for exc_cls in ( - BadRequestException, - UnauthorizedException, - ForbiddenException, - NotFoundException, - ConflictException, - NotImplementedException, - DownstreamServiceException, - ) +# 500 has no dedicated exception class (it's the unhandled-exception catch-all +# in error_handlers.py), so every endpoint gets it for free here. +_INTERNAL_SERVER_ERROR_RESPONSE: dict[str, Any] = { + "model": ErrorInfo, + "description": "Internal server error", } -_ERROR_RESPONSES[500] = {"model": ErrorInfo, "description": "Internal server error"} -def _responses(*codes: int) -> dict[int | str, dict[str, Any]]: - return {code: _ERROR_RESPONSES[code] for code in codes} +def _responses(*exceptions: OEGException) -> dict[int | str, dict[str, Any]]: + """Build OpenAPI response docs from the exceptions a route actually raises. + + Takes instances (not classes) so each one's default message is read directly, + with no need to guess whether a given exception class is callable with no + args. The description (and CAMARA error `code`, via error_code) comes + straight from each exception, so two endpoints sharing the same HTTP status + (e.g. 409) can still document different spec-accurate bodies — ALREADY_EXISTS + for submit_app/create_app_instance vs. ABORTED for delete_app. + """ + responses: dict[int | str, dict[str, Any]] = {500: _INTERNAL_SERVER_ERROR_RESPONSE} + for exc in exceptions: + responses[exc.status_code] = {"model": ErrorInfo, "description": exc.message} + return responses @router.get( @@ -69,7 +73,12 @@ def _responses(*codes: int) -> dict[int | str, dict[str, Any]]: summary="Retrieve a list of the operators Edge Cloud Zones and their status", response_model=list[EdgeCloudZone], response_model_exclude_none=True, - responses=_responses(400, 401, 403, 500, 503), + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + DownstreamServiceException(), + ), ) async def get_edge_cloud_zones( service: EdgeAppService, @@ -89,7 +98,12 @@ async def get_edge_cloud_zones( tags=["Application"], summary="Retrieve a list of existing Applications", response_model=list[AppManifest], - responses=_responses(400, 401, 403, 500, 503), + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + DownstreamServiceException(), + ), ) async def get_apps( service: EdgeAppService, @@ -103,7 +117,13 @@ async def get_apps( tags=["Application"], summary="Retrieve the information of an Application", response_model=AppManifestEnvelope, - responses=_responses(400, 401, 403, 404, 500, 503), + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + NotFoundException(), + DownstreamServiceException(), + ), ) async def get_app( appId: UUID, @@ -119,7 +139,14 @@ async def get_app( status_code=201, summary="Submit application metadata to the Edge Cloud Provider.", response_model=SubmittedApp, - responses=_responses(400, 401, 403, 409, 500, 501, 503), + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + AlreadyExistsException(), + NotImplementedException(), + DownstreamServiceException(), + ), ) async def submit_app( request: AppManifest, @@ -141,7 +168,14 @@ async def submit_app( tags=["Application"], status_code=204, summary="Delete an Application from an Edge Cloud Provider", - responses=_responses(400, 401, 403, 404, 409, 500, 503), + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + NotFoundException(), + AbortedException(), + DownstreamServiceException(), + ), ) async def delete_app( appId: UUID, @@ -163,7 +197,14 @@ async def delete_app( summary="Instantiation of an Application", response_model=AppInstanceInfo, response_model_exclude_none=True, - responses=_responses(400, 401, 403, 409, 500, 501, 503), + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + AlreadyExistsException(), + NotImplementedException(), + DownstreamServiceException(), + ), ) async def create_app_instance( request: CreateAppInstanceRequest, @@ -193,7 +234,12 @@ async def create_app_instance( summary="Retrieve the information of Application Instances for a given App", response_model=list[AppInstanceInfo], response_model_exclude_none=True, - responses=_responses(400, 401, 403, 500, 503), + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + DownstreamServiceException(), + ), ) async def get_app_instances( service: EdgeAppService, @@ -215,7 +261,13 @@ async def get_app_instances( tags=["Application"], status_code=202, summary="Terminate an Application Instance", - responses=_responses(400, 401, 403, 404, 500, 503), + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + NotFoundException(), + DownstreamServiceException(), + ), ) async def delete_app_instance( appInstanceId: UUID, diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 11f97f6..ff7171c 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -29,8 +29,9 @@ from open_exposure_gateway.application.mappers.edge_application_mapper import ( build_terminate_instance_command, ) from open_exposure_gateway.core.exceptions import ( + AbortedException, + AlreadyExistsException, BadRequestException, - ConflictException, DownstreamServiceException, NotFoundException, NotImplementedException, @@ -203,7 +204,7 @@ class EdgeApplicationManagementService: ) ) except DuplicateAppRegistrationError as exc: - raise ConflictException( + raise AlreadyExistsException( message=f"App {translation.app_id} is already registered" ) from exc @@ -229,9 +230,22 @@ class EdgeApplicationManagementService: app_provider_id: str, x_correlator: Optional[str] = None, ) -> None: - await self.srm_client.delete_app(app_id=app_id, x_correlator=x_correlator) if self._app_registration_repo is None: raise RuntimeError("AppRegistrationRepository is not available") + if self._app_instance_repo is None: + raise RuntimeError("AppInstanceRepository is not available") + + app_registration = await self._app_registration_repo.get_by_app_id(app_id) + if app_registration is not None: + has_instances = await self._app_instance_repo.exists_for_app_registration( + app_registration.app_registration_id + ) + if has_instances: + raise AbortedException( + message="App with a running application instance cannot be deleted" + ) + + await self.srm_client.delete_app(app_id=app_id, x_correlator=x_correlator) await self._app_registration_repo.delete_by_app_id(app_id) async def create_app_instance( diff --git a/src/open_exposure_gateway/ports/database/instances.py b/src/open_exposure_gateway/ports/database/instances.py index e96cc51..8b976f7 100644 --- a/src/open_exposure_gateway/ports/database/instances.py +++ b/src/open_exposure_gateway/ports/database/instances.py @@ -15,6 +15,10 @@ class AppInstanceRepository(ABC): async def get_by_operation_id(self, operation_id: UUID) -> AppInstance | None: pass + @abstractmethod + async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: + pass + @abstractmethod async def save(self, app_instance: AppInstance) -> AppInstance: pass diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 5c363c2..b5d9bc8 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -355,6 +355,9 @@ class FakeAppInstanceRepository(AppInstanceRepository): return row.model_copy(deep=True) return None + async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: + return any(row.app_registration_id == app_registration_id for row in self.rows.values()) + async def save(self, app_instance: AppInstance) -> AppInstance: stored = app_instance.model_copy(deep=True) self.rows[stored.app_instance_id] = stored diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 662964c..6e9f554 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -19,8 +19,9 @@ from open_exposure_gateway.application.services.edge_application_management_serv EdgeApplicationManagementService, ) from open_exposure_gateway.core.exceptions import ( + AbortedException, + AlreadyExistsException, BadRequestException, - ConflictException, DownstreamServiceException, NotFoundException, NotImplementedException, @@ -379,7 +380,7 @@ class TestSubmitApp: ) assert await app_registration_repo.get_by_app_id(APP_ID) is None - async def test_raises_conflict_when_app_id_already_registered( + async def test_raises_already_exists_when_app_id_already_registered( self, service: EdgeApplicationManagementService, app_registration_repo: FakeAppRegistrationRepository, @@ -395,7 +396,7 @@ class TestSubmitApp: status=AppRegistrationStatus.REGISTERED, ) ) - with pytest.raises(ConflictException): + with pytest.raises(AlreadyExistsException): await service.submit_app( manifest=_make_manifest(), app_id=APP_ID, @@ -512,6 +513,41 @@ class TestDeleteApp: assert await app_registration_repo.get_by_app_id(APP_ID) is not None + async def test_rejects_delete_when_app_has_a_running_instance( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + app_registration_id = uuid4() + 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 app_instance_repo.save( + AppInstance( + app_instance_id=INSTANCE_ID, + operation_id=uuid4(), + app_registration_id=app_registration_id, + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.READY, + ) + ) + + with pytest.raises(AbortedException): + await service.delete_app(app_id=APP_ID, app_provider_id="VideoAppsCo") + + srm_client.delete_app.assert_not_called() + assert await app_registration_repo.get_by_app_id(APP_ID) is not None + class TestCreateAppInstance: def _make_request(self) -> CreateAppInstanceRequest: -- GitLab From 425b0e97a9f5015426132bbe65285fa383f193f1 Mon Sep 17 00:00:00 2001 From: dgogos Date: Wed, 29 Jul 2026 11:51:50 +0300 Subject: [PATCH 18/36] fix: update app instance deletion logic to mark as terminated instead of deleting --- .../adapters/database/repos/app_instances.py | 6 +- .../edge_application_management_service.py | 14 ++-- .../domain/models/instances/enums.py | 1 + .../ports/database/instances.py | 4 -- tests/unit/fakes.py | 3 - tests/unit/test_eam_flows.py | 12 ++-- tests/unit/test_eam_service.py | 68 +++++++++++++++++-- 7 files changed, 78 insertions(+), 30 deletions(-) diff --git a/src/open_exposure_gateway/adapters/database/repos/app_instances.py b/src/open_exposure_gateway/adapters/database/repos/app_instances.py index c44d528..84cbec7 100644 --- a/src/open_exposure_gateway/adapters/database/repos/app_instances.py +++ b/src/open_exposure_gateway/adapters/database/repos/app_instances.py @@ -1,6 +1,6 @@ from uuid import UUID -from sqlalchemy import delete, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from open_exposure_gateway.adapters.database.mappers import AppInstanceMapper @@ -37,7 +37,3 @@ class SqlAppInstanceRepository(AppInstanceRepository): if saved is None: raise RuntimeError("Saved app instance could not be reloaded") return saved - - async def delete(self, app_instance_id: UUID) -> None: - stmt = delete(AppInstanceRow).where(AppInstanceRow.app_instance_id == app_instance_id) - await self._session.execute(stmt) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index ff7171c..8f73892 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -391,7 +391,7 @@ class EdgeApplicationManagementService: raise RuntimeError("AppInstanceRepository is not available") app_instance = await self._app_instance_repo.get_by_id(app_instance_id) - if app_instance is None: + if app_instance is None or app_instance.state == AppInstanceState.TERMINATED: raise NotFoundException(message=f"App instance {app_instance_id} not found") operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) @@ -478,15 +478,11 @@ class EdgeApplicationManagementService: ) continue if is_terminate and instance.status == "completed": - # No terminal "terminated" state exists on app_instances.state - # (only instantiating/ready/failed/terminating) -- once SRM - # confirms teardown, the row's job is done. - await self._app_instance_repo.delete(app_instance_id) - continue + state = AppInstanceState.TERMINATED + else: + state = _APP_INSTANCE_COMPLETION_STATE_MAP[instance.status] saved = await self._app_instance_repo.save( - app_instance.model_copy( - update={"state": _APP_INSTANCE_COMPLETION_STATE_MAP[instance.status]} - ) + app_instance.model_copy(update={"state": state}) ) updated_instances.append(saved) elif status == OperationStatus.FAILED: diff --git a/src/open_exposure_gateway/domain/models/instances/enums.py b/src/open_exposure_gateway/domain/models/instances/enums.py index 2eec145..6c60b77 100644 --- a/src/open_exposure_gateway/domain/models/instances/enums.py +++ b/src/open_exposure_gateway/domain/models/instances/enums.py @@ -6,3 +6,4 @@ class AppInstanceState(StrEnum): READY = "ready" FAILED = "failed" TERMINATING = "terminating" + TERMINATED = "terminated" diff --git a/src/open_exposure_gateway/ports/database/instances.py b/src/open_exposure_gateway/ports/database/instances.py index 8b976f7..eb6b1b9 100644 --- a/src/open_exposure_gateway/ports/database/instances.py +++ b/src/open_exposure_gateway/ports/database/instances.py @@ -22,7 +22,3 @@ class AppInstanceRepository(ABC): @abstractmethod async def save(self, app_instance: AppInstance) -> AppInstance: pass - - @abstractmethod - async def delete(self, app_instance_id: UUID) -> None: - pass diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index b5d9bc8..9aafcac 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -363,9 +363,6 @@ class FakeAppInstanceRepository(AppInstanceRepository): self.rows[stored.app_instance_id] = stored return stored.model_copy(deep=True) - async def delete(self, app_instance_id: UUID) -> None: - self.rows.pop(app_instance_id, None) - class FakeCallbackRegistrationRepository(CallbackRegistrationRepository): def __init__(self) -> None: diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 86db9c1..a36200d 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -327,22 +327,26 @@ class TestDeleteAppInstanceFlow: assert updated_instance is not None assert updated_instance.state == AppInstanceState.TERMINATING - def test_app_instance_row_removed_after_srm_confirms_termination( + def test_app_instance_row_marked_terminated_after_srm_confirms_termination( self, api_client: TestClient, live_srm: FakeSRMClient, app_instance_repo: FakeAppInstanceRepository, ) -> None: """End-to-end through the real DI wiring: live_srm completes the - terminate synchronously, and the app_instances row should be gone -- - not flipped to ready, which is what the deploy-completion path does.""" + terminate synchronously, and the app_instances row should stay -- + flipped to terminated, not deleted (which would also silence the + completion callback) and not flipped to ready, which is what the + deploy-completion path does.""" create_response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) instance_id = create_response.json()["appInstanceId"] response = api_client.delete(f"{EAM_BASE}/appinstances/{instance_id}") assert response.status_code == 202 - assert app_instance_repo.rows.get(UUID(instance_id)) is None + updated_instance = app_instance_repo.rows.get(UUID(instance_id)) + assert updated_instance is not None + assert updated_instance.state == AppInstanceState.TERMINATED class TestSubmitAppFlow: diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 6e9f554..ff63c99 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -953,6 +953,25 @@ class TestDeleteAppInstance: app_instance_id=uuid4(), tenant_id="tenant-1", app_provider_id="p" ) + async def test_raises_not_found_for_already_terminated_instance( + self, + service: EdgeApplicationManagementService, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + await app_instance_repo.save( + AppInstance( + app_instance_id=INSTANCE_ID, + operation_id=uuid4(), + app_registration_id=uuid4(), + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.TERMINATED, + ) + ) + with pytest.raises(NotFoundException): + await service.delete_app_instance( + app_instance_id=INSTANCE_ID, tenant_id="tenant-1", app_provider_id="p" + ) + async def test_raises_when_publisher_unavailable( self, srm_client: AsyncMock, @@ -1380,15 +1399,16 @@ class TestHandleCompleted: assert app_instance_repo.rows == {} - async def test_terminate_completion_deletes_app_instance_row( + async def test_terminate_completion_marks_terminated_not_deleted( self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository, app_instance_repo: FakeAppInstanceRepository, ) -> None: - """No terminal 'terminated' state exists on app_instances.state -- once - SRM confirms teardown, the row should be gone, not mapped to READY - (which is what the deploy-completion path would otherwise do).""" + """Once SRM confirms teardown, the row stays -- mapped to TERMINATED, + not deleted (which would also silence the completion callback) and + not mapped to READY (which is what the deploy-completion path would + otherwise do).""" await self._seed_pending_operation(operation_repo, operation_type=OperationType.TERMINATE) await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) event = SRMOperationCompleted( @@ -1406,7 +1426,45 @@ class TestHandleCompleted: await service.handle_completed(event) - assert await app_instance_repo.get_by_id(INSTANCE_ID) is None + updated = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated is not None + assert updated.state == AppInstanceState.TERMINATED + + async def test_terminate_completion_delivers_callback( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + ) -> None: + await self._seed_pending_operation(operation_repo, operation_type=OperationType.TERMINATE) + app_id = await self._seed_app_instance_with_registration( + app_registration_repo, app_instance_repo, INSTANCE_ID + ) + registration = await self._seed_active_callback_registration(callback_registration_repo) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + assert len(callback_delivery_port.delivered) == 1 + sink, cloud_event = callback_delivery_port.delivered[0] + assert sink == registration.sink + assert cloud_event.data.appInstanceId == INSTANCE_ID + assert cloud_event.data.appId == app_id + assert cloud_event.data.status == "terminated" async def test_terminate_completion_failure_marks_failed_not_deleted( self, -- GitLab From 00d388ecd2be0b3dd723d6de9e348bf9770f41ac Mon Sep 17 00:00:00 2001 From: dgogos Date: Wed, 29 Jul 2026 12:08:36 +0300 Subject: [PATCH 19/36] fix: ignore terminal states when checking app instance existence for registration --- .../adapters/database/repos/app_instances.py | 7 ++-- tests/integration/test_postgres.py | 20 +++++++++++ tests/unit/fakes.py | 7 +++- tests/unit/test_eam_service.py | 36 +++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/open_exposure_gateway/adapters/database/repos/app_instances.py b/src/open_exposure_gateway/adapters/database/repos/app_instances.py index 84cbec7..e3a1acc 100644 --- a/src/open_exposure_gateway/adapters/database/repos/app_instances.py +++ b/src/open_exposure_gateway/adapters/database/repos/app_instances.py @@ -5,9 +5,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from open_exposure_gateway.adapters.database.mappers import AppInstanceMapper from open_exposure_gateway.adapters.database.sql import AppInstanceRow -from open_exposure_gateway.domain.models import AppInstance +from open_exposure_gateway.domain.models import AppInstance, AppInstanceState from open_exposure_gateway.ports.database.instances import AppInstanceRepository +_TERMINAL_STATES = (AppInstanceState.FAILED, AppInstanceState.TERMINATED) + class SqlAppInstanceRepository(AppInstanceRepository): def __init__(self, session: AsyncSession) -> None: @@ -25,7 +27,8 @@ class SqlAppInstanceRepository(AppInstanceRepository): async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: stmt = select(AppInstanceRow.app_instance_id).where( - AppInstanceRow.app_registration_id == app_registration_id + AppInstanceRow.app_registration_id == app_registration_id, + AppInstanceRow.state.notin_(_TERMINAL_STATES), ) row = await self._session.scalar(stmt.limit(1)) return row is not None diff --git a/tests/integration/test_postgres.py b/tests/integration/test_postgres.py index f5f574c..dc7ef55 100644 --- a/tests/integration/test_postgres.py +++ b/tests/integration/test_postgres.py @@ -197,6 +197,26 @@ async def test_app_instance_repo_persists_and_loads(db_session: AsyncSession) -> assert reloaded.edge_cloud_zone_id == instance.edge_cloud_zone_id +@pytest.mark.parametrize("state", [AppInstanceState.FAILED, AppInstanceState.TERMINATED]) +async def test_app_instance_repo_exists_for_app_registration_ignores_terminal_states( + db_session: AsyncSession, state: AppInstanceState +) -> None: + registration = await SqlAppRegistrationRepository(db_session).save(_app_registration()) + operation = await SqlOperationRepository(db_session).save( + _operation(app_registration_id=registration.app_registration_id) + ) + repo = SqlAppInstanceRepository(db_session) + instance = _app_instance( + operation_id=operation.operation_id, + app_registration_id=registration.app_registration_id, + ) + instance.state = state + + await repo.save(instance) + + assert await repo.exists_for_app_registration(registration.app_registration_id) is False + + async def test_callback_registration_repo_persists_and_loads(db_session: AsyncSession) -> None: operation = await SqlOperationRepository(db_session).save(_operation()) repo = SqlCallbackRegistrationRepository(db_session) diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 9aafcac..6aa6d73 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -45,6 +45,7 @@ from open_exposure_gateway.domain.edge_application_management import ( ) from open_exposure_gateway.domain.models import ( AppInstance, + AppInstanceState, AppRegistration, CallbackDelivery, CallbackRegistration, @@ -356,7 +357,11 @@ class FakeAppInstanceRepository(AppInstanceRepository): return None async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: - return any(row.app_registration_id == app_registration_id for row in self.rows.values()) + terminal_states = (AppInstanceState.FAILED, AppInstanceState.TERMINATED) + return any( + row.app_registration_id == app_registration_id and row.state not in terminal_states + for row in self.rows.values() + ) async def save(self, app_instance: AppInstance) -> AppInstance: stored = app_instance.model_copy(deep=True) diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index ff63c99..9552f45 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -548,6 +548,42 @@ class TestDeleteApp: srm_client.delete_app.assert_not_called() assert await app_registration_repo.get_by_app_id(APP_ID) is not None + @pytest.mark.parametrize("state", [AppInstanceState.FAILED, AppInstanceState.TERMINATED]) + async def test_allows_delete_when_app_only_has_instances_in_a_terminal_state( + self, + state: AppInstanceState, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + app_registration_id = uuid4() + 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 app_instance_repo.save( + AppInstance( + app_instance_id=INSTANCE_ID, + operation_id=uuid4(), + app_registration_id=app_registration_id, + edge_cloud_zone_id=ZONE_ID, + state=state, + ) + ) + + await service.delete_app(app_id=APP_ID, app_provider_id="VideoAppsCo") + + srm_client.delete_app.assert_called_once() + assert await app_registration_repo.get_by_app_id(APP_ID) is None + class TestCreateAppInstance: def _make_request(self) -> CreateAppInstanceRequest: -- GitLab From 31b7719a338904241bb18d9a15c20bcf14265a0e Mon Sep 17 00:00:00 2001 From: dgogos Date: Wed, 29 Jul 2026 15:38:40 +0300 Subject: [PATCH 20/36] fix: update instance attributes to align with edge cloud zone model --- .../services/edge_application_management_service.py | 5 ++--- tests/unit/test_eam_service.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 8f73892..0c2d1b2 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -446,9 +446,8 @@ class EdgeApplicationManagementService: "instances": [ { "app_instance_id": instance.service_instance_id, - "service_instance_id": instance.service_instance_id, - "zone_id": instance.zone_id, - "status": instance.status, + "edge_cloud_zone_id": instance.zone_id, + "state": instance.status, } for instance in event.instances ] diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 9552f45..285739b 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -1173,9 +1173,8 @@ class TestHandleCompleted: "instances": [ { "app_instance_id": str(INSTANCE_ID), - "service_instance_id": str(INSTANCE_ID), - "zone_id": str(ZONE_ID), - "status": "completed", + "edge_cloud_zone_id": str(ZONE_ID), + "state": "completed", } ] } -- GitLab From df1d1941ffa5ce024e4c3fd0342253875fa7fe80 Mon Sep 17 00:00:00 2001 From: dgogos Date: Wed, 29 Jul 2026 17:03:11 +0300 Subject: [PATCH 21/36] test: add unit tests for HttpCallbackClient and SRMClient error handling --- tests/unit/test_callback_client.py | 114 ++++++++++++++++++++++++++ tests/unit/test_eam_service.py | 21 +++++ tests/unit/test_srm_client.py | 126 +++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 tests/unit/test_callback_client.py create mode 100644 tests/unit/test_srm_client.py diff --git a/tests/unit/test_callback_client.py b/tests/unit/test_callback_client.py new file mode 100644 index 0000000..211bb8c --- /dev/null +++ b/tests/unit/test_callback_client.py @@ -0,0 +1,114 @@ +"""HttpCallbackClient.deliver — the outbound call to a customer's webhook sink. + +Service-level tests exercise this through FakeCallbackDeliveryPort, which never +sends a real request and can never fail, so the actual request shape and the +adapter's (lack of) error translation were previously untested. +""" + +from collections.abc import Callable +from typing import Any +from uuid import uuid4 + +import httpx +import pytest + +from open_exposure_gateway.adapters.http.callback_client import HttpCallbackClient +from open_exposure_gateway.domain.edge_application_management import ( + AppInstanceStatusChangeCloudEvent, + AppInstanceStatusChangeData, +) + +HttpHandler = Callable[[httpx.Request], httpx.Response] + +SINK = "https://consumer.example.com/callbacks" + +EVENT = AppInstanceStatusChangeCloudEvent( + id=uuid4(), + source="oeg", + time="2026-07-29T00:00:00Z", + data=AppInstanceStatusChangeData( + appInstanceId=uuid4(), + appId=uuid4(), + edgeCloudZoneId=uuid4(), + status="ready", + ), +) + + +def _client(monkeypatch: pytest.MonkeyPatch, handler: HttpHandler) -> HttpCallbackClient: + transport = httpx.MockTransport(handler) + + class _PatchedAsyncClient(httpx.AsyncClient): + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs["transport"] = transport + super().__init__(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", _PatchedAsyncClient) + + client = HttpCallbackClient.__new__(HttpCallbackClient) + client.timeout = 1.0 + return client + + +async def test_posts_cloud_event_with_correct_shape(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["method"] = request.method + captured["url"] = str(request.url) + captured["content_type"] = request.headers.get("content-type") + captured["body"] = request.content + return httpx.Response(200) + + client = _client(monkeypatch, handler) + + await client.deliver(SINK, EVENT) + + assert captured["method"] == "POST" + assert captured["url"] == SINK + assert captured["content_type"] == "application/cloudevents+json" + assert captured["body"] == EVENT.model_dump_json().encode() + + +async def test_success_response_completes_without_error(monkeypatch: pytest.MonkeyPatch) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(200)) + + await client.deliver(SINK, EVENT) + + +async def test_4xx_response_raises_http_status_error(monkeypatch: pytest.MonkeyPatch) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(404)) + + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await client.deliver(SINK, EVENT) + + assert exc_info.value.response.status_code == 404 + + +async def test_5xx_response_raises_http_status_error(monkeypatch: pytest.MonkeyPatch) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(503)) + + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await client.deliver(SINK, EVENT) + + assert exc_info.value.response.status_code == 503 + + +async def test_timeout_propagates_as_httpx_exception(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException("timed out", request=request) + + client = _client(monkeypatch, handler) + + with pytest.raises(httpx.TimeoutException): + await client.deliver(SINK, EVENT) + + +async def test_connect_error_propagates_as_httpx_exception(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + client = _client(monkeypatch, handler) + + with pytest.raises(httpx.ConnectError): + await client.deliver(SINK, EVENT) diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 285739b..cd1eb75 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -754,6 +754,27 @@ class TestCreateAppInstance: ) assert len(operation_repo.rows) == 2 + async def test_replay_with_missing_app_instance_row_raises( + self, + service: EdgeApplicationManagementService, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + first = await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="retry-key-1", + ) + del app_instance_repo.rows[first.appInstanceId] + + with pytest.raises(RuntimeError, match="app_instances"): + await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="retry-key-1", + ) + def _make_request_with_subscription( self, with_credential: bool = False, expires_at: datetime | None = None ) -> CreateAppInstanceRequest: diff --git a/tests/unit/test_srm_client.py b/tests/unit/test_srm_client.py new file mode 100644 index 0000000..cf11145 --- /dev/null +++ b/tests/unit/test_srm_client.py @@ -0,0 +1,126 @@ +"""SRMClient._request error-mapping. + +This is the adapter boundary between OEG and a real SRM outage. +Service-level tests mock the port instead of the transport, so this mapping +was previously untested end-to-end. +""" + +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +from open_exposure_gateway.adapters.http.srm_client import SRMClient +from open_exposure_gateway.core.exceptions import ( + DownstreamServiceException, + NotFoundException, +) + +HttpHandler = Callable[[httpx.Request], httpx.Response] + + +def _client(monkeypatch: pytest.MonkeyPatch, handler: HttpHandler) -> SRMClient: + transport = httpx.MockTransport(handler) + + class _PatchedAsyncClient(httpx.AsyncClient): + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs["transport"] = transport + super().__init__(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", _PatchedAsyncClient) + + client = SRMClient.__new__(SRMClient) + client.base_url = "http://srm:8081" + client.timeout = 1.0 + return client + + +def _details(exc: DownstreamServiceException) -> dict[str, Any]: + assert isinstance(exc.details, dict) + return exc.details + + +async def test_404_raises_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(404)) + + with pytest.raises(NotFoundException): + await client._request("GET", "/internal/zones") + + +async def test_other_4xx_raises_downstream_with_body(monkeypatch: pytest.MonkeyPatch) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(400, text="bad request")) + + with pytest.raises(DownstreamServiceException) as exc_info: + await client._request("GET", "/internal/zones") + + details = _details(exc_info.value) + assert details["status_code"] == 400 + assert details["response"] == "bad request" + + +async def test_5xx_raises_downstream_with_body(monkeypatch: pytest.MonkeyPatch) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(503, text="srm unavailable")) + + with pytest.raises(DownstreamServiceException) as exc_info: + await client._request("GET", "/internal/zones") + + details = _details(exc_info.value) + assert details["status_code"] == 503 + assert details["response"] == "srm unavailable" + + +async def test_204_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(204)) + + result = await client._request("DELETE", "/internal/catalog/service-specifications/x") + + assert result is None + + +async def test_empty_non_204_body_raises_downstream(monkeypatch: pytest.MonkeyPatch) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(200, content=b"")) + + with pytest.raises(DownstreamServiceException) as exc_info: + await client._request("GET", "/internal/zones") + + details = _details(exc_info.value) + assert details["status_code"] == 200 + + +async def test_non_empty_2xx_returns_parsed_json(monkeypatch: pytest.MonkeyPatch) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(200, json={"ok": True})) + + result = await client._request("GET", "/internal/zones") + + assert result == {"ok": True} + + +async def test_timeout_raises_downstream(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException("timed out", request=request) + + client = _client(monkeypatch, handler) + + with pytest.raises(DownstreamServiceException): + await client._request("GET", "/internal/zones") + + +async def test_connect_error_raises_downstream(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + client = _client(monkeypatch, handler) + + with pytest.raises(DownstreamServiceException): + await client._request("GET", "/internal/zones") + + +async def test_generic_request_error_raises_downstream(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.RequestError("boom", request=request) + + client = _client(monkeypatch, handler) + + with pytest.raises(DownstreamServiceException): + await client._request("GET", "/internal/zones") -- GitLab From 28adf3995b99ac1e588ae9b76dcdaa9ed0074549 Mon Sep 17 00:00:00 2001 From: dgogos Date: Wed, 29 Jul 2026 17:08:53 +0300 Subject: [PATCH 22/36] refactor: rename ResourceZone to SRMResourceZone across the codebase for consistency --- .../adapters/http/srm_client.py | 6 ++--- .../mappers/edge_application_mapper.py | 4 ++-- .../edge_application_management_service.py | 4 ++-- .../domain/edge_application_management.py | 10 ++++---- src/open_exposure_gateway/ports/srm_port.py | 4 ++-- tests/conformance/conftest.py | 8 +++---- tests/unit/fakes.py | 6 ++--- tests/unit/test_eam_flows.py | 18 +++++++-------- tests/unit/test_eam_mapper.py | 23 +++++++++++-------- tests/unit/test_eam_service.py | 8 +++---- 10 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index c48928b..953bd27 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -13,9 +13,9 @@ from open_exposure_gateway.core.exceptions import ( NotFoundException, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, + SRMResourceZone, SRMServiceInstance, ) @@ -105,7 +105,7 @@ class SRMClient: region: str | None = None, status: str | None = None, x_correlator: str | None = None, - ) -> list[ResourceZone]: + ) -> list[SRMResourceZone]: params = {} if region is not None: params["region"] = region @@ -122,7 +122,7 @@ class SRMClient: params=params or None, headers=headers or None, ) - return [ResourceZone.model_validate(z) for z in data] + return [SRMResourceZone.model_validate(z) for z in data] async def create_qod_session( self, diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index b507fcc..946d4ac 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -41,7 +41,6 @@ from open_exposure_gateway.domain.edge_application_management import ( GpuPool, NetworkInterface, RequiredResources, - ResourceZone, SRMAccelerator, SRMCapabilityRequirement, SRMCatalogPayload, @@ -54,6 +53,7 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMDeployTarget, SRMNetworkInterface, SRMRepoMetadata, + SRMResourceZone, SRMServiceInstance, SRMServiceSpecDescriptor, SRMServiceSpecEntry, @@ -117,7 +117,7 @@ def _parse_storage_mb(value: str) -> int: return int(amount) -def build_edge_cloud_zone(srm_zone: ResourceZone) -> EdgeCloudZone: +def build_edge_cloud_zone(srm_zone: SRMResourceZone) -> EdgeCloudZone: try: status = EdgeCloudZoneStatus(srm_zone.state) except ValueError: diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 0c2d1b2..67956a9 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -37,9 +37,9 @@ from open_exposure_gateway.core.exceptions import ( NotImplementedException, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, SRMCatalogPayload, SRMOperationCompleted, + SRMResourceZone, Subject, ) from open_exposure_gateway.domain.models import ( @@ -136,7 +136,7 @@ class EdgeApplicationManagementService: return manifests def _log_skipped_entry( - self, kind: str, entry: ResourceZone | SRMCatalogPayload, exc: Exception + self, kind: str, entry: SRMResourceZone | SRMCatalogPayload, exc: Exception ) -> None: logger.warning( "unmappable_srm_entry_skipped", diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index d0c2344..a739747 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -94,21 +94,21 @@ class AppDeploymentTranslation(BaseModel): requested_at: str -class ResourceZoneLocation(BaseModel): +class SRMResourceZoneLocation(BaseModel): region: str | None = None geo: Any | None = None -class ResourceZoneMetadata(BaseModel): +class SRMResourceZoneMetadata(BaseModel): provider: str - location: ResourceZoneLocation | None = None + location: SRMResourceZoneLocation | None = None -class ResourceZone(BaseModel): +class SRMResourceZone(BaseModel): id: str name: str state: str - metadata: ResourceZoneMetadata + metadata: SRMResourceZoneMetadata class SRMAccelerator(BaseModel): diff --git a/src/open_exposure_gateway/ports/srm_port.py b/src/open_exposure_gateway/ports/srm_port.py index b69965b..d6b60da 100644 --- a/src/open_exposure_gateway/ports/srm_port.py +++ b/src/open_exposure_gateway/ports/srm_port.py @@ -5,9 +5,9 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( QoDSessionResponse, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, + SRMResourceZone, SRMServiceInstance, ) @@ -18,7 +18,7 @@ class SRMClientPort(Protocol): region: str | None, status: str | None, x_correlator: str | None, - ) -> list[ResourceZone]: ... + ) -> list[SRMResourceZone]: ... async def get_apps(self, x_correlator: str | None) -> list[SRMCatalogPayload]: ... diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py index 8350ee6..da75ce4 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -23,8 +23,8 @@ from open_exposure_gateway.dependencies import ( get_qod_service, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, - ResourceZoneMetadata, + SRMResourceZone, + SRMResourceZoneMetadata, ) from tests.conformance.harness import app from tests.unit.fakes import ( @@ -47,11 +47,11 @@ def service_overrides() -> Generator[None, None, None]: # EdgeCloudZones schema requires minItems: 1); the fake starts empty # otherwise, which no real provider would ever be. srm.zones.append( - ResourceZone( + SRMResourceZone( id=str(uuid4()), name="conformance-zone", state="active", - metadata=ResourceZoneMetadata(provider="conformance-provider"), + metadata=SRMResourceZoneMetadata(provider="conformance-provider"), ) ) operation_repo = FakeOperationRepository() diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 6aa6d73..f528842 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -37,9 +37,9 @@ from open_exposure_gateway.application.services.edge_application_management_serv from open_exposure_gateway.core.exceptions import NotFoundException from open_exposure_gateway.domain.edge_application_management import ( AppInstanceStatusChangeCloudEvent, - ResourceZone, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, + SRMResourceZone, SRMServiceInstance, Subject, ) @@ -95,7 +95,7 @@ class FakeMsg: class FakeSRMClient: def __init__(self) -> None: - self.zones: list[ResourceZone] = [] + self.zones: list[SRMResourceZone] = [] self.catalog: dict[str, dict[str, Any]] = {} self.instances: dict[str, SRMServiceInstance] = {} self.qod_sessions: dict[str, QoDSessionResponse] = {} @@ -105,7 +105,7 @@ class FakeSRMClient: region: str | None = None, status: str | None = None, x_correlator: str | None = None, - ) -> list[ResourceZone]: + ) -> list[SRMResourceZone]: return list(self.zones) async def get_apps(self, x_correlator: str | None = None) -> list[SRMCatalogPayload]: diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index a36200d..0d15554 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -22,9 +22,9 @@ from open_exposure_gateway.api.camara.edge_application_management.vwip.router im ) from open_exposure_gateway.core.exceptions import DownstreamServiceException from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, - ResourceZoneMetadata, SRMDeployCommand, + SRMResourceZone, + SRMResourceZoneMetadata, SRMTerminateCommand, Subject, ) @@ -514,11 +514,11 @@ class TestEdgeCloudZonesFlow: self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: fake_srm.zones.append( - ResourceZone( + SRMResourceZone( id=str(ZONE_ID), name="berlin-edge-1", state="active", - metadata=ResourceZoneMetadata(provider="acme"), + metadata=SRMResourceZoneMetadata(provider="acme"), ) ) response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") @@ -531,23 +531,23 @@ class TestEdgeCloudZonesFlow: def test_one_malformed_zone_id_does_not_break_listing( self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: - """SRM's ResourceZone model allows free-form string ids; one non-UUID id + """SRM's SRMResourceZone model allows free-form string ids; one non-UUID id must not turn the whole zone listing into a 500 — healthy zones must still be returned.""" fake_srm.zones.append( - ResourceZone( + SRMResourceZone( id=str(ZONE_ID), name="good-zone", state="active", - metadata=ResourceZoneMetadata(provider="acme"), + metadata=SRMResourceZoneMetadata(provider="acme"), ) ) fake_srm.zones.append( - ResourceZone( + SRMResourceZone( id="zone-west-1", name="bad-zone", state="active", - metadata=ResourceZoneMetadata(provider="acme"), + metadata=SRMResourceZoneMetadata(provider="acme"), ) ) response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index ddcdbdc..e3bddfa 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -25,9 +25,6 @@ from open_exposure_gateway.application.mappers.edge_application_mapper import ( build_edge_cloud_zone, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, - ResourceZoneLocation, - ResourceZoneMetadata, SRMAccelerator, SRMCapabilityEndpoint, SRMCapabilityInstanceSummary, @@ -39,6 +36,9 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMDeploymentUnitMetadata, SRMNetworkInterface, SRMRepoMetadata, + SRMResourceZone, + SRMResourceZoneLocation, + SRMResourceZoneMetadata, SRMResultSummary, SRMServiceInstance, SRMServiceSpecDescriptor, @@ -154,13 +154,13 @@ def _make_helm_manifest( class TestBuildEdgeCloudZone: def test_maps_all_fields(self) -> None: - zone = ResourceZone( + zone = SRMResourceZone( id=str(ZONE_ID), name="berlin-edge-1", state="active", - metadata=ResourceZoneMetadata( + metadata=SRMResourceZoneMetadata( provider="acme", - location=ResourceZoneLocation(region="eu-central-1"), + location=SRMResourceZoneLocation(region="eu-central-1"), ), ) result = build_edge_cloud_zone(zone) @@ -170,18 +170,21 @@ class TestBuildEdgeCloudZone: assert result.edgeCloudRegion == "eu-central-1" def test_unknown_status_falls_back(self) -> None: - zone = ResourceZone( + zone = SRMResourceZone( id=str(ZONE_ID), name="z", state="maintenance", - metadata=ResourceZoneMetadata(provider="p"), + metadata=SRMResourceZoneMetadata(provider="p"), ) result = build_edge_cloud_zone(zone) assert result.edgeCloudZoneStatus == EdgeCloudZoneStatus.UNKNOWN def test_no_location_gives_none_region(self) -> None: - zone = ResourceZone( - id=str(ZONE_ID), name="z", state="active", metadata=ResourceZoneMetadata(provider="p") + zone = SRMResourceZone( + id=str(ZONE_ID), + name="z", + state="active", + metadata=SRMResourceZoneMetadata(provider="p"), ) result = build_edge_cloud_zone(zone) assert result.edgeCloudRegion is None diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index cd1eb75..201f434 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -27,8 +27,6 @@ from open_exposure_gateway.core.exceptions import ( NotImplementedException, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, - ResourceZoneMetadata, SRMCapabilityRequirement, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, @@ -39,6 +37,8 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMDeploymentUnitMetadata, SRMOperationCompleted, SRMRepoMetadata, + SRMResourceZone, + SRMResourceZoneMetadata, SRMServiceInstance, SRMServiceSpecDescriptor, SRMServiceSpecEntry, @@ -221,11 +221,11 @@ class TestGetEdgeCloudZones: self, service: EdgeApplicationManagementService, srm_client: AsyncMock ) -> None: srm_client.get_resource_zones.return_value = [ - ResourceZone( + SRMResourceZone( id=str(ZONE_ID), name="berlin-edge-1", state="active", - metadata=ResourceZoneMetadata(provider="acme"), + metadata=SRMResourceZoneMetadata(provider="acme"), ) ] result = await service.get_edge_cloud_zones() -- GitLab From d72d930c7439fa9cfd020f7326d84bea98e251dd Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 30 Jul 2026 16:31:29 +0300 Subject: [PATCH 23/36] fix: app registration handling and improve error handling in EAM service --- .../database/repos/app_registrations.py | 19 +- .../adapters/database/sql.py | 13 +- .../mappers/edge_application_mapper.py | 12 +- .../edge_application_management_service.py | 145 ++++- .../domain/edge_application_management.py | 13 +- tests/integration/test_postgres.py | 44 ++ tests/unit/fakes.py | 16 +- tests/unit/test_eam_contract.py | 23 +- tests/unit/test_eam_flows.py | 28 +- tests/unit/test_eam_mapper.py | 72 ++- tests/unit/test_eam_service.py | 502 +++++++++++++++++- 11 files changed, 775 insertions(+), 112 deletions(-) diff --git a/src/open_exposure_gateway/adapters/database/repos/app_registrations.py b/src/open_exposure_gateway/adapters/database/repos/app_registrations.py index e605ddb..dd6f454 100644 --- a/src/open_exposure_gateway/adapters/database/repos/app_registrations.py +++ b/src/open_exposure_gateway/adapters/database/repos/app_registrations.py @@ -1,13 +1,13 @@ from uuid import UUID -from sqlalchemy import delete, select +from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from open_exposure_gateway.adapters.database.mappers import AppRegistrationMapper from open_exposure_gateway.adapters.database.sql import AppRegistrationRow from open_exposure_gateway.adapters.errors import DuplicateAppRegistrationError -from open_exposure_gateway.domain.models import AppRegistration +from open_exposure_gateway.domain.models import AppRegistration, AppRegistrationStatus from open_exposure_gateway.ports.database.registration import AppRegistrationRepository _UNIQUE_VIOLATION = "23505" @@ -25,7 +25,10 @@ class SqlAppRegistrationRepository(AppRegistrationRepository): return AppRegistrationMapper.to_domain(row) if row is not None else None async def get_by_app_id(self, app_id: UUID) -> AppRegistration | None: - stmt = select(AppRegistrationRow).where(AppRegistrationRow.app_id == app_id) + stmt = select(AppRegistrationRow).where( + AppRegistrationRow.app_id == app_id, + AppRegistrationRow.status != AppRegistrationStatus.DELETED, + ) row = await self._session.scalar(stmt) return AppRegistrationMapper.to_domain(row) if row is not None else None @@ -43,5 +46,13 @@ class SqlAppRegistrationRepository(AppRegistrationRepository): return saved async def delete_by_app_id(self, app_id: UUID) -> None: - stmt = delete(AppRegistrationRow).where(AppRegistrationRow.app_id == app_id) + """Soft-delete: flip status to DELETED, keep the row.""" + stmt = ( + update(AppRegistrationRow) + .where( + AppRegistrationRow.app_id == app_id, + AppRegistrationRow.status != AppRegistrationStatus.DELETED, + ) + .values(status=AppRegistrationStatus.DELETED) + ) await self._session.execute(stmt) diff --git a/src/open_exposure_gateway/adapters/database/sql.py b/src/open_exposure_gateway/adapters/database/sql.py index 5ceaf9e..e982069 100644 --- a/src/open_exposure_gateway/adapters/database/sql.py +++ b/src/open_exposure_gateway/adapters/database/sql.py @@ -14,6 +14,7 @@ from sqlalchemy import ( Text, UniqueConstraint, func, + text, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PG_UUID @@ -64,10 +65,18 @@ class AuditedMixin: class AppRegistrationRow(AuditedMixin, Base): __tablename__ = "app_registrations" - __table_args__ = (Index("idx_app_registrations_tenant", "tenant_id"),) + __table_args__ = ( + Index("idx_app_registrations_tenant", "tenant_id"), + Index( + "uq_app_registrations_app_id_active", + "app_id", + unique=True, + postgresql_where=text("status <> 'DELETED'"), + ), + ) app_registration_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True) - app_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), unique=True, nullable=False) + app_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False) tenant_id: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(64), nullable=False) version: Mapped[str] = mapped_column(String(64), nullable=False) diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index 946d4ac..968d7ff 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -277,7 +277,7 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: ) -def build_app_instance_info(instance: SRMServiceInstance) -> AppInstanceInfo: +def build_app_instance_info(instance: SRMServiceInstance, app_id: UUID) -> AppInstanceInfo: status = _SRM_STATE_TO_APP_INSTANCE_STATUS.get(instance.state, AppInstanceStatus.UNKNOWN) endpoint_info: list[ComponentEndpointInfo] = [] @@ -298,7 +298,7 @@ def build_app_instance_info(instance: SRMServiceInstance) -> AppInstanceInfo: return AppInstanceInfo( appInstanceId=UUID(instance.service_instance_id), name=instance.name or instance.service_instance_id, - appId=UUID(instance.service_specification_id), + appId=app_id, appProvider=instance.app_provider_id, status=status, edgeCloudZoneId=UUID(instance.resource_zone_id) @@ -311,6 +311,7 @@ def build_app_instance_info(instance: SRMServiceInstance) -> AppInstanceInfo: def build_app_registration_translation( manifest: AppManifest, app_id: UUID, + app_registration_id: UUID, tenant_id: str, app_provider_id: str, ) -> AppRegistrationTranslation: @@ -414,6 +415,7 @@ def build_app_registration_translation( return AppRegistrationTranslation( app_id=app_id, + app_registration_id=app_registration_id, tenant_id=tenant_id, app_provider_id=app_provider_id, name=manifest.name, @@ -542,7 +544,7 @@ def build_catalog_payload(translation: AppRegistrationTranslation) -> SRMCatalog return SRMCatalogPayload( service_specification=SRMServiceSpecEntry( - id=str(translation.app_id), + id=str(translation.app_registration_id), ref=str(translation.app_id), name=translation.name, version=translation.version, @@ -560,6 +562,7 @@ def build_app_deployment_translation( request: CreateAppInstanceRequest, operation_id: UUID, app_instance_id: UUID, + app_registration_id: UUID, tenant_id: str, app_provider_id: str, correlation_id: str, @@ -567,6 +570,7 @@ def build_app_deployment_translation( ) -> AppDeploymentTranslation: return AppDeploymentTranslation( app_id=request.appId, + app_registration_id=app_registration_id, operation_id=operation_id, app_instance_id=app_instance_id, correlation_id=correlation_id, @@ -591,7 +595,7 @@ def build_deploy_command( correlation_id=translation.correlation_id, requested_at=requested_at, app_provider_id=translation.app_provider_id, - service_specification_id=str(translation.app_id), + service_specification_id=str(translation.app_registration_id), targets=[ SRMDeployTarget( app_instance_id=str(translation.app_instance_id), diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 67956a9..44a4c55 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -85,6 +85,20 @@ _APP_INSTANCE_COMPLETION_STATE_MAP: dict[str, AppInstanceState] = { } +def _is_final(app_instance: AppInstance) -> bool: + """Whether no completion may move this instance any further.""" + return app_instance.state == AppInstanceState.TERMINATED + + +def _log_stale_completion(app_instance: AppInstance, operation_id: UUID) -> None: + logger.info( + "stale_completion_ignored_for_final_app_instance", + app_instance_id=str(app_instance.app_instance_id), + operation_id=str(operation_id), + state=app_instance.state.value, + ) + + class EdgeApplicationManagementService: def __init__( self, @@ -126,6 +140,11 @@ class EdgeApplicationManagementService: return zones async def get_apps(self, x_correlator: Optional[str] = None) -> list[AppManifest]: + # TODO: scope by caller.tenant_id/app_provider_id once JWT auth is wired in + # (dependencies.py currently hardcodes both to "placeholder"). Every other + # endpoint here already threads these through; this one doesn't yet, and + # tenant_id isn't in SRM's catalog response, so filtering must happen via + # app_registration_repo, not srm_client.get_apps. catalogs = await self.srm_client.get_apps(x_correlator=x_correlator) manifests = [] for catalog in catalogs: @@ -148,7 +167,15 @@ class EdgeApplicationManagementService: async def get_app( self, app_id: UUID, x_correlator: Optional[str] = None ) -> AppManifestEnvelope: - catalog = await self.srm_client.get_app(app_id=app_id, x_correlator=x_correlator) + if self._app_registration_repo is None: + raise RuntimeError("AppRegistrationRepository is not available") + app_registration = await self._app_registration_repo.get_by_app_id(app_id) + if app_registration is None: + raise NotFoundException(message=f"App {app_id} not found") + + catalog = await self.srm_client.get_app( + app_id=app_registration.app_registration_id, x_correlator=x_correlator + ) try: manifest = build_app_manifest(catalog) except (ValueError, TypeError, IndexError) as exc: @@ -176,17 +203,21 @@ class EdgeApplicationManagementService: "is not supported in this release" ) + app_registration_id = uuid4() translation = build_app_registration_translation( - manifest, app_id, tenant_id, app_provider_id + manifest, app_id, app_registration_id, tenant_id, app_provider_id ) catalog_payload = build_catalog_payload(translation) created = await self.srm_client.create_catalog_service_specification( payload=catalog_payload.model_dump(mode="json"), x_correlator=x_correlator ) - if created.id != translation.app_id: + if created.id != translation.app_registration_id: raise DownstreamServiceException( message="SRM confirmed a different service specification id than requested", - details={"requested_id": str(translation.app_id), "confirmed_id": str(created.id)}, + details={ + "requested_id": str(translation.app_registration_id), + "confirmed_id": str(created.id), + }, ) if self._app_registration_repo is None: @@ -194,7 +225,7 @@ class EdgeApplicationManagementService: try: await self._app_registration_repo.save( AppRegistration( - app_registration_id=uuid4(), + app_registration_id=app_registration_id, app_id=translation.app_id, tenant_id=translation.tenant_id, name=translation.name, @@ -236,16 +267,20 @@ class EdgeApplicationManagementService: raise RuntimeError("AppInstanceRepository is not available") app_registration = await self._app_registration_repo.get_by_app_id(app_id) - if app_registration is not None: - has_instances = await self._app_instance_repo.exists_for_app_registration( - app_registration.app_registration_id + if app_registration is None: + raise NotFoundException(message=f"App {app_id} not found") + + has_instances = await self._app_instance_repo.exists_for_app_registration( + app_registration.app_registration_id + ) + if has_instances: + raise AbortedException( + message="App with a running application instance cannot be deleted" ) - if has_instances: - raise AbortedException( - message="App with a running application instance cannot be deleted" - ) - await self.srm_client.delete_app(app_id=app_id, x_correlator=x_correlator) + await self.srm_client.delete_app( + app_id=app_registration.app_registration_id, x_correlator=x_correlator + ) await self._app_registration_repo.delete_by_app_id(app_id) async def create_app_instance( @@ -293,6 +328,7 @@ class EdgeApplicationManagementService: request, operation_id, app_instance_id, + app_registration_id=app_registration.app_registration_id, tenant_id=tenant_id, app_provider_id=app_provider_id, correlation_id=correlation_id, @@ -370,13 +406,44 @@ class EdgeApplicationManagementService: region: Optional[str] = None, x_correlator: Optional[str] = None, ) -> list[AppInstanceInfo]: + resolved_app_id: Optional[UUID] = app_id + if app_id is not None: + if self._app_registration_repo is None: + raise RuntimeError("AppRegistrationRepository is not available") + app_registration = await self._app_registration_repo.get_by_app_id(app_id) + if app_registration is None: + return [] + resolved_app_id = app_registration.app_registration_id + instances = await self.srm_client.get_app_instances( - app_id=app_id, + app_id=resolved_app_id, app_instance_id=app_instance_id, region=region, x_correlator=x_correlator, ) - return [build_app_instance_info(i) for i in instances] + + result: list[AppInstanceInfo] = [] + app_id_cache: dict[UUID, Optional[UUID]] = {} + for instance in instances: + if app_id is not None: + instance_app_id: Optional[UUID] = app_id + else: + if self._app_registration_repo is None: + raise RuntimeError("AppRegistrationRepository is not available") + app_registration_id = UUID(instance.service_specification_id) + if app_registration_id not in app_id_cache: + owner = await self._app_registration_repo.get_by_id(app_registration_id) + app_id_cache[app_registration_id] = owner.app_id if owner else None + instance_app_id = app_id_cache[app_registration_id] + if instance_app_id is None: + logger.warning( + "app_instance_listed_for_unresolvable_app_registration", + app_instance_id=instance.service_instance_id, + app_registration_id=instance.service_specification_id, + ) + continue + result.append(build_app_instance_info(instance, instance_app_id)) + return result async def delete_app_instance( self, @@ -413,6 +480,7 @@ class EdgeApplicationManagementService: status=OperationStatus.PENDING, subject=Subject.TASK_TERMINATE, app_registration_id=app_instance.app_registration_id, + metadata={"app_instance_id": str(app_instance_id)}, ) ) await self._app_instance_repo.save( @@ -476,6 +544,9 @@ class EdgeApplicationManagementService: app_instance_id=instance.service_instance_id, ) continue + if _is_final(app_instance): + _log_stale_completion(app_instance, operation_id) + continue if is_terminate and instance.status == "completed": state = AppInstanceState.TERMINATED else: @@ -486,15 +557,28 @@ class EdgeApplicationManagementService: updated_instances.append(saved) elif status == OperationStatus.FAILED: # Total failure carries no instances[] entries to match against. - # POST /appinstances always creates exactly one app_instances row - # per operation (ADR-0005), so fall back to that link rather than - # leaving the pre-created row stuck at instantiating forever. - app_instance = await self._app_instance_repo.get_by_operation_id(operation_id) - if app_instance is not None: - saved = await self._app_instance_repo.save( - app_instance.model_copy(update={"state": AppInstanceState.FAILED}) + if is_terminate: + raw_app_instance_id = operation.metadata.get("app_instance_id") + app_instance = ( + await self._app_instance_repo.get_by_id(UUID(raw_app_instance_id)) + if raw_app_instance_id is not None + else None ) - updated_instances.append(saved) + if app_instance is None: + logger.warning( + "terminate_total_failure_missing_app_instance_id", + operation_id=event.operation_id, + ) + else: + app_instance = await self._app_instance_repo.get_by_operation_id(operation_id) + if app_instance is not None: + if _is_final(app_instance): + _log_stale_completion(app_instance, operation_id) + else: + saved = await self._app_instance_repo.save( + app_instance.model_copy(update={"state": AppInstanceState.FAILED}) + ) + updated_instances.append(saved) if updated_instances: await self._deliver_callbacks(operation_id, event.completed_at, updated_instances) @@ -507,13 +591,22 @@ class EdgeApplicationManagementService: ) -> None: if self._callback_registration_repo is None: return - registration = await self._callback_registration_repo.get_by_operation_id(operation_id) - if registration is None or not registration.is_active: - return if self._callback_delivery_port is None or self._callback_delivery_repo is None: raise RuntimeError("CallbackDeliveryPort/CallbackDeliveryRepository is not available") + registration_by_operation: dict[UUID, CallbackRegistration | None] = {} for app_instance in app_instances: + creating_operation_id = app_instance.operation_id + if creating_operation_id not in registration_by_operation: + registration_by_operation[ + creating_operation_id + ] = await self._callback_registration_repo.get_by_operation_id( + creating_operation_id + ) + registration = registration_by_operation[creating_operation_id] + if registration is None or not registration.is_active: + continue + app_id = await self._resolve_app_id(app_instance.app_registration_id) if app_id is None: logger.warning( diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index a739747..0dc0c5a 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -69,6 +69,7 @@ class RequiredResources(BaseModel): class AppRegistrationTranslation(BaseModel): app_id: UUID + app_registration_id: UUID tenant_id: str app_provider_id: str name: str @@ -82,6 +83,7 @@ class AppRegistrationTranslation(BaseModel): class AppDeploymentTranslation(BaseModel): app_id: UUID + app_registration_id: UUID operation_id: UUID app_instance_id: UUID correlation_id: str @@ -159,8 +161,6 @@ class SRMServiceSpecDescriptor(BaseModel): class SRMServiceSpecEntry(BaseModel): - # Client-supplied specification id; SRM adopts it as the service_specification - # primary key, which is what makes service_specification_id == app_id (ADR-0011). id: str ref: str name: str @@ -274,9 +274,6 @@ class SRMOperationCompleted(BaseModel): operation_id: str status: Literal["completed", "partially_completed", "failed"] service_order_id: str | None = None - # One entry per app instance produced (one per targeted zone); required - # unless status == failed, since "failed" means none were produced - # (srm/interface-contract.md §C.2). instances: list[SRMCompletedInstance] = [] metadata: dict[str, Any] | None = None error: dict[str, Any] | None = None @@ -285,8 +282,10 @@ class SRMOperationCompleted(BaseModel): @model_validator(mode="after") def _require_error_when_failed(self) -> SRMOperationCompleted: - if self.status == "failed" and self.error is None: - raise ValueError("error is required when status is failed") + if self.status == "failed" and not self.instances and self.error is None: + raise ValueError( + "error is required when status is failed and no instances were produced" + ) return self @model_validator(mode="after") diff --git a/tests/integration/test_postgres.py b/tests/integration/test_postgres.py index dc7ef55..e100752 100644 --- a/tests/integration/test_postgres.py +++ b/tests/integration/test_postgres.py @@ -217,6 +217,50 @@ async def test_app_instance_repo_exists_for_app_registration_ignores_terminal_st assert await repo.exists_for_app_registration(registration.app_registration_id) is False +async def test_app_registration_delete_keeps_row_referenced_by_terminated_instance( + db_session: AsyncSession, +) -> None: + repo = SqlAppRegistrationRepository(db_session) + registration = await repo.save(_app_registration()) + operation = await SqlOperationRepository(db_session).save( + _operation(app_registration_id=registration.app_registration_id) + ) + instance = _app_instance( + operation_id=operation.operation_id, + app_registration_id=registration.app_registration_id, + ) + instance.state = AppInstanceState.TERMINATED + await SqlAppInstanceRepository(db_session).save(instance) + + await repo.delete_by_app_id(registration.app_id) + await db_session.flush() + + assert await repo.get_by_app_id(registration.app_id) is None + retained = await repo.get_by_id(registration.app_registration_id) + assert retained is not None + assert retained.status == AppRegistrationStatus.DELETED + + +async def test_app_registration_app_id_reusable_after_soft_delete( + db_session: AsyncSession, +) -> None: + repo = SqlAppRegistrationRepository(db_session) + first = await repo.save(_app_registration()) + + await repo.delete_by_app_id(first.app_id) + await db_session.flush() + + second = _app_registration() + second.app_id = first.app_id + saved = await repo.save(second) + + assert saved.app_id == first.app_id + assert saved.app_registration_id != first.app_registration_id + active = await repo.get_by_app_id(first.app_id) + assert active is not None + assert active.app_registration_id == second.app_registration_id + + async def test_callback_registration_repo_persists_and_loads(db_session: AsyncSession) -> None: operation = await SqlOperationRepository(db_session).save(_operation()) repo = SqlCallbackRegistrationRepository(db_session) diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index f528842..0200a8a 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -47,6 +47,7 @@ from open_exposure_gateway.domain.models import ( AppInstance, AppInstanceState, AppRegistration, + AppRegistrationStatus, CallbackDelivery, CallbackRegistration, Operation, @@ -120,9 +121,9 @@ class FakeSRMClient: async def create_catalog_service_specification( self, payload: dict[str, Any], x_correlator: str | None = None ) -> SRMCatalogServiceSpecificationCreated: - ref = payload["service_specification"]["ref"] - self.catalog[ref] = payload - return SRMCatalogServiceSpecificationCreated(id=UUID(ref)) + spec_id = payload["service_specification"]["id"] + self.catalog[spec_id] = payload + return SRMCatalogServiceSpecificationCreated(id=UUID(spec_id)) async def delete_app(self, app_id: UUID, x_correlator: str | None = None) -> None: self.catalog.pop(str(app_id), None) @@ -289,7 +290,7 @@ class FakeAppRegistrationRepository(AppRegistrationRepository): async def get_by_app_id(self, app_id: UUID) -> AppRegistration | None: for row in self.rows.values(): - if row.app_id == app_id: + if row.app_id == app_id and row.status != AppRegistrationStatus.DELETED: return row.model_copy(deep=True) return None @@ -298,6 +299,7 @@ class FakeAppRegistrationRepository(AppRegistrationRepository): if ( existing.app_registration_id != app_registration.app_registration_id and existing.app_id == app_registration.app_id + and existing.status != AppRegistrationStatus.DELETED ): raise DuplicateAppRegistrationError() stored = app_registration.model_copy(deep=True) @@ -306,8 +308,10 @@ class FakeAppRegistrationRepository(AppRegistrationRepository): async def delete_by_app_id(self, app_id: UUID) -> None: for registration_id, row in list(self.rows.items()): - if row.app_id == app_id: - del self.rows[registration_id] + if row.app_id == app_id and row.status != AppRegistrationStatus.DELETED: + self.rows[registration_id] = row.model_copy( + update={"status": AppRegistrationStatus.DELETED} + ) class FakeOperationRepository(OperationRepository): diff --git a/tests/unit/test_eam_contract.py b/tests/unit/test_eam_contract.py index 816ab28..23497bb 100644 --- a/tests/unit/test_eam_contract.py +++ b/tests/unit/test_eam_contract.py @@ -144,8 +144,7 @@ class TestOperationCompletedConformance: completed_at="2026-07-03T12:00:00+00:00", ) - def test_failed_completion_must_carry_error(self) -> None: - """`error` (an RFC 7807 object) is required when status=failed.""" + def test_failed_completion_with_no_instances_must_carry_error(self) -> None: with pytest.raises(ValidationError): SRMOperationCompleted( schema_version="1.0", @@ -155,6 +154,26 @@ class TestOperationCompletedConformance: completed_at="2026-07-03T12:00:00+00:00", ) + def test_failed_completion_with_instances_needs_no_top_level_error(self) -> None: + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(uuid4()), + status="failed", + correlation_id="corr-1", + completed_at="2026-07-03T12:00:00+00:00", + instances=[ + SRMCompletedInstance( + service_instance_id=str(uuid4()), + zone_id=str(uuid4()), + status="failed", + error={"title": "Zone capacity exceeded", "status": 503}, + ) + ], + ) + + assert event.error is None + assert event.instances[0].status == "failed" + def test_completion_status_restricted_to_contract_enum(self) -> None: """`status` is an enum — `completed` | `partially_completed` | `failed`, nothing else.""" diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 0d15554..a2a8e83 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -31,6 +31,7 @@ from open_exposure_gateway.domain.edge_application_management import ( from open_exposure_gateway.domain.models import AppInstanceState, OperationStatus, OperationType from tests.unit.fakes import ( FakeAppInstanceRepository, + FakeAppRegistrationRepository, FakeCallbackDeliveryPort, FakeCallbackRegistrationRepository, FakeDataBus, @@ -169,14 +170,19 @@ class TestCreateAppInstanceFlow: assert cloud_event.data.status == "ready" def test_srm_receives_a_valid_deploy_command( - self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient + self, + api_client: TestClient, + fake_bus: FakeDataBus, + live_srm: FakeSRMClient, + app_registration_repo: FakeAppRegistrationRepository, ) -> None: api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) deploys = [p for s, p in fake_bus.published if s == Subject.TASK_DEPLOY] assert len(deploys) == 1 command = SRMDeployCommand.model_validate(deploys[0]) - assert command.service_specification_id == str(APP_ID) + registration = next(r for r in app_registration_repo.rows.values() if r.app_id == APP_ID) + assert command.service_specification_id == str(registration.app_registration_id) assert len(command.targets) == 1 assert command.targets[0].resource_zone_id == str(ZONE_ID) assert command.deploy.instance_name == "myvideoapp_inst" @@ -357,10 +363,10 @@ class TestSubmitAppFlow: assert response.status_code == 201 app_id = response.json()["appId"] - assert app_id in fake_srm.catalog - compute = fake_srm.catalog[app_id]["service_deployment_units"][0]["resource_requirements"][ - "compute" - ] + entry = next( + v for v in fake_srm.catalog.values() if v["service_specification"]["ref"] == app_id + ) + compute = entry["service_deployment_units"][0]["resource_requirements"]["compute"] assert compute["cpu_millicores"] == 2000 assert compute["memory_mb"] == 4096 @@ -501,12 +507,18 @@ class TestDeleteAppFlow: self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: app_id = api_client.post(f"{EAM_BASE}/apps", json=_manifest_body(num_cpu=2)).json()["appId"] - assert app_id in fake_srm.catalog + + def _has_entry_for(app_id: str) -> bool: + return any( + v["service_specification"]["ref"] == app_id for v in fake_srm.catalog.values() + ) + + assert _has_entry_for(app_id) response = api_client.delete(f"{EAM_BASE}/apps/{app_id}") assert response.status_code == 204 - assert app_id not in fake_srm.catalog + assert not _has_entry_for(app_id) class TestEdgeCloudZonesFlow: diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index e3bddfa..7ce1ec7 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -51,6 +51,7 @@ APP_ID = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") ZONE_ID = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") INSTANCE_ID = UUID("cccccccc-cccc-cccc-cccc-cccccccccccc") OP_ID = UUID("dddddddd-dddd-dddd-dddd-dddddddddddd") +APP_REGISTRATION_ID = UUID("ffffffff-ffff-ffff-ffff-ffffffffffff") def _make_srm_catalog( @@ -316,7 +317,7 @@ class TestBuildAppInstanceInfo: def _make_instance(self, state: str = "active") -> SRMServiceInstance: return SRMServiceInstance( service_instance_id=str(INSTANCE_ID), - service_specification_id=str(APP_ID), + service_specification_id=str(APP_REGISTRATION_ID), state=state, app_provider_id="VideoAppsCo", resource_zone_id=str(ZONE_ID), @@ -325,30 +326,30 @@ class TestBuildAppInstanceInfo: ) def test_maps_ids_and_provider(self) -> None: - result = build_app_instance_info(self._make_instance()) + result = build_app_instance_info(self._make_instance(), APP_ID) assert result.appInstanceId == INSTANCE_ID assert result.appId == APP_ID assert result.appProvider == "VideoAppsCo" assert result.edgeCloudZoneId == ZONE_ID def test_state_mapping_active_to_ready(self) -> None: - result = build_app_instance_info(self._make_instance(state="active")) + result = build_app_instance_info(self._make_instance(state="active"), APP_ID) assert result.status == AppInstanceStatus.READY def test_state_mapping_creating_to_instantiating(self) -> None: - result = build_app_instance_info(self._make_instance(state="creating")) + result = build_app_instance_info(self._make_instance(state="creating"), APP_ID) assert result.status == AppInstanceStatus.INSTANTIATING def test_state_mapping_failed_to_failed(self) -> None: - result = build_app_instance_info(self._make_instance(state="failed")) + result = build_app_instance_info(self._make_instance(state="failed"), APP_ID) assert result.status == AppInstanceStatus.FAILED def test_state_mapping_terminating(self) -> None: - result = build_app_instance_info(self._make_instance(state="terminating")) + result = build_app_instance_info(self._make_instance(state="terminating"), APP_ID) assert result.status == AppInstanceStatus.TERMINATING def test_unknown_state_defaults_to_unknown(self) -> None: - result = build_app_instance_info(self._make_instance(state="exotic")) + result = build_app_instance_info(self._make_instance(state="exotic"), APP_ID) assert result.status == AppInstanceStatus.UNKNOWN def test_endpoints_extracted_from_capability_instances(self) -> None: @@ -365,7 +366,7 @@ class TestBuildAppInstanceInfo: ), ) ] - result = build_app_instance_info(instance) + result = build_app_instance_info(instance, APP_ID) assert result.componentEndpointInfo is not None assert len(result.componentEndpointInfo) == 1 ep = result.componentEndpointInfo[0] @@ -374,20 +375,22 @@ class TestBuildAppInstanceInfo: assert ep.accessPoints.port == 80 def test_no_endpoints_gives_none(self) -> None: - result = build_app_instance_info(self._make_instance()) + result = build_app_instance_info(self._make_instance(), APP_ID) assert result.componentEndpointInfo is None def test_name_falls_back_to_instance_id_when_missing(self) -> None: instance = self._make_instance() instance.name = None - result = build_app_instance_info(instance) + result = build_app_instance_info(instance, APP_ID) assert result.name == str(INSTANCE_ID) class TestBuildAppRegistrationTranslation: def test_helm_translation_fields(self) -> None: manifest = _make_helm_manifest() - result = build_app_registration_translation(manifest, APP_ID, "tenant-1", "provider-1") + result = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "tenant-1", "provider-1" + ) assert result.app_id == APP_ID assert result.name == "myvideoapp" assert result.package_type == "HELM" @@ -396,7 +399,7 @@ class TestBuildAppRegistrationTranslation: def test_cpu_pool_extracted(self) -> None: manifest = _make_helm_manifest(cpu=4, memory=8192) - result = build_app_registration_translation(manifest, APP_ID, "t", "p") + result = build_app_registration_translation(manifest, APP_ID, APP_REGISTRATION_ID, "t", "p") assert result.required_resources is not None assert result.required_resources.application_resources is not None assert result.required_resources.application_resources.cpu_pool is not None @@ -405,7 +408,7 @@ class TestBuildAppRegistrationTranslation: def test_network_interfaces_mapped(self) -> None: manifest = _make_helm_manifest() - result = build_app_registration_translation(manifest, APP_ID, "t", "p") + result = build_app_registration_translation(manifest, APP_ID, APP_REGISTRATION_ID, "t", "p") assert len(result.component_spec) == 1 assert result.component_spec[0].component_name == "nginx_server" ni = result.component_spec[0].network_interfaces[0] @@ -445,7 +448,7 @@ class TestBuildAppRegistrationTranslation: ), componentSpec=[], ) - result = build_app_registration_translation(manifest, APP_ID, "t", "p") + result = build_app_registration_translation(manifest, APP_ID, APP_REGISTRATION_ID, "t", "p") assert result.app_repo.credentials == f"secret://oeg/{APP_ID}/repo-credentials" assert result.app_repo.user_name == "user" @@ -475,7 +478,7 @@ class TestBuildAppRegistrationTranslation: ), componentSpec=[], ) - result = build_app_registration_translation(manifest, APP_ID, "t", "p") + result = build_app_registration_translation(manifest, APP_ID, APP_REGISTRATION_ID, "t", "p") assert result.app_repo.credentials is None def test_gpu_pool_extracted(self) -> None: @@ -507,7 +510,7 @@ class TestBuildAppRegistrationTranslation: ), componentSpec=[], ) - result = build_app_registration_translation(manifest, APP_ID, "t", "p") + result = build_app_registration_translation(manifest, APP_ID, APP_REGISTRATION_ID, "t", "p") assert result.required_resources is not None assert result.required_resources.application_resources is not None gpu_pool = result.required_resources.application_resources.gpu_pool @@ -526,14 +529,18 @@ class TestBuildAppRegistrationTranslation: class TestBuildCatalogPayload: def test_runtime_kind_from_package_type(self) -> None: manifest = _make_helm_manifest() - translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) catalog = build_catalog_payload(translation) unit = catalog.service_deployment_units[0] assert unit.runtime_kind == "helm" def test_cpu_converted_to_millicores(self) -> None: manifest = _make_helm_manifest(cpu=2, memory=4096) - translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) catalog = build_catalog_payload(translation) compute = catalog.service_deployment_units[0].resource_requirements.compute assert compute is not None @@ -542,21 +549,25 @@ class TestBuildCatalogPayload: def test_spec_ref_is_app_id(self) -> None: manifest = _make_helm_manifest() - translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) catalog = build_catalog_payload(translation) assert catalog.service_specification.ref == str(APP_ID) - def test_spec_id_is_app_id(self) -> None: - """service_specification.id must carry app_id so SRM adopts it as the - specification PK, making service_specification_id == app_id (ADR-0011).""" + def test_spec_id_is_app_registration_id(self) -> None: manifest = _make_helm_manifest() - translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) catalog = build_catalog_payload(translation) - assert catalog.service_specification.id == str(APP_ID) + assert catalog.service_specification.id == str(APP_REGISTRATION_ID) def test_interfaces_visibility_mapped(self) -> None: manifest = _make_helm_manifest() - translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) catalog = build_catalog_payload(translation) ifaces = catalog.service_deployment_units[0].resource_requirements.interfaces assert ifaces is not None @@ -564,7 +575,9 @@ class TestBuildCatalogPayload: def test_capability_requirement_present(self) -> None: manifest = _make_helm_manifest() - translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) catalog = build_catalog_payload(translation) assert len(catalog.service_capability_requirements) == 1 req = catalog.service_capability_requirements[0] @@ -599,7 +612,9 @@ class TestBuildCatalogPayload: ), componentSpec=[], ) - translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) catalog = build_catalog_payload(translation) resource_requirements = catalog.service_deployment_units[0].resource_requirements compute = resource_requirements.compute @@ -630,12 +645,13 @@ class TestBuildDeployCommand: request, OP_ID, INSTANCE_ID, + app_registration_id=APP_REGISTRATION_ID, tenant_id="tenant-1", app_provider_id="provider-1", correlation_id="corr-456", ) cmd = build_deploy_command(translation, "2026-07-02T12:00:00Z") - assert cmd.service_specification_id == str(APP_ID) + assert cmd.service_specification_id == str(APP_REGISTRATION_ID) # POST /appinstances always carries exactly one targets[] entry (ADR-0005). assert len(cmd.targets) == 1 assert cmd.targets[0].app_instance_id == str(INSTANCE_ID) diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 201f434..6db9dd9 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from typing import Any from unittest.mock import AsyncMock from uuid import UUID, uuid4 @@ -68,6 +69,25 @@ from tests.unit.fakes import ( APP_ID = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") ZONE_ID = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") INSTANCE_ID = UUID("cccccccc-cccc-cccc-cccc-cccccccccccc") +APP_REGISTRATION_ID = UUID("ffffffff-ffff-ffff-ffff-ffffffffffff") + + +async def _seed_registration( + app_registration_repo: FakeAppRegistrationRepository, + app_id: UUID = APP_ID, + app_registration_id: UUID = APP_REGISTRATION_ID, +) -> AppRegistration: + return 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, + ) + ) def _make_srm_catalog() -> SRMCatalogPayload: @@ -111,7 +131,7 @@ def _make_srm_catalog() -> SRMCatalogPayload: def _make_srm_instance(state: str = "active") -> SRMServiceInstance: return SRMServiceInstance( service_instance_id=str(INSTANCE_ID), - service_specification_id=str(APP_ID), + service_specification_id=str(APP_REGISTRATION_ID), state=state, app_provider_id="VideoAppsCo", resource_zone_id=str(ZONE_ID), @@ -149,12 +169,16 @@ def _make_manifest() -> AppManifest: ) +def _confirm_requested_id( + payload: dict[str, Any], x_correlator: str | None = None +) -> SRMCatalogServiceSpecificationCreated: + return SRMCatalogServiceSpecificationCreated(id=UUID(payload["service_specification"]["id"])) + + @pytest.fixture() def srm_client() -> AsyncMock: client = AsyncMock() - client.create_catalog_service_specification.return_value = ( - SRMCatalogServiceSpecificationCreated(id=APP_ID) - ) + client.create_catalog_service_specification.side_effect = _confirm_requested_id return client @@ -264,23 +288,46 @@ class TestGetApps: class TestGetApp: async def test_returns_app_manifest_envelope( - self, service: EdgeApplicationManagementService, srm_client: AsyncMock + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, ) -> None: + await _seed_registration(app_registration_repo) srm_client.get_app.return_value = _make_srm_catalog() result = await service.get_app(app_id=APP_ID) assert result.appManifest.appId == APP_ID assert result.appManifest.name == "myvideoapp" - async def test_passes_app_id_and_correlator( - self, service: EdgeApplicationManagementService, srm_client: AsyncMock + async def test_resolves_app_id_to_app_registration_id_for_srm_lookup( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, ) -> None: + """SRM's service_specification primary key is app_registration_id, not + app_id (ADR-0011); oeg_db is what maps one to the other.""" + await _seed_registration(app_registration_repo) srm_client.get_app.return_value = _make_srm_catalog() await service.get_app(app_id=APP_ID, x_correlator="corr-1") - srm_client.get_app.assert_called_once_with(app_id=APP_ID, x_correlator="corr-1") + srm_client.get_app.assert_called_once_with( + app_id=APP_REGISTRATION_ID, x_correlator="corr-1" + ) - async def test_raises_downstream_exception_for_unmappable_catalog_entry( + async def test_raises_not_found_when_app_not_registered( self, service: EdgeApplicationManagementService, srm_client: AsyncMock ) -> None: + with pytest.raises(NotFoundException, match=str(APP_ID)): + await service.get_app(app_id=APP_ID) + srm_client.get_app.assert_not_called() + + async def test_raises_downstream_exception_for_unmappable_catalog_entry( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + ) -> None: + await _seed_registration(app_registration_repo) catalog = _make_srm_catalog() catalog.service_deployment_units = [] srm_client.get_app.return_value = catalog @@ -312,8 +359,11 @@ class TestSubmitApp: ) srm_client.create_catalog_service_specification.assert_called_once() - async def test_catalog_payload_contains_app_id( - self, service: EdgeApplicationManagementService, srm_client: AsyncMock + async def test_catalog_payload_ref_is_app_id_and_id_is_app_registration_id( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, ) -> None: await service.submit_app( manifest=_make_manifest(), @@ -324,15 +374,18 @@ class TestSubmitApp: call_kwargs = srm_client.create_catalog_service_specification.call_args.kwargs payload = call_kwargs["payload"] assert payload["service_specification"]["ref"] == str(APP_ID) - # id must also carry app_id: SRM adopts it as the specification PK, - # making service_specification_id == app_id (ADR-0011). - assert payload["service_specification"]["id"] == str(APP_ID) + + stored = await app_registration_repo.get_by_app_id(APP_ID) + assert stored is not None + assert payload["service_specification"]["id"] == str(stored.app_registration_id) + assert payload["service_specification"]["id"] != str(APP_ID) async def test_raises_when_srm_confirms_a_different_id( self, service: EdgeApplicationManagementService, srm_client: AsyncMock ) -> None: + srm_client.create_catalog_service_specification.side_effect = None srm_client.create_catalog_service_specification.return_value = ( - SRMCatalogServiceSpecificationCreated(id=UUID(int=APP_ID.int + 1)) + SRMCatalogServiceSpecificationCreated(id=uuid4()) ) with pytest.raises(DownstreamServiceException): await service.submit_app( @@ -368,8 +421,9 @@ class TestSubmitApp: srm_client: AsyncMock, app_registration_repo: FakeAppRegistrationRepository, ) -> None: + srm_client.create_catalog_service_specification.side_effect = None srm_client.create_catalog_service_specification.return_value = ( - SRMCatalogServiceSpecificationCreated(id=UUID(int=APP_ID.int + 1)) + SRMCatalogServiceSpecificationCreated(id=uuid4()) ) with pytest.raises(DownstreamServiceException): await service.submit_app( @@ -418,6 +472,37 @@ class TestSubmitApp: app_provider_id="provider-1", ) + async def test_app_id_is_registrable_again_after_delete( + self, + service: EdgeApplicationManagementService, + app_registration_repo: FakeAppRegistrationRepository, + ) -> None: + await service.submit_app( + manifest=_make_manifest(), + app_id=APP_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + first = await app_registration_repo.get_by_app_id(APP_ID) + assert first is not None + + await service.delete_app( + app_id=APP_ID, + app_provider_id="provider-1", + ) + assert await app_registration_repo.get_by_app_id(APP_ID) is None + + result = await service.submit_app( + manifest=_make_manifest(), + app_id=APP_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + assert result.appId == APP_ID + second = await app_registration_repo.get_by_app_id(APP_ID) + assert second is not None + assert second.app_registration_id != first.app_registration_id + class TestSubmitAppRejectsUnsupportedVariants: """ADR-0009: schema-valid but unfulfilled AppManifest variants must be @@ -460,22 +545,38 @@ class TestSubmitAppRejectsUnsupportedVariants: class TestDeleteApp: - async def test_delegates_to_srm_client( - self, service: EdgeApplicationManagementService, srm_client: AsyncMock + async def test_delegates_to_srm_client_using_app_registration_id( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, ) -> None: + await _seed_registration(app_registration_repo) + await service.delete_app( app_id=APP_ID, app_provider_id="VideoAppsCo", x_correlator="corr-1" ) - srm_client.delete_app.assert_called_once_with(app_id=APP_ID, x_correlator="corr-1") - async def test_removes_local_app_registration( + srm_client.delete_app.assert_called_once_with( + app_id=APP_REGISTRATION_ID, x_correlator="corr-1" + ) + + async def test_raises_not_found_when_app_not_registered( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + with pytest.raises(NotFoundException, match=str(APP_ID)): + await service.delete_app(app_id=APP_ID, app_provider_id="VideoAppsCo") + srm_client.delete_app.assert_not_called() + + async def test_soft_deletes_local_app_registration( self, service: EdgeApplicationManagementService, app_registration_repo: FakeAppRegistrationRepository, ) -> None: + app_registration_id = uuid4() await app_registration_repo.save( AppRegistration( - app_registration_id=uuid4(), + app_registration_id=app_registration_id, app_id=APP_ID, tenant_id="tenant-1", name="myvideoapp", @@ -488,6 +589,9 @@ class TestDeleteApp: await service.delete_app(app_id=APP_ID, app_provider_id="VideoAppsCo") assert await app_registration_repo.get_by_app_id(APP_ID) is None + retained = await app_registration_repo.get_by_id(app_registration_id) + assert retained is not None + assert retained.status == AppRegistrationStatus.DELETED async def test_does_not_delete_local_registration_when_srm_call_fails( self, @@ -931,29 +1035,84 @@ class TestCreateAppInstanceUnregisteredApp: request=request, tenant_id="tenant-1", app_provider_id="provider-1" ) + async def test_raises_bad_request_when_app_was_deregistered( + self, + service: EdgeApplicationManagementService, + app_registration_repo: FakeAppRegistrationRepository, + ) -> None: + """A soft-deleted registration must not be instantiable. + + The row survives deletion, so an existence-only check would let a caller + deploy an app they already deregistered. + """ + await app_registration_repo.save( + AppRegistration( + app_registration_id=uuid4(), + app_id=APP_ID, + tenant_id="tenant-1", + name="myvideoapp", + version="1.0.0", + package_type=PackageType.HELM, + status=AppRegistrationStatus.DELETED, + ) + ) + request = CreateAppInstanceRequest( + name="myapp_inst", + appId=APP_ID, + edgeCloudZoneId=ZONE_ID, + ) + + with pytest.raises(BadRequestException, match=str(APP_ID)): + await service.create_app_instance( + request=request, tenant_id="tenant-1", app_provider_id="provider-1" + ) + class TestGetAppInstances: async def test_returns_mapped_instances( - self, service: EdgeApplicationManagementService, srm_client: AsyncMock + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, ) -> None: + await _seed_registration(app_registration_repo) srm_client.get_app_instances.return_value = [_make_srm_instance(state="active")] result = await service.get_app_instances() assert len(result) == 1 assert result[0].appInstanceId == INSTANCE_ID + assert result[0].appId == APP_ID assert result[0].status == AppInstanceStatus.READY - async def test_passes_filters_to_srm( + async def test_unresolvable_instance_is_skipped_not_raised( self, service: EdgeApplicationManagementService, srm_client: AsyncMock ) -> None: + srm_client.get_app_instances.return_value = [_make_srm_instance(state="active")] + result = await service.get_app_instances() + assert result == [] + + async def test_passes_resolved_app_registration_id_filter_to_srm( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + ) -> None: + await _seed_registration(app_registration_repo) srm_client.get_app_instances.return_value = [] await service.get_app_instances(app_id=APP_ID, app_instance_id=INSTANCE_ID) srm_client.get_app_instances.assert_called_once_with( - app_id=APP_ID, + app_id=APP_REGISTRATION_ID, app_instance_id=INSTANCE_ID, region=None, x_correlator=None, ) + async def test_unregistered_app_id_filter_returns_empty_without_calling_srm( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + result = await service.get_app_instances(app_id=APP_ID) + assert result == [] + srm_client.get_app_instances.assert_not_called() + class TestDeleteAppInstance: @pytest.fixture(autouse=True) @@ -1085,12 +1244,18 @@ class TestHandleCompleted: self, operation_repo: FakeOperationRepository, operation_type: OperationType = OperationType.DEPLOY, + terminate_target_instance_id: UUID | None = None, ) -> None: subject = ( Subject.TASK_TERMINATE if operation_type == OperationType.TERMINATE else Subject.TASK_DEPLOY ) + metadata = ( + {"app_instance_id": str(terminate_target_instance_id)} + if terminate_target_instance_id is not None + else {} + ) await operation_repo.save( Operation( operation_id=self.OPERATION_ID, @@ -1100,6 +1265,7 @@ class TestHandleCompleted: operation_type=operation_type, status=OperationStatus.PENDING, subject=subject, + metadata=metadata, ) ) @@ -1431,6 +1597,178 @@ class TestHandleCompleted: assert updated is not None assert updated.state == AppInstanceState.FAILED + async def test_failure_expressed_only_via_instances_is_processed( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + await self._seed_pending_operation(operation_repo) + await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="failed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), + zone_id=str(ZONE_ID), + status="failed", + error={"title": "Zone Capacity Exceeded", "status": 503}, + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated_operation = await operation_repo.get_by_id(self.OPERATION_ID) + assert updated_operation is not None + assert updated_operation.status == OperationStatus.FAILED + updated = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated is not None + assert updated.state == AppInstanceState.FAILED + + async def _seed_terminated_app_instance( + self, app_instance_repo: FakeAppInstanceRepository, app_instance_id: UUID + ) -> None: + await app_instance_repo.save( + AppInstance( + app_instance_id=app_instance_id, + operation_id=self.OPERATION_ID, + app_registration_id=uuid4(), + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.TERMINATED, + ) + ) + + async def test_late_deploy_completion_does_not_revive_terminated_instance( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + await self._seed_pending_operation(operation_repo, OperationType.DEPLOY) + await self._seed_terminated_app_instance(app_instance_repo, INSTANCE_ID) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + unchanged = await app_instance_repo.get_by_id(INSTANCE_ID) + assert unchanged is not None + assert unchanged.state == AppInstanceState.TERMINATED + + async def test_redelivered_completion_is_idempotent_for_terminated_instance( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + ) -> None: + await self._seed_pending_operation(operation_repo, OperationType.TERMINATE) + await self._seed_app_instance_with_registration( + app_registration_repo, + app_instance_repo, + INSTANCE_ID, + ) + await self._seed_active_callback_registration(callback_registration_repo) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + await service.handle_completed(event) + + final = await app_instance_repo.get_by_id(INSTANCE_ID) + assert final is not None + assert final.state == AppInstanceState.TERMINATED + assert len(callback_delivery_port.delivered) == 1 + + async def test_stale_total_failure_does_not_overwrite_terminated_instance( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """The no-instances fallback must respect the same rule as the main path.""" + await self._seed_pending_operation(operation_repo) + await self._seed_terminated_app_instance(app_instance_repo, INSTANCE_ID) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="failed", + instances=[], + error={"title": "Zone Capacity Exceeded", "status": 503}, + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + unchanged = await app_instance_repo.get_by_id(INSTANCE_ID) + assert unchanged is not None + assert unchanged.state == AppInstanceState.TERMINATED + + async def test_terminate_completion_still_finalizes_a_failed_instance( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """`failed` is not final: terminating a broken instance must still be able + to reach `terminated`, so the staleness guard must not cover it.""" + await self._seed_pending_operation(operation_repo, OperationType.TERMINATE) + await app_instance_repo.save( + AppInstance( + app_instance_id=INSTANCE_ID, + operation_id=self.OPERATION_ID, + app_registration_id=uuid4(), + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.FAILED, + ) + ) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated is not None + assert updated.state == AppInstanceState.TERMINATED + async def test_unknown_app_instance_id_is_skipped_not_raised( self, service: EdgeApplicationManagementService, @@ -1522,6 +1860,74 @@ class TestHandleCompleted: assert cloud_event.data.appId == app_id assert cloud_event.data.status == "terminated" + async def test_terminate_completion_delivers_callback_via_original_deploy_operation( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + ) -> None: + deploy_operation_id = uuid4() + app_id = uuid4() + app_registration_id = uuid4() + 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 app_instance_repo.save( + AppInstance( + app_instance_id=INSTANCE_ID, + operation_id=deploy_operation_id, + app_registration_id=app_registration_id, + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.TERMINATING, + ) + ) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=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-instance-status-change" + ], + is_active=True, + ) + ) + await self._seed_pending_operation(operation_repo, operation_type=OperationType.TERMINATE) + + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + assert len(callback_delivery_port.delivered) == 1 + sink, cloud_event = callback_delivery_port.delivered[0] + assert cloud_event.data.appInstanceId == INSTANCE_ID + assert cloud_event.data.appId == app_id + assert cloud_event.data.status == "terminated" + async def test_terminate_completion_failure_marks_failed_not_deleted( self, service: EdgeApplicationManagementService, @@ -1565,7 +1971,11 @@ class TestHandleCompleted: operation_repo: FakeOperationRepository, app_instance_repo: FakeAppInstanceRepository, ) -> None: - await self._seed_pending_operation(operation_repo, operation_type=OperationType.TERMINATE) + await self._seed_pending_operation( + operation_repo, + operation_type=OperationType.TERMINATE, + terminate_target_instance_id=INSTANCE_ID, + ) await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) event = SRMOperationCompleted( schema_version="1.0", @@ -1588,6 +1998,48 @@ class TestHandleCompleted: assert updated is not None assert updated.state == AppInstanceState.FAILED + async def test_terminate_total_failure_fallback_resolves_instance_via_stashed_id( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + deploy_operation_id = uuid4() + await app_instance_repo.save( + AppInstance( + app_instance_id=INSTANCE_ID, + operation_id=deploy_operation_id, + app_registration_id=uuid4(), + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.TERMINATING, + ) + ) + await self._seed_pending_operation( + operation_repo, + operation_type=OperationType.TERMINATE, + terminate_target_instance_id=INSTANCE_ID, + ) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="failed", + instances=[], + error={ + "type": "about:blank", + "title": "Termination Failed", + "status": 503, + "detail": "backend unreachable", + }, + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + + updated = await app_instance_repo.get_by_id(INSTANCE_ID) + assert updated is not None + assert updated.state == AppInstanceState.FAILED + async def test_delivers_callback_when_registration_exists( self, service: EdgeApplicationManagementService, -- GitLab From 2abc89dcd4e3a0b08c157f85eb3f463117f96a4a Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 30 Jul 2026 18:18:41 +0300 Subject: [PATCH 24/36] feat: add terminal operation status handling and improve idempotency in completion callbacks --- .../edge_application_management_service.py | 16 +++++++ tests/unit/test_eam_service.py | 42 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 44a4c55..4af7b25 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -84,6 +84,14 @@ _APP_INSTANCE_COMPLETION_STATE_MAP: dict[str, AppInstanceState] = { "failed": AppInstanceState.FAILED, } +_TERMINAL_OPERATION_STATUSES = frozenset( + { + OperationStatus.COMPLETED, + OperationStatus.PARTIALLY_COMPLETED, + OperationStatus.FAILED, + } +) + def _is_final(app_instance: AppInstance) -> bool: """Whether no completion may move this instance any further.""" @@ -507,6 +515,14 @@ class EdgeApplicationManagementService: ) return + if operation.status in _TERMINAL_OPERATION_STATUSES: + logger.info( + "redelivered_completion_ignored_for_terminal_operation", + operation_id=event.operation_id, + status=operation.status.value, + ) + return + status = _OPERATION_COMPLETION_STATUS_MAP[event.status] result: Optional[dict[str, Any]] = None if status != OperationStatus.FAILED: diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 6db9dd9..37e69dd 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -1707,6 +1707,48 @@ class TestHandleCompleted: assert final.state == AppInstanceState.TERMINATED assert len(callback_delivery_port.delivered) == 1 + async def test_redelivered_deploy_completion_delivers_one_callback( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeCallbackDeliveryPort, + callback_delivery_repo: FakeCallbackDeliveryRepository, + ) -> None: + """JetStream delivers at least once, so the same completion can arrive + repeatedly; §L.2 requires OEG to apply it idempotently by operation_id. + `ready` is not a final instance state, so only the operation-level guard + stops the customer's webhook firing once per redelivery.""" + await self._seed_pending_operation(operation_repo) + await self._seed_app_instance_with_registration( + app_registration_repo, app_instance_repo, INSTANCE_ID + ) + await self._seed_active_callback_registration(callback_registration_repo) + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(self.OPERATION_ID), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" + ) + ], + correlation_id="corr-1", + completed_at="2026-07-04T10:02:35+00:00", + ) + + await service.handle_completed(event) + await service.handle_completed(event) + await service.handle_completed(event) + + final = await app_instance_repo.get_by_id(INSTANCE_ID) + assert final is not None + assert final.state == AppInstanceState.READY + assert len(callback_delivery_port.delivered) == 1 + assert len(callback_delivery_repo.rows) == 1 + async def test_stale_total_failure_does_not_overwrite_terminated_instance( self, service: EdgeApplicationManagementService, -- GitLab From cf39aa5120e7e7e7643264a35a44dd4b58c630f2 Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 30 Jul 2026 18:30:25 +0300 Subject: [PATCH 25/36] fix: update AppInstance status mapping and add utility function for state conversion --- .../mappers/edge_application_mapper.py | 18 ++++++++++-- .../edge_application_management_service.py | 3 +- tests/unit/test_eam_mapper.py | 29 +++++++++++++++++++ tests/unit/test_eam_service.py | 28 ++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index 968d7ff..db03524 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -62,7 +62,7 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMTerminatePayload, SRMTopologyConstraints, ) -from open_exposure_gateway.domain.models import AppInstance +from open_exposure_gateway.domain.models import AppInstance, AppInstanceState _PACKAGE_TYPE_TO_RUNTIME_KIND: dict[str, str] = { "HELM": "helm", @@ -95,9 +95,23 @@ _SRM_STATE_TO_APP_INSTANCE_STATUS: dict[str, AppInstanceStatus] = { "degraded": AppInstanceStatus.FAILED, "failed": AppInstanceStatus.FAILED, "terminating": AppInstanceStatus.TERMINATING, - "terminated": AppInstanceStatus.TERMINATING, + "terminated": AppInstanceStatus.UNKNOWN, } +# CAMARA's AppInstanceStatus has no `terminated`; the internal terminal state +# surfaces as `unknown` (app-instance-flow.md, AppInstanceStatus Mapping). +_APP_INSTANCE_STATE_TO_STATUS: dict[AppInstanceState, AppInstanceStatus] = { + AppInstanceState.INSTANTIATING: AppInstanceStatus.INSTANTIATING, + AppInstanceState.READY: AppInstanceStatus.READY, + AppInstanceState.FAILED: AppInstanceStatus.FAILED, + AppInstanceState.TERMINATING: AppInstanceStatus.TERMINATING, + AppInstanceState.TERMINATED: AppInstanceStatus.UNKNOWN, +} + + +def to_app_instance_status(state: AppInstanceState) -> AppInstanceStatus: + return _APP_INSTANCE_STATE_TO_STATUS[state] + def _parse_container_cpu_cores(value: str) -> float: if value.endswith("m"): diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 4af7b25..38aed47 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -27,6 +27,7 @@ from open_exposure_gateway.application.mappers.edge_application_mapper import ( build_edge_cloud_zone, build_submitted_app, build_terminate_instance_command, + to_app_instance_status, ) from open_exposure_gateway.core.exceptions import ( AbortedException, @@ -322,7 +323,7 @@ class EdgeApplicationManagementService: name=request.name, appId=request.appId, appProvider=app_provider_id, - status=AppInstanceStatus(existing_instance.state), + status=to_app_instance_status(existing_instance.state), edgeCloudZoneId=request.edgeCloudZoneId, ) diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index 7ce1ec7..487f968 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -1,5 +1,7 @@ from uuid import UUID +import pytest + from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( AppInstanceStatus, ApplicationResources, @@ -23,6 +25,7 @@ from open_exposure_gateway.application.mappers.edge_application_mapper import ( build_catalog_payload, build_deploy_command, build_edge_cloud_zone, + to_app_instance_status, ) from open_exposure_gateway.domain.edge_application_management import ( SRMAccelerator, @@ -348,6 +351,10 @@ class TestBuildAppInstanceInfo: result = build_app_instance_info(self._make_instance(state="terminating"), APP_ID) assert result.status == AppInstanceStatus.TERMINATING + def test_state_mapping_terminated_to_unknown(self) -> None: + result = build_app_instance_info(self._make_instance(state="terminated"), APP_ID) + assert result.status == AppInstanceStatus.UNKNOWN + def test_unknown_state_defaults_to_unknown(self) -> None: result = build_app_instance_info(self._make_instance(state="exotic"), APP_ID) assert result.status == AppInstanceStatus.UNKNOWN @@ -385,6 +392,28 @@ class TestBuildAppInstanceInfo: assert result.name == str(INSTANCE_ID) +class TestToAppInstanceStatus: + """Every internal state must map to a CAMARA-legal status; `terminated` has + no CAMARA counterpart and surfaces as `unknown` (app-instance-flow.md).""" + + @pytest.mark.parametrize( + ("state", "expected"), + [ + (AppInstanceState.INSTANTIATING, AppInstanceStatus.INSTANTIATING), + (AppInstanceState.READY, AppInstanceStatus.READY), + (AppInstanceState.FAILED, AppInstanceStatus.FAILED), + (AppInstanceState.TERMINATING, AppInstanceStatus.TERMINATING), + (AppInstanceState.TERMINATED, AppInstanceStatus.UNKNOWN), + ], + ) + def test_maps_every_state(self, state: AppInstanceState, expected: AppInstanceStatus) -> None: + assert to_app_instance_status(state) == expected + + def test_covers_every_state(self) -> None: + for state in AppInstanceState: + to_app_instance_status(state) + + class TestBuildAppRegistrationTranslation: def test_helm_translation_fields(self) -> None: manifest = _make_helm_manifest() diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 37e69dd..abe94d4 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -830,6 +830,34 @@ class TestCreateAppInstance: ) assert second.status == AppInstanceStatus.READY + async def test_replay_of_terminated_instance_returns_unknown_status( + self, + service: EdgeApplicationManagementService, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """`terminated` has no CAMARA AppInstanceStatus counterpart, so the + replay path must map it rather than construct the enum from the state.""" + first = await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="retry-key-1", + ) + stored = await app_instance_repo.get_by_id(first.appInstanceId) + assert stored is not None + await app_instance_repo.save( + stored.model_copy(update={"state": AppInstanceState.TERMINATED}) + ) + + second = await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key="retry-key-1", + ) + assert second.appInstanceId == first.appInstanceId + assert second.status == AppInstanceStatus.UNKNOWN + async def test_different_keys_create_separate_operations( self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository ) -> None: -- GitLab From 1d27f62a1743a493e709a8c4c536b52b01b1b46b Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 30 Jul 2026 18:35:12 +0300 Subject: [PATCH 26/36] fix: update app instance status mapping to handle terminated state as unknown --- .../application/mappers/edge_application_mapper.py | 2 +- tests/unit/test_eam_mapper.py | 9 +++++++++ tests/unit/test_eam_service.py | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index db03524..0baa25c 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -657,6 +657,6 @@ def build_app_instance_status_change_event( appInstanceId=app_instance.app_instance_id, appId=app_id, edgeCloudZoneId=app_instance.edge_cloud_zone_id, - status=app_instance.state.value, + status=to_app_instance_status(app_instance.state).value, ), ) diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index 487f968..acbd312 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -735,6 +735,15 @@ class TestBuildAppInstanceStatusChangeEvent: ) assert event.data.status == "failed" + def test_terminated_state_maps_to_unknown_status(self) -> None: + """The CloudEvent carries the CAMARA status, not the internal state.""" + event = build_app_instance_status_change_event( + self._make_app_instance(AppInstanceState.TERMINATED), + APP_ID, + "2026-07-04T10:02:35+00:00", + ) + assert event.data.status == "unknown" + def test_each_call_gets_a_distinct_event_id(self) -> None: first = build_app_instance_status_change_event( self._make_app_instance(AppInstanceState.READY), diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index abe94d4..2300262 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -1928,7 +1928,7 @@ class TestHandleCompleted: assert sink == registration.sink assert cloud_event.data.appInstanceId == INSTANCE_ID assert cloud_event.data.appId == app_id - assert cloud_event.data.status == "terminated" + assert cloud_event.data.status == "unknown" async def test_terminate_completion_delivers_callback_via_original_deploy_operation( self, @@ -1996,7 +1996,7 @@ class TestHandleCompleted: sink, cloud_event = callback_delivery_port.delivered[0] assert cloud_event.data.appInstanceId == INSTANCE_ID assert cloud_event.data.appId == app_id - assert cloud_event.data.status == "terminated" + assert cloud_event.data.status == "unknown" async def test_terminate_completion_failure_marks_failed_not_deleted( self, -- GitLab From 46e3f87d5c2fd365a414b677b2c24608c6a3b98f Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 30 Jul 2026 18:43:34 +0300 Subject: [PATCH 27/36] feat: implement exists_in_zone method in AppInstanceRepository and SqlAppInstanceRepository, add checks in EdgeApplicationManagementService for duplicate instantiation in the same zone --- .../adapters/database/repos/app_instances.py | 9 ++ .../edge_application_management_service.py | 12 ++ .../ports/database/instances.py | 4 + tests/integration/test_postgres.py | 45 ++++++++ tests/unit/fakes.py | 9 ++ tests/unit/test_eam_flows.py | 19 +++- tests/unit/test_eam_service.py | 106 +++++++++++++++++- 7 files changed, 198 insertions(+), 6 deletions(-) diff --git a/src/open_exposure_gateway/adapters/database/repos/app_instances.py b/src/open_exposure_gateway/adapters/database/repos/app_instances.py index e3a1acc..28611b6 100644 --- a/src/open_exposure_gateway/adapters/database/repos/app_instances.py +++ b/src/open_exposure_gateway/adapters/database/repos/app_instances.py @@ -33,6 +33,15 @@ class SqlAppInstanceRepository(AppInstanceRepository): row = await self._session.scalar(stmt.limit(1)) return row is not None + async def exists_in_zone(self, app_registration_id: UUID, edge_cloud_zone_id: UUID) -> bool: + stmt = select(AppInstanceRow.app_instance_id).where( + AppInstanceRow.app_registration_id == app_registration_id, + AppInstanceRow.edge_cloud_zone_id == edge_cloud_zone_id, + AppInstanceRow.state.notin_(_TERMINAL_STATES), + ) + row = await self._session.scalar(stmt.limit(1)) + return row is not None + async def save(self, app_instance: AppInstance) -> AppInstance: merged = await self._session.merge(AppInstanceMapper.to_row(app_instance)) await self._session.flush() diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 38aed47..748d986 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -331,6 +331,13 @@ class EdgeApplicationManagementService: if app_registration is None: raise BadRequestException(message=f"App {request.appId} is not registered") + if await self._app_instance_repo.exists_in_zone( + app_registration.app_registration_id, request.edgeCloudZoneId + ): + raise AlreadyExistsException( + message="Application already instantiated in the given Edge Cloud Zone" + ) + operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) app_instance_id = uuid4() translation = build_app_deployment_translation( @@ -375,6 +382,11 @@ class EdgeApplicationManagementService: if request.subscriptionRequest is not None: if self._callback_registration_repo is None: raise RuntimeError("CallbackRegistrationRepository is not available") + # TODO: the raw subscription.sinkCredential token is dropped here — the + # secret:// ref below points at a store that does not exist yet, and + # HttpCallbackClient sends no Authorization header, so any sink requiring + # auth rejects every callback (ADR-0008 requires a sinkCredential bearer + # token). Either persist the credential or reject sinkCredential with 501. subscription = request.subscriptionRequest await self._callback_registration_repo.save( CallbackRegistration( diff --git a/src/open_exposure_gateway/ports/database/instances.py b/src/open_exposure_gateway/ports/database/instances.py index eb6b1b9..6241ca4 100644 --- a/src/open_exposure_gateway/ports/database/instances.py +++ b/src/open_exposure_gateway/ports/database/instances.py @@ -19,6 +19,10 @@ class AppInstanceRepository(ABC): async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: pass + @abstractmethod + async def exists_in_zone(self, app_registration_id: UUID, edge_cloud_zone_id: UUID) -> bool: + pass + @abstractmethod async def save(self, app_instance: AppInstance) -> AppInstance: pass diff --git a/tests/integration/test_postgres.py b/tests/integration/test_postgres.py index e100752..ad620be 100644 --- a/tests/integration/test_postgres.py +++ b/tests/integration/test_postgres.py @@ -217,6 +217,51 @@ async def test_app_instance_repo_exists_for_app_registration_ignores_terminal_st assert await repo.exists_for_app_registration(registration.app_registration_id) is False +async def test_app_instance_repo_exists_in_zone_matches_only_same_zone( + db_session: AsyncSession, +) -> None: + registration = await SqlAppRegistrationRepository(db_session).save(_app_registration()) + operation = await SqlOperationRepository(db_session).save( + _operation(app_registration_id=registration.app_registration_id) + ) + repo = SqlAppInstanceRepository(db_session) + instance = _app_instance( + operation_id=operation.operation_id, + app_registration_id=registration.app_registration_id, + ) + + await repo.save(instance) + + assert ( + await repo.exists_in_zone(registration.app_registration_id, instance.edge_cloud_zone_id) + is True + ) + assert await repo.exists_in_zone(registration.app_registration_id, uuid4()) is False + + +@pytest.mark.parametrize("state", [AppInstanceState.FAILED, AppInstanceState.TERMINATED]) +async def test_app_instance_repo_exists_in_zone_ignores_terminal_states( + db_session: AsyncSession, state: AppInstanceState +) -> None: + registration = await SqlAppRegistrationRepository(db_session).save(_app_registration()) + operation = await SqlOperationRepository(db_session).save( + _operation(app_registration_id=registration.app_registration_id) + ) + repo = SqlAppInstanceRepository(db_session) + instance = _app_instance( + operation_id=operation.operation_id, + app_registration_id=registration.app_registration_id, + ) + instance.state = state + + await repo.save(instance) + + assert ( + await repo.exists_in_zone(registration.app_registration_id, instance.edge_cloud_zone_id) + is False + ) + + async def test_app_registration_delete_keeps_row_referenced_by_terminated_instance( db_session: AsyncSession, ) -> None: diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 0200a8a..49cf5fd 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -367,6 +367,15 @@ class FakeAppInstanceRepository(AppInstanceRepository): for row in self.rows.values() ) + async def exists_in_zone(self, app_registration_id: UUID, edge_cloud_zone_id: UUID) -> bool: + terminal_states = (AppInstanceState.FAILED, AppInstanceState.TERMINATED) + return any( + row.app_registration_id == app_registration_id + and row.edge_cloud_zone_id == edge_cloud_zone_id + and row.state not in terminal_states + for row in self.rows.values() + ) + async def save(self, app_instance: AppInstance) -> AppInstance: stored = app_instance.model_copy(deep=True) self.rows[stored.app_instance_id] = stored diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index a2a8e83..383c0e7 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -44,6 +44,8 @@ from tests.unit.fakes import ( APP_ID = uuid4() ZONE_ID = uuid4() +OTHER_ZONE_ID = UUID("bbbbbbbb-3333-4000-8000-000000000003") + CREATE_INSTANCE_BODY: dict[str, Any] = { "name": "myvideoapp_inst", "appId": str(APP_ID), @@ -203,11 +205,26 @@ class TestCreateAppInstanceFlow: def test_each_request_gets_a_distinct_operation_id( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient ) -> None: + # Distinct zones: the same app twice in one zone is a 409 (CAMARA + # ALREADY_EXISTS), which is covered separately. api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) - api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + api_client.post( + f"{EAM_BASE}/appinstances", + json={**CREATE_INSTANCE_BODY, "edgeCloudZoneId": str(OTHER_ZONE_ID)}, + ) deploys = [p for s, p in fake_bus.published if s == Subject.TASK_DEPLOY] assert len({d["operation_id"] for d in deploys}) == 2 + def test_duplicate_instantiation_in_same_zone_returns_409( + self, api_client: TestClient, live_srm: FakeSRMClient + ) -> None: + api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + assert response.status_code == 409 + body = response.json() + assert body["status"] == 409 + assert body["code"] == "ALREADY_EXISTS" + def test_repeated_idempotency_key_returns_same_instance_and_does_not_republish( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient ) -> None: diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 2300262..d33d7f4 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -6,6 +6,7 @@ from uuid import UUID, uuid4 import pytest from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( + AppInstanceInfo, AppInstanceStatus, ApplicationResources, AppManifest, @@ -24,6 +25,7 @@ from open_exposure_gateway.core.exceptions import ( AlreadyExistsException, BadRequestException, DownstreamServiceException, + ErrorCode, NotFoundException, NotImplementedException, ) @@ -690,11 +692,13 @@ class TestDeleteApp: class TestCreateAppInstance: - def _make_request(self) -> CreateAppInstanceRequest: + OTHER_ZONE_ID = UUID("bbbbbbbb-2222-4000-8000-000000000002") + + def _make_request(self, zone_id: UUID = ZONE_ID) -> CreateAppInstanceRequest: return CreateAppInstanceRequest( name="myapp_inst", appId=APP_ID, - edgeCloudZoneId=ZONE_ID, + edgeCloudZoneId=zone_id, ) @pytest.fixture(autouse=True) @@ -868,7 +872,7 @@ class TestCreateAppInstance: idempotency_key="key-a", ) await service.create_app_instance( - request=self._make_request(), + request=self._make_request(self.OTHER_ZONE_ID), tenant_id="tenant-1", app_provider_id="provider-1", idempotency_key="key-b", @@ -878,9 +882,9 @@ class TestCreateAppInstance: async def test_absent_key_never_dedupes( self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository ) -> None: - for _ in range(2): + for zone_id in (ZONE_ID, self.OTHER_ZONE_ID): await service.create_app_instance( - request=self._make_request(), + request=self._make_request(zone_id), tenant_id="tenant-1", app_provider_id="provider-1", ) @@ -1049,6 +1053,98 @@ class TestCreateAppInstance: ) +class TestCreateAppInstanceDuplicateInZone: + """The vendored CAMARA spec defines 409 ALREADY_EXISTS for createAppInstance: + "Application already instantiated in the given Edge Cloud Zone".""" + + OTHER_ZONE_ID = UUID("bbbbbbbb-1111-4000-8000-000000000001") + + def _make_request(self, zone_id: UUID = ZONE_ID) -> CreateAppInstanceRequest: + return CreateAppInstanceRequest(name="myapp_inst", appId=APP_ID, edgeCloudZoneId=zone_id) + + @pytest.fixture(autouse=True) + async def _registered_app(self, app_registration_repo: FakeAppRegistrationRepository) -> None: + await app_registration_repo.save( + AppRegistration( + app_registration_id=uuid4(), + app_id=APP_ID, + tenant_id="tenant-1", + name="myvideoapp", + version="1.0.0", + package_type=PackageType.HELM, + status=AppRegistrationStatus.REGISTERED, + ) + ) + + async def _create( + self, + service: EdgeApplicationManagementService, + zone_id: UUID = ZONE_ID, + idempotency_key: str | None = None, + ) -> AppInstanceInfo: + return await service.create_app_instance( + request=self._make_request(zone_id), + tenant_id="tenant-1", + app_provider_id="provider-1", + idempotency_key=idempotency_key, + ) + + async def test_second_instantiation_in_same_zone_raises_already_exists( + self, service: EdgeApplicationManagementService + ) -> None: + await self._create(service) + with pytest.raises(AlreadyExistsException) as exc_info: + await self._create(service) + assert exc_info.value.status_code == 409 + assert exc_info.value.error_code == ErrorCode.ALREADY_EXISTS + + async def test_duplicate_does_not_publish_or_persist( + self, + service: EdgeApplicationManagementService, + publisher: AsyncMock, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + await self._create(service) + with pytest.raises(AlreadyExistsException): + await self._create(service) + publisher.publish.assert_called_once() + assert len(operation_repo.rows) == 1 + assert len(app_instance_repo.rows) == 1 + + async def test_same_app_in_a_different_zone_is_allowed( + self, service: EdgeApplicationManagementService + ) -> None: + first = await self._create(service) + second = await self._create(service, zone_id=self.OTHER_ZONE_ID) + assert second.appInstanceId != first.appInstanceId + + @pytest.mark.parametrize("state", [AppInstanceState.FAILED, AppInstanceState.TERMINATED]) + async def test_terminal_instance_does_not_block_reinstantiation( + self, + service: EdgeApplicationManagementService, + app_instance_repo: FakeAppInstanceRepository, + state: AppInstanceState, + ) -> None: + first = await self._create(service) + stored = await app_instance_repo.get_by_id(first.appInstanceId) + assert stored is not None + await app_instance_repo.save(stored.model_copy(update={"state": state})) + + second = await self._create(service) + assert second.appInstanceId != first.appInstanceId + + async def test_idempotent_retry_replays_instead_of_conflicting( + self, service: EdgeApplicationManagementService, publisher: AsyncMock + ) -> None: + """The idempotency replay is checked before the duplicate guard, so a + retried request must return the original instance, not 409.""" + first = await self._create(service, idempotency_key="retry-key-1") + second = await self._create(service, idempotency_key="retry-key-1") + assert second.appInstanceId == first.appInstanceId + publisher.publish.assert_called_once() + + class TestCreateAppInstanceUnregisteredApp: async def test_raises_bad_request_when_app_not_registered( self, service: EdgeApplicationManagementService -- GitLab From 305146a0ae9026ffb55843660dafd84052e81597 Mon Sep 17 00:00:00 2001 From: dgogos Date: Fri, 31 Jul 2026 01:27:22 +0300 Subject: [PATCH 28/36] refactor: rename SRMResourceZone to SRMZone and update related references across the codebase --- .../adapters/http/srm_client.py | 8 +++---- .../mappers/edge_application_mapper.py | 18 ++++++-------- .../edge_application_management_service.py | 12 +++++----- .../domain/edge_application_management.py | 20 ++++++++-------- src/open_exposure_gateway/ports/srm_port.py | 6 ++--- tests/conformance/conftest.py | 8 +++---- tests/unit/fakes.py | 14 +++++------ tests/unit/test_eam_contract.py | 4 ++-- tests/unit/test_eam_flows.py | 22 ++++++++--------- tests/unit/test_eam_mapper.py | 24 +++++++++---------- tests/unit/test_eam_service.py | 18 +++++++------- 11 files changed, 75 insertions(+), 79 deletions(-) diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index 953bd27..85d317d 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -15,8 +15,8 @@ from open_exposure_gateway.core.exceptions import ( from open_exposure_gateway.domain.edge_application_management import ( SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, - SRMResourceZone, SRMServiceInstance, + SRMZone, ) logger = structlog.get_logger(__name__) @@ -100,12 +100,12 @@ class SRMClient: details=str(exc), ) - async def get_resource_zones( + async def get_zones( self, region: str | None = None, status: str | None = None, x_correlator: str | None = None, - ) -> list[SRMResourceZone]: + ) -> list[SRMZone]: params = {} if region is not None: params["region"] = region @@ -122,7 +122,7 @@ class SRMClient: params=params or None, headers=headers or None, ) - return [SRMResourceZone.model_validate(z) for z in data] + return [SRMZone.model_validate(z) for z in data] async def create_qod_session( self, diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index 0baa25c..47e7291 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -53,7 +53,6 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMDeployTarget, SRMNetworkInterface, SRMRepoMetadata, - SRMResourceZone, SRMServiceInstance, SRMServiceSpecDescriptor, SRMServiceSpecEntry, @@ -61,6 +60,7 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMTerminateCommand, SRMTerminatePayload, SRMTopologyConstraints, + SRMZone, ) from open_exposure_gateway.domain.models import AppInstance, AppInstanceState @@ -131,7 +131,7 @@ def _parse_storage_mb(value: str) -> int: return int(amount) -def build_edge_cloud_zone(srm_zone: SRMResourceZone) -> EdgeCloudZone: +def build_edge_cloud_zone(srm_zone: SRMZone) -> EdgeCloudZone: try: status = EdgeCloudZoneStatus(srm_zone.state) except ValueError: @@ -315,9 +315,7 @@ def build_app_instance_info(instance: SRMServiceInstance, app_id: UUID) -> AppIn appId=app_id, appProvider=instance.app_provider_id, status=status, - edgeCloudZoneId=UUID(instance.resource_zone_id) - if instance.resource_zone_id - else UUID(int=0), + edgeCloudZoneId=UUID(instance.zone_id) if instance.zone_id else UUID(int=0), componentEndpointInfo=endpoint_info or None, ) @@ -590,11 +588,9 @@ def build_app_deployment_translation( correlation_id=correlation_id, tenant_id=tenant_id, app_provider_id=app_provider_id, - resource_zone_id=str(request.edgeCloudZoneId), + zone_id=str(request.edgeCloudZoneId), name=request.name, - compute_domain_id=str(request.kubernetesClusterRef) - if request.kubernetesClusterRef - else None, + domain_id=str(request.kubernetesClusterRef) if request.kubernetesClusterRef else None, idempotency_key=idempotency_key, requested_at="", ) @@ -613,8 +609,8 @@ def build_deploy_command( targets=[ SRMDeployTarget( app_instance_id=str(translation.app_instance_id), - resource_zone_id=translation.resource_zone_id, - compute_domain_id=translation.compute_domain_id, + zone_id=translation.zone_id, + domain_id=translation.domain_id, ) ], deploy=SRMDeployPayload( diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 748d986..ab9116a 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -40,7 +40,7 @@ from open_exposure_gateway.core.exceptions import ( from open_exposure_gateway.domain.edge_application_management import ( SRMCatalogPayload, SRMOperationCompleted, - SRMResourceZone, + SRMZone, Subject, ) from open_exposure_gateway.domain.models import ( @@ -135,7 +135,7 @@ class EdgeApplicationManagementService: status: Optional[str] = None, x_correlator: Optional[str] = None, ) -> list[EdgeCloudZone]: - srm_zones = await self.srm_client.get_resource_zones( + srm_zones = await self.srm_client.get_zones( region=region, status=status, x_correlator=x_correlator, @@ -164,7 +164,7 @@ class EdgeApplicationManagementService: return manifests def _log_skipped_entry( - self, kind: str, entry: SRMResourceZone | SRMCatalogPayload, exc: Exception + self, kind: str, entry: SRMZone | SRMCatalogPayload, exc: Exception ) -> None: logger.warning( "unmappable_srm_entry_skipped", @@ -364,8 +364,8 @@ class EdgeApplicationManagementService: app_registration_id=app_registration.app_registration_id, metadata={ "name": translation.name, - "resource_zone_id": translation.resource_zone_id, - "compute_domain_id": translation.compute_domain_id, + "zone_id": translation.zone_id, + "domain_id": translation.domain_id, }, ) ) @@ -417,7 +417,7 @@ class EdgeApplicationManagementService: appId=translation.app_id, appProvider=translation.app_provider_id, status=AppInstanceStatus.INSTANTIATING, - edgeCloudZoneId=UUID(translation.resource_zone_id), + edgeCloudZoneId=UUID(translation.zone_id), ) async def get_app_instances( diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index 0dc0c5a..3938efb 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -89,28 +89,28 @@ class AppDeploymentTranslation(BaseModel): correlation_id: str tenant_id: str app_provider_id: str - resource_zone_id: str + zone_id: str name: str - compute_domain_id: str | None = None + domain_id: str | None = None idempotency_key: str | None = None requested_at: str -class SRMResourceZoneLocation(BaseModel): +class SRMZoneLocation(BaseModel): region: str | None = None geo: Any | None = None -class SRMResourceZoneMetadata(BaseModel): +class SRMZoneMetadata(BaseModel): provider: str - location: SRMResourceZoneLocation | None = None + location: SRMZoneLocation | None = None -class SRMResourceZone(BaseModel): +class SRMZone(BaseModel): id: str name: str state: str - metadata: SRMResourceZoneMetadata + metadata: SRMZoneMetadata class SRMAccelerator(BaseModel): @@ -218,8 +218,8 @@ class SRMDeployPayload(BaseModel): class SRMDeployTarget(BaseModel): app_instance_id: str - resource_zone_id: str - compute_domain_id: str | None = None + zone_id: str + domain_id: str | None = None class SRMDeployCommand(BaseModel): @@ -320,7 +320,7 @@ class SRMServiceInstance(BaseModel): service_specification_id: str state: str app_provider_id: str - resource_zone_id: str | None = None + zone_id: str | None = None name: str | None = None capability_instances: list[SRMCapabilityInstanceSummary] = [] diff --git a/src/open_exposure_gateway/ports/srm_port.py b/src/open_exposure_gateway/ports/srm_port.py index d6b60da..fe0f3ee 100644 --- a/src/open_exposure_gateway/ports/srm_port.py +++ b/src/open_exposure_gateway/ports/srm_port.py @@ -7,18 +7,18 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( from open_exposure_gateway.domain.edge_application_management import ( SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, - SRMResourceZone, SRMServiceInstance, + SRMZone, ) class SRMClientPort(Protocol): - async def get_resource_zones( + async def get_zones( self, region: str | None, status: str | None, x_correlator: str | None, - ) -> list[SRMResourceZone]: ... + ) -> list[SRMZone]: ... async def get_apps(self, x_correlator: str | None) -> list[SRMCatalogPayload]: ... diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py index da75ce4..4885ac5 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -23,8 +23,8 @@ from open_exposure_gateway.dependencies import ( get_qod_service, ) from open_exposure_gateway.domain.edge_application_management import ( - SRMResourceZone, - SRMResourceZoneMetadata, + SRMZone, + SRMZoneMetadata, ) from tests.conformance.harness import app from tests.unit.fakes import ( @@ -47,11 +47,11 @@ def service_overrides() -> Generator[None, None, None]: # EdgeCloudZones schema requires minItems: 1); the fake starts empty # otherwise, which no real provider would ever be. srm.zones.append( - SRMResourceZone( + SRMZone( id=str(uuid4()), name="conformance-zone", state="active", - metadata=SRMResourceZoneMetadata(provider="conformance-provider"), + metadata=SRMZoneMetadata(provider="conformance-provider"), ) ) operation_repo = FakeOperationRepository() diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 49cf5fd..ec7dfef 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -39,8 +39,8 @@ from open_exposure_gateway.domain.edge_application_management import ( AppInstanceStatusChangeCloudEvent, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, - SRMResourceZone, SRMServiceInstance, + SRMZone, Subject, ) from open_exposure_gateway.domain.models import ( @@ -96,17 +96,17 @@ class FakeMsg: class FakeSRMClient: def __init__(self) -> None: - self.zones: list[SRMResourceZone] = [] + self.zones: list[SRMZone] = [] self.catalog: dict[str, dict[str, Any]] = {} self.instances: dict[str, SRMServiceInstance] = {} self.qod_sessions: dict[str, QoDSessionResponse] = {} - async def get_resource_zones( + async def get_zones( self, region: str | None = None, status: str | None = None, x_correlator: str | None = None, - ) -> list[SRMResourceZone]: + ) -> list[SRMZone]: return list(self.zones) async def get_apps(self, x_correlator: str | None = None) -> list[SRMCatalogPayload]: @@ -201,7 +201,7 @@ def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None: service_specification_id=command["service_specification_id"], state="active", app_provider_id=command["app_provider_id"], - resource_zone_id=target["resource_zone_id"], + zone_id=target["zone_id"], name=command["deploy"]["instance_name"], ) await bus.publish( @@ -212,7 +212,7 @@ def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None: instances=[ { "service_instance_id": srm_id, - "zone_id": target["resource_zone_id"], + "zone_id": target["zone_id"], "status": "completed", } ], @@ -224,7 +224,7 @@ def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None: zone_id = None if instance_id is not None: existing = srm.instances.pop(instance_id, None) - zone_id = existing.resource_zone_id if existing else None + zone_id = existing.zone_id if existing else None await bus.publish( Subject.OPERATION_COMPLETED, completion_payload( diff --git a/tests/unit/test_eam_contract.py b/tests/unit/test_eam_contract.py index 23497bb..06d184f 100644 --- a/tests/unit/test_eam_contract.py +++ b/tests/unit/test_eam_contract.py @@ -297,7 +297,7 @@ class TestInternalHttpPaths: return [] client._request = record # type: ignore[method-assign] - await client.get_resource_zones() + await client.get_zones() assert calls == [("GET", "/internal/zones")] @@ -322,7 +322,7 @@ class TestInternalHttpPaths: return [] client._request = record # type: ignore[method-assign] - await client.get_resource_zones(region="athens", status="active") + await client.get_zones(region="athens", status="active") assert recorded_params == {"region": "athens", "state": "active"} diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 383c0e7..ccf025d 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -23,9 +23,9 @@ from open_exposure_gateway.api.camara.edge_application_management.vwip.router im from open_exposure_gateway.core.exceptions import DownstreamServiceException from open_exposure_gateway.domain.edge_application_management import ( SRMDeployCommand, - SRMResourceZone, - SRMResourceZoneMetadata, SRMTerminateCommand, + SRMZone, + SRMZoneMetadata, Subject, ) from open_exposure_gateway.domain.models import AppInstanceState, OperationStatus, OperationType @@ -186,7 +186,7 @@ class TestCreateAppInstanceFlow: registration = next(r for r in app_registration_repo.rows.values() if r.app_id == APP_ID) assert command.service_specification_id == str(registration.app_registration_id) assert len(command.targets) == 1 - assert command.targets[0].resource_zone_id == str(ZONE_ID) + assert command.targets[0].zone_id == str(ZONE_ID) assert command.deploy.instance_name == "myvideoapp_inst" assert command.source == "nbi_camara" assert command.deploy.placement_constraints == {} @@ -543,11 +543,11 @@ class TestEdgeCloudZonesFlow: self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: fake_srm.zones.append( - SRMResourceZone( + SRMZone( id=str(ZONE_ID), name="berlin-edge-1", state="active", - metadata=SRMResourceZoneMetadata(provider="acme"), + metadata=SRMZoneMetadata(provider="acme"), ) ) response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") @@ -560,23 +560,23 @@ class TestEdgeCloudZonesFlow: def test_one_malformed_zone_id_does_not_break_listing( self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: - """SRM's SRMResourceZone model allows free-form string ids; one non-UUID id + """SRM's SRMZone model allows free-form string ids; one non-UUID id must not turn the whole zone listing into a 500 — healthy zones must still be returned.""" fake_srm.zones.append( - SRMResourceZone( + SRMZone( id=str(ZONE_ID), name="good-zone", state="active", - metadata=SRMResourceZoneMetadata(provider="acme"), + metadata=SRMZoneMetadata(provider="acme"), ) ) fake_srm.zones.append( - SRMResourceZone( + SRMZone( id="zone-west-1", name="bad-zone", state="active", - metadata=SRMResourceZoneMetadata(provider="acme"), + metadata=SRMZoneMetadata(provider="acme"), ) ) response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") @@ -597,7 +597,7 @@ class TestEdgeCloudZonesFlow: def test_downstream_failure_maps_to_503_envelope_with_correlator( self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: - fake_srm.get_resource_zones = AsyncMock( # type: ignore[method-assign] + fake_srm.get_zones = AsyncMock( # type: ignore[method-assign] side_effect=DownstreamServiceException("SRM request failed") ) response = api_client.get( diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index acbd312..87b0501 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -39,14 +39,14 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMDeploymentUnitMetadata, SRMNetworkInterface, SRMRepoMetadata, - SRMResourceZone, - SRMResourceZoneLocation, - SRMResourceZoneMetadata, SRMResultSummary, SRMServiceInstance, SRMServiceSpecDescriptor, SRMServiceSpecEntry, SRMTopologyConstraints, + SRMZone, + SRMZoneLocation, + SRMZoneMetadata, ) from open_exposure_gateway.domain.models import AppInstance, AppInstanceState @@ -158,13 +158,13 @@ def _make_helm_manifest( class TestBuildEdgeCloudZone: def test_maps_all_fields(self) -> None: - zone = SRMResourceZone( + zone = SRMZone( id=str(ZONE_ID), name="berlin-edge-1", state="active", - metadata=SRMResourceZoneMetadata( + metadata=SRMZoneMetadata( provider="acme", - location=SRMResourceZoneLocation(region="eu-central-1"), + location=SRMZoneLocation(region="eu-central-1"), ), ) result = build_edge_cloud_zone(zone) @@ -174,21 +174,21 @@ class TestBuildEdgeCloudZone: assert result.edgeCloudRegion == "eu-central-1" def test_unknown_status_falls_back(self) -> None: - zone = SRMResourceZone( + zone = SRMZone( id=str(ZONE_ID), name="z", state="maintenance", - metadata=SRMResourceZoneMetadata(provider="p"), + metadata=SRMZoneMetadata(provider="p"), ) result = build_edge_cloud_zone(zone) assert result.edgeCloudZoneStatus == EdgeCloudZoneStatus.UNKNOWN def test_no_location_gives_none_region(self) -> None: - zone = SRMResourceZone( + zone = SRMZone( id=str(ZONE_ID), name="z", state="active", - metadata=SRMResourceZoneMetadata(provider="p"), + metadata=SRMZoneMetadata(provider="p"), ) result = build_edge_cloud_zone(zone) assert result.edgeCloudRegion is None @@ -323,7 +323,7 @@ class TestBuildAppInstanceInfo: service_specification_id=str(APP_REGISTRATION_ID), state=state, app_provider_id="VideoAppsCo", - resource_zone_id=str(ZONE_ID), + zone_id=str(ZONE_ID), name="myvideoapp_inst", capability_instances=[], ) @@ -684,7 +684,7 @@ class TestBuildDeployCommand: # POST /appinstances always carries exactly one targets[] entry (ADR-0005). assert len(cmd.targets) == 1 assert cmd.targets[0].app_instance_id == str(INSTANCE_ID) - assert cmd.targets[0].resource_zone_id == str(ZONE_ID) + assert cmd.targets[0].zone_id == str(ZONE_ID) assert cmd.operation_id == str(OP_ID) assert cmd.correlation_id == "corr-456" assert cmd.deploy.instance_name == "myapp_inst" diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index d33d7f4..32606e4 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -40,12 +40,12 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMDeploymentUnitMetadata, SRMOperationCompleted, SRMRepoMetadata, - SRMResourceZone, - SRMResourceZoneMetadata, SRMServiceInstance, SRMServiceSpecDescriptor, SRMServiceSpecEntry, SRMTopologyConstraints, + SRMZone, + SRMZoneMetadata, Subject, ) from open_exposure_gateway.domain.models import ( @@ -136,7 +136,7 @@ def _make_srm_instance(state: str = "active") -> SRMServiceInstance: service_specification_id=str(APP_REGISTRATION_ID), state=state, app_provider_id="VideoAppsCo", - resource_zone_id=str(ZONE_ID), + zone_id=str(ZONE_ID), name="myvideoapp_inst", capability_instances=[], ) @@ -246,12 +246,12 @@ class TestGetEdgeCloudZones: async def test_returns_mapped_camara_zones( self, service: EdgeApplicationManagementService, srm_client: AsyncMock ) -> None: - srm_client.get_resource_zones.return_value = [ - SRMResourceZone( + srm_client.get_zones.return_value = [ + SRMZone( id=str(ZONE_ID), name="berlin-edge-1", state="active", - metadata=SRMResourceZoneMetadata(provider="acme"), + metadata=SRMZoneMetadata(provider="acme"), ) ] result = await service.get_edge_cloud_zones() @@ -262,9 +262,9 @@ class TestGetEdgeCloudZones: async def test_passes_filters_to_srm( self, service: EdgeApplicationManagementService, srm_client: AsyncMock ) -> None: - srm_client.get_resource_zones.return_value = [] + srm_client.get_zones.return_value = [] await service.get_edge_cloud_zones(region="eu-west", status="active", x_correlator="c-1") - srm_client.get_resource_zones.assert_called_once_with( + srm_client.get_zones.assert_called_once_with( region="eu-west", status="active", x_correlator="c-1" ) @@ -764,7 +764,7 @@ class TestCreateAppInstance: assert registered is not None assert operation.app_registration_id == registered.app_registration_id assert operation.metadata["name"] == "myapp_inst" - assert operation.metadata["resource_zone_id"] == str(ZONE_ID) + assert operation.metadata["zone_id"] == str(ZONE_ID) async def test_persists_instantiating_app_instance_row( self, -- GitLab From 749a6720f1396120d7c294029e43e8ffa8acc62a Mon Sep 17 00:00:00 2001 From: dgogos Date: Fri, 31 Jul 2026 01:41:39 +0300 Subject: [PATCH 29/36] fix: update standalone handling in resource requirements and catalog payload --- .../mappers/edge_application_mapper.py | 4 ++-- .../edge_application_management_service.py | 3 ++- .../domain/edge_application_management.py | 2 +- tests/unit/test_eam_mapper.py | 23 +++++++++++++++++++ tests/unit/test_eam_service.py | 20 ++++++++++++++++ 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index 47e7291..b4b0134 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -227,7 +227,7 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: required_resources = KubernetesResources( infraKind="kubernetes", applicationResources=CamaraApplicationResources.model_validate(app_res), - isStandalone=compute.standalone if compute else False, + isStandalone=unit.resource_requirements.standalone, ) elif unit.runtime_kind == "container": millicores = compute.cpu_millicores if compute and compute.cpu_millicores is not None else 0 @@ -502,7 +502,6 @@ def build_catalog_payload(translation: AppRegistrationTranslation) -> SRMCatalog compute = SRMComputeResources( cpu_millicores=cpu_millicores, memory_mb=memory_mb, - standalone=standalone, accelerator=accelerator, storage=storage, ) @@ -523,6 +522,7 @@ def build_catalog_payload(translation: AppRegistrationTranslation) -> SRMCatalog compute=compute, topology=topology, interfaces=interfaces or None, + standalone=standalone, ) repo = translation.app_repo diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index ab9116a..13e20a1 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -218,7 +218,8 @@ class EdgeApplicationManagementService: ) catalog_payload = build_catalog_payload(translation) created = await self.srm_client.create_catalog_service_specification( - payload=catalog_payload.model_dump(mode="json"), x_correlator=x_correlator + payload=catalog_payload.model_dump(mode="json", exclude_none=True), + x_correlator=x_correlator, ) if created.id != translation.app_registration_id: raise DownstreamServiceException( diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index 3938efb..300ef97 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -128,7 +128,6 @@ class SRMStorageVolume(BaseModel): class SRMComputeResources(BaseModel): cpu_millicores: int | None = None memory_mb: int | None = None - standalone: bool = False accelerator: SRMAccelerator | None = None storage: list[SRMStorageVolume] | None = None @@ -153,6 +152,7 @@ class SRMComputeIntent(BaseModel): compute: SRMComputeResources | None = None topology: SRMTopologyConstraints | None = None interfaces: list[SRMNetworkInterface] | None = None + standalone: bool = False class SRMServiceSpecDescriptor(BaseModel): diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index 87b0501..0d93708 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -64,6 +64,7 @@ def _make_srm_catalog( cpu_millicores: int = 2000, memory_mb: int = 4096, interfaces: list[SRMNetworkInterface] | None = None, + standalone: bool = False, ) -> SRMCatalogPayload: return SRMCatalogPayload( service_specification=SRMServiceSpecEntry( @@ -94,6 +95,7 @@ def _make_srm_catalog( min_node_memory_mb=1024, ), interfaces=interfaces, + standalone=standalone, ), ) ], @@ -274,6 +276,14 @@ class TestBuildAppManifest: assert result.componentSpec[0].componentName == "frontend" assert len(result.componentSpec[0].networkInterfaces) == 2 + def test_standalone_read_from_top_level_field(self) -> None: + # standalone lives on resource_requirements itself, not nested under + # resource_requirements.compute (srm/canonical-parameters-schema.md). + catalog = _make_srm_catalog(standalone=True) + result = build_app_manifest(catalog) + assert isinstance(result.requiredResources, KubernetesResources) + assert result.requiredResources.isStandalone is True + def test_helm_topology_reconstructed(self) -> None: catalog = _make_srm_catalog() catalog.service_deployment_units[0].resource_requirements.topology = SRMTopologyConstraints( @@ -576,6 +586,19 @@ class TestBuildCatalogPayload: assert compute.cpu_millicores == 2000 assert compute.memory_mb == 4096 + def test_standalone_is_top_level_not_nested_in_compute(self) -> None: + # SRM's srm.compute/v1 shape puts `standalone` as a sibling of `compute`, + # not inside it (srm/canonical-parameters-schema.md); SRM rejects unknown + # fields, so a nested `compute.standalone` fails app registration outright. + manifest = _make_helm_manifest(standalone=True) + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) + catalog = build_catalog_payload(translation) + resource_requirements = catalog.service_deployment_units[0].resource_requirements + assert resource_requirements.standalone is True + assert not hasattr(resource_requirements.compute, "standalone") + def test_spec_ref_is_app_id(self) -> None: manifest = _make_helm_manifest() translation = build_app_registration_translation( diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 32606e4..194fdde 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -382,6 +382,26 @@ class TestSubmitApp: assert payload["service_specification"]["id"] == str(stored.app_registration_id) assert payload["service_specification"]["id"] != str(APP_ID) + async def test_catalog_payload_omits_unset_fields_instead_of_sending_null( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + ) -> None: + # SRM rejects unknown/null fields outright; unset optional fields must be + # absent from the JSON body, not present with a `null` value. + await service.submit_app( + manifest=_make_manifest(), + app_id=APP_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + call_kwargs = srm_client.create_catalog_service_specification.call_args.kwargs + payload = call_kwargs["payload"] + resource_requirements = payload["service_deployment_units"][0]["resource_requirements"] + assert "accelerator" not in resource_requirements["compute"] + assert "standalone" in resource_requirements + assert "standalone" not in resource_requirements["compute"] + async def test_raises_when_srm_confirms_a_different_id( self, service: EdgeApplicationManagementService, srm_client: AsyncMock ) -> None: -- GitLab From 526c1891195cdeccf5f378d1eb2208bd5fb5f018 Mon Sep 17 00:00:00 2001 From: dgogos Date: Fri, 31 Jul 2026 10:12:10 +0300 Subject: [PATCH 30/36] fix: update parameter names in get_apps method for clarity --- src/open_exposure_gateway/adapters/http/srm_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index 85d317d..f3fad30 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -188,9 +188,9 @@ class SRMClient: ) -> list[SRMServiceInstance]: params: dict[str, Any] = {} if app_id is not None: - params["appId"] = str(app_id) + params["service_specification_id"] = str(app_id) if app_instance_id is not None: - params["appInstanceId"] = str(app_instance_id) + params["service_instance_id"] = str(app_instance_id) if region is not None: params["region"] = region headers = {"x-correlator": x_correlator} if x_correlator else None -- GitLab From 290526c9a79c135b2556b53851569c6e86d1f1c1 Mon Sep 17 00:00:00 2001 From: dgogos Date: Fri, 31 Jul 2026 11:18:53 +0300 Subject: [PATCH 31/36] feat: enhance CloudEvent ID type to string --- pyproject.toml | 1 + .../vwip/common/CAMARA_event_common.yaml | 650 ++++++++++++++++++ .../mappers/edge_application_mapper.py | 2 +- .../domain/edge_application_management.py | 11 +- tests/unit/test_callback_client.py | 2 +- tests/unit/test_vendored_spec_refs.py | 70 ++ uv.lock | 2 + 7 files changed, 729 insertions(+), 9 deletions(-) create mode 100644 src/open_exposure_gateway/api/camara/edge_application_management/vwip/common/CAMARA_event_common.yaml create mode 100644 tests/unit/test_vendored_spec_refs.py diff --git a/pyproject.toml b/pyproject.toml index e06e922..24677a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ "pytest>=9.0.2", "pytest-asyncio>=0.24", "pytest-cov>=6.0.0", + "pyyaml>=6.0.1", "ruff>=0.15.6", "schemathesis>=4.22", "testcontainers>=4.0.0", diff --git a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/common/CAMARA_event_common.yaml b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/common/CAMARA_event_common.yaml new file mode 100644 index 0000000..42e72db --- /dev/null +++ b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/common/CAMARA_event_common.yaml @@ -0,0 +1,650 @@ +info: + title: CAMARA common event and subscription data types + description: | + Common data types for CAMARA event notification and subscription management. + This file contains Commonalities-owned schemas that are identical across all + CAMARA APIs supporting event notifications and/or explicit subscriptions. + + API repositories place this file in `code/common/` alongside `CAMARA_common.yaml` + and reference schemas via `$ref: "../common/CAMARA_event_common.yaml#/components/schemas/"`. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: wip + x-camara-commonalities: 0.8.0 + +components: + securitySchemes: + notificationsBearerAuth: + type: http + scheme: bearer + bearerFormat: "{$request.body#/sinkCredential.credentialType}" + description: | + Bearer token for notification delivery. Token format is determined + by `sinkCredential.credentialType` in the subscription request. + + schemas: + + # ───────────────────────────────────────────────────────────────────────── + # Section 1: CloudEvents 1.0 envelope + # + # Pure CloudEvents 1.0 specification envelope. Knows nothing about CAMARA + # event types, data payloads, or discriminator mappings. Any CAMARA API + # that needs to send a notification starts here. + # ───────────────────────────────────────────────────────────────────────── + + CloudEvent: + type: object + description: | + CloudEvents 1.0 specification envelope. + This schema is the stable base for all CAMARA event notifications. + It imposes no constraints on `type` values or `data` structure — + those concerns belong to the API-specific and lifecycle group schemas. + required: + - id + - source + - specversion + - type + - time + properties: + id: + type: string + maxLength: 256 + description: Identifier of this event, unique within the source context. + source: + $ref: "#/components/schemas/Source" + type: + type: string + maxLength: 512 + description: | + Identifies the event type. CAMARA APIs use reverse-DNS notation: + `org.camaraproject...` + The api-name segment makes each type globally unique across API groups. + specversion: + type: string + description: Version of the specification to which this event conforms (must be 1.0 if it conforms to cloudevents 1.0.2 version) + enum: + - "1.0" + datacontenttype: + type: string + description: 'media-type that describes the event payload encoding, must be "application/json" for CAMARA APIs' + enum: + - application/json + data: + type: object + description: Event details payload. Structure is defined by each concrete event schema. + time: + $ref: "CAMARA_common.yaml#/components/schemas/DateTime" + + Source: + type: string + format: uri-reference + minLength: 1 + maxLength: 2048 + description: | + Identifies the context in which an event happened - be a non-empty `URI-reference` like: + - URI with a DNS authority: + * https://github.com/cloudevents + * mailto:cncf-wg-serverless@lists.cncf.io + - Universally-unique URN with a UUID: + * urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66 + - Application-specific identifier: + * /cloudevents/spec/pull/123 + * 1-555-123-4567 + example: "https://notificationSendServer12.example.com" + + # ───────────────────────────────────────────────────────────────────────── + # Section 2: Subscription management + # + # Configuration and identification schemas used by the subscription + # management endpoints. These are Commonalities-owned and identical + # across all CAMARA APIs that support explicit subscriptions. + # ───────────────────────────────────────────────────────────────────────── + + SubscriptionId: + type: string + maxLength: 256 + description: The unique identifier of the subscription in the scope of the subscription manager. When this information is contained within an event notification, it SHALL be referred to as `subscriptionId` as per the Commonalities Event Notification Model. + example: qs15-h556-rt89-1298 + + Config: + description: | + Implementation-specific configuration parameters needed by the subscription manager for acquiring events. + In CAMARA we have predefined attributes like `subscriptionExpireTime`, `subscriptionMaxEvents`, `initialEvent` + Specific event type attributes must be defined in `subscriptionDetail`. + Note: if a request is performed for several event types, all subscribed events will use same `config` parameters. + type: object + required: + - subscriptionDetail + properties: + subscriptionDetail: + $ref: "#/components/schemas/CreateSubscriptionDetail" + subscriptionExpireTime: + type: string + format: date-time + maxLength: 64 + example: 2023-01-17T13:18:23.682Z + description: The subscription expiration time (in date-time format) requested by the API consumer. It must follow [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6) and must have time zone. Up to API project decision to keep it. + subscriptionMaxEvents: + type: integer + format: int32 + description: Identifies the maximum number of event reports to be generated (>=1) requested by the API consumer - Once this number is reached, the subscription ends. Up to API project decision to keep it. + minimum: 1 + maximum: 1000000 + example: 5 + initialEvent: + type: boolean + description: | + Set to `true` by API consumer if consumer wants to get an event as soon as the subscription is created and current situation reflects event request. + Example: Consumer request Roaming event. If consumer sets initialEvent to true and device is in roaming situation, an event is triggered + Up to API project decision to keep it. + + CreateSubscriptionDetail: + description: The detail of the requested event subscription. + type: object + + # ───────────────────────────────────────────────────────────────────────── + # Section 3: Protocol support + # + # Protocol selection and protocol-specific delivery settings. + # These are Commonalities-owned and identical across all CAMARA APIs. + # ───────────────────────────────────────────────────────────────────────── + + Protocol: + type: string + enum: + - HTTP + # Future protocol support (not yet used in CAMARA): + # - MQTT3 + # - MQTT5 + # - AMQP + # - NATS + # - KAFKA + description: Identifier of a delivery protocol. Only HTTP is allowed for now + example: "HTTP" + + HTTPSettings: + type: object + description: HTTP protocol settings for event delivery. + properties: + headers: + type: object + description: |- + A set of key/value pairs that is copied into the HTTP request as custom headers. + + NOTE: Use/Applicability of this concept has not been discussed in Commonalities. When required by an API project as an option to meet a UC/Requirement, please generate an issue for Commonalities discussion about it. + additionalProperties: + type: string + maxLength: 512 + method: + type: string + description: The HTTP method to use for sending the message. + enum: + - POST + + # Future protocol support (not yet used in CAMARA): + # MQTTSettings: + # type: object + # properties: + # topicName: + # type: string + # maxLength: 256 + # description: MQTT topic name + # qos: + # type: integer + # format: int32 + # minimum: 0 + # maximum: 2 + # description: Quality of Service level (0, 1, or 2) + # retain: + # type: boolean + # expiry: + # type: integer + # format: int32 + # minimum: 0 + # maximum: 2147483647 + # description: Message expiry interval in seconds + # userProperties: + # type: object + # required: + # - topicName + + # AMQPSettings: + # type: object + # properties: + # address: + # type: string + # maxLength: 512 + # linkName: + # type: string + # maxLength: 256 + # senderSettlementMode: + # type: string + # enum: ["settled", "unsettled"] + # linkProperties: + # type: object + # additionalProperties: + # type: string + # maxLength: 1024 + + # ApacheKafkaSettings: + # type: object + # properties: + # topicName: + # type: string + # maxLength: 249 + # partitionKeyExtractor: + # type: string + # maxLength: 512 + # clientId: + # type: string + # maxLength: 256 + # ackMode: + # type: integer + # format: int32 + # minimum: 0 + # maximum: 2 + # description: Acknowledgment mode (0=no ack, 1=leader ack, 2=all replicas ack) + # required: + # - topicName + + # NATSSettings: + # type: object + # properties: + # subject: + # type: string + # maxLength: 256 + # description: NATS subject + # required: + # - subject + + # ───────────────────────────────────────────────────────────────────────── + # Section 4: Sink credentials + # + # Authentication and authorization information for event delivery. + # These are Commonalities-owned and identical across all CAMARA APIs. + # ───────────────────────────────────────────────────────────────────────── + + SinkCredential: + description: A sink credential provides authentication or authorization information necessary to enable delivery of events to a target. + type: object + properties: + credentialType: + type: string + enum: + # - PLAIN # not used in CAMARA + - ACCESSTOKEN + - PRIVATE_KEY_JWT + description: | + The type of the credential - MUST be set to ACCESSTOKEN or PRIVATE_KEY_JWT for now + discriminator: + propertyName: credentialType + mapping: + # PLAIN: "#/components/schemas/PlainCredential" # not used in CAMARA + ACCESSTOKEN: "#/components/schemas/AccessTokenCredential" + PRIVATE_KEY_JWT: "#/components/schemas/PrivateKeyJWTCredential" + required: + - credentialType + + # PlainCredential: # not used in CAMARA + # type: object + # description: A plain credential as a combination of an identifier and a secret. + # allOf: + # - $ref: "#/components/schemas/SinkCredential" + # - type: object + # required: + # - identifier + # - secret + # properties: + # identifier: + # description: The identifier might be an account or username. + # type: string + # maxLength: 256 + # secret: + # description: The secret might be a password or passphrase. + # type: string + # maxLength: 512 + + AccessTokenCredential: + type: object + description: An access token credential. This type of credential is meant to be used by API Consumers that have limited capabilities to handle authorization requests. + allOf: + - $ref: "#/components/schemas/SinkCredential" + - type: object + properties: + accessToken: + description: REQUIRED. An access token is a token granting access to the target resource. + type: string + maxLength: 4096 + writeOnly: true + accessTokenExpiresUtc: + type: string + format: date-time + maxLength: 64 + description: | + REQUIRED. An absolute (UTC) timestamp at which the token shall be considered expired. + In the case of an ACCESS_TOKEN_EXPIRED termination reason, implementation should notify the client before the expiration date. + If the access token is a JWT and registered "exp" (Expiration Time) claim is present, the two expiry times should match. + It must follow [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6) and must have time zone. + example: "2023-07-03T12:27:08.312Z" + accessTokenType: + description: REQUIRED. Type of the access token (See [OAuth 2.0](https://tools.ietf.org/html/rfc6749#section-7.1)). + type: string + writeOnly: true + enum: + - bearer + required: + - accessToken + - accessTokenExpiresUtc + - accessTokenType + + PrivateKeyJWTCredential: + type: object + description: Use PRIVATE_KEY_JWT to get an access token. This type of credential is to be used by clients that have an authorization server. + allOf: + - $ref: "#/components/schemas/SinkCredential" + - type: object + properties: + clientId: + description: The client ID used to authenticate when requesting an access token using PRIVATE_KEY_JWT. + type: string + maxLength: 128 + writeOnly: true + tokenUri: + description: The URI where to request an access token using PRIVATE_KEY_JWT. + type: string + format: uri + maxLength: 2048 + pattern: ^https:\/\/.+$ + writeOnly: true + jwksUri: + description: The URI used to request the public key to verify that the JWT assertion was signed by PRIVATE_KEY_JWT. + type: string + format: uri + maxLength: 2048 + pattern: ^https:\/\/.+$ + readOnly: true + + # ───────────────────────────────────────────────────────────────────────── + # Section 5: Subscription lifecycle data + # + # Data payload schemas for subscription lifecycle events. These define the + # `data` content of subscription-started, subscription-updated, and + # subscription-ended events. The lifecycle event wrappers (which contain + # api-name placeholders in their event type strings) stay in API templates. + # ───────────────────────────────────────────────────────────────────────── + + SubscriptionStarted: + description: Event detail structure for subscription started event + type: object + required: + - initiationReason + - subscriptionId + properties: + initiationReason: + $ref: "#/components/schemas/InitiationReason" + subscriptionId: + $ref: "#/components/schemas/SubscriptionId" + initiationDescription: + type: string + maxLength: 512 + description: Description of subscription initiation + + InitiationReason: + type: string + description: | + - SUBSCRIPTION_CREATED - Subscription created by API Server + enum: + - SUBSCRIPTION_CREATED + + SubscriptionUpdated: + description: Event detail structure for subscription updated event + type: object + required: + - updateReason + - subscriptionId + properties: + updateReason: + $ref: "#/components/schemas/UpdateReason" + subscriptionId: + $ref: "#/components/schemas/SubscriptionId" + updateDescription: + type: string + maxLength: 512 + description: Description of subscription update + + UpdateReason: + type: string + description: | + - SUBSCRIPTION_ACTIVE - API server transitioned subscription status to `ACTIVE` + - SUBSCRIPTION_INACTIVE - API server transitioned subscription status to `INACTIVE` + enum: + - SUBSCRIPTION_ACTIVE + - SUBSCRIPTION_INACTIVE + + SubscriptionEnded: + description: Event detail structure for subscription ended event + type: object + required: + - terminationReason + - subscriptionId + properties: + terminationReason: + $ref: "#/components/schemas/TerminationReason" + subscriptionId: + $ref: "#/components/schemas/SubscriptionId" + terminationDescription: + type: string + maxLength: 512 + description: Description of subscription termination + + TerminationReason: + type: string + description: | + - NETWORK_TERMINATED - API server stopped sending notification + - SUBSCRIPTION_EXPIRED - Subscription expire time (optionally set by the requester) has been reached + - MAX_EVENTS_REACHED - Maximum number of events (optionally set by the requester) has been reached + - ACCESS_TOKEN_EXPIRED - Access Token sinkCredential (optionally set by the requester with credential type `ACCESSTOKEN`) expiration time has been reached + - SUBSCRIPTION_DELETED - Subscription was deleted by the requester + enum: + - MAX_EVENTS_REACHED + - NETWORK_TERMINATED + - SUBSCRIPTION_EXPIRED + - ACCESS_TOKEN_EXPIRED + - SUBSCRIPTION_DELETED + + # ───────────────────────────────────────────────────────────────────────── + # Subscription-specific error responses + # + # These extend generic CAMARA error codes with subscription-specific codes. + # Commonalities-owned and identical across all APIs using explicit subscriptions. + # ───────────────────────────────────────────────────────────────────────── + + responses: + CreateSubscriptionBadRequest400: + description: Problem with the client request + headers: + x-correlator: + $ref: "CAMARA_common.yaml#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "CAMARA_common.yaml#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 400 + code: + enum: + - INVALID_ARGUMENT + - OUT_OF_RANGE + - INVALID_PROTOCOL + - INVALID_CREDENTIAL + - INVALID_TOKEN + - INVALID_SINK + examples: + GENERIC_400_INVALID_ARGUMENT: + description: Invalid Argument. Generic Syntax Exception + value: + status: 400 + code: INVALID_ARGUMENT + message: Client specified an invalid argument, request body or query param. + GENERIC_400_OUT_OF_RANGE: + description: Out of Range. Specific Syntax Exception used when a given field has a pre-defined range or a invalid filter criteria combination is requested + value: + status: 400 + code: OUT_OF_RANGE + message: Client specified an invalid range. + GENERIC_400_INVALID_PROTOCOL: + description: Invalid protocol for events subscription management + value: + status: 400 + code: INVALID_PROTOCOL + message: Only HTTP is supported + GENERIC_400_INVALID_CREDENTIAL: + description: Invalid sink credential type + value: + status: 400 + code: INVALID_CREDENTIAL + message: Only Access token or Private key JWT are supported + GENERIC_400_INVALID_SINK: + description: Invalid sink value + value: + status: 400 + code: INVALID_SINK + message: sink not valid for the specified protocol + + SubscriptionIdRequired400: + description: Problem with the client request + headers: + x-correlator: + $ref: "CAMARA_common.yaml#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "CAMARA_common.yaml#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 400 + code: + enum: + - INVALID_ARGUMENT + examples: + GENERIC_400_INVALID_ARGUMENT: + description: Invalid Argument. Generic Syntax Exception + value: + status: 400 + code: INVALID_ARGUMENT + message: Client specified an invalid argument, request body or query param. + GENERIC_400_SUBSCRIPTION_ID_REQUIRED: + description: subscription id is required + value: + status: 400 + code: INVALID_ARGUMENT + message: "Expected property is missing: subscriptionId" + + SubscriptionPermissionDenied403: + description: Client does not have sufficient permission + headers: + x-correlator: + $ref: "CAMARA_common.yaml#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "CAMARA_common.yaml#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 403 + code: + enum: + - PERMISSION_DENIED + - SUBSCRIPTION_MISMATCH + examples: + GENERIC_403_PERMISSION_DENIED: + description: Permission denied. OAuth2 token access does not have the required scope or when the user fails operational security + value: + status: 403 + code: PERMISSION_DENIED + message: Client does not have sufficient permissions to perform this action. + GENERIC_403_SUBSCRIPTION_MISMATCH: + description: Inconsistent access token for requested subscription + value: + status: 403 + code: "SUBSCRIPTION_MISMATCH" + message: "Inconsistent access token for requested events subscription" + + CreateSubscriptionUnprocessableEntity422: + description: Unprocessable Entity + headers: + x-correlator: + $ref: "CAMARA_common.yaml#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "CAMARA_common.yaml#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 422 + code: + enum: + - SERVICE_NOT_APPLICABLE + - MISSING_IDENTIFIER + - UNSUPPORTED_IDENTIFIER + - UNNECESSARY_IDENTIFIER + - MULTIEVENT_SUBSCRIPTION_NOT_SUPPORTED + - MULTIEVENT_COMBINATION_TEMPORARILY_NOT_SUPPORTED + - PRIVATE_KEY_JWT_NOT_CONFIGURED + examples: + GENERIC_422_SERVICE_NOT_APPLICABLE: + description: Service not applicable for the provided identifier + value: + status: 422 + code: SERVICE_NOT_APPLICABLE + message: The service is not available for the provided identifier. + GENERIC_422_MISSING_IDENTIFIER: + description: An identifier is not included in the request and the device or phone number identification cannot be derived from the 3-legged access token + value: + status: 422 + code: MISSING_IDENTIFIER + message: The device cannot be identified. + GENERIC_422_UNSUPPORTED_IDENTIFIER: + description: None of the provided identifiers is supported by the implementation + value: + status: 422 + code: UNSUPPORTED_IDENTIFIER + message: The identifier provided is not supported. + GENERIC_422_UNNECESSARY_IDENTIFIER: + description: An explicit identifier is provided when a device or phone number has already been identified from the access token + value: + status: 422 + code: UNNECESSARY_IDENTIFIER + message: The device is already identified by the access token. + GENERIC_422_MULTIEVENT_SUBSCRIPTION_NOT_SUPPORTED: + description: Multi event types subscription is not supported + value: + status: 422 + code: MULTIEVENT_SUBSCRIPTION_NOT_SUPPORTED + message: Multi event types subscription not managed + GENERIC_422_MULTIEVENT_COMBINATION_TEMPORARILY_NOT_SUPPORTED: + description: Combination of multiple event types is temporarily not supported + value: + status: 422 + code: MULTIEVENT_COMBINATION_TEMPORARILY_NOT_SUPPORTED + message: The requested combination of event types is temporarily not supported. + GENERIC_422_PRIVATE_KEY_JWT_NOT_CONFIGURED: + description: Private key JWT sink credential type is used but no configuration was pre-shared + value: + status: 422 + code: PRIVATE_KEY_JWT_NOT_CONFIGURED + message: No JWK Set configured for PRIVATE_KEY_JWT authentication. diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index b4b0134..faae425 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -646,7 +646,7 @@ def build_app_instance_status_change_event( occurred_at: str, ) -> AppInstanceStatusChangeCloudEvent: return AppInstanceStatusChangeCloudEvent( - id=uuid4(), + id=str(uuid4()), source=_CALLBACK_EVENT_SOURCE, time=occurred_at, data=AppInstanceStatusChangeData( diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index 300ef97..6e10241 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -333,13 +333,10 @@ class AppInstanceStatusChangeData(BaseModel): class AppInstanceStatusChangeCloudEvent(BaseModel): - # CloudEvents v1.0 attributes, per the vendored spec's onAppInstanceStatusChange - # callback (ADR-0008). The referenced ../common/CAMARA_event_common.yaml - # defining the shared CloudEvent schema is not itself vendored into this repo; - # this shape is transcribed from the worked example in - # architecture/oeg/app-instance-flow.md instead. - id: UUID - source: str + # CloudEvents v1.0 attributes, per the vendored CAMARA_event_common.yaml + # CloudEvent schema (../common/CAMARA_event_common.yaml). + id: str = Field(max_length=256) + source: str = Field(min_length=1, max_length=2048) specversion: str = "1.0" type: str = "org.camaraproject.edge-application-management.v0.app-instance-status-change" time: str diff --git a/tests/unit/test_callback_client.py b/tests/unit/test_callback_client.py index 211bb8c..94d67a3 100644 --- a/tests/unit/test_callback_client.py +++ b/tests/unit/test_callback_client.py @@ -23,7 +23,7 @@ HttpHandler = Callable[[httpx.Request], httpx.Response] SINK = "https://consumer.example.com/callbacks" EVENT = AppInstanceStatusChangeCloudEvent( - id=uuid4(), + id=str(uuid4()), source="oeg", time="2026-07-29T00:00:00Z", data=AppInstanceStatusChangeData( diff --git a/tests/unit/test_vendored_spec_refs.py b/tests/unit/test_vendored_spec_refs.py new file mode 100644 index 0000000..67f22d5 --- /dev/null +++ b/tests/unit/test_vendored_spec_refs.py @@ -0,0 +1,70 @@ +"""Every $ref in the vendored CAMARA EAM spec must resolve. + +Schemathesis's schema.parametrize() only walks path operations, not +`components.callbacks` — so a dangling $ref inside a callback body schema +(the onAppInstanceStatusChange CloudEvent, referencing an external file that +wasn't vendored in) was never dereferenced and never failed conformance. +This test walks every $ref in the spec, including callbacks, and fails on +any target file or JSON pointer that doesn't resolve. +""" + +from pathlib import Path +from typing import Any + +import yaml + +SPEC = ( + Path(__file__).parents[2] + / "src" + / "open_exposure_gateway" + / "api" + / "camara" + / "edge_application_management" + / "vwip" + / "API_definitions" + / "edge-application-management.yaml" +) + + +def _load(path: Path) -> Any: + return yaml.safe_load(path.read_text()) + + +def _resolve_pointer(document: Any, pointer: str, path: Path) -> Any: + node = document + segments = pointer.strip("/").split("/") if pointer.strip("/") else [] + for raw_segment in segments: + segment = raw_segment.replace("~1", "/").replace("~0", "~") + if isinstance(node, list): + node = node[int(segment)] + else: + assert segment in node, f"{path}: $ref pointer {pointer!r} has no segment {segment!r}" + node = node[segment] + return node + + +def _walk(node: Any, path: Path, document: Any, seen: set[tuple[Path, str]]) -> None: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str): + file_part, _, pointer = ref.partition("#") + target_path = (path.parent / file_part).resolve() if file_part else path + assert target_path.is_file(), f"{path}: $ref {ref!r} points at a missing file" + key = (target_path, pointer) + if key in seen: + return + seen.add(key) + target_document = document if target_path == path else _load(target_path) + resolved = _resolve_pointer(target_document, pointer, target_path) + _walk(resolved, target_path, target_document, seen) + return + for value in node.values(): + _walk(value, path, document, seen) + elif isinstance(node, list): + for item in node: + _walk(item, path, document, seen) + + +def test_every_ref_in_the_vendored_eam_spec_resolves() -> None: + document = _load(SPEC) + _walk(document, SPEC, document, set()) diff --git a/uv.lock b/uv.lock index 21708ed..416f557 100644 --- a/uv.lock +++ b/uv.lock @@ -713,6 +713,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "schemathesis" }, { name = "testcontainers" }, @@ -731,6 +732,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.6" }, { name = "schemathesis", marker = "extra == 'dev'", specifier = ">=4.22" }, { name = "sqlalchemy", specifier = ">=2.0.48" }, -- GitLab From 20219328b91f49e1db05857b25826e7182a59091 Mon Sep 17 00:00:00 2001 From: dgogos Date: Fri, 31 Jul 2026 11:33:12 +0300 Subject: [PATCH 32/36] fix: update build_app_instance_info to include zone_id and adjust related logic in EdgeApplicationManagementService and tests --- .../mappers/edge_application_mapper.py | 6 ++- .../edge_application_management_service.py | 18 +++++++- tests/unit/test_eam_mapper.py | 20 ++++----- tests/unit/test_eam_service.py | 43 ++++++++++++++++++- 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index faae425..b54dbd5 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -291,7 +291,9 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: ) -def build_app_instance_info(instance: SRMServiceInstance, app_id: UUID) -> AppInstanceInfo: +def build_app_instance_info( + instance: SRMServiceInstance, app_id: UUID, zone_id: UUID +) -> AppInstanceInfo: status = _SRM_STATE_TO_APP_INSTANCE_STATUS.get(instance.state, AppInstanceStatus.UNKNOWN) endpoint_info: list[ComponentEndpointInfo] = [] @@ -315,7 +317,7 @@ def build_app_instance_info(instance: SRMServiceInstance, app_id: UUID) -> AppIn appId=app_id, appProvider=instance.app_provider_id, status=status, - edgeCloudZoneId=UUID(instance.zone_id) if instance.zone_id else UUID(int=0), + edgeCloudZoneId=zone_id, componentEndpointInfo=endpoint_info or None, ) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index 13e20a1..e097d4e 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -464,7 +464,23 @@ class EdgeApplicationManagementService: app_registration_id=instance.service_specification_id, ) continue - result.append(build_app_instance_info(instance, instance_app_id)) + + zone_id = UUID(instance.zone_id) if instance.zone_id else None + if zone_id is None: + if self._app_instance_repo is None: + raise RuntimeError("AppInstanceRepository is not available") + local_instance = await self._app_instance_repo.get_by_id( + UUID(instance.service_instance_id) + ) + if local_instance is None: + logger.warning( + "app_instance_zone_unresolvable", + app_instance_id=instance.service_instance_id, + ) + continue + zone_id = local_instance.edge_cloud_zone_id + + result.append(build_app_instance_info(instance, instance_app_id, zone_id)) return result async def delete_app_instance( diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index 0d93708..fd43aab 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -339,34 +339,34 @@ class TestBuildAppInstanceInfo: ) def test_maps_ids_and_provider(self) -> None: - result = build_app_instance_info(self._make_instance(), APP_ID) + result = build_app_instance_info(self._make_instance(), APP_ID, ZONE_ID) assert result.appInstanceId == INSTANCE_ID assert result.appId == APP_ID assert result.appProvider == "VideoAppsCo" assert result.edgeCloudZoneId == ZONE_ID def test_state_mapping_active_to_ready(self) -> None: - result = build_app_instance_info(self._make_instance(state="active"), APP_ID) + result = build_app_instance_info(self._make_instance(state="active"), APP_ID, ZONE_ID) assert result.status == AppInstanceStatus.READY def test_state_mapping_creating_to_instantiating(self) -> None: - result = build_app_instance_info(self._make_instance(state="creating"), APP_ID) + result = build_app_instance_info(self._make_instance(state="creating"), APP_ID, ZONE_ID) assert result.status == AppInstanceStatus.INSTANTIATING def test_state_mapping_failed_to_failed(self) -> None: - result = build_app_instance_info(self._make_instance(state="failed"), APP_ID) + result = build_app_instance_info(self._make_instance(state="failed"), APP_ID, ZONE_ID) assert result.status == AppInstanceStatus.FAILED def test_state_mapping_terminating(self) -> None: - result = build_app_instance_info(self._make_instance(state="terminating"), APP_ID) + result = build_app_instance_info(self._make_instance(state="terminating"), APP_ID, ZONE_ID) assert result.status == AppInstanceStatus.TERMINATING def test_state_mapping_terminated_to_unknown(self) -> None: - result = build_app_instance_info(self._make_instance(state="terminated"), APP_ID) + result = build_app_instance_info(self._make_instance(state="terminated"), APP_ID, ZONE_ID) assert result.status == AppInstanceStatus.UNKNOWN def test_unknown_state_defaults_to_unknown(self) -> None: - result = build_app_instance_info(self._make_instance(state="exotic"), APP_ID) + result = build_app_instance_info(self._make_instance(state="exotic"), APP_ID, ZONE_ID) assert result.status == AppInstanceStatus.UNKNOWN def test_endpoints_extracted_from_capability_instances(self) -> None: @@ -383,7 +383,7 @@ class TestBuildAppInstanceInfo: ), ) ] - result = build_app_instance_info(instance, APP_ID) + result = build_app_instance_info(instance, APP_ID, ZONE_ID) assert result.componentEndpointInfo is not None assert len(result.componentEndpointInfo) == 1 ep = result.componentEndpointInfo[0] @@ -392,13 +392,13 @@ class TestBuildAppInstanceInfo: assert ep.accessPoints.port == 80 def test_no_endpoints_gives_none(self) -> None: - result = build_app_instance_info(self._make_instance(), APP_ID) + result = build_app_instance_info(self._make_instance(), APP_ID, ZONE_ID) assert result.componentEndpointInfo is None def test_name_falls_back_to_instance_id_when_missing(self) -> None: instance = self._make_instance() instance.name = None - result = build_app_instance_info(instance, APP_ID) + result = build_app_instance_info(instance, APP_ID, ZONE_ID) assert result.name == str(INSTANCE_ID) diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 194fdde..3c8df12 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -130,13 +130,15 @@ def _make_srm_catalog() -> SRMCatalogPayload: ) -def _make_srm_instance(state: str = "active") -> SRMServiceInstance: +def _make_srm_instance( + state: str = "active", zone_id: str | None = str(ZONE_ID) +) -> SRMServiceInstance: return SRMServiceInstance( service_instance_id=str(INSTANCE_ID), service_specification_id=str(APP_REGISTRATION_ID), state=state, app_provider_id="VideoAppsCo", - zone_id=str(ZONE_ID), + zone_id=zone_id, name="myvideoapp_inst", capability_instances=[], ) @@ -1257,6 +1259,43 @@ class TestGetAppInstances: assert result == [] srm_client.get_app_instances.assert_not_called() + async def test_falls_back_to_local_zone_when_srm_zone_missing( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + await _seed_registration(app_registration_repo) + await app_instance_repo.save( + AppInstance( + app_instance_id=INSTANCE_ID, + operation_id=uuid4(), + app_registration_id=APP_REGISTRATION_ID, + edge_cloud_zone_id=ZONE_ID, + state=AppInstanceState.READY, + ) + ) + srm_client.get_app_instances.return_value = [ + _make_srm_instance(state="active", zone_id=None) + ] + result = await service.get_app_instances() + assert len(result) == 1 + assert result[0].edgeCloudZoneId == ZONE_ID + + async def test_skips_instance_when_zone_unresolvable_locally_too( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + app_registration_repo: FakeAppRegistrationRepository, + ) -> None: + await _seed_registration(app_registration_repo) + srm_client.get_app_instances.return_value = [ + _make_srm_instance(state="active", zone_id=None) + ] + result = await service.get_app_instances() + assert result == [] + class TestDeleteAppInstance: @pytest.fixture(autouse=True) -- GitLab From 2daa37a9d18d5de4387d71a98ce6411d0729cf8e Mon Sep 17 00:00:00 2001 From: dgogos Date: Fri, 31 Jul 2026 11:48:15 +0300 Subject: [PATCH 33/36] feat: add kubernetesClusterRef to AppInstanceInfo and update related logic in build_app_instance_info --- .../mappers/edge_application_mapper.py | 4 ++++ .../domain/edge_application_management.py | 1 + tests/unit/test_eam_mapper.py | 17 +++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index b54dbd5..8977b12 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -297,6 +297,7 @@ def build_app_instance_info( status = _SRM_STATE_TO_APP_INSTANCE_STATUS.get(instance.state, AppInstanceStatus.UNKNOWN) endpoint_info: list[ComponentEndpointInfo] = [] + kubernetes_cluster_ref: UUID | None = None for cap in instance.capability_instances: if cap.result_summary and cap.result_summary.endpoints: for ep in cap.result_summary.endpoints: @@ -310,6 +311,8 @@ def build_app_instance_info( ), ) ) + if cap.kind == "deploy_workload" and cap.control_path_binding_id: + kubernetes_cluster_ref = UUID(cap.control_path_binding_id) return AppInstanceInfo( appInstanceId=UUID(instance.service_instance_id), @@ -319,6 +322,7 @@ def build_app_instance_info( status=status, edgeCloudZoneId=zone_id, componentEndpointInfo=endpoint_info or None, + kubernetesClusterRef=kubernetes_cluster_ref, ) diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index 6e10241..5eae701 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -312,6 +312,7 @@ class SRMCapabilityInstanceSummary(BaseModel): capability_instance_id: str kind: str external_ref: str | None = None + control_path_binding_id: str | None = None result_summary: SRMResultSummary | None = None diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index fd43aab..64e8fac 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -395,6 +395,23 @@ class TestBuildAppInstanceInfo: result = build_app_instance_info(self._make_instance(), APP_ID, ZONE_ID) assert result.componentEndpointInfo is None + def test_kubernetes_cluster_ref_from_deploy_workload_binding(self) -> None: + binding_id = "642f6105-7015-4af1-a4d1-e1ecb8437abc" + instance = self._make_instance() + instance.capability_instances = [ + SRMCapabilityInstanceSummary( + capability_instance_id="cap-1", + kind="deploy_workload", + control_path_binding_id=binding_id, + ) + ] + result = build_app_instance_info(instance, APP_ID, ZONE_ID) + assert result.kubernetesClusterRef == UUID(binding_id) + + def test_no_deploy_workload_binding_gives_none(self) -> None: + result = build_app_instance_info(self._make_instance(), APP_ID, ZONE_ID) + assert result.kubernetesClusterRef is None + def test_name_falls_back_to_instance_id_when_missing(self) -> None: instance = self._make_instance() instance.name = None -- GitLab From b1caa8aa3d078328aeb3f2bf7d13b04161287674 Mon Sep 17 00:00:00 2001 From: dgogos Date: Fri, 31 Jul 2026 12:05:00 +0300 Subject: [PATCH 34/36] refactor: enforce strict model configuration in schemas and improve error handling in app manifest building --- .../vwip/schemas.py | 36 ++++++++++++++++- .../mappers/edge_application_mapper.py | 11 +++-- .../edge_application_management_service.py | 40 +++++++------------ tests/unit/test_eam_flows.py | 26 +++++++----- tests/unit/test_eam_mapper.py | 25 ++++++++++++ tests/unit/test_eam_service.py | 38 ++++++++++++++++++ 6 files changed, 135 insertions(+), 41 deletions(-) diff --git a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/schemas.py b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/schemas.py index d09fbdd..304b7fe 100644 --- a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/schemas.py +++ b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/schemas.py @@ -3,7 +3,7 @@ from enum import StrEnum from typing import Any, Literal, Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class AppInstanceStatus(StrEnum): @@ -33,6 +33,8 @@ class SubmittedApp(BaseModel): class AppRepo(BaseModel): + model_config = ConfigDict(extra="forbid") + type: Literal["PRIVATEREPO", "PUBLICREPO"] imagePath: str = Field(max_length=2048) userName: Optional[str] = Field(default=None, max_length=64) @@ -42,6 +44,8 @@ class AppRepo(BaseModel): class OperatingSystem(BaseModel): + model_config = ConfigDict(extra="forbid") + architecture: Literal["x86_64", "x86"] family: Literal["RHEL", "UBUNTU", "COREOS", "WINDOWS", "OTHER"] version: Literal[ @@ -54,6 +58,8 @@ class OperatingSystem(BaseModel): class NetworkInterface(BaseModel): + model_config = ConfigDict(extra="forbid") + interfaceId: str = Field( min_length=4, max_length=32, @@ -65,41 +71,55 @@ class NetworkInterface(BaseModel): class ComponentSpecItem(BaseModel): + model_config = ConfigDict(extra="forbid") + componentName: str = Field(max_length=64) networkInterfaces: list[NetworkInterface] class VmResources(BaseModel): + model_config = ConfigDict(extra="forbid") + infraKind: Literal["virtualMachine"] numCPU: int = Field(ge=1, le=256) memory: int = Field(ge=1, le=32768) class ContainerResources(BaseModel): + model_config = ConfigDict(extra="forbid") + infraKind: Literal["container"] numCPU: str = Field(pattern=r"^\d+((\.\d{1,3})|(m))?$") memory: int = Field(ge=1, le=16384) class DockerComposeResources(BaseModel): + model_config = ConfigDict(extra="forbid") + infraKind: Literal["dockerCompose"] numCPU: int = Field(ge=1, le=256) memory: int = Field(ge=1, le=16384) class CpuPoolTopology(BaseModel): + model_config = ConfigDict(extra="forbid") + minNumberOfNodes: int = Field(ge=1, le=1000) minNodeCpu: int = Field(ge=1, le=256) minNodeMemory: int = Field(ge=1, le=16384) class CpuPool(BaseModel): + model_config = ConfigDict(extra="forbid") + numCPU: int = Field(ge=1, le=256) memory: int = Field(ge=1, le=16384) topology: CpuPoolTopology class GpuPoolTopology(BaseModel): + model_config = ConfigDict(extra="forbid") + minNumberOfNodes: int = Field(ge=1, le=1000) minNodeCpu: int = Field(ge=1, le=256) minNodeMemory: int = Field(ge=1, le=16384) @@ -107,6 +127,8 @@ class GpuPoolTopology(BaseModel): class GpuPool(BaseModel): + model_config = ConfigDict(extra="forbid") + numCPU: int = Field(ge=1, le=1024) memory: int = Field(ge=1, le=16384) gpuMemory: int = Field(ge=1, le=16) @@ -114,11 +136,15 @@ class GpuPool(BaseModel): class ApplicationResources(BaseModel): + model_config = ConfigDict(extra="forbid") + cpuPool: Optional[CpuPool] = None gpuPool: Optional[GpuPool] = None class KubernetesResources(BaseModel): + model_config = ConfigDict(extra="forbid") + infraKind: Literal["kubernetes"] applicationResources: ApplicationResources isStandalone: bool @@ -129,6 +155,8 @@ RequiredResources = VmResources | ContainerResources | DockerComposeResources | class AppManifest(BaseModel): + model_config = ConfigDict(extra="forbid") + appId: Optional[UUID] = None name: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$") appProvider: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{7,63}$") @@ -168,6 +196,8 @@ class AppInstanceInfo(BaseModel): class SubscriptionConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + subscriptionDetail: Optional[dict[str, Any]] = None subscriptionExpireTime: Optional[datetime] = None subscriptionMaxEvents: Optional[int] = None @@ -175,6 +205,8 @@ class SubscriptionConfig(BaseModel): class SubscriptionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + sink: str sinkCredential: Optional[dict[str, Any]] = None types: list[str] @@ -182,6 +214,8 @@ class SubscriptionRequest(BaseModel): class CreateAppInstanceRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$") appId: UUID edgeCloudZoneId: UUID diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index 8977b12..e59c26d 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -149,9 +149,14 @@ def build_edge_cloud_zone(srm_zone: SRMZone) -> EdgeCloudZone: def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: spec = catalog.service_specification - unit = catalog.service_deployment_units[0] - if unit.artifact_ref is None: - raise ValueError(f"deployment unit {unit.ref!r} has no artifact_ref") + unit = next( + (u for u in catalog.service_deployment_units if u.artifact_ref is not None), + None, + ) + if unit is None: + raise ValueError( + f"no deployment unit in catalog entry {spec.ref!r} has an artifact_ref" + ) try: app_id: Optional[UUID] = UUID(spec.ref) diff --git a/src/open_exposure_gateway/application/services/edge_application_management_service.py b/src/open_exposure_gateway/application/services/edge_application_management_service.py index e097d4e..a33f9c4 100644 --- a/src/open_exposure_gateway/application/services/edge_application_management_service.py +++ b/src/open_exposure_gateway/application/services/edge_application_management_service.py @@ -38,9 +38,7 @@ from open_exposure_gateway.core.exceptions import ( NotImplementedException, ) from open_exposure_gateway.domain.edge_application_management import ( - SRMCatalogPayload, SRMOperationCompleted, - SRMZone, Subject, ) from open_exposure_gateway.domain.models import ( @@ -140,13 +138,13 @@ class EdgeApplicationManagementService: status=status, x_correlator=x_correlator, ) - zones = [] - for zone in srm_zones: - try: - zones.append(build_edge_cloud_zone(zone)) - except (ValueError, TypeError) as exc: - self._log_skipped_entry("zone", zone, exc) - return zones + try: + return [build_edge_cloud_zone(zone) for zone in srm_zones] + except (ValueError, TypeError) as exc: + raise DownstreamServiceException( + message="SRM returned a malformed edge cloud zone", + details=str(exc), + ) from exc async def get_apps(self, x_correlator: Optional[str] = None) -> list[AppManifest]: # TODO: scope by caller.tenant_id/app_provider_id once JWT auth is wired in @@ -155,23 +153,13 @@ class EdgeApplicationManagementService: # tenant_id isn't in SRM's catalog response, so filtering must happen via # app_registration_repo, not srm_client.get_apps. catalogs = await self.srm_client.get_apps(x_correlator=x_correlator) - manifests = [] - for catalog in catalogs: - try: - manifests.append(build_app_manifest(catalog)) - except (ValueError, TypeError, IndexError) as exc: - self._log_skipped_entry("catalog entry", catalog, exc) - return manifests - - def _log_skipped_entry( - self, kind: str, entry: SRMZone | SRMCatalogPayload, exc: Exception - ) -> None: - logger.warning( - "unmappable_srm_entry_skipped", - kind=kind, - error=str(exc), - entry=entry.model_dump(mode="json"), - ) + try: + return [build_app_manifest(catalog) for catalog in catalogs] + except (ValueError, TypeError, IndexError) as exc: + raise DownstreamServiceException( + message="SRM returned a malformed service specification", + details=str(exc), + ) from exc async def get_app( self, app_id: UUID, x_correlator: Optional[str] = None diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index ccf025d..4ce7404 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -484,11 +484,13 @@ class TestGetAppsFlow: assert response.status_code == 200 assert len(response.json()) == 1 - def test_entry_without_deployment_units_does_not_break_listing( + def test_entry_without_deployment_units_fails_the_whole_listing( self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: - """One malformed catalog entry must not take down the whole listing: - the healthy entries must still be returned.""" + """CAMARA's 200 for getApps has no partial-success shape (bare array, + no metadata slot) and its description promises the complete list, so + a malformed catalog entry must surface as a downstream failure (503) + rather than silently shrinking the response.""" api_client.post(f"{EAM_BASE}/apps", json=_manifest_body(num_cpu=2)) broken_id = str(uuid4()) fake_srm.catalog[broken_id] = { @@ -505,8 +507,8 @@ class TestGetAppsFlow: } response = api_client.get(f"{EAM_BASE}/apps") - assert response.status_code == 200 - assert "myvideoapp" in [m["name"] for m in response.json()] + assert response.status_code == 503 + assert response.json()["code"] == "UNAVAILABLE" class TestGetAppFlow: @@ -557,12 +559,14 @@ class TestEdgeCloudZonesFlow: assert zone["edgeCloudZoneName"] == "berlin-edge-1" assert zone["edgeCloudZoneStatus"] == "active" - def test_one_malformed_zone_id_does_not_break_listing( + def test_one_malformed_zone_id_fails_the_whole_listing( self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: - """SRM's SRMZone model allows free-form string ids; one non-UUID id - must not turn the whole zone listing into a 500 — healthy zones must - still be returned.""" + """CAMARA's 200 for getEdgeCloudZones has no partial-success shape + (bare array, no metadata slot) and promises the Available Edge Cloud + Zones, so SRM's free-form string ids producing a non-UUID id must + surface as a downstream failure (503) rather than silently dropping + the zone from the list.""" fake_srm.zones.append( SRMZone( id=str(ZONE_ID), @@ -580,8 +584,8 @@ class TestEdgeCloudZonesFlow: ) ) response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") - assert response.status_code == 200 - assert str(ZONE_ID) in [z["edgeCloudZoneId"] for z in response.json()] + assert response.status_code == 503 + assert response.json()["code"] == "UNAVAILABLE" def test_x_correlator_is_echoed_on_success_responses( self, api_client: TestClient, fake_srm: FakeSRMClient diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index 64e8fac..2e3e0a8 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -197,6 +197,31 @@ class TestBuildEdgeCloudZone: class TestBuildAppManifest: + def test_skips_leading_units_without_artifact_ref(self) -> None: + """A catalog entry's artifact-bearing unit isn't always index 0; + earlier units (e.g. init containers, sidecars) may have no + artifact_ref of their own.""" + catalog = _make_srm_catalog() + sidecar = SRMDeploymentUnit( + ref="sidecar", + name="Sidecar", + runtime_kind="helm", + artifact_ref=None, + resource_requirements=SRMComputeIntent(), + ) + catalog.service_deployment_units.insert(0, sidecar) + + result = build_app_manifest(catalog) + assert result.appRepo.imagePath == "oci://registry.example.com/charts/app:1.0" + + def test_raises_when_no_unit_has_artifact_ref(self) -> None: + catalog = _make_srm_catalog() + for unit in catalog.service_deployment_units: + unit.artifact_ref = None + + with pytest.raises(ValueError, match="artifact_ref"): + build_app_manifest(catalog) + def test_helm_maps_app_id_and_metadata(self) -> None: catalog = _make_srm_catalog() result = build_app_manifest(catalog) diff --git a/tests/unit/test_eam_service.py b/tests/unit/test_eam_service.py index 3c8df12..f79e533 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -270,6 +270,30 @@ class TestGetEdgeCloudZones: region="eu-west", status="active", x_correlator="c-1" ) + async def test_raises_downstream_exception_for_malformed_zone( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + """One malformed SRM zone must fail the whole list, not silently + shrink it: CAMARA's 200 for getEdgeCloudZones has no partial-success + shape, so a bad entry is a downstream failure, not a skip.""" + srm_client.get_zones.return_value = [ + SRMZone( + id=str(ZONE_ID), + name="good-zone", + state="active", + metadata=SRMZoneMetadata(provider="acme"), + ), + SRMZone( + id="not-a-uuid", + name="bad-zone", + state="active", + metadata=SRMZoneMetadata(provider="acme"), + ), + ] + + with pytest.raises(DownstreamServiceException): + await service.get_edge_cloud_zones() + class TestGetApps: async def test_returns_camara_app_manifests( @@ -289,6 +313,20 @@ class TestGetApps: result = await service.get_apps() assert result == [] + async def test_raises_downstream_exception_for_unmappable_catalog_entry( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + """One malformed SRM entry must fail the whole list, not silently + shrink it: CAMARA's 200 for getApps has no partial-success shape, so + a bad entry is a downstream failure, not a skip.""" + good_catalog = _make_srm_catalog() + bad_catalog = _make_srm_catalog() + bad_catalog.service_deployment_units = [] + srm_client.get_apps.return_value = [good_catalog, bad_catalog] + + with pytest.raises(DownstreamServiceException): + await service.get_apps() + class TestGetApp: async def test_returns_app_manifest_envelope( -- GitLab From feba9f0920ea5af6e52fb6e4952349a203477b77 Mon Sep 17 00:00:00 2001 From: dgogos Date: Fri, 31 Jul 2026 12:10:01 +0300 Subject: [PATCH 35/36] fix: correct GPU memory calculations in build_app_manifest and build_catalog_payload functions --- .../application/mappers/edge_application_mapper.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index e59c26d..9f47dc1 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -193,7 +193,7 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: gpu_entry["numCPU"] = compute.cpu_millicores / 1000 if compute.memory_mb is not None: gpu_entry["memory"] = compute.memory_mb - gpu_entry["gpuMemory"] = compute.accelerator.memory_mb / 1024 + gpu_entry["gpuMemory"] = compute.accelerator.memory_mb / 1000 if topo: gpu_entry["topology"] = { k: v @@ -203,7 +203,7 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: if topo.min_node_cpu_millicores else None, "minNodeMemory": topo.min_node_memory_mb, - "minNodeGpuMemory": (topo.min_node_gpu_memory_mb / 1024) + "minNodeGpuMemory": (topo.min_node_gpu_memory_mb / 1000) if topo.min_node_gpu_memory_mb else None, }.items() @@ -483,7 +483,7 @@ def build_catalog_payload(translation: AppRegistrationTranslation) -> SRMCatalog if gp.memory is not None: memory_mb = gp.memory - accelerator_memory_mb = int(gp.gpu_memory * 1024) if gp.gpu_memory is not None else 0 + accelerator_memory_mb = int(gp.gpu_memory * 1000) if gp.gpu_memory is not None else 0 accelerator = SRMAccelerator( type="gpu", units=gp.num_gpu or 0, @@ -491,7 +491,7 @@ def build_catalog_payload(translation: AppRegistrationTranslation) -> SRMCatalog ) min_node_gpu_memory_mb = ( - int(gp.topology.min_node_gpu_memory * 1024) + int(gp.topology.min_node_gpu_memory * 1000) if gp.topology and gp.topology.min_node_gpu_memory is not None else None ) -- GitLab From bec5b473be2302906d5474fdd8b0c2cced0a6b21 Mon Sep 17 00:00:00 2001 From: dgogos Date: Fri, 31 Jul 2026 12:50:02 +0300 Subject: [PATCH 36/36] feat: add GPU and storage management to application resources and update related schemas --- .pre-commit-config.yaml | 2 +- pyproject.toml | 1 + .../vwip/schemas.py | 60 +++++- .../mappers/edge_application_mapper.py | 129 +++++++++++-- .../domain/edge_application_management.py | 20 ++ tests/unit/test_eam_mapper.py | 178 +++++++++++++++++- uv.lock | 11 ++ 7 files changed, 380 insertions(+), 21 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b465c65..a981f97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,4 +23,4 @@ repos: - id: mypy files: ^src/open_exposure_gateway/|^tests/ args: [--strict, --ignore-missing-imports, --cache-dir, .cache/mypy] - additional_dependencies: ["fastapi[standard]>=0.135.1", "pydantic>=2.0", "pydantic-settings>=2.0", "httpx>=0.27", "pytest>=9.0.2", "sqlalchemy>=2.0.48", "pytest-asyncio>=0.24", "testcontainers>=4.0.0"] #TODO, always add necessary + additional_dependencies: ["fastapi[standard]>=0.135.1", "pydantic>=2.0", "pydantic-settings>=2.0", "httpx>=0.27", "pytest>=9.0.2", "sqlalchemy>=2.0.48", "pytest-asyncio>=0.24", "testcontainers>=4.0.0", "types-PyYAML>=6.0.1"] diff --git a/pyproject.toml b/pyproject.toml index 24677a8..9396cfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "ruff>=0.15.6", "schemathesis>=4.22", "testcontainers>=4.0.0", + "types-PyYAML>=6.0.1", ] [tool.setuptools.packages.find] diff --git a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/schemas.py b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/schemas.py index 304b7fe..565af10 100644 --- a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/schemas.py +++ b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/schemas.py @@ -3,7 +3,7 @@ from enum import StrEnum from typing import Any, Literal, Optional from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class AppInstanceStatus(StrEnum): @@ -77,12 +77,32 @@ class ComponentSpecItem(BaseModel): networkInterfaces: list[NetworkInterface] +class GpuInfo(BaseModel): + model_config = ConfigDict(extra="forbid") + + gpuMemory: int = Field(ge=0, le=16384) + numGPU: int = Field(ge=0, le=16) + + +class AdditionalStorageItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(default=None, max_length=64) + storageSize: str = Field(max_length=32, pattern=r"^\d+(GB|MB)$") + mountPoint: str = Field(max_length=64) + + +AdditionalStorage = list[AdditionalStorageItem] + + class VmResources(BaseModel): model_config = ConfigDict(extra="forbid") infraKind: Literal["virtualMachine"] numCPU: int = Field(ge=1, le=256) memory: int = Field(ge=1, le=32768) + additionalStorages: Optional[AdditionalStorage] = Field(default=None, max_length=50) + gpu: Optional[GpuInfo] = None class ContainerResources(BaseModel): @@ -91,6 +111,8 @@ class ContainerResources(BaseModel): infraKind: Literal["container"] numCPU: str = Field(pattern=r"^\d+((\.\d{1,3})|(m))?$") memory: int = Field(ge=1, le=16384) + storage: Optional[AdditionalStorage] = Field(default=None, max_length=50) + gpu: Optional[GpuInfo] = None class DockerComposeResources(BaseModel): @@ -99,6 +121,8 @@ class DockerComposeResources(BaseModel): infraKind: Literal["dockerCompose"] numCPU: int = Field(ge=1, le=256) memory: int = Field(ge=1, le=16384) + storage: Optional[AdditionalStorage] = Field(default=None, max_length=50) + gpu: Optional[GpuInfo] = None class CpuPoolTopology(BaseModel): @@ -142,6 +166,30 @@ class ApplicationResources(BaseModel): gpuPool: Optional[GpuPool] = None +class K8sPrimaryNetwork(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider: Optional[str] = Field(default=None, max_length=64) + version: Optional[str] = Field(default=None, max_length=64) + + +class K8sAdditionalNetwork(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(default=None, max_length=64) + interfaceType: Optional[Literal["netdevice", "vfio-pci", "interface"]] = None + + +class K8sNetworking(BaseModel): + model_config = ConfigDict(extra="forbid") + + primaryNetwork: K8sPrimaryNetwork + additionalNetworks: Optional[list[K8sAdditionalNetwork]] = Field(default=None, max_length=100) + + +K8sAddon = Literal["monitoring", "ingress"] + + class KubernetesResources(BaseModel): model_config = ConfigDict(extra="forbid") @@ -149,6 +197,16 @@ class KubernetesResources(BaseModel): applicationResources: ApplicationResources isStandalone: bool additionalStorage: Optional[str] = Field(default=None, max_length=32, pattern=r"^\d+(GB|MB)$") + version: Optional[str] = Field(default=None, max_length=64) + networking: Optional[K8sNetworking] = None + addons: Optional[list[K8sAddon]] = Field(default=None, max_length=2) + + @field_validator("addons") + @classmethod + def _addons_unique(cls, v: Optional[list[str]]) -> Optional[list[str]]: + if v is not None and len(set(v)) != len(v): + raise ValueError("addons must be unique") + return v RequiredResources = VmResources | ContainerResources | DockerComposeResources | KubernetesResources diff --git a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py index 9f47dc1..5c4da10 100644 --- a/src/open_exposure_gateway/application/mappers/edge_application_mapper.py +++ b/src/open_exposure_gateway/application/mappers/edge_application_mapper.py @@ -19,12 +19,18 @@ from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas i SubmittedApp, VmResources, ) +from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( + AdditionalStorageItem as CamaraAdditionalStorageItem, +) from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( ApplicationResources as CamaraApplicationResources, ) from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( AppRepo as CamaraAppRepo, ) +from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( + GpuInfo as CamaraGpuInfo, +) from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( NetworkInterface as CamaraNetworkInterface, ) @@ -39,6 +45,8 @@ from open_exposure_gateway.domain.edge_application_management import ( CpuPool, CpuPoolTopology, GpuPool, + GpuRequest, + K8sClusterConfig, NetworkInterface, RequiredResources, SRMAccelerator, @@ -61,6 +69,7 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMTerminatePayload, SRMTopologyConstraints, SRMZone, + StorageRequest, ) from open_exposure_gateway.domain.models import AppInstance, AppInstanceState @@ -125,12 +134,65 @@ def _parse_storage_mb(value: str) -> int: raise ValueError(f"Cannot parse storage value: {value!r}") amount, unit = float(match.group(1)), match.group(2).upper() if unit == "TB": - return int(amount * 1024 * 1024) + return int(amount * 1000 * 1000) if unit == "GB": - return int(amount * 1024) + return int(amount * 1000) return int(amount) +def _format_storage_size(size_mb: int) -> str: + """Inverse of `_parse_storage_mb` — whole decimal GB where it divides evenly.""" + if size_mb % 1000 == 0: + return f"{size_mb // 1000}GB" + return f"{size_mb}MB" + + +def _build_camara_gpu(compute: Optional[SRMComputeResources]) -> Optional[CamaraGpuInfo]: + """SRM accelerator -> CAMARA `GpuInfo` (megabytes on both sides).""" + if compute is None or compute.accelerator is None: + return None + acc = compute.accelerator + if acc.type != "gpu": + return None + return CamaraGpuInfo(gpuMemory=acc.memory_mb, numGPU=acc.units) + + +def _build_camara_storage( + compute: Optional[SRMComputeResources], +) -> Optional[list[CamaraAdditionalStorageItem]]: + """SRM storage -> CAMARA `AdditionalStorage` (megabytes on both sides).""" + if compute is None or not compute.storage: + return None + items = [ + CamaraAdditionalStorageItem( + name=v.name, + storageSize=_format_storage_size(v.size_mb), + mountPoint=v.mount_point, + ) + for v in compute.storage + if v.mount_point + ] + return items or None + + +def _build_gpu_request(gpu: Optional[CamaraGpuInfo]) -> Optional[GpuRequest]: + """CAMARA `GpuInfo` -> internal GPU request. Already megabytes; no conversion.""" + if gpu is None: + return None + return GpuRequest(num_gpu=gpu.numGPU, gpu_memory_mb=gpu.gpuMemory) + + +def _build_storage_requests( + storages: Optional[list[CamaraAdditionalStorageItem]], +) -> list[StorageRequest]: + """CAMARA `AdditionalStorage` -> internal storage requests.""" + if not storages: + return [] + return [ + StorageRequest(name=s.name, size=s.storageSize, mount_point=s.mountPoint) for s in storages + ] + + def build_edge_cloud_zone(srm_zone: SRMZone) -> EdgeCloudZone: try: status = EdgeCloudZoneStatus(srm_zone.state) @@ -154,9 +216,9 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: None, ) if unit is None: - raise ValueError( - f"no deployment unit in catalog entry {spec.ref!r} has an artifact_ref" - ) + raise ValueError(f"no deployment unit in catalog entry {spec.ref!r} has an artifact_ref") + artifact_ref = unit.artifact_ref + assert artifact_ref is not None try: app_id: Optional[UUID] = UUID(spec.ref) @@ -169,7 +231,7 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: if repo_meta and repo_meta.type == "PRIVATEREPO": app_repo = CamaraAppRepo( type="PRIVATEREPO", - imagePath=unit.artifact_ref, + imagePath=artifact_ref, userName=repo_meta.user_ref, credentials=repo_meta.credentials, authType=repo_meta.auth_type, # type: ignore[arg-type] @@ -177,7 +239,7 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: else: app_repo = CamaraAppRepo( type="PUBLICREPO", - imagePath=unit.artifact_ref, + imagePath=artifact_ref, ) compute = unit.resource_requirements.compute @@ -241,6 +303,8 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: infraKind="container", numCPU=num_cpu_str, memory=compute.memory_mb if compute and compute.memory_mb else 0, + storage=_build_camara_storage(compute), + gpu=_build_camara_gpu(compute), ) elif unit.runtime_kind in ("qcow2", "ova"): required_resources = VmResources( @@ -249,6 +313,8 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: if compute and compute.cpu_millicores else 1, memory=compute.memory_mb if compute and compute.memory_mb else 1, + additionalStorages=_build_camara_storage(compute), + gpu=_build_camara_gpu(compute), ) elif unit.runtime_kind == "docker-compose": required_resources = DockerComposeResources( @@ -257,6 +323,8 @@ def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: if compute and compute.cpu_millicores else 1, memory=compute.memory_mb if compute and compute.memory_mb else 1, + storage=_build_camara_storage(compute), + gpu=_build_camara_gpu(compute), ) else: required_resources = None @@ -391,33 +459,48 @@ def build_app_registration_translation( ), ) + cluster_config = K8sClusterConfig( + version=rr.version, + networking=rr.networking.model_dump(exclude_none=True) if rr.networking else None, + addons=list(rr.addons) if rr.addons else None, + ) required_resources = RequiredResources( infra_kind=rr.infraKind, is_standalone=rr.isStandalone or False, application_resources=ApplicationResources(cpu_pool=cpu_pool, gpu_pool=gpu_pool), additional_storage=rr.additionalStorage, + k8s_cluster_config=( + cluster_config if cluster_config.model_dump(exclude_none=True) else None + ), ) elif isinstance(manifest.requiredResources, (VmResources, DockerComposeResources)): + vm_rr = manifest.requiredResources + raw_storage = vm_rr.additionalStorages if isinstance(vm_rr, VmResources) else vm_rr.storage required_resources = RequiredResources( - infra_kind=manifest.requiredResources.infraKind, + infra_kind=vm_rr.infraKind, is_standalone=False, application_resources=ApplicationResources( cpu_pool=CpuPool( - num_cpu=float(manifest.requiredResources.numCPU), - memory=manifest.requiredResources.memory, + num_cpu=float(vm_rr.numCPU), + memory=vm_rr.memory, ) ), + gpu=_build_gpu_request(vm_rr.gpu), + additional_storages=_build_storage_requests(raw_storage), ) elif isinstance(manifest.requiredResources, ContainerResources): + ctr_rr = manifest.requiredResources required_resources = RequiredResources( - infra_kind=manifest.requiredResources.infraKind, + infra_kind=ctr_rr.infraKind, is_standalone=False, application_resources=ApplicationResources( cpu_pool=CpuPool( - num_cpu=_parse_container_cpu_cores(manifest.requiredResources.numCPU), - memory=manifest.requiredResources.memory, + num_cpu=_parse_container_cpu_cores(ctr_rr.numCPU), + memory=ctr_rr.memory, ) ), + gpu=_build_gpu_request(ctr_rr.gpu), + additional_storages=_build_storage_requests(ctr_rr.storage), ) component_spec = [ @@ -506,9 +589,25 @@ def build_catalog_payload(translation: AppRegistrationTranslation) -> SRMCatalog min_node_gpu_memory_mb=min_node_gpu_memory_mb, ) - if rr and rr.additional_storage: + if rr and rr.gpu is not None and accelerator is None: + accelerator = SRMAccelerator( + type="gpu", + units=rr.gpu.num_gpu, + memory_mb=rr.gpu.gpu_memory_mb, + ) + + if rr and rr.additional_storages: + storage = [ + SRMStorageVolume( + name=s.name or "additional", + size_mb=_parse_storage_mb(s.size), + mount_point=s.mount_point, + ) + for s in rr.additional_storages + ] + elif rr and rr.additional_storage: size_mb = _parse_storage_mb(rr.additional_storage) - storage = [SRMStorageVolume(name="additional", size_mb=size_mb)] + storage = [SRMStorageVolume(name="data", size_mb=size_mb, mount_point="/data")] compute = SRMComputeResources( cpu_millicores=cpu_millicores, diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index 5eae701..592b0d6 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -60,11 +60,31 @@ class ApplicationResources(BaseModel): gpu_pool: GpuPool | None = None +class GpuRequest(BaseModel): + num_gpu: int + gpu_memory_mb: int + + +class StorageRequest(BaseModel): + name: str | None = None + size: str + mount_point: str + + +class K8sClusterConfig(BaseModel): + version: str | None = None + networking: Any | None = None + addons: list[str] | None = None + + class RequiredResources(BaseModel): infra_kind: str application_resources: ApplicationResources | None = None is_standalone: bool = False additional_storage: str | None = None + gpu: GpuRequest | None = None + additional_storages: list[StorageRequest] = [] + k8s_cluster_config: K8sClusterConfig | None = None class AppRegistrationTranslation(BaseModel): diff --git a/tests/unit/test_eam_mapper.py b/tests/unit/test_eam_mapper.py index 2e3e0a8..d19ebb4 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -1,6 +1,7 @@ from uuid import UUID import pytest +from pydantic import ValidationError from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( AppInstanceStatus, @@ -329,12 +330,12 @@ class TestBuildAppManifest: catalog = _make_srm_catalog(cpu_millicores=2000, memory_mb=2048) compute = catalog.service_deployment_units[0].resource_requirements.compute assert compute is not None - compute.accelerator = SRMAccelerator(type="gpu", units=1, memory_mb=16 * 1024) + compute.accelerator = SRMAccelerator(type="gpu", units=1, memory_mb=16 * 1000) catalog.service_deployment_units[0].resource_requirements.topology = SRMTopologyConstraints( min_nodes=2, min_node_cpu_millicores=1000, min_node_memory_mb=1024, - min_node_gpu_memory_mb=16 * 1024, + min_node_gpu_memory_mb=16 * 1000, ) result = build_app_manifest(catalog) assert isinstance(result.requiredResources, KubernetesResources) @@ -718,14 +719,183 @@ class TestBuildCatalogPayload: assert compute.accelerator is not None assert compute.accelerator.type == "gpu" assert compute.accelerator.units == 1 - assert compute.accelerator.memory_mb == 16 * 1024 + assert compute.accelerator.memory_mb == 16 * 1000 topology = resource_requirements.topology assert topology is not None assert topology.min_nodes == 2 assert topology.min_node_cpu_millicores == 1000 assert topology.min_node_memory_mb == 1024 - assert topology.min_node_gpu_memory_mb == 16 * 1024 + assert topology.min_node_gpu_memory_mb == 16 * 1000 + + +def _make_vm_manifest(**required_resources: object) -> AppManifest: + return AppManifest( + appId=APP_ID, + name="myvmapp", + version="1.0.0", + appProvider="VideoAppsCo", + packageType="QCOW2", + appRepo=AppRepo(type="PUBLICREPO", imagePath="https://images.example.com/app.qcow2"), + requiredResources=VmResources.model_validate( + {"infraKind": "virtualMachine", "numCPU": 4, "memory": 8192, **required_resources} + ), + componentSpec=[], + ) + + +class TestDirectGpuAndStorage: + """CAMARA `gpu` / `additionalStorages` on the VM, container and dockerCompose + variants — spec-declared optional fields that must be accepted and carried + through to SRM, not rejected by extra="forbid" nor silently dropped.""" + + def test_vm_gpu_accepted_and_mapped_to_accelerator(self) -> None: + manifest = _make_vm_manifest(gpu={"gpuMemory": 16384, "numGPU": 2}) + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) + catalog = build_catalog_payload(translation) + compute = catalog.service_deployment_units[0].resource_requirements.compute + assert compute is not None + assert compute.accelerator is not None + assert compute.accelerator.type == "gpu" + assert compute.accelerator.units == 2 + # GpuInfo.gpuMemory is already megabytes — no GB conversion applies. + assert compute.accelerator.memory_mb == 16384 + + def test_vm_additional_storages_carry_real_name_and_mount_point(self) -> None: + manifest = _make_vm_manifest( + additionalStorages=[ + {"name": "logs", "storageSize": "80GB", "mountPoint": "/logs"}, + {"storageSize": "500MB", "mountPoint": "/scratch"}, + ] + ) + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) + catalog = build_catalog_payload(translation) + compute = catalog.service_deployment_units[0].resource_requirements.compute + assert compute is not None + storage = compute.storage + assert storage is not None + assert [(v.name, v.size_mb, v.mount_point) for v in storage] == [ + ("logs", 80_000, "/logs"), + ("additional", 500, "/scratch"), + ] + + def test_container_gpu_and_storage_accepted(self) -> None: + manifest = AppManifest( + appId=APP_ID, + name="myctrapp", + version="1.0.0", + appProvider="VideoAppsCo", + packageType="CONTAINER", + appRepo=AppRepo(type="PUBLICREPO", imagePath="docker.io/lib/app:1.0"), + requiredResources=ContainerResources.model_validate( + { + "infraKind": "container", + "numCPU": "500m", + "memory": 2048, + "gpu": {"gpuMemory": 8192, "numGPU": 1}, + "storage": [{"storageSize": "10GB", "mountPoint": "/data"}], + } + ), + componentSpec=[], + ) + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) + catalog = build_catalog_payload(translation) + compute = catalog.service_deployment_units[0].resource_requirements.compute + assert compute is not None + assert compute.accelerator is not None + assert compute.accelerator.units == 1 + assert compute.accelerator.memory_mb == 8192 + assert compute.storage is not None + assert compute.storage[0].size_mb == 10_000 + + def test_vm_gpu_and_storage_round_trip(self) -> None: + """SRM -> CAMARA must reproduce what CAMARA -> SRM consumed.""" + manifest = _make_vm_manifest( + gpu={"gpuMemory": 16384, "numGPU": 2}, + additionalStorages=[{"name": "logs", "storageSize": "80GB", "mountPoint": "/logs"}], + ) + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "VideoAppsCo" + ) + catalog = build_catalog_payload(translation) + rebuilt = build_app_manifest(catalog) + assert isinstance(rebuilt.requiredResources, VmResources) + assert rebuilt.requiredResources.gpu is not None + assert rebuilt.requiredResources.gpu.numGPU == 2 + assert rebuilt.requiredResources.gpu.gpuMemory == 16384 + assert rebuilt.requiredResources.additionalStorages is not None + storage = rebuilt.requiredResources.additionalStorages[0] + assert (storage.name, storage.storageSize, storage.mountPoint) == ("logs", "80GB", "/logs") + + def test_k8s_cluster_fields_accepted(self) -> None: + """version / networking / addons are spec-valid and must not 400.""" + manifest = AppManifest( + appId=APP_ID, + name="myk8sapp", + version="1.0.0", + appProvider="VideoAppsCo", + packageType="HELM", + appRepo=AppRepo(type="PUBLICREPO", imagePath="oci://pub.registry.com/app:1.0"), + requiredResources=KubernetesResources.model_validate( + { + "infraKind": "kubernetes", + "applicationResources": { + "cpuPool": { + "numCPU": 2, + "memory": 2048, + "topology": { + "minNumberOfNodes": 1, + "minNodeCpu": 1, + "minNodeMemory": 1024, + }, + } + }, + "isStandalone": False, + "version": "v1.28.2", + "networking": {"primaryNetwork": {"provider": "cilium", "version": "1.13"}}, + "addons": ["monitoring", "ingress"], + } + ), + componentSpec=[], + ) + translation = build_app_registration_translation( + manifest, APP_ID, APP_REGISTRATION_ID, "t", "p" + ) + assert translation.required_resources is not None + cluster = translation.required_resources.k8s_cluster_config + assert cluster is not None + assert cluster.version == "v1.28.2" + assert cluster.addons == ["monitoring", "ingress"] + assert cluster.networking == {"primaryNetwork": {"provider": "cilium", "version": "1.13"}} + + def test_duplicate_addons_rejected(self) -> None: + with pytest.raises(ValidationError): + KubernetesResources.model_validate( + { + "infraKind": "kubernetes", + "applicationResources": {}, + "isStandalone": False, + "addons": ["monitoring", "monitoring"], + } + ) + + def test_unknown_field_still_rejected(self) -> None: + """Modelling the spec's optional fields must not weaken extra="forbid".""" + with pytest.raises(ValidationError): + VmResources.model_validate( + { + "infraKind": "virtualMachine", + "numCPU": 4, + "memory": 8192, + "notASpecField": True, + } + ) class TestBuildDeployCommand: diff --git a/uv.lock b/uv.lock index 416f557..8410863 100644 --- a/uv.lock +++ b/uv.lock @@ -717,6 +717,7 @@ dev = [ { name = "ruff" }, { name = "schemathesis" }, { name = "testcontainers" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -738,6 +739,7 @@ requires-dist = [ { name = "sqlalchemy", specifier = ">=2.0.48" }, { name = "structlog", specifier = ">=25.5.0" }, { name = "testcontainers", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.1" }, ] provides-extras = ["dev"] @@ -1268,6 +1270,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" -- GitLab