diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 60a86e5fad1134d5ee40dbfefa76ce9cf103ecd2..4a0d4de0c83e8c9fbd5f06473c2bf9b454ba6f0f 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b465c65b3ad9f509a60e368fab2cc48c561de659..a981f97c2244536eb247176d38ca606bd28eddc0 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 3e60f91a978381dea839f788179a3fe4de1d8c73..9396cfd52164d1219ce591a5cca86d149c06f809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,11 @@ 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", + "types-PyYAML>=6.0.1", ] [tool.setuptools.packages.find] @@ -58,6 +60,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/src/open_exposure_gateway/adapters/database/repos/app_instances.py b/src/open_exposure_gateway/adapters/database/repos/app_instances.py index 13ba07a95ced4bfb87ae73148cd8cfb5398af6c8..28611b6d79b5ac69d97b8694406edbedf6fe4e04 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: @@ -18,6 +20,28 @@ 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 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.state.notin_(_TERMINAL_STATES), + ) + 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/adapters/database/repos/app_registrations.py b/src/open_exposure_gateway/adapters/database/repos/app_registrations.py index d557056572bb4f337f32293026d111438ccb827d..dd6f4549c92ac85d997a079d70288f95dd75e674 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 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 @@ -41,3 +44,15 @@ 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: + """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 5ceaf9e745fa622354fe6c7096d5bdfa8deeb128..e9820691fad07bb7a45c3e27e6bd34b9bd408c2a 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/adapters/databus/nats_adapter.py b/src/open_exposure_gateway/adapters/databus/nats_adapter.py index d85bda33ea4be40edd48289c64013bb6f88a5520..51ce781b1e11707bc76e373611b85754f5de490b 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/adapters/http/callback_client.py b/src/open_exposure_gateway/adapters/http/callback_client.py new file mode 100644 index 0000000000000000000000000000000000000000..aeed2f2292dd1c62a08a4e1e06f69065829da7ab --- /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/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index bafe2f9208503ad6523af1c0a0c4d82206420c9b..f3fad30f29f5eb1df5bca07236e410de7d763941 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -13,10 +13,10 @@ from open_exposure_gateway.core.exceptions import ( NotFoundException, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, SRMServiceInstance, + SRMZone, ) logger = structlog.get_logger(__name__) @@ -100,17 +100,17 @@ 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[ResourceZone]: + ) -> list[SRMZone]: params = {} if region is not None: params["region"] = region if status is not None: - params["status"] = status + params["state"] = status headers = {} if x_correlator: @@ -122,7 +122,7 @@ class SRMClient: params=params or None, headers=headers or None, ) - return [ResourceZone.model_validate(z) for z in data] + return [SRMZone.model_validate(z) for z in data] async def create_qod_session( self, @@ -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 diff --git a/src/open_exposure_gateway/api/camara/common.py b/src/open_exposure_gateway/api/camara/common.py index 4ea32b25b4663db00389f0d83314c7cc2f93aad4..4b6fef04f53f8f5ef4cb471c623c19ea5fded72c 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/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 0000000000000000000000000000000000000000..42e72db4f3e8562b93b54b6182c40afb2b850d48 --- /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/api/camara/edge_application_management/vwip/router.py b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py index 0b2911fbc0a847ec1b6844f845b89db97aaa6c5a..346f10b6d457b1588ab0dd3099e240642badc460 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, @@ -16,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 ( @@ -40,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( @@ -68,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, @@ -88,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, @@ -102,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, @@ -118,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, @@ -140,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, @@ -162,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, @@ -170,12 +212,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. @@ -190,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, @@ -212,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, @@ -221,6 +276,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/api/camara/edge_application_management/vwip/schemas.py b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/schemas.py index d09fbddd4a5ff81282ccc1e437ab84d2fa2e903d..565af10754bf6a50f52d3a9828afe0ccfa7cadca 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, field_validator 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,79 @@ class NetworkInterface(BaseModel): class ComponentSpecItem(BaseModel): + model_config = ConfigDict(extra="forbid") + componentName: str = Field(max_length=64) 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): + 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) + storage: Optional[AdditionalStorage] = Field(default=None, max_length=50) + gpu: Optional[GpuInfo] = None 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) + storage: Optional[AdditionalStorage] = Field(default=None, max_length=50) + gpu: Optional[GpuInfo] = None 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 +151,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,21 +160,61 @@ class GpuPool(BaseModel): class ApplicationResources(BaseModel): + model_config = ConfigDict(extra="forbid") + cpuPool: Optional[CpuPool] = None 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") + infraKind: Literal["kubernetes"] 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 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 +254,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 +263,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 +272,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 ae936ef3015b997d4af7f7860b2a1178849b4e24..5c4da10e3233387e7f439237b8353cd45b49b73d 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, @@ -19,17 +19,25 @@ 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, ) from open_exposure_gateway.domain.edge_application_management import ( AppDeploymentTranslation, + AppInstanceStatusChangeCloudEvent, + AppInstanceStatusChangeData, ApplicationResources, AppRegistrationTranslation, AppRepo, @@ -37,9 +45,10 @@ from open_exposure_gateway.domain.edge_application_management import ( CpuPool, CpuPoolTopology, GpuPool, + GpuRequest, + K8sClusterConfig, NetworkInterface, RequiredResources, - ResourceZone, SRMAccelerator, SRMCapabilityRequirement, SRMCatalogPayload, @@ -59,7 +68,10 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMTerminateCommand, SRMTerminatePayload, SRMTopologyConstraints, + SRMZone, + StorageRequest, ) +from open_exposure_gateway.domain.models import AppInstance, AppInstanceState _PACKAGE_TYPE_TO_RUNTIME_KIND: dict[str, str] = { "HELM": "helm", @@ -78,6 +90,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] = { @@ -87,10 +104,24 @@ _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"): return int(value[:-1]) / 1000.0 @@ -103,30 +134,91 @@ 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 build_edge_cloud_zone(srm_zone: ResourceZone) -> EdgeCloudZone: +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.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, ) def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: spec = catalog.service_specification - unit = catalog.service_deployment_units[0] + 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") + artifact_ref = unit.artifact_ref + assert artifact_ref is not None try: app_id: Optional[UUID] = UUID(spec.ref) @@ -139,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] @@ -147,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 @@ -163,7 +255,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 @@ -173,7 +265,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() @@ -202,7 +294,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 @@ -211,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( @@ -219,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( @@ -227,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 @@ -266,10 +364,13 @@ 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, zone_id: UUID +) -> AppInstanceInfo: 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: @@ -283,23 +384,25 @@ def build_app_instance_info(instance: SRMServiceInstance) -> AppInstanceInfo: ), ) ) + 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), 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) - if instance.resource_zone_id - else UUID(int=0), + edgeCloudZoneId=zone_id, componentEndpointInfo=endpoint_info or None, + kubernetesClusterRef=kubernetes_cluster_ref, ) def build_app_registration_translation( manifest: AppManifest, app_id: UUID, + app_registration_id: UUID, tenant_id: str, app_provider_id: str, ) -> AppRegistrationTranslation: @@ -356,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 = [ @@ -403,6 +521,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, @@ -447,7 +566,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, @@ -455,7 +574,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 ) @@ -470,14 +589,29 @@ 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, memory_mb=memory_mb, - standalone=standalone, accelerator=accelerator, storage=storage, ) @@ -498,6 +632,7 @@ def build_catalog_payload(translation: AppRegistrationTranslation) -> SRMCatalog compute=compute, topology=topology, interfaces=interfaces or None, + standalone=standalone, ) repo = translation.app_repo @@ -531,7 +666,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, @@ -549,6 +684,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, @@ -556,16 +692,15 @@ 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, 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="", ) @@ -580,12 +715,12 @@ 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), - 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( @@ -613,3 +748,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=str(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=to_app_instance_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 780f1a3e7478b0df99d159c6332779c2956c512b..a33f9c41096607dd09ca6a697ca0225f41d6f5d6 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 @@ -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, @@ -26,22 +27,39 @@ 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 ( - ConflictException, + AbortedException, + AlreadyExistsException, + BadRequestException, DownstreamServiceException, + NotFoundException, NotImplementedException, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, - SRMCatalogPayload, + SRMOperationCompleted, Subject, ) from open_exposure_gateway.domain.models import ( + AppInstance, + AppInstanceState, AppRegistration, AppRegistrationStatus, + CallbackDelivery, + CallbackRegistration, + Operation, + OperationStatus, + OperationType, PackageType, ) +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 from open_exposure_gateway.ports.databus_port import DataBusPort from open_exposure_gateway.ports.srm_port import SRMClientPort @@ -54,6 +72,39 @@ 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, +} + +_APP_INSTANCE_COMPLETION_STATE_MAP: dict[str, AppInstanceState] = { + "completed": AppInstanceState.READY, + "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.""" + 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__( @@ -61,10 +112,20 @@ 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, + callback_delivery_port: CallbackDeliveryPort | None = None, + callback_delivery_repo: CallbackDeliveryRepository | 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 + self._callback_delivery_port = callback_delivery_port + self._callback_delivery_repo = callback_delivery_repo async def get_edge_cloud_zones( self, @@ -72,44 +133,54 @@ 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, ) - 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 + # (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: - 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: ResourceZone | 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 ) -> AppManifestEnvelope: - catalog = await self.srm_client.get_app(app_id=app_id, x_correlator=x_correlator) - return AppManifestEnvelope(appManifest=build_app_manifest(catalog)) + 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: + raise DownstreamServiceException( + message="SRM returned a malformed service specification", + details=str(exc), + ) from exc + return AppManifestEnvelope(appManifest=manifest) async def submit_app( self, @@ -129,17 +200,22 @@ 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 + payload=catalog_payload.model_dump(mode="json", exclude_none=True), + 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: @@ -147,7 +223,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, @@ -157,7 +233,7 @@ class EdgeApplicationManagementService: ) ) except DuplicateAppRegistrationError as exc: - raise ConflictException( + raise AlreadyExistsException( message=f"App {translation.app_id} is already registered" ) from exc @@ -183,7 +259,27 @@ 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 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" + ) + + 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( self, @@ -191,17 +287,113 @@ 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=to_app_instance_status(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") + + 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( 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, + idempotency_key=idempotency_key, + ) + + 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, + idempotency_key=idempotency_key, + app_registration_id=app_registration.app_registration_id, + metadata={ + "name": translation.name, + "zone_id": translation.zone_id, + "domain_id": translation.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, + ) + ) + + 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( + 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, @@ -214,7 +406,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( @@ -224,17 +416,77 @@ 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 + + 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( - 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 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) command = build_terminate_instance_command( app_instance_id=app_instance_id, @@ -243,8 +495,183 @@ 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, + metadata={"app_instance_id": str(app_instance_id)}, + ) + ) + await self._app_instance_repo.save( + app_instance.model_copy(update={"state": AppInstanceState.TERMINATING}) + ) + await self._publish( Subject.TASK_TERMINATE, 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") + 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) + if operation is None: + logger.warning( + "operation_completed_for_unknown_operation", operation_id=event.operation_id + ) + 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: + result = { + "instances": [ + { + "app_instance_id": instance.service_instance_id, + "edge_cloud_zone_id": instance.zone_id, + "state": 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) + + is_terminate = operation.operation_type == OperationType.TERMINATE + updated_instances: list[AppInstance] = [] + + 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 + if _is_final(app_instance): + _log_stale_completion(app_instance, operation_id) + continue + if is_terminate and instance.status == "completed": + 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": state}) + ) + updated_instances.append(saved) + elif status == OperationStatus.FAILED: + # Total failure carries no instances[] entries to match against. + 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 + ) + 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) + + async def _deliver_callbacks( + self, + operation_id: UUID, + occurred_at: str, + app_instances: list[AppInstance], + ) -> None: + if self._callback_registration_repo is None: + 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( + "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 5bca7d691da5f2cb27499cffb42dbf3834d7f648..4335083144100207b473f3ce317094beadce4d8a 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/dependencies.py b/src/open_exposure_gateway/dependencies.py index e5cb2752952ec543a4b7ba07e3061d86e1a371d1..f826c1934d2a908f22790676865e44fbf4edfc85 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/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index ca5348f175c7fca27cb2c2e8f40ae8e5d7e4f725..592b0d62967969b794f6ec44ab85078030235c7a 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -60,15 +60,36 @@ 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): app_id: UUID + app_registration_id: UUID tenant_id: str app_provider_id: str name: str @@ -82,29 +103,34 @@ class AppRegistrationTranslation(BaseModel): class AppDeploymentTranslation(BaseModel): app_id: UUID + app_registration_id: UUID operation_id: UUID app_instance_id: UUID 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 ResourceZoneLocation(BaseModel): +class SRMZoneLocation(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 SRMZoneMetadata(BaseModel): provider: str - location: ResourceZoneLocation | None = None + location: SRMZoneLocation | None = None + + +class SRMZone(BaseModel): + id: str + name: str + state: str + metadata: SRMZoneMetadata class SRMAccelerator(BaseModel): @@ -122,7 +148,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 @@ -147,16 +172,15 @@ class SRMComputeIntent(BaseModel): compute: SRMComputeResources | None = None topology: SRMTopologyConstraints | None = None interfaces: list[SRMNetworkInterface] | None = None + standalone: bool = False class SRMServiceSpecDescriptor(BaseModel): - artifact_type: str + artifact_type: str | None = None source_api: str = "edge-application-management" 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 @@ -180,16 +204,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 @@ -212,8 +238,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): @@ -268,9 +294,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 @@ -279,8 +302,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") @@ -307,6 +332,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 @@ -315,6 +341,25 @@ 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] = [] + + +class AppInstanceStatusChangeData(BaseModel): + appInstanceId: UUID + appId: UUID + edgeCloudZoneId: UUID + status: str + + +class AppInstanceStatusChangeCloudEvent(BaseModel): + # 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 + datacontenttype: str = "application/json" + data: AppInstanceStatusChangeData diff --git a/src/open_exposure_gateway/domain/models/instances/enums.py b/src/open_exposure_gateway/domain/models/instances/enums.py index 2eec145977bc51d4c10115afc43b0829b1602d32..6c60b771a1725e543ab7e6e49408b169dd94b60b 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/main.py b/src/open_exposure_gateway/main.py index 23a9f42d40474087d9b81310c38259d3e5dbebcc..fb4967d585e0cd6b7450bf237543c3a6a4b2f185 100644 --- a/src/open_exposure_gateway/main.py +++ b/src/open_exposure_gateway/main.py @@ -4,16 +4,29 @@ 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.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, @@ -26,9 +39,44 @@ 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.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() + except Exception: + await session.rollback() + raise + + return handle + openapi_tags = [ { @@ -94,6 +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, 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 0000000000000000000000000000000000000000..67d78414802de9f2fddbbc207ef25363d675c535 --- /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/src/open_exposure_gateway/ports/database/instances.py b/src/open_exposure_gateway/ports/database/instances.py index 118b572e1c7307d6a0ff0f747b573d9c44ec6a0d..6241ca4788de1c9a9fdea35a0de1441655b60b0c 100644 --- a/src/open_exposure_gateway/ports/database/instances.py +++ b/src/open_exposure_gateway/ports/database/instances.py @@ -11,6 +11,18 @@ 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 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/src/open_exposure_gateway/ports/database/registration.py b/src/open_exposure_gateway/ports/database/registration.py index 33b55c8831c843ccbc931422d1a84a7d0bee541a..0596ea2cc9a7239a8aaffcc3cd869a01aee6cf4c 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/src/open_exposure_gateway/ports/srm_port.py b/src/open_exposure_gateway/ports/srm_port.py index b69965b755612a12c5ad90e86ada1dc250d223cd..fe0f3eeb38d065f27032acf17db5a0ffde90296e 100644 --- a/src/open_exposure_gateway/ports/srm_port.py +++ b/src/open_exposure_gateway/ports/srm_port.py @@ -5,20 +5,20 @@ 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, 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[ResourceZone]: ... + ) -> 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 322427eb8a6ac9e2db15fc4f9469271a62045ba0..4885ac5c1e44419c25bf3a8d8220e1b6520320af 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -22,10 +22,17 @@ 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 ( + SRMZone, + SRMZoneMetadata, +) from tests.conformance.harness import app from tests.unit.fakes import ( + FakeAppInstanceRepository, + FakeAppRegistrationRepository, + FakeCallbackRegistrationRepository, FakeDataBus, + FakeOperationRepository, FakeSRMClient, wire_operation_consumer, wire_srm_worker, @@ -40,17 +47,24 @@ 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( - resource_zone_id=str(uuid4()), + SRMZone( + id=str(uuid4()), name="conformance-zone", - status="active", - provider="conformance-provider", + state="active", + metadata=SRMZoneMetadata(provider="conformance-provider"), ) ) - wire_operation_consumer(bus) + operation_repo = FakeOperationRepository() + 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 + srm_client=srm, + publisher=bus, + app_registration_repo=FakeAppRegistrationRepository(), + operation_repo=operation_repo, + app_instance_repo=app_instance_repo, + 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 6e55abca73ee9e0e0da7dd4c295b6c063357db6e..5caaeacd90362a0272f7f42a0bf58a843032a834 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() diff --git a/tests/integration/test_nats_consumer.py b/tests/integration/test_nats_consumer.py index ed65aaf87b2f9bd1ff7f563b290f6dee32946203..5fcd185333066b9ae04eac2ec2292fe02cd463ab 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/integration/test_postgres.py b/tests/integration/test_postgres.py index f5f574cc7f630701f24327718d80eb5b4e9b8bb8..ad620beb7c12745a1e1b6451ee3841062c0cca2d 100644 --- a/tests/integration/test_postgres.py +++ b/tests/integration/test_postgres.py @@ -197,6 +197,115 @@ 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_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: + 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/conftest.py b/tests/unit/conftest.py index 78b989a289997fc30e0edd5a5ed3f2d5bbbb3209..075f84795eee88190e5872091797859067201c62 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, ) @@ -19,8 +22,13 @@ from open_exposure_gateway.dependencies import ( ) from open_exposure_gateway.main import app from tests.unit.fakes import ( + FakeAppInstanceRepository, FakeAppRegistrationRepository, + FakeCallbackDeliveryPort, + FakeCallbackDeliveryRepository, + FakeCallbackRegistrationRepository, FakeDataBus, + FakeOperationRepository, FakeSRMClient, wire_operation_consumer, wire_srm_worker, @@ -59,16 +67,58 @@ def fake_srm() -> FakeSRMClient: @pytest.fixture() -def live_srm(fake_bus: FakeDataBus, fake_srm: FakeSRMClient) -> FakeSRMClient: - """Fake SRM with its async side running: consumes commands, publishes completions.""" - wire_operation_consumer(fake_bus) - wire_srm_worker(fake_bus, fake_srm) - return fake_srm +def app_registration_repo() -> FakeAppRegistrationRepository: + return FakeAppRegistrationRepository() @pytest.fixture() -def app_registration_repo() -> FakeAppRegistrationRepository: - return FakeAppRegistrationRepository() +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 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() @@ -76,11 +126,17 @@ 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, ) @@ -89,6 +145,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/fakes.py b/tests/unit/fakes.py index 3ca2375e5937dc0ebc0740997d06b2cf283a8b6a..ec7dfef5dc252be9a6619a4d47d87e9f4e256d81 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -31,21 +31,28 @@ 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, + AppInstanceStatusChangeCloudEvent, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, SRMServiceInstance, + SRMZone, Subject, ) from open_exposure_gateway.domain.models import ( AppInstance, + AppInstanceState, AppRegistration, + AppRegistrationStatus, CallbackDelivery, CallbackRegistration, Operation, ) +from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, CallbackRegistrationRepository, @@ -89,17 +96,17 @@ class FakeMsg: class FakeSRMClient: def __init__(self) -> None: - self.zones: list[ResourceZone] = [] + 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[ResourceZone]: + ) -> list[SRMZone]: return list(self.zones) async def get_apps(self, x_correlator: str | None = None) -> list[SRMCatalogPayload]: @@ -114,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) @@ -194,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( @@ -205,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", } ], @@ -217,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( @@ -237,8 +244,32 @@ 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, + 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 -- 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 + ) async def deliver(payload: dict[str, Any]) -> None: await consumer._handle_message( @@ -259,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 @@ -268,12 +299,20 @@ 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) 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 and row.status != AppRegistrationStatus.DELETED: + self.rows[registration_id] = row.model_copy( + update={"status": AppRegistrationStatus.DELETED} + ) + class FakeOperationRepository(OperationRepository): def __init__(self) -> None: @@ -315,6 +354,28 @@ 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 exists_for_app_registration(self, app_registration_id: UUID) -> bool: + 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 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 @@ -354,3 +415,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_callback_client.py b/tests/unit/test_callback_client.py new file mode 100644 index 0000000000000000000000000000000000000000..94d67a3f1120c04ce5079c806f5cb5aa33975ece --- /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=str(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_contract.py b/tests/unit/test_eam_contract.py index ee5eceeaa3821d258cbe2825e2311abc06ee55a4..06d184f0df6e230b19ed27122874ca66aa28cf0d 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: @@ -135,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", @@ -146,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.""" @@ -237,7 +265,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() @@ -267,6 +297,90 @@ class TestInternalHttpPaths: return [] client._request = record # type: ignore[method-assign] - await client.get_resource_zones() + await client.get_zones() 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_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 + 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 diff --git a/tests/unit/test_eam_endpoints.py b/tests/unit/test_eam_endpoints.py index 44778507646be57f76a8397c8ae39f9b579c05c1..1ece8e1ebd9378763034d527e2cc84f1d9753a92 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 59d652159bf54141065398424c5817e47e265dd6..4ce7404df6906ffb995f3fd7f4ecb1b2847f7e39 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -9,10 +9,12 @@ 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 +from uuid import UUID, uuid4 +import pytest from fastapi.testclient import TestClient from open_exposure_gateway.api.camara.edge_application_management.vwip.router import ( @@ -20,14 +22,21 @@ 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, SRMDeployCommand, SRMTerminateCommand, + SRMZone, + SRMZoneMetadata, Subject, ) +from open_exposure_gateway.domain.models import AppInstanceState, OperationStatus, OperationType from tests.unit.fakes import ( + FakeAppInstanceRepository, + FakeAppRegistrationRepository, + FakeCallbackDeliveryPort, + FakeCallbackRegistrationRepository, FakeDataBus, FakeMsg, + FakeOperationRepository, FakeSRMClient, wire_operation_consumer, ) @@ -35,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), @@ -65,6 +76,10 @@ def _manifest_body(num_cpu: float | int) -> dict[str, Any]: class TestCreateAppInstanceFlow: + @pytest.fixture(autouse=True) + 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 ) -> None: @@ -87,17 +102,91 @@ class TestCreateAppInstanceFlow: assert body["status"] == "instantiating" assert "appInstanceId" in body + def test_persists_operation_and_app_instance_rows( + self, + api_client: TestClient, + 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. 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"] + + 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_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_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 + 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.targets[0].zone_id == str(ZONE_ID) assert command.deploy.instance_name == "myvideoapp_inst" assert command.source == "nbi_camara" assert command.deploy.placement_constraints == {} @@ -116,11 +205,40 @@ 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: + 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: @@ -147,8 +265,43 @@ 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 + 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, 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 ) -> None: @@ -165,6 +318,59 @@ 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 + + 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 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 + updated_instance = app_instance_repo.rows.get(UUID(instance_id)) + assert updated_instance is not None + assert updated_instance.state == AppInstanceState.TERMINATED + class TestSubmitAppFlow: def test_returns_201_and_registers_catalog_entry_in_srm( @@ -174,10 +380,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 @@ -278,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] = { @@ -299,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: @@ -318,12 +526,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: @@ -331,11 +545,11 @@ class TestEdgeCloudZonesFlow: self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: fake_srm.zones.append( - ResourceZone( - resource_zone_id=str(ZONE_ID), + SRMZone( + id=str(ZONE_ID), name="berlin-edge-1", - status="active", - provider="acme", + state="active", + metadata=SRMZoneMetadata(provider="acme"), ) ) response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") @@ -345,25 +559,33 @@ 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 ResourceZone 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( - ResourceZone( - resource_zone_id=str(ZONE_ID), name="good-zone", status="active", provider="acme" + SRMZone( + id=str(ZONE_ID), + name="good-zone", + state="active", + metadata=SRMZoneMetadata(provider="acme"), ) ) fake_srm.zones.append( - ResourceZone( - resource_zone_id="zone-west-1", name="bad-zone", status="active", provider="acme" + SRMZone( + id="zone-west-1", + name="bad-zone", + state="active", + metadata=SRMZoneMetadata(provider="acme"), ) ) 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 @@ -379,7 +601,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( @@ -393,16 +615,24 @@ 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, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + 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 + self, + fake_bus: FakeDataBus, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, ) -> None: - consumer = wire_operation_consumer(fake_bus) + 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_mapper.py b/tests/unit/test_eam_mapper.py index 87c7c65fad082b2fd0a354a5e26f944a5f28636a..d19ebb43acded8ff6cc1a32dabc7eed72df5eab2 100644 --- a/tests/unit/test_eam_mapper.py +++ b/tests/unit/test_eam_mapper.py @@ -1,5 +1,8 @@ from uuid import UUID +import pytest +from pydantic import ValidationError + from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( AppInstanceStatus, ApplicationResources, @@ -17,15 +20,15 @@ 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, build_deploy_command, build_edge_cloud_zone, + to_app_instance_status, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, - ResourceZoneLocation, SRMAccelerator, SRMCapabilityEndpoint, SRMCapabilityInstanceSummary, @@ -42,12 +45,17 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMServiceSpecDescriptor, SRMServiceSpecEntry, SRMTopologyConstraints, + SRMZone, + SRMZoneLocation, + SRMZoneMetadata, ) +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") 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( @@ -57,6 +65,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( @@ -87,6 +96,7 @@ def _make_srm_catalog( min_node_memory_mb=1024, ), interfaces=interfaces, + standalone=standalone, ), ) ], @@ -151,12 +161,14 @@ def _make_helm_manifest( class TestBuildEdgeCloudZone: def test_maps_all_fields(self) -> None: - zone = ResourceZone( - resource_zone_id=str(ZONE_ID), + zone = SRMZone( + id=str(ZONE_ID), name="berlin-edge-1", - status="active", - provider="acme", - location=ResourceZoneLocation(region="eu-central-1"), + state="active", + metadata=SRMZoneMetadata( + provider="acme", + location=SRMZoneLocation(region="eu-central-1"), + ), ) result = build_edge_cloud_zone(zone) assert result.edgeCloudZoneId == ZONE_ID @@ -165,22 +177,52 @@ class TestBuildEdgeCloudZone: assert result.edgeCloudRegion == "eu-central-1" def test_unknown_status_falls_back(self) -> None: - zone = ResourceZone( - resource_zone_id=str(ZONE_ID), + zone = SRMZone( + id=str(ZONE_ID), name="z", - status="maintenance", - provider="p", + state="maintenance", + 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 = ResourceZone(resource_zone_id=str(ZONE_ID), name="z", status="active", provider="p") + zone = SRMZone( + id=str(ZONE_ID), + name="z", + state="active", + metadata=SRMZoneMetadata(provider="p"), + ) result = build_edge_cloud_zone(zone) assert result.edgeCloudRegion is None 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) @@ -260,6 +302,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( @@ -280,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) @@ -306,39 +356,43 @@ 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), + zone_id=str(ZONE_ID), name="myvideoapp_inst", capability_instances=[], ) 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, 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")) + 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")) + 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")) + 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")) + 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, 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")) + 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: @@ -355,7 +409,7 @@ class TestBuildAppInstanceInfo: ), ) ] - result = build_app_instance_info(instance) + 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] @@ -364,20 +418,61 @@ 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, 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 - result = build_app_instance_info(instance) + result = build_app_instance_info(instance, APP_ID, ZONE_ID) 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() - 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" @@ -386,7 +481,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 @@ -395,7 +490,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] @@ -435,7 +530,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" @@ -465,7 +560,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: @@ -497,7 +592,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 @@ -516,37 +611,58 @@ 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 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(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 @@ -554,7 +670,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] @@ -589,7 +707,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 @@ -599,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: @@ -620,19 +909,85 @@ 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) - 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" 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_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), + 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 0eb707f7fc7d307c2459d98dae9a72c40653b491..f79e5335bcf276dbf5498f5ae005529097faef08 100644 --- a/tests/unit/test_eam_service.py +++ b/tests/unit/test_eam_service.py @@ -1,51 +1,95 @@ +from datetime import datetime, timezone +from typing import Any from unittest.mock import AsyncMock from uuid import UUID, uuid4 import pytest from open_exposure_gateway.api.camara.edge_application_management.vwip.schemas import ( + AppInstanceInfo, AppInstanceStatus, ApplicationResources, AppManifest, AppRepo, CreateAppInstanceRequest, KubernetesResources, + SubscriptionConfig, + SubscriptionRequest, VmResources, ) from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) from open_exposure_gateway.core.exceptions import ( - ConflictException, + AbortedException, + AlreadyExistsException, + BadRequestException, DownstreamServiceException, + ErrorCode, + NotFoundException, NotImplementedException, ) from open_exposure_gateway.domain.edge_application_management import ( - ResourceZone, SRMCapabilityRequirement, SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, + SRMCompletedInstance, SRMComputeIntent, SRMComputeResources, SRMDeploymentUnit, SRMDeploymentUnitMetadata, + SRMOperationCompleted, SRMRepoMetadata, SRMServiceInstance, SRMServiceSpecDescriptor, SRMServiceSpecEntry, SRMTopologyConstraints, + SRMZone, + SRMZoneMetadata, Subject, ) from open_exposure_gateway.domain.models import ( + AppInstance, + AppInstanceState, AppRegistration, AppRegistrationStatus, + CallbackRegistration, + Operation, + OperationStatus, + OperationType, PackageType, ) -from tests.unit.fakes import FakeAppRegistrationRepository +from tests.unit.fakes import ( + FakeAppInstanceRepository, + FakeAppRegistrationRepository, + FakeCallbackDeliveryPort, + FakeCallbackDeliveryRepository, + FakeCallbackRegistrationRepository, + FakeOperationRepository, +) 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: @@ -86,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_ID), + service_specification_id=str(APP_REGISTRATION_ID), state=state, app_provider_id="VideoAppsCo", - resource_zone_id=str(ZONE_ID), + zone_id=zone_id, name="myvideoapp_inst", capability_instances=[], ) @@ -127,12 +173,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 @@ -146,16 +196,51 @@ 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 callback_delivery_port() -> FakeCallbackDeliveryPort: + return FakeCallbackDeliveryPort() + + +@pytest.fixture() +def callback_delivery_repo() -> FakeCallbackDeliveryRepository: + return FakeCallbackDeliveryRepository() + + @pytest.fixture() def service( srm_client: AsyncMock, publisher: AsyncMock, app_registration_repo: FakeAppRegistrationRepository, + 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, publisher=publisher, 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, ) @@ -163,12 +248,12 @@ class TestGetEdgeCloudZones: async def test_returns_mapped_camara_zones( self, service: EdgeApplicationManagementService, srm_client: AsyncMock ) -> None: - srm_client.get_resource_zones.return_value = [ - ResourceZone( - resource_zone_id=str(ZONE_ID), + srm_client.get_zones.return_value = [ + SRMZone( + id=str(ZONE_ID), name="berlin-edge-1", - status="active", - provider="acme", + state="active", + metadata=SRMZoneMetadata(provider="acme"), ) ] result = await service.get_edge_cloud_zones() @@ -179,12 +264,36 @@ 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" ) + 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( @@ -204,22 +313,69 @@ 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( - 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_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 + + with pytest.raises(DownstreamServiceException): + await service.get_app(app_id=APP_ID) class TestSubmitApp: @@ -245,8 +401,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(), @@ -257,15 +416,38 @@ 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_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: + 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( @@ -301,8 +483,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( @@ -313,7 +496,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, @@ -329,7 +512,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, @@ -351,6 +534,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 @@ -393,21 +607,174 @@ 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") + + 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=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 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, + 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 + + 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 + + @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: + 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) + 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( @@ -434,68 +801,1743 @@ 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) - 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" - ) - - async def test_wraps_publish_error( - self, service: EdgeApplicationManagementService, publisher: AsyncMock - ) -> None: - publisher.publish.side_effect = Exception("NATS down") - with pytest.raises(DownstreamServiceException, match="deployment"): - await service.create_app_instance( - request=self._make_request(), tenant_id="t", app_provider_id="p" - ) - - -class TestGetAppInstances: - async def test_returns_mapped_instances( - self, service: EdgeApplicationManagementService, srm_client: AsyncMock + async def test_persists_pending_operation_row( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_registration_repo: FakeAppRegistrationRepository, ) -> None: - 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].status == AppInstanceStatus.READY + 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["zone_id"] == str(ZONE_ID) - async def test_passes_filters_to_srm( - self, service: EdgeApplicationManagementService, srm_client: AsyncMock + async def test_persists_instantiating_app_instance_row( + self, + service: EdgeApplicationManagementService, + app_instance_repo: FakeAppInstanceRepository, ) -> None: - 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_instance_id=INSTANCE_ID, - region=None, - x_correlator=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 - -class TestDeleteAppInstance: - async def test_publishes_terminate_command( + async def test_retried_request_with_same_key_does_not_republish( self, service: EdgeApplicationManagementService, publisher: AsyncMock ) -> None: - await service.delete_app_instance( - app_instance_id=INSTANCE_ID, app_provider_id="VideoAppsCo", x_correlator="corr-1" + 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() - subject, payload = publisher.publish.call_args.args - assert subject == Subject.TASK_TERMINATE - assert payload["service_instance_id"] == str(INSTANCE_ID) - 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_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_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: + 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(self.OTHER_ZONE_ID), + 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 zone_id in (ZONE_ID, self.OTHER_ZONE_ID): + await service.create_app_instance( + request=self._make_request(zone_id), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + 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: + 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, + 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, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + service = EdgeApplicationManagementService( + 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.delete_app_instance(app_instance_id=INSTANCE_ID, app_provider_id="p") + await service.create_app_instance( + request=self._make_request(), tenant_id="t", 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") + with pytest.raises(DownstreamServiceException, match="deployment"): + await service.create_app_instance( + request=self._make_request(), tenant_id="t", app_provider_id="p" + ) + + +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 + ) -> 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" + ) + + 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, + 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_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_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() + + 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) + 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, + tenant_id="tenant-1", + app_provider_id="VideoAppsCo", + x_correlator="corr-1", + ) + publisher.publish.assert_called_once() + subject, payload = publisher.publish.call_args.args + assert subject == Subject.TASK_TERMINATE + assert payload["service_instance_id"] == str(INSTANCE_ID) + assert payload["app_provider_id"] == "VideoAppsCo" + assert payload["correlation_id"] == "corr-1" + + 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_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, + 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, 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, tenant_id="tenant-1", app_provider_id="p" + ) + + +class TestHandleCompleted: + OPERATION_ID = UUID("dddddddd-dddd-dddd-dddd-dddddddddddd") + + async def _seed_pending_operation( + 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, + correlation_id="corr-1", + tenant_id="tenant-1", + app_provider_id="provider-1", + operation_type=operation_type, + status=OperationStatus.PENDING, + subject=subject, + metadata=metadata, + ) + ) + + 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 _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: + 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), + "edge_cloud_zone_id": str(ZONE_ID), + "state": "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) + + 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_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_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, + 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, + 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 == {} + + async def test_terminate_completion_marks_terminated_not_deleted( + self, + service: EdgeApplicationManagementService, + operation_repo: FakeOperationRepository, + app_instance_repo: FakeAppInstanceRepository, + ) -> None: + """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( + 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_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 == "unknown" + + 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 == "unknown" + + 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, + terminate_target_instance_id=INSTANCE_ID, + ) + 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_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, + 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"} diff --git a/tests/unit/test_nats_adapter.py b/tests/unit/test_nats_adapter.py index e66efbf564710ada28f0cf1680dad01462b008c4..b6355e8b2c7f45f0ad79573e29fc8428b5f8d5ad 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())) diff --git a/tests/unit/test_srm_client.py b/tests/unit/test_srm_client.py new file mode 100644 index 0000000000000000000000000000000000000000..cf11145bec7e7725ff002a2933c947b233fbf6bb --- /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") diff --git a/tests/unit/test_vendored_spec_refs.py b/tests/unit/test_vendored_spec_refs.py new file mode 100644 index 0000000000000000000000000000000000000000..67f22d5d970be6a77c5adc2ca7af9c2e6b141984 --- /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 21708ed142a991b0fadb870051b1a6190f594bcb..84108630db83ba169523b1b769458e98c5fcccc2 100644 --- a/uv.lock +++ b/uv.lock @@ -713,9 +713,11 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "schemathesis" }, { name = "testcontainers" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -731,11 +733,13 @@ 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" }, { 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"] @@ -1266,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"