Loading src/federation_manager/adapters/database/agreement_repo.py +21 −5 Changes for src/federation_manager/adapters/database/agreement_repo.py: 21 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -2,11 +2,15 @@ from typing import Any from uuid import UUID from sqlalchemy import Row, and_, func, insert, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from federation_manager.adapters.database.tables import federation_agreements as agreements from federation_manager.domain.errors import AgreementExists from federation_manager.domain.models import Agreement, AppMapping _ONE_PER_PARTNER = "uq_federation_agreements_one_per_partner" class PostgresAgreementRepo: def __init__(self, session: AsyncSession) -> None: Loading Loading @@ -43,18 +47,30 @@ class PostgresAgreementRepo: return [_to_agreement(row) for row in (await self._session.execute(stmt)).all()] async def add(self, agreement: Agreement) -> None: await self._session.execute( await self._write( agreement, insert(agreements).values( id=agreement.id, partner_op_id=agreement.partner_op_id, **_values(agreement) ), ) ) await self._session.commit() async def update(self, agreement: Agreement) -> None: await self._session.execute( update(agreements).where(agreements.c.id == agreement.id).values(**_values(agreement)) await self._write( agreement, update(agreements).where(agreements.c.id == agreement.id).values(**_values(agreement)), ) async def _write(self, agreement: Agreement, stmt: Any) -> None: try: await self._session.execute(stmt) await self._session.commit() except IntegrityError as error: await self._session.rollback() # a concurrent create won, or an expired old agreement is being brought back # while the partner has a live one if _ONE_PER_PARTNER not in str(error.orig): raise raise AgreementExists(agreement.partner_op_id, None) from None def _to_agreement(row: Row[Any]) -> Agreement: Loading src/federation_manager/adapters/database/tables.py +7 −0 Changes for src/federation_manager/adapters/database/tables.py: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -106,6 +106,13 @@ federation_agreements = Table( ), Index("idx_federation_agreements_partner", "partner_op_id"), Index("idx_federation_agreements_status_validity", "status", "valid_from", "valid_until"), # one agreement per partner; rows from before that rule stay, as expired history Index( "uq_federation_agreements_one_per_partner", "partner_op_id", unique=True, postgresql_where=text("status <> 'expired'"), ), CheckConstraint( _one_of("status", "draft", "active", "suspended", "expired"), name="ck_federation_agreements_status", Loading src/federation_manager/api/errors.py +26 −0 Changes for src/federation_manager/api/errors.py: 26 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -9,8 +9,10 @@ from pydantic.json_schema import models_json_schema from federation_manager.contracts.ewbi import InvalidParam, ProblemDetails from federation_manager.domain.errors import ( AgreementExists, AgreementExpired, AgreementNotFound, AgreementTermsInvalid, AgreementValidityInvalid, AgreementViolation, AppInstanceUnknown, Loading Loading @@ -414,6 +416,30 @@ def register_exception_handlers(app: FastAPI) -> None: 422, "agreement-validity-invalid", "Invalid Validity Period", str(exc), request.url.path ) @app.exception_handler(AgreementExists) async def _agreement_exists(request: Request, exc: AgreementExists) -> JSONResponse: existing = f" Edit agreement {exc.agreement_id} instead." if exc.agreement_id else "" return problem( 409, "agreement-exists", "Agreement Exists", f"This partner already has an agreement.{existing}", request.url.path, ) @app.exception_handler(AgreementTermsInvalid) async def _agreement_terms_invalid( request: Request, exc: AgreementTermsInvalid ) -> JSONResponse: return problem( 400, "invalid-request", "Invalid Request", "One or more request parameters or body fields are invalid; see invalidParams.", request.url.path, invalid_params=[InvalidParam(param="zone_resources", reason=str(exc))], ) @app.exception_handler(PartnerCredentialsIncomplete) async def _credentials_incomplete( request: Request, exc: PartnerCredentialsIncomplete Loading src/federation_manager/api/internal/agreements.py +53 −19 Changes for src/federation_manager/api/internal/agreements.py: 53 added lines, 19 removed lines. Original line number Diff line number Diff line Loading @@ -74,22 +74,14 @@ class AgreementCreate(BaseModel): def fields(self) -> dict[str, Any]: return { "partner_op_id": self.partner_op_id, "permitted_api_types": set(self.permitted_api_types), "permitted_zone_ids": set(self.permitted_zone_ids), "zone_resources": { zone: terms.model_dump(mode="json", by_alias=True, exclude_none=True) for zone, terms in self.zone_resources.items() }, "app_mappings": tuple( AppMapping( app_id=m.appId, app_version=m.appVersion, flavour_id=m.flavourId, service_specification_id=m.service_specification_id, ) for m in self.service_spec_mappings.apps **_terms( { "permitted_api_types": self.permitted_api_types, "permitted_zone_ids": self.permitted_zone_ids, "zone_resources": self.zone_resources, "service_spec_mappings": self.service_spec_mappings, } ), "api_family_mappings": dict(self.service_spec_mappings.api_families), "usage_limits": self.usage_limits, "valid_from": self.valid_from, "valid_until": self.valid_until, Loading @@ -98,24 +90,66 @@ class AgreementCreate(BaseModel): class AgreementUpdate(BaseModel): """Partial update of status, limits and validity; other terms need a new agreement.""" """Partial update: a partner has one agreement, so any of its terms can change in place.""" model_config = ConfigDict(extra="forbid") status: Status | None = None permitted_api_types: list[ApiType] | None = None permitted_zone_ids: list[UUID] | None = None zone_resources: dict[UUID, ZoneTerms] | None = None service_spec_mappings: ServiceSpecMappings | None = None usage_limits: dict[str, Any] | None = None valid_from: AwareDatetime | None = None valid_until: AwareDatetime | None = None @model_validator(mode="after") def _required_not_cleared(self) -> Self: for field in ("status", "usage_limits", "valid_from"): if field in self.model_fields_set and getattr(self, field) is None: # only valid_until can be cleared; every other field always has a value for field in self.model_fields_set - {"valid_until"}: if getattr(self, field) is None: raise ValueError(f"{field} cannot be null") return self def changes(self) -> dict[str, Any]: return self.model_dump(exclude_unset=True) sent = {field: getattr(self, field) for field in self.model_fields_set} plain = {k: v for k, v in sent.items() if k not in _TERM_FIELDS} return {**plain, **_terms(sent)} _TERM_FIELDS = { "permitted_api_types", "permitted_zone_ids", "zone_resources", "service_spec_mappings", } def _terms(sent: dict[str, Any]) -> dict[str, Any]: """The agreement's terms as the domain holds them, for whichever of them were sent.""" terms: dict[str, Any] = {} if "permitted_api_types" in sent: terms["permitted_api_types"] = set(sent["permitted_api_types"]) if "permitted_zone_ids" in sent: terms["permitted_zone_ids"] = set(sent["permitted_zone_ids"]) if "zone_resources" in sent: terms["zone_resources"] = { zone: zone_terms.model_dump(mode="json", by_alias=True, exclude_none=True) for zone, zone_terms in sent["zone_resources"].items() } if "service_spec_mappings" in sent: mappings: ServiceSpecMappings = sent["service_spec_mappings"] terms["app_mappings"] = tuple( AppMapping( app_id=m.appId, app_version=m.appVersion, flavour_id=m.flavourId, service_specification_id=m.service_specification_id, ) for m in mappings.apps ) terms["api_family_mappings"] = dict(mappings.api_families) return terms class AgreementView(BaseModel): Loading src/federation_manager/application/agreements.py +24 −1 Changes for src/federation_manager/application/agreements.py: 24 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -5,7 +5,9 @@ from uuid import UUID, uuid4 from federation_manager.application.zone_notifications import ZoneOfferNotifier from federation_manager.domain.errors import ( AgreementExists, AgreementNotFound, AgreementTermsInvalid, AgreementValidityInvalid, PartnerNotRegistered, ) Loading @@ -14,7 +16,11 @@ from federation_manager.domain.ports import AgreementRepositoryPort, PartnerRepo class AgreementService: """Records the terms two operators agreed on; FM enforces them on every partner request.""" """Records the terms two operators agreed on; FM enforces them on every partner request. A partner has one agreement, edited in place, so what applies is never a matter of which of several rows is newest. """ def __init__( self, Loading @@ -31,8 +37,12 @@ class AgreementService: async def create(self, fields: dict[str, Any]) -> Agreement: partner = await self._require_partner(fields["partner_op_id"]) existing = await self._agreements.list_for_partner(partner.id) if existing: raise AgreementExists(partner.id, _current(existing).id) agreement = Agreement(id=self._new_id(), **fields) _require_valid_period(agreement) _require_terms_for_permitted_zones(agreement) before = await self._offer(partner) await self._agreements.add(agreement) await self._tell(partner, before) Loading @@ -47,6 +57,7 @@ class AgreementService: async def update(self, agreement_id: UUID, changes: dict[str, Any]) -> Agreement: updated = replace(await self.get(agreement_id), **changes) _require_valid_period(updated) _require_terms_for_permitted_zones(updated) partner = await self._require_partner(updated.partner_op_id) before = await self._offer(partner) await self._agreements.update(updated) Loading Loading @@ -76,3 +87,15 @@ class AgreementService: def _require_valid_period(agreement: Agreement) -> None: if agreement.valid_until is not None and agreement.valid_until <= agreement.valid_from: raise AgreementValidityInvalid def _require_terms_for_permitted_zones(agreement: Agreement) -> None: # checked on the merged agreement, since a PATCH may change either side alone if set(agreement.zone_resources) - agreement.permitted_zone_ids: raise AgreementTermsInvalid("zone_resources lists zones missing from permitted_zone_ids") def _current(agreements: list[Agreement]) -> Agreement: # partners agreed before agreements became one per partner keep the older ones as expired live = [a for a in agreements if a.status != "expired"] return (live or agreements)[-1] Loading
src/federation_manager/adapters/database/agreement_repo.py +21 −5 Changes for src/federation_manager/adapters/database/agreement_repo.py: 21 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -2,11 +2,15 @@ from typing import Any from uuid import UUID from sqlalchemy import Row, and_, func, insert, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from federation_manager.adapters.database.tables import federation_agreements as agreements from federation_manager.domain.errors import AgreementExists from federation_manager.domain.models import Agreement, AppMapping _ONE_PER_PARTNER = "uq_federation_agreements_one_per_partner" class PostgresAgreementRepo: def __init__(self, session: AsyncSession) -> None: Loading Loading @@ -43,18 +47,30 @@ class PostgresAgreementRepo: return [_to_agreement(row) for row in (await self._session.execute(stmt)).all()] async def add(self, agreement: Agreement) -> None: await self._session.execute( await self._write( agreement, insert(agreements).values( id=agreement.id, partner_op_id=agreement.partner_op_id, **_values(agreement) ), ) ) await self._session.commit() async def update(self, agreement: Agreement) -> None: await self._session.execute( update(agreements).where(agreements.c.id == agreement.id).values(**_values(agreement)) await self._write( agreement, update(agreements).where(agreements.c.id == agreement.id).values(**_values(agreement)), ) async def _write(self, agreement: Agreement, stmt: Any) -> None: try: await self._session.execute(stmt) await self._session.commit() except IntegrityError as error: await self._session.rollback() # a concurrent create won, or an expired old agreement is being brought back # while the partner has a live one if _ONE_PER_PARTNER not in str(error.orig): raise raise AgreementExists(agreement.partner_op_id, None) from None def _to_agreement(row: Row[Any]) -> Agreement: Loading
src/federation_manager/adapters/database/tables.py +7 −0 Changes for src/federation_manager/adapters/database/tables.py: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -106,6 +106,13 @@ federation_agreements = Table( ), Index("idx_federation_agreements_partner", "partner_op_id"), Index("idx_federation_agreements_status_validity", "status", "valid_from", "valid_until"), # one agreement per partner; rows from before that rule stay, as expired history Index( "uq_federation_agreements_one_per_partner", "partner_op_id", unique=True, postgresql_where=text("status <> 'expired'"), ), CheckConstraint( _one_of("status", "draft", "active", "suspended", "expired"), name="ck_federation_agreements_status", Loading
src/federation_manager/api/errors.py +26 −0 Changes for src/federation_manager/api/errors.py: 26 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -9,8 +9,10 @@ from pydantic.json_schema import models_json_schema from federation_manager.contracts.ewbi import InvalidParam, ProblemDetails from federation_manager.domain.errors import ( AgreementExists, AgreementExpired, AgreementNotFound, AgreementTermsInvalid, AgreementValidityInvalid, AgreementViolation, AppInstanceUnknown, Loading Loading @@ -414,6 +416,30 @@ def register_exception_handlers(app: FastAPI) -> None: 422, "agreement-validity-invalid", "Invalid Validity Period", str(exc), request.url.path ) @app.exception_handler(AgreementExists) async def _agreement_exists(request: Request, exc: AgreementExists) -> JSONResponse: existing = f" Edit agreement {exc.agreement_id} instead." if exc.agreement_id else "" return problem( 409, "agreement-exists", "Agreement Exists", f"This partner already has an agreement.{existing}", request.url.path, ) @app.exception_handler(AgreementTermsInvalid) async def _agreement_terms_invalid( request: Request, exc: AgreementTermsInvalid ) -> JSONResponse: return problem( 400, "invalid-request", "Invalid Request", "One or more request parameters or body fields are invalid; see invalidParams.", request.url.path, invalid_params=[InvalidParam(param="zone_resources", reason=str(exc))], ) @app.exception_handler(PartnerCredentialsIncomplete) async def _credentials_incomplete( request: Request, exc: PartnerCredentialsIncomplete Loading
src/federation_manager/api/internal/agreements.py +53 −19 Changes for src/federation_manager/api/internal/agreements.py: 53 added lines, 19 removed lines. Original line number Diff line number Diff line Loading @@ -74,22 +74,14 @@ class AgreementCreate(BaseModel): def fields(self) -> dict[str, Any]: return { "partner_op_id": self.partner_op_id, "permitted_api_types": set(self.permitted_api_types), "permitted_zone_ids": set(self.permitted_zone_ids), "zone_resources": { zone: terms.model_dump(mode="json", by_alias=True, exclude_none=True) for zone, terms in self.zone_resources.items() }, "app_mappings": tuple( AppMapping( app_id=m.appId, app_version=m.appVersion, flavour_id=m.flavourId, service_specification_id=m.service_specification_id, ) for m in self.service_spec_mappings.apps **_terms( { "permitted_api_types": self.permitted_api_types, "permitted_zone_ids": self.permitted_zone_ids, "zone_resources": self.zone_resources, "service_spec_mappings": self.service_spec_mappings, } ), "api_family_mappings": dict(self.service_spec_mappings.api_families), "usage_limits": self.usage_limits, "valid_from": self.valid_from, "valid_until": self.valid_until, Loading @@ -98,24 +90,66 @@ class AgreementCreate(BaseModel): class AgreementUpdate(BaseModel): """Partial update of status, limits and validity; other terms need a new agreement.""" """Partial update: a partner has one agreement, so any of its terms can change in place.""" model_config = ConfigDict(extra="forbid") status: Status | None = None permitted_api_types: list[ApiType] | None = None permitted_zone_ids: list[UUID] | None = None zone_resources: dict[UUID, ZoneTerms] | None = None service_spec_mappings: ServiceSpecMappings | None = None usage_limits: dict[str, Any] | None = None valid_from: AwareDatetime | None = None valid_until: AwareDatetime | None = None @model_validator(mode="after") def _required_not_cleared(self) -> Self: for field in ("status", "usage_limits", "valid_from"): if field in self.model_fields_set and getattr(self, field) is None: # only valid_until can be cleared; every other field always has a value for field in self.model_fields_set - {"valid_until"}: if getattr(self, field) is None: raise ValueError(f"{field} cannot be null") return self def changes(self) -> dict[str, Any]: return self.model_dump(exclude_unset=True) sent = {field: getattr(self, field) for field in self.model_fields_set} plain = {k: v for k, v in sent.items() if k not in _TERM_FIELDS} return {**plain, **_terms(sent)} _TERM_FIELDS = { "permitted_api_types", "permitted_zone_ids", "zone_resources", "service_spec_mappings", } def _terms(sent: dict[str, Any]) -> dict[str, Any]: """The agreement's terms as the domain holds them, for whichever of them were sent.""" terms: dict[str, Any] = {} if "permitted_api_types" in sent: terms["permitted_api_types"] = set(sent["permitted_api_types"]) if "permitted_zone_ids" in sent: terms["permitted_zone_ids"] = set(sent["permitted_zone_ids"]) if "zone_resources" in sent: terms["zone_resources"] = { zone: zone_terms.model_dump(mode="json", by_alias=True, exclude_none=True) for zone, zone_terms in sent["zone_resources"].items() } if "service_spec_mappings" in sent: mappings: ServiceSpecMappings = sent["service_spec_mappings"] terms["app_mappings"] = tuple( AppMapping( app_id=m.appId, app_version=m.appVersion, flavour_id=m.flavourId, service_specification_id=m.service_specification_id, ) for m in mappings.apps ) terms["api_family_mappings"] = dict(mappings.api_families) return terms class AgreementView(BaseModel): Loading
src/federation_manager/application/agreements.py +24 −1 Changes for src/federation_manager/application/agreements.py: 24 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -5,7 +5,9 @@ from uuid import UUID, uuid4 from federation_manager.application.zone_notifications import ZoneOfferNotifier from federation_manager.domain.errors import ( AgreementExists, AgreementNotFound, AgreementTermsInvalid, AgreementValidityInvalid, PartnerNotRegistered, ) Loading @@ -14,7 +16,11 @@ from federation_manager.domain.ports import AgreementRepositoryPort, PartnerRepo class AgreementService: """Records the terms two operators agreed on; FM enforces them on every partner request.""" """Records the terms two operators agreed on; FM enforces them on every partner request. A partner has one agreement, edited in place, so what applies is never a matter of which of several rows is newest. """ def __init__( self, Loading @@ -31,8 +37,12 @@ class AgreementService: async def create(self, fields: dict[str, Any]) -> Agreement: partner = await self._require_partner(fields["partner_op_id"]) existing = await self._agreements.list_for_partner(partner.id) if existing: raise AgreementExists(partner.id, _current(existing).id) agreement = Agreement(id=self._new_id(), **fields) _require_valid_period(agreement) _require_terms_for_permitted_zones(agreement) before = await self._offer(partner) await self._agreements.add(agreement) await self._tell(partner, before) Loading @@ -47,6 +57,7 @@ class AgreementService: async def update(self, agreement_id: UUID, changes: dict[str, Any]) -> Agreement: updated = replace(await self.get(agreement_id), **changes) _require_valid_period(updated) _require_terms_for_permitted_zones(updated) partner = await self._require_partner(updated.partner_op_id) before = await self._offer(partner) await self._agreements.update(updated) Loading Loading @@ -76,3 +87,15 @@ class AgreementService: def _require_valid_period(agreement: Agreement) -> None: if agreement.valid_until is not None and agreement.valid_until <= agreement.valid_from: raise AgreementValidityInvalid def _require_terms_for_permitted_zones(agreement: Agreement) -> None: # checked on the merged agreement, since a PATCH may change either side alone if set(agreement.zone_resources) - agreement.permitted_zone_ids: raise AgreementTermsInvalid("zone_resources lists zones missing from permitted_zone_ids") def _current(agreements: list[Agreement]) -> Agreement: # partners agreed before agreements became one per partner keep the older ones as expired live = [a for a in agreements if a.status != "expired"] return (live or agreements)[-1]