Loading .gitlab-ci.yml +1 −1 Original line number Diff line number Diff line Loading @@ -49,7 +49,7 @@ test: services: - docker:dind script: - pytest tests/unit tests/integration tests/api --cov=src/srm --cov-branch --cov-report=term-missing || [ $? -eq 5 ] #bypass no tests found error. - pytest --cov=src/srm --cov-branch --cov-report=term-missing coverage: '/TOTAL.+ ([0-9]{1,3}(?:\.[0-9]+)?%)/' build-and-push: Loading src/srm/adapters/database/repos/catalog.py +60 −2 Original line number Diff line number Diff line Loading @@ -3,7 +3,7 @@ from __future__ import annotations from uuid import UUID import structlog from sqlalchemy import select from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload Loading @@ -16,14 +16,17 @@ from srm.adapters.database.mappers import ( from srm.adapters.database.sql import ( ServiceCapabilityRequirementRow, ServiceDeploymentUnitRow, ServiceInstanceRow, ServiceSpecificationRow, ) from srm.adapters.errors import DuplicateServiceSpecificationError from srm.adapters.errors import DuplicateServiceSpecificationError, ServiceSpecificationInUseError from srm.domain.models.catalog import ( ServiceCapabilityRequirement, ServiceDeploymentUnit, ServiceSpecification, ) from srm.domain.models.catalog.enums import ServiceSpecificationState from srm.domain.models.runtime_inventory.enums import ServiceInstanceState from srm.domain.ports.database.catalog import ( ServiceCapabilityRequirementRepository, ServiceDeploymentUnitRepository, Loading @@ -47,6 +50,13 @@ def _log_integrity_error(exc: IntegrityError, *, entity: str, operation: str) -> ) def _link_requirement_deployment_unit_rows(row: ServiceSpecificationRow) -> None: deployment_units_by_id = {unit.id: unit for unit in row.deployment_units} for requirement in row.capability_requirements: if requirement.deployment_unit_id in deployment_units_by_id: requirement.deployment_unit = deployment_units_by_id[requirement.deployment_unit_id] class SqlServiceSpecificationRepository(ServiceSpecificationRepository): def __init__(self, session: AsyncSession) -> None: self._session = session Loading @@ -65,6 +75,7 @@ class SqlServiceSpecificationRepository(ServiceSpecificationRepository): async def create(self, service_specification: ServiceSpecification) -> ServiceSpecification: row = ServiceSpecificationMapper.to_row(service_specification) _link_requirement_deployment_unit_rows(row) self._session.add(row) try: await self._session.flush() Loading @@ -79,6 +90,53 @@ class SqlServiceSpecificationRepository(ServiceSpecificationRepository): raise RuntimeError("Created service specification could not be reloaded") return saved async def delete_or_retire(self, id: UUID) -> bool: row = await self._session.get(ServiceSpecificationRow, id) if row is None: return False any_instance_id = await self._session.scalar( select(ServiceInstanceRow.id) .where(ServiceInstanceRow.service_specification_id == id) .limit(1) ) if any_instance_id is None: await self._session.execute( delete(ServiceCapabilityRequirementRow).where( ServiceCapabilityRequirementRow.service_specification_id == id ) ) await self._session.execute( delete(ServiceDeploymentUnitRow).where( ServiceDeploymentUnitRow.service_specification_id == id ) ) await self._session.execute( delete(ServiceSpecificationRow).where(ServiceSpecificationRow.id == id) ) await self._session.flush() return True live_instance_id = await self._session.scalar( select(ServiceInstanceRow.id) .where(ServiceInstanceRow.service_specification_id == id) .where( ServiceInstanceRow.state.not_in( [ ServiceInstanceState.TERMINATED, ServiceInstanceState.FAILED, ] ) ) .limit(1) ) if live_instance_id is not None: raise ServiceSpecificationInUseError() row.state = ServiceSpecificationState.RETIRED await self._session.flush() return True class SqlServiceDeploymentUnitRepository(ServiceDeploymentUnitRepository): def __init__(self, session: AsyncSession) -> None: Loading src/srm/adapters/database/sql.py +3 −0 Original line number Diff line number Diff line Loading @@ -227,6 +227,9 @@ class ServiceCapabilityRequirementRow(AuditedMixin, Base): deployment_unit_id: Mapped[UUID | None] = mapped_column( ForeignKey("service_deployment_unit.id") ) deployment_unit: Mapped["ServiceDeploymentUnitRow | None"] = relationship( foreign_keys=[deployment_unit_id] ) ref: Mapped[str] = mapped_column(String(255), nullable=False) capability_kind: Mapped[CapabilityKind] = mapped_column( _enum_type(CapabilityKind), nullable=False Loading src/srm/adapters/errors.py +5 −0 Original line number Diff line number Diff line Loading @@ -10,3 +10,8 @@ class DuplicateEntryError(Exception): class DuplicateServiceSpecificationError(DuplicateEntryError): entity_name: ClassVar[str] = "Service Specification" class ServiceSpecificationInUseError(Exception): def __init__(self) -> None: super().__init__("Service specification has live service instances.") src/srm/api/dependencies.py +38 −0 Original line number Diff line number Diff line Loading @@ -3,7 +3,13 @@ from typing import Annotated, AsyncGenerator, cast from fastapi import Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository from srm.app_state import AppState from srm.application.use_cases.catalog import ( CreateServiceSpecificationUseCase, DeleteServiceSpecificationUseCase, GetServiceSpecificationUseCase, ) def get_app_state(request: Request) -> AppState: Loading @@ -21,3 +27,35 @@ async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]: SessionDep = Annotated[AsyncSession, Depends(get_session)] def get_create_service_specification_use_case( session: SessionDep, ) -> CreateServiceSpecificationUseCase: return CreateServiceSpecificationUseCase(SqlServiceSpecificationRepository(session)) def get_service_specification_use_case( session: SessionDep, ) -> GetServiceSpecificationUseCase: return GetServiceSpecificationUseCase(SqlServiceSpecificationRepository(session)) def get_delete_service_specification_use_case( session: SessionDep, ) -> DeleteServiceSpecificationUseCase: return DeleteServiceSpecificationUseCase(SqlServiceSpecificationRepository(session)) CreateServiceSpecificationUseCaseDep = Annotated[ CreateServiceSpecificationUseCase, Depends(get_create_service_specification_use_case), ] GetServiceSpecificationUseCaseDep = Annotated[ GetServiceSpecificationUseCase, Depends(get_service_specification_use_case), ] DeleteServiceSpecificationUseCaseDep = Annotated[ DeleteServiceSpecificationUseCase, Depends(get_delete_service_specification_use_case), ] Loading
.gitlab-ci.yml +1 −1 Original line number Diff line number Diff line Loading @@ -49,7 +49,7 @@ test: services: - docker:dind script: - pytest tests/unit tests/integration tests/api --cov=src/srm --cov-branch --cov-report=term-missing || [ $? -eq 5 ] #bypass no tests found error. - pytest --cov=src/srm --cov-branch --cov-report=term-missing coverage: '/TOTAL.+ ([0-9]{1,3}(?:\.[0-9]+)?%)/' build-and-push: Loading
src/srm/adapters/database/repos/catalog.py +60 −2 Original line number Diff line number Diff line Loading @@ -3,7 +3,7 @@ from __future__ import annotations from uuid import UUID import structlog from sqlalchemy import select from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload Loading @@ -16,14 +16,17 @@ from srm.adapters.database.mappers import ( from srm.adapters.database.sql import ( ServiceCapabilityRequirementRow, ServiceDeploymentUnitRow, ServiceInstanceRow, ServiceSpecificationRow, ) from srm.adapters.errors import DuplicateServiceSpecificationError from srm.adapters.errors import DuplicateServiceSpecificationError, ServiceSpecificationInUseError from srm.domain.models.catalog import ( ServiceCapabilityRequirement, ServiceDeploymentUnit, ServiceSpecification, ) from srm.domain.models.catalog.enums import ServiceSpecificationState from srm.domain.models.runtime_inventory.enums import ServiceInstanceState from srm.domain.ports.database.catalog import ( ServiceCapabilityRequirementRepository, ServiceDeploymentUnitRepository, Loading @@ -47,6 +50,13 @@ def _log_integrity_error(exc: IntegrityError, *, entity: str, operation: str) -> ) def _link_requirement_deployment_unit_rows(row: ServiceSpecificationRow) -> None: deployment_units_by_id = {unit.id: unit for unit in row.deployment_units} for requirement in row.capability_requirements: if requirement.deployment_unit_id in deployment_units_by_id: requirement.deployment_unit = deployment_units_by_id[requirement.deployment_unit_id] class SqlServiceSpecificationRepository(ServiceSpecificationRepository): def __init__(self, session: AsyncSession) -> None: self._session = session Loading @@ -65,6 +75,7 @@ class SqlServiceSpecificationRepository(ServiceSpecificationRepository): async def create(self, service_specification: ServiceSpecification) -> ServiceSpecification: row = ServiceSpecificationMapper.to_row(service_specification) _link_requirement_deployment_unit_rows(row) self._session.add(row) try: await self._session.flush() Loading @@ -79,6 +90,53 @@ class SqlServiceSpecificationRepository(ServiceSpecificationRepository): raise RuntimeError("Created service specification could not be reloaded") return saved async def delete_or_retire(self, id: UUID) -> bool: row = await self._session.get(ServiceSpecificationRow, id) if row is None: return False any_instance_id = await self._session.scalar( select(ServiceInstanceRow.id) .where(ServiceInstanceRow.service_specification_id == id) .limit(1) ) if any_instance_id is None: await self._session.execute( delete(ServiceCapabilityRequirementRow).where( ServiceCapabilityRequirementRow.service_specification_id == id ) ) await self._session.execute( delete(ServiceDeploymentUnitRow).where( ServiceDeploymentUnitRow.service_specification_id == id ) ) await self._session.execute( delete(ServiceSpecificationRow).where(ServiceSpecificationRow.id == id) ) await self._session.flush() return True live_instance_id = await self._session.scalar( select(ServiceInstanceRow.id) .where(ServiceInstanceRow.service_specification_id == id) .where( ServiceInstanceRow.state.not_in( [ ServiceInstanceState.TERMINATED, ServiceInstanceState.FAILED, ] ) ) .limit(1) ) if live_instance_id is not None: raise ServiceSpecificationInUseError() row.state = ServiceSpecificationState.RETIRED await self._session.flush() return True class SqlServiceDeploymentUnitRepository(ServiceDeploymentUnitRepository): def __init__(self, session: AsyncSession) -> None: Loading
src/srm/adapters/database/sql.py +3 −0 Original line number Diff line number Diff line Loading @@ -227,6 +227,9 @@ class ServiceCapabilityRequirementRow(AuditedMixin, Base): deployment_unit_id: Mapped[UUID | None] = mapped_column( ForeignKey("service_deployment_unit.id") ) deployment_unit: Mapped["ServiceDeploymentUnitRow | None"] = relationship( foreign_keys=[deployment_unit_id] ) ref: Mapped[str] = mapped_column(String(255), nullable=False) capability_kind: Mapped[CapabilityKind] = mapped_column( _enum_type(CapabilityKind), nullable=False Loading
src/srm/adapters/errors.py +5 −0 Original line number Diff line number Diff line Loading @@ -10,3 +10,8 @@ class DuplicateEntryError(Exception): class DuplicateServiceSpecificationError(DuplicateEntryError): entity_name: ClassVar[str] = "Service Specification" class ServiceSpecificationInUseError(Exception): def __init__(self) -> None: super().__init__("Service specification has live service instances.")
src/srm/api/dependencies.py +38 −0 Original line number Diff line number Diff line Loading @@ -3,7 +3,13 @@ from typing import Annotated, AsyncGenerator, cast from fastapi import Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository from srm.app_state import AppState from srm.application.use_cases.catalog import ( CreateServiceSpecificationUseCase, DeleteServiceSpecificationUseCase, GetServiceSpecificationUseCase, ) def get_app_state(request: Request) -> AppState: Loading @@ -21,3 +27,35 @@ async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]: SessionDep = Annotated[AsyncSession, Depends(get_session)] def get_create_service_specification_use_case( session: SessionDep, ) -> CreateServiceSpecificationUseCase: return CreateServiceSpecificationUseCase(SqlServiceSpecificationRepository(session)) def get_service_specification_use_case( session: SessionDep, ) -> GetServiceSpecificationUseCase: return GetServiceSpecificationUseCase(SqlServiceSpecificationRepository(session)) def get_delete_service_specification_use_case( session: SessionDep, ) -> DeleteServiceSpecificationUseCase: return DeleteServiceSpecificationUseCase(SqlServiceSpecificationRepository(session)) CreateServiceSpecificationUseCaseDep = Annotated[ CreateServiceSpecificationUseCase, Depends(get_create_service_specification_use_case), ] GetServiceSpecificationUseCaseDep = Annotated[ GetServiceSpecificationUseCase, Depends(get_service_specification_use_case), ] DeleteServiceSpecificationUseCaseDep = Annotated[ DeleteServiceSpecificationUseCase, Depends(get_delete_service_specification_use_case), ]