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

fix: block DELETE /apps/{appId} while running instances exist

parent 63aa0950
Loading
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -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()
+78 −26
Original line number Diff line number Diff line
@@ -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,
+17 −3
Original line number Diff line number Diff line
@@ -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(
+4 −0
Original line number Diff line number Diff line
@@ -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
+3 −0
Original line number Diff line number Diff line
@@ -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
Loading