From 053011955468d0b3b2f42f2633855b1332640784 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 10:37:05 +0300 Subject: [PATCH 01/32] fix: align session request/response schemas with CAMARA spec --- .../adapters/http/srm_client.py | 10 +- .../API_definitions/qod-api.yaml | 1234 +++++++++++++++++ .../quality_on_demand/v0_10_1/router.py | 62 +- .../quality_on_demand/v0_10_1/schemas.py | 71 +- .../services/quality_on_demand_service.py | 10 +- src/open_exposure_gateway/ports/srm_port.py | 8 +- tests/unit/fakes.py | 20 +- tests/unit/test_qod_flows.py | 52 +- 8 files changed, 1415 insertions(+), 52 deletions(-) create mode 100644 src/open_exposure_gateway/api/camara/quality_on_demand/API_definitions/qod-api.yaml diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index f3fad30..6cc0b85 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -5,7 +5,7 @@ import httpx import structlog from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( - QoDSessionResponse, + SessionInfo, ) from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.exceptions import ( @@ -128,19 +128,19 @@ class SRMClient: self, payload: dict[str, Any], x_correlator: str | None = None, - ) -> QoDSessionResponse: + ) -> SessionInfo: headers = {"x-correlator": x_correlator} if x_correlator else None data = await self._request("POST", "/sessions", json=payload, headers=headers) - return QoDSessionResponse.model_validate(data) + return SessionInfo.model_validate(data) async def get_qod_session( self, session_id: str, x_correlator: str | None = None, - ) -> QoDSessionResponse: + ) -> SessionInfo: headers = {"x-correlator": x_correlator} if x_correlator else None data = await self._request("GET", f"/sessions/{session_id}", headers=headers) - return QoDSessionResponse.model_validate(data) + return SessionInfo.model_validate(data) async def delete_qod_session( self, diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/API_definitions/qod-api.yaml b/src/open_exposure_gateway/api/camara/quality_on_demand/API_definitions/qod-api.yaml new file mode 100644 index 0000000..c207cda --- /dev/null +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/API_definitions/qod-api.yaml @@ -0,0 +1,1234 @@ +openapi: 3.0.3 +info: + title: QoD for enhanced communication + description: | + The Quality-On-Demand (QoD) API provides programmable interface for developers and other users (capabilities consumers) to request stable latency or throughput managed by Telco networks without the necessity to have an in-depth knowledge of the 4G/5G system or the overall complexity of the Telecom Systems. + + # Introduction + + Industrial (IoT), VR/Gaming, live video streaming, autonomous driving and many other scenarios demand network communication quality and are sensitive to any change in transmission conditions. Being able to request a stable latency (reduced jitter) or prioritized throughput from the network can improve user experience substantially. + + The QoD API offers the application developers the capability to request for stable latency (reduced jitter) or throughput for some specified application data flows between application clients (within a user device) and Application Servers (backend services). The developer has a pre-defined set of Quality of Service (QoS) profiles which they could choose from depending on their latency or throughput requirements. + + ![QoD API Overview](https://raw.githubusercontent.com/camaraproject/QualityOnDemand/main/documentation/API_documentation/resources/QoD_latency_overview.PNG) + + The usage of the API is based on QoS session resources, which can be created (based on available QoS profiles), queried and deleted. The deletion of a requested session can be triggered by the API consumer or can be triggered automatically. The automatic process is triggered either when the requested specified duration of a QoS session has reached its limit or the default session expiration time has been reached (within an example provider implementation it is set to 24hrs). + + # Relevant terms and definitions + + * **QOD service endpoint**: + The URL pointing to the RESTful resource of the QoD API. + + * **Authentication**: + Security access keys such as OAuth 2.0 client credentials used by client applications to invoke the QoD API. + + * **QoS profiles and QoS profile labels**: + Latency or throughput requirements of the application mapped to relevant QoS profile class. + + * **Identifier for the device**: + At least one identifier for the device (user equipment) out of four options: IPv4 address, IPv6 address, Phone number, or Network Access Identifier [[5]](#5) assigned by the mobile network operator for the device. + + * **Identifier for the application server**: + IPv4 and/or IPv6 address of the application server (application backend) + + * **App-Flow (between the application client and application server)**: + The precise application data flow the developer wants to prioritize and have stable latency or throughput for. This flow is in the current API version determined by the identifiers used for the device and the application server. And it can be further elaborated with details such as ports or port-ranges. Future version of the API might allow more detailed flow identification features. + + * **Duration**: + Duration (in seconds) for which the QoS session (between application client and application server) should be created. This parameter is optional. When not specified, a default session duration (e.g. 24 hours) is applied. The user may request a termination before its expiration. + + * **Notification URL and token**: + Developers may provide a callback URL on which notifications about all status change events of the session (eg. session termination) can be received from the service provider. This is an optional parameter. + + # API functionality + + The usage of the QoD API is based on QoS profile classes and parameters which define App-Flows. + Based on the API, QoS session resources can be created, queried, and deleted. Once an offered QoS profile class is requested, application users get a prioritized service with stable latency or throughput even in the case of congestion. The QoD API has the following characteristics: + + * A specified App-Flow is prioritized to ensure stable latency or throughput for that flow. + * The prioritized App-Flow is described by providing information such as device IP address (or other device identifier) & application server IP addresses and port/port-ranges. + * The developer can optionally specify the duration for which they need the prioritized App-flow. + * Stable latency or throughput is requested by selecting from the list of QoS profiles made available by the service provider (e.g. QOS_E) to map latency and throughput requirements. + * The developer can optionally also specify callback URL on which notifications for the session can be sent.
+ + Following diagram shows the interaction between different components + + ![QoD Management API](https://raw.githubusercontent.com/camaraproject/QualityOnDemand/main/documentation/API_documentation/resources/QoD_details.PNG) + + How QoS profiles are mapped to connectivity characteristics are subject to agreements between the communication service provider and the API invoker. Within the CAMARA project, you can find a sample for such a mapping of QoS profiles. [CAMARA QoS Profiles Mapping Table (REFERENCE DRAFT)](https://github.com/camaraproject/QualityOnDemand/blob/main/documentation/API_documentation/QoSProfile_Mapping_Table.md) + + # Further info and support + + (FAQs will be added in a later version of the documentation) + termsOfService: http://swagger.io/terms/ + contact: + email: project-email@sample.com + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 0.10.1 +externalDocs: + description: Product documentation at Camara + url: https://github.com/camaraproject/ +security: + - oAuth2ClientCredentials: [] +servers: + - url: "{apiRoot}/qod/v0" + variables: + apiRoot: + default: http://localhost:9091 + description: API root, defined by the service provider, e.g. `api.example.com` or `api.example.com/somepath` +tags: + - name: QoS Sessions + description: Manage QoS sessions + - name: QoS Profiles + description: Manage QoS Profiles +paths: + /sessions: + post: + tags: + - QoS Sessions + summary: Creates a new session + description: | + Create QoS Session to manage latency/throughput priorities + + If the qosStatus in the API response is "AVAILABLE" and a notification callback is provided the client will receive in addition to the response a + `QOS_STATUS_CHANGED` event notification with `qosStatus` as `AVAILABLE`. + + If the `qosStatus` in the API response is `REQUESTED`, the client will receive either + - a `QOS_STATUS_CHANGED` event notification with `qosStatus` as `AVAILABLE` after the network notifies that it has created the requested session, or + - a `QOS_STATUS_CHANGED` event notification with `qosStatus` as `UNAVAILABLE` and `statusInfo` as `NETWORK_TERMINATED` after the network notifies that it has failed to provide the requested session. + + A `QOS_STATUS_CHANGED` event notification with `qosStatus` as `UNAVAILABLE` will also be send if the network terminates the session before the requested duration expired + + NOTE: in case of a `QOS_STATUS_CHANGED` event with `qosStatus` as `UNAVAILABLE` and `statusInfo` as `NETWORK_TERMINATED` the resources of the QoS session + are not directly released, but will get deleted automatically at earliest 360 seconds after the event. + This behavior allows clients which are not receiving notification events but are polling to get the session information with + the `qosStatus` `UNAVAILABLE` (the `statusInfo` parameter is not included in the current version but will be adding to `SessionInfo` in an upcoming release). Before a client can attempt to create a new QoD session + for the same device and flow period they must release the session resources with an explicit `delete` operation if not yet automatically deleted. + + operationId: createSession + requestBody: + description: Parameters to create a new session + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSession" + required: true + callbacks: + notifications: + "{$request.body#/webhook/notificationUrl}": + post: + tags: + - Session notifications callback + summary: "Session notifications callback" + description: | + Important: this endpoint is to be implemented by the API consumer. + The QoD server will call this endpoint whenever any QoS session change (e.g. network termination) related event occurs. + Currently only QOS_STATUS_CHANGED event is defined. + operationId: postNotification + requestBody: + required: true + content: + application/cloudevents+json: + schema: + $ref: "#/components/schemas/CloudEvent" + examples: + QOS_STATUS_CHANGED_EXAMPLE: + $ref: "#/components/examples/QOS_STATUS_CHANGED_EXAMPLE" + responses: + "204": + description: Successful notification + "400": + $ref: "#/components/responses/Generic400" + "401": + $ref: "#/components/responses/Generic401" + "403": + $ref: "#/components/responses/Generic403" + "500": + $ref: "#/components/responses/Generic500" + "503": + $ref: "#/components/responses/Generic503" + security: + - {} + - notificationsBearerAuth: [] + responses: + "201": + description: Session created + content: + application/json: + schema: + $ref: "#/components/schemas/SessionInfo" + "400": + description: Invalid input for createSession operation + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + examples: + Generic400: + summary: Some parameter combinations or parameter values provided are not schema compliant + value: + status: 400 + code: INVALID_ARGUMENT + message: "Schema validation failed at ..." + DeviceMissing: + summary: Device must be specified + value: + status: 400 + code: INVALID_ARGUMENT + message: "Expected property is missing: device" + InsufficientDeviceProperties: + summary: Device must be identified by at least one parameter + value: + status: 400 + code: INVALID_ARGUMENT + message: "Insufficient properties specified: device" + InconsistentDeviceProperties: + summary: Device parameters provided identify different devices + value: + status: 400 + code: INVALID_ARGUMENT + message: "Multiple inconsistent parameters specified: device" + CannotIdentifyDevice: + summary: No device can be identified from provided parameters + value: + status: 400 + code: INVALID_ARGUMENT + message: "Unable to identify device from specified parameters: device" + InvalidDevicePublicPortValue: + summary: Invalid port specified for device public port + value: + status: 400 + code: OUT_OF_RANGE + message: "Invalid port value specified: device.ipv4Address.publicPort" + ApplicationServerMissing: + summary: Application server must be specified + value: + status: 400 + code: INVALID_ARGUMENT + message: "Expected property is missing: applicationServer" + QoSProfileMissing: + summary: Required QoS profile must be specified + value: + status: 400 + code: INVALID_ARGUMENT + message: "Expected property is missing: qosProfile" + InvalidDevicePortsRanges: + summary: Invalid port ranges specified for devicePorts + value: + status: 400 + code: OUT_OF_RANGE + message: "Invalid port ranges specified: devicePorts" + DurationOutOfRangeForQoSProfile: + summary: The requested duration is out of the allowed range for the specific QoS profile + value: + status: 400 + code: OUT_OF_RANGE + message: "The requested duration is out of the allowed range for the specific QoS profile" + "401": + $ref: "#/components/responses/Generic401" + "403": + $ref: "#/components/responses/Generic403" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 409 + code: CONFLICT + message: "Another session is created for the same device" + "500": + $ref: "#/components/responses/Generic500" + "501": + $ref: "#/components/responses/Generic501" + "503": + $ref: "#/components/responses/Generic503" + security: + - oAuth2ClientCredentials: [] + - threeLegged: + - "qod-sessions-write" + + /sessions/{sessionId}: + get: + tags: + - QoS Sessions + summary: Get QoS session information + description: Querying for QoS session resource information details + operationId: getSession + parameters: + - name: sessionId + in: path + description: Session ID that was obtained from the createSession operation + required: true + schema: + $ref: "#/components/schemas/SessionId" + responses: + "200": + description: Contains information about active session + content: + application/json: + schema: + $ref: "#/components/schemas/SessionInfo" + "400": + $ref: "#/components/responses/Generic400" + "401": + $ref: "#/components/responses/Generic401" + "403": + $ref: "#/components/responses/Generic403" + "404": + $ref: "#/components/responses/SessionNotFound404" + "500": + $ref: "#/components/responses/Generic500" + "503": + $ref: "#/components/responses/Generic503" + security: + - oAuth2ClientCredentials: [] + - threeLegged: + - "qod-sessions-read" + + delete: + tags: + - QoS Sessions + summary: Delete a QoS session + description: | + Release resources related to QoS session + + If the notification callback is provided and the `qosStatus` of the session was `AVAILABLE` the client will receive in addition to the response a `QOS_STATUS_CHANGED` event with + - `qosStatus` as `UNAVAILABLE` and + - `statusInfo` as `DELETE_REQUESTED` + There will be no notification event if the `qosStatus` was already `UNAVAILABLE`. + operationId: deleteSession + parameters: + - name: sessionId + in: path + description: Session ID that was obtained from the createSession operation + required: true + schema: + $ref: "#/components/schemas/SessionId" + responses: + "204": + description: Session deleted + "400": + $ref: "#/components/responses/Generic400" + "401": + $ref: "#/components/responses/Generic401" + "403": + $ref: "#/components/responses/Generic403" + "404": + $ref: "#/components/responses/SessionNotFound404" + "500": + $ref: "#/components/responses/Generic500" + "503": + $ref: "#/components/responses/Generic503" + security: + - oAuth2ClientCredentials: [] + - threeLegged: + - "qod-sessions-delete" + + /sessions/{sessionId}/extend: + post: + tags: + - QoS Sessions + summary: "Extend the duration of an active session" + description: | + Extend the overall duration of an active QoS session. If this operation is executed successfully, the new duration of the target session will be the original duration plus the additionally requested duration. + The new remaining duration of the QoS session shall not exceed the maximum remaining duration limit (currently fixed at 86,400 seconds) where the remaining duration is calculated as the difference between the `expiresAt` and current time when the request to extend the session duration is received. If this maximum limit would be exceeded, the overall duration shall be set such that the remaining duration is equal to this limit. + An example: A QoD session was originally created with duration 80,000 seconds. 10,000 seconds later, the developer requested to extend the session by 20,000 seconds. + - Original duration: 80,000 seconds + - Elapsed time: 10,000 seconds + - Remaining duration: 70,000 seconds + - New remaining duration: 86,400 seconds (the maximum allowed) + - New overall session duration: 96,400 seconds + operationId: extendQosSessionDuration + parameters: + - name: sessionId + in: path + description: Session ID that was obtained from the createSession operation + required: true + schema: + $ref: "#/components/schemas/SessionId" + requestBody: + description: Parameters to extend the duration of an active session + content: + application/json: + schema: + $ref: "#/components/schemas/ExtendSessionDuration" + required: true + responses: + "200": + description: Contains information about active session + content: + application/json: + schema: + $ref: "#/components/schemas/SessionInfo" + "400": + description: Invalid input for extendQosSessionDuration operation + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + examples: + Generic400: + summary: Some parameter combinations or parameter values provided are not schema compliant + value: + status: 400 + code: INVALID_ARGUMENT + message: "Schema validation failed at ..." + InactiveSession: + summary: The target session is inactive + value: + status: 400 + code: INVALID_ARGUMENT + message: "The target session is inactive" + "401": + $ref: "#/components/responses/Generic401" + "403": + $ref: "#/components/responses/Generic403" + "404": + $ref: "#/components/responses/SessionNotFound404" + "500": + $ref: "#/components/responses/Generic500" + "503": + $ref: "#/components/responses/Generic503" + security: + - oAuth2ClientCredentials: [] + - threeLegged: + - "qod-sessions-write" + + /qos-profiles: + get: + tags: + - QoS Profiles + summary: "Get All QoS Profiles" + description: | + Returns all QoS Profiles that match the given criteria. + If no criteria is given, all QoS Profiles are returned. + operationId: getQosProfiles + parameters: + - name: name + in: query + description: QoS Profile name + schema: + type: string + required: false + - name: status + in: query + schema: + $ref: '#/components/schemas/QosProfileStatusEnum' + required: false + responses: + "200": + description: Contains information about QoS Profiles + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/QosProfile" + "401": + $ref: "#/components/responses/Generic401" + "403": + $ref: "#/components/responses/Generic403" + "404": + $ref: "#/components/responses/QosProfilesNotFound404" + "500": + $ref: "#/components/responses/QoSProfile500" + "503": + $ref: "#/components/responses/Generic503" + + /qos-profiles/{name}: + get: + tags: + - QoS Profiles + summary: "Get QoS Profile for a given name" + operationId: getQosProfile + description: | + Returns a QoS Profile that matches the given name. + parameters: + - name: name + in: path + required: true + schema: + $ref: "#/components/schemas/QosProfileName" + responses: + "200": + description: Contains information about QoS Profiles + content: + application/json: + schema: + $ref: "#/components/schemas/QosProfile" + "400": + $ref: "#/components/responses/Generic400" + "401": + $ref: "#/components/responses/Generic401" + "403": + $ref: "#/components/responses/Generic403" + "404": + $ref: "#/components/responses/QosProfileNotFound404" + "500": + $ref: "#/components/responses/QoSProfile500" + "503": + $ref: "#/components/responses/Generic503" + +components: + securitySchemes: + oAuth2ClientCredentials: + description: | + The QoD API makes use of the OAUTH 2.0 client credentials grant which is applicable for server to server use cases involving trusted partners or clients without any protected user data involved. In this method the API invoker client is registered as a confidential client with an authorization grant type of client_credentials + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://api.example.com/oauth/token + scopes: {} + notificationsBearerAuth: + type: http + scheme: bearer + bearerFormat: "{$request.body#/webhook/notificationAuthToken}" + threeLegged: + type: oauth2 + description: This API uses OAuth 2 with the authorization code grant flow. + flows: + authorizationCode: + authorizationUrl: https://api.example.com/oauth2/authorize + tokenUrl: https://api.example.com/oauth/token + scopes: + qod-sessions-read: Retrieval of QoS sessions + qod-sessions-write: Creation and update of QoS sessions + qod-sessions-delete: Deletion of QoS sessions + qod-profiles-read: Retrieval of QoS profiles + + schemas: + SessionId: + description: Session ID in UUID format + type: string + format: uuid + + BaseSessionInfo: + description: Common attributes of a QoD session + type: object + properties: + device: + $ref: "#/components/schemas/Device" + applicationServer: + $ref: "#/components/schemas/ApplicationServer" + devicePorts: + description: The ports used locally by the device for flows to which the requested QoS profile should apply. If omitted, then the qosProfile will apply to all flows between the device and the specified application server address and ports + allOf: + - $ref: "#/components/schemas/PortsSpec" + applicationServerPorts: + description: A list of single ports or port ranges on the application server + allOf: + - $ref: "#/components/schemas/PortsSpec" + qosProfile: + $ref: "#/components/schemas/QosProfileName" + webhook: + type: object + required: + - notificationUrl + properties: + notificationUrl: + type: string + format: uri + example: "https://application-server.com" + description: Allows asynchronous delivery of session related events + notificationAuthToken: + type: string + minLength: 20 + maxLength: 256 + example: "c8974e592c2fa383d4a3960714" + description: Authentication token for callback API + required: + - device + - applicationServer + - qosProfile + + SessionInfo: + description: Session related information. + allOf: + - $ref: "#/components/schemas/BaseSessionInfo" + - type: object + properties: + sessionId: + $ref: "#/components/schemas/SessionId" + duration: + type: integer + format: int32 + minimum: 1 + example: 86400 + startedAt: + type: integer + example: 1639479600 + description: Timestamp of session start in seconds since Unix epoch + format: int64 + expiresAt: + type: integer + example: 1639566000 + description: Timestamp of session expiration if the session was not deleted, in seconds since Unix epoch + format: int64 + qosStatus: + $ref: "#/components/schemas/QosStatus" + messages: + type: array + items: + $ref: "#/components/schemas/Message" + required: + - sessionId + - duration + - startedAt + - expiresAt + - qosStatus + + CreateSession: + description: Attributes required to create a session + allOf: + - $ref: "#/components/schemas/BaseSessionInfo" + - type: object + properties: + duration: + description: | + Session duration in seconds. Maximal value of 24 hours is used if not set. + After session is expired the, client will receive a `QOS_STATUS_CHANGED` event with + - `qosStatus` as `UNAVAILABLE`, and, + - `statusInfo` as `DURATION_EXPIRED`. + See notification callback. + type: integer + format: int32 + minimum: 1 + maximum: 86400 + default: 86400 + example: 86400 + + Port: + description: TCP or UDP port number + type: integer + minimum: 0 + maximum: 65535 + + PortsSpec: + type: object + minProperties: 1 + properties: + ranges: + type: array + minItems: 1 + items: + type: object + required: + - from + - to + properties: + from: + $ref: "#/components/schemas/Port" + to: + $ref: "#/components/schemas/Port" + ports: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/Port" + example: + ranges: + - from: 5010 + to: 5020 + ports: + - 5060 + - 5070 + + ExtendSessionDuration: + description: Attributes required to extend the duration of an active session + type: object + properties: + requestedAdditionalDuration: + description: | + Additional duration in seconds to be extended. + type: integer + format: int32 + minimum: 1 + maximum: 86399 + example: 60 + required: + - requestedAdditionalDuration + + + QosProfile: + description: | + Data type with attributes of a QosProfile + type: object + properties: + name: + $ref: "#/components/schemas/QosProfileName" + description: + description: | + A description of the QoS profile. + type: string + example: "QoS profile for video streaming" + status: + $ref: "#/components/schemas/QosProfileStatusEnum" + targetMinUpstreamRate: + description: | + This is the target minimum upstream rate for the QoS profile. + For 5G networks 3GPP Guaranteed Bit Rate (GBR) refers to a dedicated, fixed data rate assigned to + specific services, ensuring a minimum performance level. As per 3GPP TS 23.203, + GBR is a QoS parameter used to manage traffic classes in mobile networks. It + provides a stable data rate for latency-sensitive applications, such as voice calls or + video streaming, helping maintain a consistent user experience. When this attribute is set + this could imply that a GBR QCI is used, though mechanisms on the network can be used to + ensure a minimum performance level without using a GBR QCI. + The committed data rate allocated to specific services, ensuring a consistent level of + performance. For DOCSIS networks, the "Minimum Reserved Traffic Rate" is defined in the + DOCSIS 3.1 - MAC and Upper Layer Protocols Interface Specification" + and it ensures a consistent level of performance for specific services within the network. + allOf: + - $ref: "#/components/schemas/Rate" + maxUpstreamRate: + description: | + The maximum best effort data + allOf: + - $ref: "#/components/schemas/Rate" + maxUpstreamBurstRate: + description: | + When defined, this is the maximum upstream burst rate for the QoS profile, that will enable + the network to burst data at a higher rate than the maxUpstreamRate for a period of time. + allOf: + - $ref: "#/components/schemas/Rate" + targetMinDownstreamRate: + description: | + This is the target minimum downstream rate for the QoS profile. + For 5G networks 3GPP Guaranteed Bit Rate (GBR) refers to a dedicated, fixed data rate assigned to + specific services, ensuring a minimum performance level. As per 3GPP TS 23.203, + GBR is a QoS parameter used to manage traffic classes in mobile networks. It + provides a stable data rate for latency-sensitive applications, such as voice calls or + video streaming, helping maintain a consistent user experience. When this attribute is set + this could imply that a GBR QCI is used, though mechanisms on the network can be used to + ensure a minimum performance level without using a GBR QCI. + The committed data rate allocated to specific services, ensuring a consistent level of + performance. DOCSIS networks, the "Minimum Reserved Traffic Rate" is defined in the + DOCSIS 3.1 - MAC and Upper Layer Protocols Interface Specification" + and it ensures a consistent level of performance for specific services within the network. + allOf: + - $ref: "#/components/schemas/Rate" + maxDownstreamRate: + description: | + The maximum best effort rate + allOf: + - $ref: "#/components/schemas/Rate" + maxDownstreamBurstRate: + description: | + When defined, this is the maximum downstream burst rate for the QoS profile, that will enable + the network to burst data at a higher rate than the maxDownstreamRate for a period of time. + This can result in improved user experience when there is additional network capacity. + For instance, when a user is streaming a video, the network can burst data at a higher rate + to fill the buffer, and then return to the maxUpstreamRate once the buffer is full. + allOf: + - $ref: "#/components/schemas/Rate" + minDuration: + description: | + The shortest time period that this profile can be deployed. + allOf: + - $ref: "#/components/schemas/Duration" + maxDuration: + description: | + The maximum time period that this profile can be deployed. + NOTE: currently the duration within `sessionInfo` is limited to 86400 seconds (1 day). + The value of `maxDuration` shouldn't therefore exceed this time period. The limitation might be removed in later versions. + allOf: + - $ref: "#/components/schemas/Duration" + priority: + type: integer + example: 20 + description: | + Priority levels allow efficient resource allocation and ensure optimal performance + for various services in each technology, with the highest priority traffic receiving + preferential treatment. + The lower value the higher priority. + Not all access networks use the same priority range, so this priority will be + scaled to the access network's priority range. + format: int32 + minimum: 1 + maximum: 100 + packetDelayBudget: + description: | + The packet delay budget is the maximum allowable one-way latency between the customer's device + and the gateway from the operator's network to other networks. By limiting the delay, the network + can provide an acceptable level of performance for various services, such as voice calls, + video streaming, and data. + The end-to-end or round trip latency will be about two times this value plus the latency not controlled + by the operator + allOf: + - $ref: "#/components/schemas/Duration" + jitter: + description: | + The jitter requirement aims to limit the maximum variation in round-trip + packet delay for the 99th percentile of traffic, following ITU Y.1540 + standards. It considers only acknowledged packets in a session, which are + packets that receive a confirmation of receipt from the recipient (e.g., + using TCP). This requirement helps maintain consistent latency, essential + for real-time applications such as VoIP, video calls, and gaming. + allOf: + - $ref: "#/components/schemas/Duration" + packetErrorLossRate: + type: integer + description: | + The exponential power of the allowable error loss rate 10^(-N). + For instance 3 would be an error loss rate of 10 to the power of -3 (0.001) + + For 5G network the 3GPP specification TS 23.203 defines the packet error loss rate QCI attribute. It + describes the Quality of Service (QoS) Class Identifier (QCI) parameters used to + differentiate traffic classes in mobile networks, ensuring appropriate resource + allocation and performance for various services. + + The packet error loss rate is one of the QCI attributes, providing information on the + acceptable packet loss rate for a specific traffic class. This attribute helps maintain + the desired performance level for services like voice calls, video streaming, or data + transfers within the 3GPP mobile network. + format: int32 + minimum: 1 + maximum: 10 + example: 3 + required: + - name + - status + + QosProfileName: + description: | + A unique name for identifying a specific QoS profile. + This may follow different formats depending on the service providers implementation. + Some options addresses: + - A UUID style string + - Support for predefined profiles QOS_S, QOS_M, QOS_L, and QOS_E + - A searchable descriptive name + type: string + example: QCI_1_voice + minLength: 3 + maxLength: 256 + format: string + pattern: "^[a-zA-Z0-9_.-]+$" + + Rate: + type: object + properties: + value: + type: integer + example: 10 + format: int32 + minimum: 0 + maximum: 1024 + unit: + $ref: "#/components/schemas/RateUnitEnum" + + Duration: + type: object + properties: + value: + type: integer + example: 12 + format: int32 + minimum: 1 + unit: + allOf: + - $ref: "#/components/schemas/TimeUnitEnum" + - example: Minutes + + TimeUnitEnum: + type: string + enum: + - Days + - Hours + - Minutes + - Seconds + - Milliseconds + - Microseconds + - Nanoseconds + + QosProfileStatusEnum: + description: | + The current status of the QoS Profile + - `ACTIVE`- QoS Profile is available to be used + - `INACTIVE`- QoS Profile is not currently available to be deployed + - `DEPRECATED`- QoS profile is actively being used in a QoD session, but can not be deployed in new QoD sessions + type: string + enum: + - ACTIVE + - INACTIVE + - DEPRECATED + + RateUnitEnum: + type: string + enum: + - bps + - kbps + - Mbps + - Gbps + - Tbps + + CloudEvent: + description: Event compliant with the CloudEvents specification + required: + - id + - source + - specversion + - type + - time + properties: + id: + description: Identifier of this event, that must be unique in the source context. + type: string + source: + description: Identifies the context in which an event happened in the specific Provider Implementation. + type: string + format: uri-reference + type: + description: The type of the event. + type: string + enum: + - 'org.camaraproject.qod.v0.qos-status-changed' + specversion: + description: Version of the specification to which this event conforms (must be 1.0 if it conforms to cloudevents 1.0.2 version) + type: string + enum: + - '1.0' + datacontenttype: + description: 'media-type that describes the event payload encoding, must be "application/json" for CAMARA APIs' + type: string + enum: + - 'application/json' + data: + description: Event notification details payload, which depends on the event type + type: object + time: + description: | + Timestamp of when the occurrence happened. It must follow RFC 3339 + type: string + format: date-time + discriminator: + propertyName: 'type' + mapping: + org.camaraproject.qod.v0.qos-status-changed: '#/components/schemas/EventQosStatusChanged' + + EventQosStatusChanged: + allOf: + - $ref: "#/components/schemas/CloudEvent" + - type: object + properties: + data: + type: object + description: Event details depending on the event type + required: + - sessionId + - qosStatus + properties: + sessionId: + $ref: "#/components/schemas/SessionId" + qosStatus: + $ref: "#/components/schemas/EventQosStatus" + statusInfo: + $ref: "#/components/schemas/StatusInfo" + required: + - data + + StatusInfo: + description: | + Reason for the new `qosStatus`. Currently `statusInfo` is only applicable when `qosStatus` is 'UNAVAILABLE'. + * `DURATION_EXPIRED` - Session terminated due to requested duration expired + * `NETWORK_TERMINATED` - Network terminated the session before the requested duration expired + * `DELETE_REQUESTED`- User requested the deletion of the session before the requested duration expired + + type: string + enum: + - DURATION_EXPIRED + - NETWORK_TERMINATED + - DELETE_REQUESTED + + Device: + description: | + End-user equipment able to connect to a mobile network. Examples of devices include smartphones or IoT sensors/actuators. + + The developer can choose to provide the below specified device identifiers: + + * `ipv4Address` + * `ipv6Address` + * `phoneNumber` + * `networkAccessIdentifier` + + NOTE: the MNO might support only a subset of these options. The API invoker can provide multiple identifiers to be compatible across different MNOs. In this case the identifiers MUST belong to the same device + type: object + properties: + phoneNumber: + $ref: "#/components/schemas/PhoneNumber" + networkAccessIdentifier: + $ref: "#/components/schemas/NetworkAccessIdentifier" + ipv4Address: + $ref: "#/components/schemas/DeviceIpv4Addr" + ipv6Address: + $ref: "#/components/schemas/DeviceIpv6Address" + minProperties: 1 + + ApplicationServer: + description: | + A server hosting backend applications to deliver some business logic to clients. + + The developer can choose to provide the below specified device identifiers: + + * `ipv4Address` + * `ipv6Address` + type: object + properties: + ipv4Address: + $ref: "#/components/schemas/ApplicationServerIpv4Address" + ipv6Address: + $ref: "#/components/schemas/ApplicationServerIpv6Address" + minProperties: 1 + + NetworkAccessIdentifier: + description: A public identifier addressing a subscription in a mobile network. In 3GPP terminology, it corresponds to the GPSI formatted with the External Identifier ({Local Identifier}@{Domain Identifier}). Unlike the telephone number, the network access identifier is not subjected to portability ruling in force, and is individually managed by each operator. + type: string + example: "123456789@domain.com" + + PhoneNumber: + description: A public identifier addressing a telephone subscription. In mobile networks it corresponds to the MSISDN (Mobile Station International Subscriber Directory Number). In order to be globally unique it has to be formatted in international format, according to E.164 standard, optionally prefixed with '+'. + type: string + pattern: '^\+?[0-9]{5,15}$' + example: "123456789" + + DeviceIpv4Addr: + type: object + description: | + The device should be identified by either the public (observed) IP address and port as seen by the application server, or the private (local) and any public (observed) IP addresses in use by the device (this information can be obtained by various means, for example from some DNS servers). + + If the allocated and observed IP addresses are the same (i.e. NAT is not in use) then the same address should be specified for both publicAddress and privateAddress. + + If NAT64 is in use, the device should be identified by its publicAddress and publicPort, or separately by its allocated IPv6 address (field ipv6Address of the Device object) + + In all cases, publicAddress must be specified, along with at least one of either privateAddress or publicPort, dependent upon which is known. In general, mobile devices cannot be identified by their public IPv4 address alone. + properties: + publicAddress: + $ref: "#/components/schemas/SingleIpv4Addr" + privateAddress: + $ref: "#/components/schemas/SingleIpv4Addr" + publicPort: + $ref: "#/components/schemas/Port" + anyOf: + - required: [publicAddress, privateAddress] + - required: [publicAddress, publicPort] + example: + { + "publicAddress": "84.125.93.10", + "publicPort": 59765 + } + + SingleIpv4Addr: + description: A single IPv4 address with no subnet mask + type: string + format: ipv4 + example: "84.125.93.10" + + DeviceIpv6Address: + description: | + The device should be identified by the observed IPv6 address, or by any single IPv6 address from within the subnet allocated to the device (e.g. adding ::0 to the /64 prefix). + + The session shall apply to all IP flows between the device subnet and the specified application server, unless further restricted by the optional parameters devicePorts or applicationServerPorts. + type: string + format: ipv6 + example: 2001:db8:85a3:8d3:1319:8a2e:370:7344 + + ApplicationServerIpv4Address: + type: string + example: "192.168.0.1/24" + description: | + IPv4 address may be specified in form
as: + - address - an IPv4 number in dotted-quad form 1.2.3.4. Only this exact IP number will match the flow control rule. + - address/mask - an IP number as above with a mask width of the form 1.2.3.4/24. + In this case, all IP numbers from 1.2.3.0 to 1.2.3.255 will match. The bit width MUST be valid for the IP version. + + ApplicationServerIpv6Address: + type: string + example: "2001:db8:85a3:8d3:1319:8a2e:370:7344" + description: | + IPv6 address may be specified in form
as: + - address - The /128 subnet is optional for single addresses: + - 2001:db8:85a3:8d3:1319:8a2e:370:7344 + - 2001:db8:85a3:8d3:1319:8a2e:370:7344/128 + - address/mask - an IP v6 number with a mask: + - 2001:db8:85a3:8d3::0/64 + - 2001:db8:85a3:8d3::/64 + + Message: + type: object + properties: + severity: + description: Message severity + type: string + enum: ["INFO", "WARNING"] + description: + description: Detailed message text + type: string + required: + - severity + - description + + QosStatus: + description: | + The current status of the requested QoS session. The status can be one of the following: + * `REQUESTED` - QoS has been requested by creating a session + * `AVAILABLE` - The requested QoS has been provided by the network + * `UNAVAILABLE` - The requested QoS cannot be provided by the network due to some reason + type: string + enum: + - REQUESTED + - AVAILABLE + - UNAVAILABLE + + EventQosStatus: + description: | + The current status of a requested or previously available session. Applicable values in the event are: + * `AVAILABLE` - The requested QoS has been provided by the network. + * `UNAVAILABLE` - A requested or previously available QoS session is now unavailable. `statusInfo` may provide additional information about the reason for the unavailability. + type: string + enum: + - AVAILABLE + - UNAVAILABLE + + ErrorInfo: + type: object + properties: + status: + type: integer + description: HTTP status code returned along with this error response + code: + type: string + description: Code given to this error + message: + type: string + description: Detailed error description + required: + - status + - code + - message + + responses: + Generic400: + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 400 + code: INVALID_ARGUMENT + message: "Schema validation failed at ..." + + Generic401: + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 401 + code: UNAUTHENTICATED + message: "Authorization failed: ..." + + Generic403: + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 403 + code: PERMISSION_DENIED + message: "Operation not allowed: ..." + + SessionNotFound404: + description: Session not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 404 + code: NOT_FOUND + message: "Session Id does not exist" + + QosProfilesNotFound404: + description: Qos Profiles not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 404 + code: NOT_FOUND + message: "No QoS Profiles found" + + QosProfileNotFound404: + description: Qos Profile not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 404 + code: NOT_FOUND + message: "QosProfile Id does not exist" + + Generic500: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 500 + code: INTERNAL + message: "Internal server error: ..." + + QoSProfile500: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 500 + code: INTERNAL + message: "Internal server error: Could not get QoS Profile" + + Generic501: + description: Not Implemented + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 501 + code: NOT_IMPLEMENTED + message: "Service not implemented for the specified user device" + + Generic503: + description: Service unavailable + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 503 + code: UNAVAILABLE + message: "Service unavailable" + + examples: + QOS_STATUS_CHANGED_EXAMPLE: + summary: QoS status changed + value: + id: 83a0d986-0866-4f38-b8c0-fc65bfcda452 + source: 'https://api.example.com/qod/v0/sessions/123e4567-e89b-12d3-a456-426614174000' + specversion: '1.0' + type: 'org.camaraproject.qod.v0.qos-status-changed' + time: '2021-12-12T00:00:00Z' + data: + sessionId: '123e4567-e89b-12d3-a456-426614174000' + qosStatus: 'UNAVAILABLE' + statusInfo: 'DURATION_EXPIRED' diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py index 92f02de..5f4cc7b 100644 --- a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py @@ -1,16 +1,26 @@ -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends, Response, status from open_exposure_gateway.api.camara.common import XCorrelatorHeader from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( - QoDSessionRequest, - QoDSessionResponse, + CreateSession, + SessionInfo, ) from open_exposure_gateway.application.services.quality_on_demand_service import ( QualityOnDemandService, ) +from open_exposure_gateway.core.exceptions import ( + BadRequestException, + ConflictException, + DownstreamServiceException, + ForbiddenException, + NotFoundException, + NotImplementedException, + UnauthorizedException, +) from open_exposure_gateway.dependencies import get_qod_service +from open_exposure_gateway.schemas.common import ErrorInfo # CAMARA base path: the spec serves at {apiRoot}/qod/v0 (wire version of v0.10.1). BASE_PATH = "/qod/v0" @@ -19,18 +29,42 @@ router = APIRouter(prefix=BASE_PATH) QoDService = Annotated[QualityOnDemandService, Depends(get_qod_service)] +# 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, + ) +} +_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} + @router.post( "/sessions", - tags=["Quality on Demand Functions"], - summary="Creates a new QoD Session", + tags=["QoS Sessions"], + summary="Creates a new session", + response_model=SessionInfo, + response_model_exclude_none=True, status_code=status.HTTP_201_CREATED, + responses=_responses(400, 401, 403, 409, 500, 501, 503), ) async def create_qod_session( - request: QoDSessionRequest, + request: CreateSession, service: QoDService, x_correlator: XCorrelatorHeader = None, -) -> QoDSessionResponse: +) -> Any: return await service.create_session( request=request, x_correlator=x_correlator, @@ -39,14 +73,17 @@ async def create_qod_session( @router.get( "/sessions/{sessionId}", - tags=["Quality on Demand Functions"], - summary="Retrieve details of a QoD Session", + tags=["QoS Sessions"], + summary="Get QoS session information", + response_model=SessionInfo, + response_model_exclude_none=True, + responses=_responses(400, 401, 403, 404, 500, 503), ) async def get_qod_session( sessionId: str, service: QoDService, x_correlator: XCorrelatorHeader = None, -) -> QoDSessionResponse: +) -> Any: return await service.get_session( session_id=sessionId, x_correlator=x_correlator, @@ -55,9 +92,10 @@ async def get_qod_session( @router.delete( "/sessions/{sessionId}", - tags=["Quality on Demand Functions"], - summary="Remove QoD Session", + tags=["QoS Sessions"], + summary="Delete a QoS session", status_code=status.HTTP_204_NO_CONTENT, + responses=_responses(400, 401, 403, 404, 500, 503), ) async def delete_qod_session( sessionId: str, diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py index 4a75dc9..7c0ad29 100644 --- a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py @@ -1,8 +1,8 @@ from enum import StrEnum -from typing import Optional +from typing import Literal, Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class QosStatus(StrEnum): @@ -11,27 +11,49 @@ class QosStatus(StrEnum): UNAVAILABLE = "UNAVAILABLE" -class Ipv4Address(BaseModel): +class DeviceIpv4Addr(BaseModel): publicAddress: Optional[str] = None privateAddress: Optional[str] = None publicPort: Optional[int] = Field(default=None, ge=0, le=65535) + @model_validator(mode="after") + def _require_public_plus_one(self) -> "DeviceIpv4Addr": + if self.publicAddress is None: + raise ValueError("publicAddress is required") + if self.privateAddress is None and self.publicPort is None: + raise ValueError("at least one of privateAddress or publicPort is required") + return self + class Device(BaseModel): - phoneNumber: Optional[str] = None + phoneNumber: Optional[str] = Field(default=None, pattern=r"^\+?[0-9]{5,15}$") networkAccessIdentifier: Optional[str] = None - ipv4Address: Optional[Ipv4Address] = None + ipv4Address: Optional[DeviceIpv4Addr] = None ipv6Address: Optional[str] = None + @model_validator(mode="after") + def _require_at_least_one_identifier(self) -> "Device": + if not any( + (self.phoneNumber, self.networkAccessIdentifier, self.ipv4Address, self.ipv6Address) + ): + raise ValueError("at least one device identifier must be provided") + return self + class ApplicationServer(BaseModel): ipv4Address: Optional[str] = None ipv6Address: Optional[str] = None + @model_validator(mode="after") + def _require_at_least_one_address(self) -> "ApplicationServer": + if not any((self.ipv4Address, self.ipv6Address)): + raise ValueError("at least one of ipv4Address or ipv6Address must be provided") + return self + class PortRange(BaseModel): - from_: int = Field(alias="from") - to: int + from_: int = Field(alias="from", ge=0, le=65535) + to: int = Field(ge=0, le=65535) model_config = { "populate_by_name": True, @@ -42,25 +64,40 @@ class PortsSpec(BaseModel): ranges: Optional[list[PortRange]] = None ports: Optional[list[int]] = None + @model_validator(mode="after") + def _require_ranges_or_ports(self) -> "PortsSpec": + if not self.ranges and not self.ports: + raise ValueError("at least one of ranges or ports must be provided") + return self + + +class Webhook(BaseModel): + notificationUrl: str + notificationAuthToken: Optional[str] = Field(default=None, min_length=20, max_length=256) + + +class Message(BaseModel): + severity: Literal["INFO", "WARNING"] + description: str -class QoDSessionRequest(BaseModel): + +class BaseSessionInfo(BaseModel): device: Device applicationServer: ApplicationServer - qosProfile: str devicePorts: Optional[PortsSpec] = None applicationServerPorts: Optional[PortsSpec] = None - sink: Optional[str] = None + qosProfile: str = Field(min_length=3, max_length=256, pattern=r"^[a-zA-Z0-9_.-]+$") + webhook: Optional[Webhook] = None + + +class CreateSession(BaseSessionInfo): duration: int = Field(default=86400, ge=1, le=86400) -class QoDSessionResponse(BaseModel): +class SessionInfo(BaseSessionInfo): sessionId: UUID - device: Device - applicationServer: ApplicationServer - qosProfile: str - duration: int + duration: int = Field(ge=1) startedAt: int expiresAt: int qosStatus: QosStatus - devicePorts: Optional[PortsSpec] = None - applicationServerPorts: Optional[PortsSpec] = None + messages: Optional[list[Message]] = None diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index 40501a5..c90cc69 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -1,8 +1,8 @@ from typing import Optional from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( - QoDSessionRequest, - QoDSessionResponse, + CreateSession, + SessionInfo, ) from open_exposure_gateway.ports.srm_port import SRMClientPort @@ -13,9 +13,9 @@ class QualityOnDemandService: async def create_session( self, - request: QoDSessionRequest, + request: CreateSession, x_correlator: Optional[str] = None, - ) -> QoDSessionResponse: + ) -> SessionInfo: return await self.srm_client.create_qod_session( payload=request.model_dump(mode="json"), x_correlator=x_correlator, @@ -25,7 +25,7 @@ class QualityOnDemandService: self, session_id: str, x_correlator: Optional[str] = None, - ) -> QoDSessionResponse: + ) -> SessionInfo: return await self.srm_client.get_qod_session( session_id=session_id, x_correlator=x_correlator, diff --git a/src/open_exposure_gateway/ports/srm_port.py b/src/open_exposure_gateway/ports/srm_port.py index fe0f3ee..fe1cfd6 100644 --- a/src/open_exposure_gateway/ports/srm_port.py +++ b/src/open_exposure_gateway/ports/srm_port.py @@ -2,7 +2,7 @@ from typing import Any, Protocol from uuid import UUID from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( - QoDSessionResponse, + SessionInfo, ) from open_exposure_gateway.domain.edge_application_management import ( SRMCatalogPayload, @@ -40,10 +40,8 @@ class SRMClientPort(Protocol): async def create_qod_session( self, payload: dict[str, Any], x_correlator: str | None - ) -> QoDSessionResponse: ... + ) -> SessionInfo: ... - async def get_qod_session( - self, session_id: str, x_correlator: str | None - ) -> QoDSessionResponse: ... + async def get_qod_session(self, session_id: str, x_correlator: str | None) -> SessionInfo: ... async def delete_qod_session(self, session_id: str, x_correlator: str | None) -> None: ... diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index ec7dfef..8f7cfe6 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -28,8 +28,8 @@ from open_exposure_gateway.adapters.errors import ( DuplicateOperationError, ) from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( - QoDSessionResponse, QosStatus, + SessionInfo, ) from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, @@ -99,7 +99,7 @@ class FakeSRMClient: self.zones: list[SRMZone] = [] self.catalog: dict[str, dict[str, Any]] = {} self.instances: dict[str, SRMServiceInstance] = {} - self.qod_sessions: dict[str, QoDSessionResponse] = {} + self.qod_sessions: dict[str, SessionInfo] = {} async def get_zones( self, @@ -144,9 +144,9 @@ class FakeSRMClient: async def create_qod_session( self, payload: dict[str, Any], x_correlator: str | None = None - ) -> QoDSessionResponse: + ) -> SessionInfo: duration = payload.get("duration", 86400) - session = QoDSessionResponse( + session = SessionInfo( sessionId=uuid4(), device=payload["device"], applicationServer=payload["applicationServer"], @@ -157,17 +157,23 @@ class FakeSRMClient: qosStatus=QosStatus.REQUESTED, devicePorts=payload.get("devicePorts"), applicationServerPorts=payload.get("applicationServerPorts"), + webhook=payload.get("webhook"), ) self.qod_sessions[str(session.sessionId)] = session return session async def get_qod_session( self, session_id: str, x_correlator: str | None = None - ) -> QoDSessionResponse: - return self.qod_sessions[session_id] + ) -> SessionInfo: + session = self.qod_sessions.get(session_id) + if session is None: + raise NotFoundException(message=f"Session {session_id} not found") + return session async def delete_qod_session(self, session_id: str, x_correlator: str | None = None) -> None: - self.qod_sessions.pop(session_id, None) + if session_id not in self.qod_sessions: + raise NotFoundException(message=f"Session {session_id} not found") + del self.qod_sessions[session_id] def completion_payload(operation_id: str, **overrides: Any) -> dict[str, Any]: diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index b910a2b..054fad9 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -5,6 +5,7 @@ A failing test here means a real hole in the flow, not a broken test. """ from typing import Any +from uuid import uuid4 from fastapi.testclient import TestClient @@ -14,7 +15,9 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.router import ( from tests.unit.fakes import FakeSRMClient SESSION_BODY: dict[str, Any] = { - "device": {"ipv4Address": {"publicAddress": "84.125.93.10"}}, + "device": { + "ipv4Address": {"publicAddress": "84.125.93.10", "publicPort": 59765}, + }, "applicationServer": {"ipv4Address": "192.168.0.1"}, "qosProfile": "QOS_E", "duration": 3600, @@ -32,6 +35,13 @@ class TestQodSessionFlow: assert body["qosProfile"] == "QOS_E" assert body["sessionId"] in fake_srm.qod_sessions + def test_create_session_accepts_webhook( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + body = {**SESSION_BODY, "webhook": {"notificationUrl": "https://application-server.com"}} + response = api_client.post(f"{QOD_BASE}/sessions", json=body) + assert response.status_code == 201 + def test_get_session_returns_created_session( self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: @@ -40,6 +50,10 @@ class TestQodSessionFlow: assert response.status_code == 200 assert response.json()["sessionId"] == session_id + def test_get_session_returns_404_when_unknown(self, api_client: TestClient) -> None: + response = api_client.get(f"{QOD_BASE}/sessions/{uuid4()}") + assert response.status_code == 404 + def test_delete_session_releases_the_downstream_reservation( self, api_client: TestClient, fake_srm: FakeSRMClient ) -> None: @@ -51,3 +65,39 @@ class TestQodSessionFlow: assert response.status_code == 204 assert session_id not in fake_srm.qod_sessions + + def test_delete_session_returns_404_when_unknown(self, api_client: TestClient) -> None: + response = api_client.delete(f"{QOD_BASE}/sessions/{uuid4()}") + assert response.status_code == 404 + + +class TestQodSessionValidation: + """CAMARA qod-api.yaml constraints the request schema must enforce (400s).""" + + def test_device_with_no_identifier_is_rejected(self, api_client: TestClient) -> None: + body = {**SESSION_BODY, "device": {}} + response = api_client.post(f"{QOD_BASE}/sessions", json=body) + assert response.status_code == 400 + + def test_application_server_with_no_address_is_rejected(self, api_client: TestClient) -> None: + body = {**SESSION_BODY, "applicationServer": {}} + response = api_client.post(f"{QOD_BASE}/sessions", json=body) + assert response.status_code == 400 + + def test_device_ipv4_public_address_alone_is_rejected(self, api_client: TestClient) -> None: + """Spec anyOf: publicAddress must be paired with privateAddress or publicPort.""" + body = {**SESSION_BODY, "device": {"ipv4Address": {"publicAddress": "84.125.93.10"}}} + response = api_client.post(f"{QOD_BASE}/sessions", json=body) + assert response.status_code == 400 + + def test_invalid_qos_profile_name_is_rejected(self, api_client: TestClient) -> None: + body = {**SESSION_BODY, "qosProfile": "no spaces allowed"} + response = api_client.post(f"{QOD_BASE}/sessions", json=body) + assert response.status_code == 400 + + def test_devports_with_neither_ranges_nor_ports_is_rejected( + self, api_client: TestClient + ) -> None: + body = {**SESSION_BODY, "devicePorts": {}} + response = api_client.post(f"{QOD_BASE}/sessions", json=body) + assert response.status_code == 400 -- GitLab From 1bf9d598b8e6441acf2ae3775e21916a63b2e044 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 12:44:04 +0300 Subject: [PATCH 02/32] feat: add SRM network-capability-activate command model --- .../domain/quality_on_demand.py | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index f3a59eb..d169cb6 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -1,3 +1,53 @@ -# TODO: Define internal OEG↔SRM domain models for Quality on Demand. -# These will represent the platform-internal schema used for both the sync path -# (SRM HTTP client) and the async path (DataBus), translated from the CAMARA schema. +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field + + +class Subject(StrEnum): + TASK_ACTIVATE = "command.srm.network.capability.activate" + + +class NetworkCapabilityTargetDevice(BaseModel): + phone_number: str | None = None + ipv4: str | None = None + ipv6: str | None = None + network_access_id: str | None = None + + +class NetworkCapabilityTargetApplicationServer(BaseModel): + ipv4: str | None = None + ipv6: str | None = None + + +class NetworkCapabilityTarget(BaseModel): + device: NetworkCapabilityTargetDevice + application_server: NetworkCapabilityTargetApplicationServer + + +class NetworkCapabilityParameters(BaseModel): + duration_seconds: int + # Always {} — nothing sources this yet; reserved by srm.params/v1. + extra: dict[str, Any] = Field(default_factory=dict) + + +class NetworkCapabilityPayload(BaseModel): + capability_type: str = "qod_session" + target: NetworkCapabilityTarget + profile_ref: str + parameters: NetworkCapabilityParameters + + +class SRMNetworkCapabilityActivateCommand(BaseModel): + schema_version: str = "1.0" + operation_id: str + correlation_id: str + requested_at: str + app_provider_id: str + source: str = "nbi_camara" + service_specification_id: str + resource_zone_id: str | None = None + network_domain_id: str | None = None + network_capability: NetworkCapabilityPayload -- GitLab From 167d342e4fa8fbb16f8f0704f999d293896726a1 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 12:50:43 +0300 Subject: [PATCH 03/32] feat: add qod_sessions persistence layer --- .../adapters/database/mappers.py | 30 +++++++++++++++++++ .../adapters/database/repos/qod_sessions.py | 27 +++++++++++++++++ .../adapters/database/sql.py | 16 ++++++++++ .../domain/models/__init__.py | 3 ++ .../domain/models/qod_sessions/__init__.py | 4 +++ .../domain/models/qod_sessions/enums.py | 7 +++++ .../domain/models/qod_sessions/models.py | 18 +++++++++++ .../ports/database/__init__.py | 2 ++ .../ports/database/qod_sessions.py | 16 ++++++++++ 9 files changed, 123 insertions(+) create mode 100644 src/open_exposure_gateway/adapters/database/repos/qod_sessions.py create mode 100644 src/open_exposure_gateway/domain/models/qod_sessions/__init__.py create mode 100644 src/open_exposure_gateway/domain/models/qod_sessions/enums.py create mode 100644 src/open_exposure_gateway/domain/models/qod_sessions/models.py create mode 100644 src/open_exposure_gateway/ports/database/qod_sessions.py diff --git a/src/open_exposure_gateway/adapters/database/mappers.py b/src/open_exposure_gateway/adapters/database/mappers.py index 6f8067f..b18dde2 100644 --- a/src/open_exposure_gateway/adapters/database/mappers.py +++ b/src/open_exposure_gateway/adapters/database/mappers.py @@ -4,6 +4,7 @@ from open_exposure_gateway.adapters.database.sql import ( CallbackDeliveryRow, CallbackRegistrationRow, OperationRow, + QodSessionRow, ) from open_exposure_gateway.domain.models import ( AppInstance, @@ -11,6 +12,7 @@ from open_exposure_gateway.domain.models import ( CallbackDelivery, CallbackRegistration, Operation, + QodSession, ) @@ -108,6 +110,34 @@ class AppInstanceMapper: ) +class QodSessionMapper: + @staticmethod + def to_domain(row: QodSessionRow) -> QodSession: + return QodSession( + session_id=row.session_id, + operation_id=row.operation_id, + service_specification_id=row.service_specification_id, + qos_profile=row.qos_profile, + duration_seconds=row.duration_seconds, + state=row.state, + external_ref=row.external_ref, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: QodSession) -> QodSessionRow: + return QodSessionRow( + session_id=domain.session_id, + operation_id=domain.operation_id, + service_specification_id=domain.service_specification_id, + qos_profile=domain.qos_profile, + duration_seconds=domain.duration_seconds, + state=domain.state, + external_ref=domain.external_ref, + ) + + class CallbackRegistrationMapper: @staticmethod def to_domain(row: CallbackRegistrationRow) -> CallbackRegistration: diff --git a/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py b/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py new file mode 100644 index 0000000..50e2215 --- /dev/null +++ b/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py @@ -0,0 +1,27 @@ +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.adapters.database.mappers import QodSessionMapper +from open_exposure_gateway.adapters.database.sql import QodSessionRow +from open_exposure_gateway.domain.models import QodSession +from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository + + +class SqlQodSessionRepository(QodSessionRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, session_id: UUID) -> QodSession | None: + stmt = select(QodSessionRow).where(QodSessionRow.session_id == session_id) + row = await self._session.scalar(stmt) + return QodSessionMapper.to_domain(row) if row is not None else None + + async def save(self, qod_session: QodSession) -> QodSession: + merged = await self._session.merge(QodSessionMapper.to_row(qod_session)) + await self._session.flush() + saved = await self.get_by_id(merged.session_id) + if saved is None: + raise RuntimeError("Saved QoD session could not be reloaded") + return saved diff --git a/src/open_exposure_gateway/adapters/database/sql.py b/src/open_exposure_gateway/adapters/database/sql.py index e982069..0c26b97 100644 --- a/src/open_exposure_gateway/adapters/database/sql.py +++ b/src/open_exposure_gateway/adapters/database/sql.py @@ -26,6 +26,7 @@ from open_exposure_gateway.domain.models.operations.enums import ( OperationStatus, OperationType, ) +from open_exposure_gateway.domain.models.qod_sessions.enums import QodSessionState from open_exposure_gateway.domain.models.registration.enums import ( AppRegistrationStatus, PackageType, @@ -184,3 +185,18 @@ class AppInstanceRow(AuditedMixin, Base): ) edge_cloud_zone_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False) state: Mapped[AppInstanceState] = mapped_column(_enum_type(AppInstanceState), nullable=False) + + +class QodSessionRow(AuditedMixin, Base): + __tablename__ = "qod_sessions" + __table_args__ = (Index("idx_qod_sessions_operation", "operation_id"),) + + session_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True) + operation_id: Mapped[UUID] = mapped_column( + ForeignKey("operations.operation_id"), nullable=False + ) + service_specification_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False) + qos_profile: Mapped[str] = mapped_column(String(256), nullable=False) + duration_seconds: Mapped[int] = mapped_column(Integer, nullable=False) + state: Mapped[QodSessionState] = mapped_column(_enum_type(QodSessionState), nullable=False) + external_ref: Mapped[str | None] = mapped_column(String(255)) diff --git a/src/open_exposure_gateway/domain/models/__init__.py b/src/open_exposure_gateway/domain/models/__init__.py index b3f8795..c9c51c2 100644 --- a/src/open_exposure_gateway/domain/models/__init__.py +++ b/src/open_exposure_gateway/domain/models/__init__.py @@ -8,6 +8,7 @@ from open_exposure_gateway.domain.models.operations import ( OperationStatus, OperationType, ) +from open_exposure_gateway.domain.models.qod_sessions import QodSession, QodSessionState from open_exposure_gateway.domain.models.registration import ( AppRegistration, AppRegistrationStatus, @@ -25,4 +26,6 @@ __all__ = [ "OperationStatus", "OperationType", "PackageType", + "QodSession", + "QodSessionState", ] diff --git a/src/open_exposure_gateway/domain/models/qod_sessions/__init__.py b/src/open_exposure_gateway/domain/models/qod_sessions/__init__.py new file mode 100644 index 0000000..be92fc9 --- /dev/null +++ b/src/open_exposure_gateway/domain/models/qod_sessions/__init__.py @@ -0,0 +1,4 @@ +from open_exposure_gateway.domain.models.qod_sessions.enums import QodSessionState +from open_exposure_gateway.domain.models.qod_sessions.models import QodSession + +__all__ = ["QodSession", "QodSessionState"] diff --git a/src/open_exposure_gateway/domain/models/qod_sessions/enums.py b/src/open_exposure_gateway/domain/models/qod_sessions/enums.py new file mode 100644 index 0000000..f326d61 --- /dev/null +++ b/src/open_exposure_gateway/domain/models/qod_sessions/enums.py @@ -0,0 +1,7 @@ +from enum import StrEnum + + +class QodSessionState(StrEnum): + REQUESTED = "REQUESTED" + AVAILABLE = "AVAILABLE" + UNAVAILABLE = "UNAVAILABLE" diff --git a/src/open_exposure_gateway/domain/models/qod_sessions/models.py b/src/open_exposure_gateway/domain/models/qod_sessions/models.py new file mode 100644 index 0000000..7053b6c --- /dev/null +++ b/src/open_exposure_gateway/domain/models/qod_sessions/models.py @@ -0,0 +1,18 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + +from open_exposure_gateway.domain.models.qod_sessions.enums import QodSessionState + + +class QodSession(BaseModel): + session_id: UUID + operation_id: UUID + service_specification_id: UUID + qos_profile: str + duration_seconds: int + state: QodSessionState + external_ref: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/src/open_exposure_gateway/ports/database/__init__.py b/src/open_exposure_gateway/ports/database/__init__.py index 340826b..9ad4646 100644 --- a/src/open_exposure_gateway/ports/database/__init__.py +++ b/src/open_exposure_gateway/ports/database/__init__.py @@ -4,6 +4,7 @@ from open_exposure_gateway.ports.database.callbacks import ( ) from open_exposure_gateway.ports.database.instances import AppInstanceRepository from open_exposure_gateway.ports.database.operations import OperationRepository +from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository from open_exposure_gateway.ports.database.registration import AppRegistrationRepository __all__ = [ @@ -12,4 +13,5 @@ __all__ = [ "CallbackDeliveryRepository", "CallbackRegistrationRepository", "OperationRepository", + "QodSessionRepository", ] diff --git a/src/open_exposure_gateway/ports/database/qod_sessions.py b/src/open_exposure_gateway/ports/database/qod_sessions.py new file mode 100644 index 0000000..292c49b --- /dev/null +++ b/src/open_exposure_gateway/ports/database/qod_sessions.py @@ -0,0 +1,16 @@ +"""QoD session repository ports.""" + +from abc import ABC, abstractmethod +from uuid import UUID + +from open_exposure_gateway.domain.models import QodSession + + +class QodSessionRepository(ABC): + @abstractmethod + async def get_by_id(self, session_id: UUID) -> QodSession | None: + pass + + @abstractmethod + async def save(self, qod_session: QodSession) -> QodSession: + pass -- GitLab From d59f781296bcae70f75d6c6144f4833c4b32c3dc Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 12:58:46 +0300 Subject: [PATCH 04/32] feat: add CAMARA to SRm activate command mapper --- .../mappers/quality_on_demand_mapper.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py diff --git a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py new file mode 100644 index 0000000..e8787b4 --- /dev/null +++ b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py @@ -0,0 +1,49 @@ +from uuid import UUID + +from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import CreateSession +from open_exposure_gateway.domain.quality_on_demand import ( + NetworkCapabilityParameters, + NetworkCapabilityPayload, + NetworkCapabilityTarget, + NetworkCapabilityTargetApplicationServer, + NetworkCapabilityTargetDevice, + SRMNetworkCapabilityActivateCommand, +) + + +def build_activate_command( + request: CreateSession, + operation_id: UUID, + correlation_id: str, + requested_at: str, + service_specification_id: UUID, + app_provider_id: str, +) -> SRMNetworkCapabilityActivateCommand: + device = request.device + application_server = request.applicationServer + + target = NetworkCapabilityTarget( + device=NetworkCapabilityTargetDevice( + phone_number=device.phoneNumber, + ipv4=device.ipv4Address.publicAddress if device.ipv4Address else None, + ipv6=device.ipv6Address, + network_access_id=device.networkAccessIdentifier, + ), + application_server=NetworkCapabilityTargetApplicationServer( + ipv4=application_server.ipv4Address, + ipv6=application_server.ipv6Address, + ), + ) + + return SRMNetworkCapabilityActivateCommand( + operation_id=str(operation_id), + correlation_id=correlation_id, + requested_at=requested_at, + app_provider_id=app_provider_id, + service_specification_id=str(service_specification_id), + network_capability=NetworkCapabilityPayload( + target=target, + profile_ref=request.qosProfile, + parameters=NetworkCapabilityParameters(duration_seconds=request.duration), + ), + ) -- GitLab From 31e28fd6c125a276096f95a683a9409b9eb67935 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 13:14:57 +0300 Subject: [PATCH 05/32] feat: make POST /sessions async via the databus, tests added --- .../quality_on_demand/v0_10_1/router.py | 6 +- .../services/quality_on_demand_service.py | 111 ++++++++++++- src/open_exposure_gateway/dependencies.py | 13 +- tests/unit/conftest.py | 25 ++- tests/unit/fakes.py | 16 ++ tests/unit/test_qod_flows.py | 75 +++++++-- tests/unit/test_qod_service.py | 149 ++++++++++++++++++ 7 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 tests/unit/test_qod_service.py diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py index 5f4cc7b..cfbb5aa 100644 --- a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py @@ -19,7 +19,7 @@ from open_exposure_gateway.core.exceptions import ( NotImplementedException, UnauthorizedException, ) -from open_exposure_gateway.dependencies import get_qod_service +from open_exposure_gateway.dependencies import CallerContext, get_caller_context, get_qod_service from open_exposure_gateway.schemas.common import ErrorInfo # CAMARA base path: the spec serves at {apiRoot}/qod/v0 (wire version of v0.10.1). @@ -28,6 +28,7 @@ BASE_PATH = "/qod/v0" router = APIRouter(prefix=BASE_PATH) QoDService = Annotated[QualityOnDemandService, Depends(get_qod_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 @@ -63,10 +64,13 @@ def _responses(*codes: int) -> dict[int | str, dict[str, Any]]: async def create_qod_session( request: CreateSession, service: QoDService, + caller: Caller, x_correlator: XCorrelatorHeader = None, ) -> Any: return await service.create_session( request=request, + tenant_id=caller.tenant_id, + app_provider_id=caller.app_provider_id, x_correlator=x_correlator, ) diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index c90cc69..57fcfa1 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -1,24 +1,127 @@ +from datetime import datetime, timezone from typing import Optional +from uuid import UUID, uuid4 + +from pydantic import BaseModel from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( CreateSession, + QosStatus, SessionInfo, ) +from open_exposure_gateway.application.mappers.quality_on_demand_mapper import ( + build_activate_command, +) +from open_exposure_gateway.core.exceptions import DownstreamServiceException +from open_exposure_gateway.domain.models import ( + Operation, + OperationStatus, + OperationType, + QodSession, + QodSessionState, +) +from open_exposure_gateway.domain.quality_on_demand import Subject +from open_exposure_gateway.ports.database.operations import OperationRepository +from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository +from open_exposure_gateway.ports.databus_port import DataBusPort from open_exposure_gateway.ports.srm_port import SRMClientPort class QualityOnDemandService: - def __init__(self, srm_client: SRMClientPort) -> None: + def __init__( + self, + srm_client: SRMClientPort, + publisher: Optional[DataBusPort] = None, + operation_repo: Optional[OperationRepository] = None, + qod_session_repo: Optional[QodSessionRepository] = None, + ) -> None: self.srm_client = srm_client + self._publisher = publisher + self._operation_repo = operation_repo + self._qod_session_repo = qod_session_repo + + def _new_operation_metadata(self, x_correlator: Optional[str]) -> tuple[UUID, str, str]: + operation_id = uuid4() + correlation_id = x_correlator or str(uuid4()) + requested_at = datetime.now(timezone.utc).isoformat() + return operation_id, correlation_id, requested_at + + async def _publish(self, subject: Subject, command: BaseModel, error_msg: str) -> None: + if self._publisher is None: + raise RuntimeError("DataBus publisher is not available") + try: + await self._publisher.publish(subject, command.model_dump(mode="json")) + except Exception as exc: + raise DownstreamServiceException(error_msg) from exc async def create_session( self, request: CreateSession, + tenant_id: str, + app_provider_id: str, x_correlator: Optional[str] = None, ) -> SessionInfo: - return await self.srm_client.create_qod_session( - payload=request.model_dump(mode="json"), - x_correlator=x_correlator, + if self._operation_repo is None or self._qod_session_repo is None: + raise RuntimeError("Operation/QodSession repositories are not available") + + operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) + session_id = uuid4() + service_specification_id = uuid4() + + command = build_activate_command( + request=request, + operation_id=operation_id, + correlation_id=correlation_id, + requested_at=requested_at, + service_specification_id=service_specification_id, + app_provider_id=app_provider_id, + ) + + await self._operation_repo.save( + Operation( + operation_id=operation_id, + correlation_id=correlation_id, + tenant_id=tenant_id, + app_provider_id=app_provider_id, + operation_type=OperationType.NETWORK_CAPABILITY, + status=OperationStatus.PENDING, + subject=Subject.TASK_ACTIVATE, + metadata={ + "qos_profile": request.qosProfile, + "service_specification_id": str(service_specification_id), + }, + ) + ) + await self._qod_session_repo.save( + QodSession( + session_id=session_id, + operation_id=operation_id, + service_specification_id=service_specification_id, + qos_profile=request.qosProfile, + duration_seconds=request.duration, + state=QodSessionState.REQUESTED, + ) + ) + + await self._publish( + Subject.TASK_ACTIVATE, + command, + "Failed to publish QoD session activation", + ) + + started_at = int(datetime.now(timezone.utc).timestamp()) + return SessionInfo( + sessionId=session_id, + device=request.device, + applicationServer=request.applicationServer, + devicePorts=request.devicePorts, + applicationServerPorts=request.applicationServerPorts, + qosProfile=request.qosProfile, + webhook=request.webhook, + duration=request.duration, + startedAt=started_at, + expiresAt=started_at + request.duration, + qosStatus=QosStatus.REQUESTED, ) async def get_session( diff --git a/src/open_exposure_gateway/dependencies.py b/src/open_exposure_gateway/dependencies.py index f826c19..86b7682 100644 --- a/src/open_exposure_gateway/dependencies.py +++ b/src/open_exposure_gateway/dependencies.py @@ -18,6 +18,9 @@ from open_exposure_gateway.adapters.database.repos.callback_registrations import from open_exposure_gateway.adapters.database.repos.operations import ( SqlOperationRepository, ) +from open_exposure_gateway.adapters.database.repos.qod_sessions import ( + SqlQodSessionRepository, +) from open_exposure_gateway.api.camara.common import XCorrelatorHeader from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, @@ -29,6 +32,7 @@ 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.qod_sessions import QodSessionRepository 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 @@ -111,6 +115,10 @@ def get_callback_registration_repo(session: SessionDep) -> CallbackRegistrationR return SqlCallbackRegistrationRepository(session) +def get_qod_session_repo(session: SessionDep) -> QodSessionRepository: + return SqlQodSessionRepository(session) + + def get_edge_app_service( srm: SRMClientPort = Depends(get_client), publisher: DataBusPort = Depends(get_publisher), @@ -133,5 +141,8 @@ def get_edge_app_service( def get_qod_service( srm: SRMClientPort = Depends(get_client), + publisher: DataBusPort = Depends(get_publisher), + operation_repo: OperationRepository = Depends(get_operation_repo), + qod_session_repo: QodSessionRepository = Depends(get_qod_session_repo), ) -> QualityOnDemandService: - return QualityOnDemandService(srm) + return QualityOnDemandService(srm, publisher, operation_repo, qod_session_repo) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 075f847..7f96712 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -29,6 +29,7 @@ from tests.unit.fakes import ( FakeCallbackRegistrationRepository, FakeDataBus, FakeOperationRepository, + FakeQodSessionRepository, FakeSRMClient, wire_operation_consumer, wire_srm_worker, @@ -121,6 +122,16 @@ def live_srm( return fake_srm +@pytest.fixture() +def operation_repo() -> FakeOperationRepository: + return FakeOperationRepository() + + +@pytest.fixture() +def qod_session_repo() -> FakeQodSessionRepository: + return FakeQodSessionRepository() + + @pytest.fixture() def eam_service( fake_srm: FakeSRMClient, @@ -141,8 +152,18 @@ def eam_service( @pytest.fixture() -def qod_service(fake_srm: FakeSRMClient) -> QualityOnDemandService: - return QualityOnDemandService(srm_client=fake_srm) +def qod_service( + fake_srm: FakeSRMClient, + fake_bus: FakeDataBus, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, +) -> QualityOnDemandService: + return QualityOnDemandService( + srm_client=fake_srm, + publisher=fake_bus, + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) @pytest.fixture() diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 8f7cfe6..62cba78 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -51,6 +51,7 @@ from open_exposure_gateway.domain.models import ( CallbackDelivery, CallbackRegistration, Operation, + QodSession, ) from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.database.callbacks import ( @@ -59,6 +60,7 @@ from open_exposure_gateway.ports.database.callbacks import ( ) from open_exposure_gateway.ports.database.instances import AppInstanceRepository from open_exposure_gateway.ports.database.operations import OperationRepository +from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository from open_exposure_gateway.ports.database.registration import AppRegistrationRepository Handler = Callable[[dict[str, Any]], Awaitable[None]] @@ -388,6 +390,20 @@ class FakeAppInstanceRepository(AppInstanceRepository): return stored.model_copy(deep=True) +class FakeQodSessionRepository(QodSessionRepository): + def __init__(self) -> None: + self.rows: dict[UUID, QodSession] = {} + + async def get_by_id(self, session_id: UUID) -> QodSession | None: + found = self.rows.get(session_id) + return found.model_copy(deep=True) if found is not None else None + + async def save(self, qod_session: QodSession) -> QodSession: + stored = qod_session.model_copy(deep=True) + self.rows[stored.session_id] = stored + return stored.model_copy(deep=True) + + class FakeCallbackRegistrationRepository(CallbackRegistrationRepository): def __init__(self) -> None: self.rows: dict[UUID, CallbackRegistration] = {} diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 054fad9..1065e82 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -1,18 +1,24 @@ """End-to-end flow tests for Quality on Demand. -Same setup as test_eam_flows.py: real HTTP API against the in-memory FakeSRMClient. +Same setup as test_eam_flows.py: real HTTP API against the in-memory fakes. A failing test here means a real hole in the flow, not a broken test. """ from typing import Any -from uuid import uuid4 +from uuid import UUID, uuid4 +import pytest from fastapi.testclient import TestClient from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.router import ( BASE_PATH as QOD_BASE, ) -from tests.unit.fakes import FakeSRMClient +from open_exposure_gateway.domain.models import OperationStatus, QodSessionState +from open_exposure_gateway.domain.quality_on_demand import ( + SRMNetworkCapabilityActivateCommand, + Subject, +) +from tests.unit.fakes import FakeDataBus, FakeOperationRepository, FakeQodSessionRepository SESSION_BODY: dict[str, Any] = { "device": { @@ -25,26 +31,61 @@ SESSION_BODY: dict[str, Any] = { class TestQodSessionFlow: - def test_create_session_returns_201_and_reserves_downstream( - self, api_client: TestClient, fake_srm: FakeSRMClient - ) -> None: + def test_create_session_returns_201(self, api_client: TestClient) -> None: response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY) assert response.status_code == 201 body = response.json() assert body["qosStatus"] == "REQUESTED" assert body["qosProfile"] == "QOS_E" - assert body["sessionId"] in fake_srm.qod_sessions + assert "sessionId" in body - def test_create_session_accepts_webhook( - self, api_client: TestClient, fake_srm: FakeSRMClient - ) -> None: + def test_create_session_accepts_webhook(self, api_client: TestClient) -> None: body = {**SESSION_BODY, "webhook": {"notificationUrl": "https://application-server.com"}} response = api_client.post(f"{QOD_BASE}/sessions", json=body) assert response.status_code == 201 - def test_get_session_returns_created_session( - self, api_client: TestClient, fake_srm: FakeSRMClient + def test_srm_receives_a_valid_activate_command( + self, api_client: TestClient, fake_bus: FakeDataBus ) -> None: + api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY) + + activates = [p for s, p in fake_bus.published if s == Subject.TASK_ACTIVATE] + assert len(activates) == 1 + command = SRMNetworkCapabilityActivateCommand.model_validate(activates[0]) + assert command.network_capability.profile_ref == "QOS_E" + assert command.network_capability.parameters.duration_seconds == 3600 + assert command.network_capability.target.application_server.ipv4 == "192.168.0.1" + assert command.source == "nbi_camara" + + def test_persists_operation_and_qod_session_rows( + self, + api_client: TestClient, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + """Proves the DI wiring end-to-end: the router/service must reach the + real repositories, not just the constructor accepting them.""" + response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY) + session_id = UUID(response.json()["sessionId"]) + + operations = list(operation_repo.rows.values()) + assert len(operations) == 1 + assert operations[0].status == OperationStatus.PENDING + + stored_session = qod_session_repo.rows.get(session_id) + assert stored_session is not None + assert stored_session.state == QodSessionState.REQUESTED + assert stored_session.operation_id == operations[0].operation_id + + @pytest.mark.skip( + reason="TODO: GET /sessions/{id} is a synchronous SRM call (unchanged by the async " + "POST /sessions work) and OEG's minted sessionId has no reconciled mapping to SRM's " + "future external_ref (SrmNetworkCapabilityActivateV1 has no client-supplied session id " + "field for SRM to adopt, unlike appInstanceId — ADR-0005 doesn't apply here). A session " + "created via the new async POST is therefore not retrievable via GET until that mapping " + "is designed (likely as part of the event.srm.operation.completed consumer)." + ) + def test_get_session_returns_created_session(self, api_client: TestClient) -> None: session_id = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"] response = api_client.get(f"{QOD_BASE}/sessions/{session_id}") assert response.status_code == 200 @@ -54,8 +95,15 @@ class TestQodSessionFlow: response = api_client.get(f"{QOD_BASE}/sessions/{uuid4()}") assert response.status_code == 404 + @pytest.mark.skip( + reason="TODO: DELETE /sessions/{id} still calls srm_client.delete_qod_session " + "unchanged, keyed on OEG's sessionId — same unresolved identity mapping as GET " + "(see test_get_session_returns_created_session). Deletion 404s until that mapping " + "exists and the deactivate command (command.srm.network.capability.deactivate) is " + "implemented." + ) def test_delete_session_releases_the_downstream_reservation( - self, api_client: TestClient, fake_srm: FakeSRMClient + self, api_client: TestClient ) -> None: """DELETE must actually tear the session down at SRM: a 204 with the reservation still alive downstream silently leaks QoS resources.""" @@ -64,7 +112,6 @@ class TestQodSessionFlow: response = api_client.delete(f"{QOD_BASE}/sessions/{session_id}") assert response.status_code == 204 - assert session_id not in fake_srm.qod_sessions def test_delete_session_returns_404_when_unknown(self, api_client: TestClient) -> None: response = api_client.delete(f"{QOD_BASE}/sessions/{uuid4()}") diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py new file mode 100644 index 0000000..c914351 --- /dev/null +++ b/tests/unit/test_qod_service.py @@ -0,0 +1,149 @@ +from unittest.mock import AsyncMock + +import pytest + +from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( + ApplicationServer, + CreateSession, + Device, +) +from open_exposure_gateway.application.services.quality_on_demand_service import ( + QualityOnDemandService, +) +from open_exposure_gateway.core.exceptions import DownstreamServiceException +from open_exposure_gateway.domain.models import OperationStatus, OperationType, QodSessionState +from open_exposure_gateway.domain.quality_on_demand import Subject +from tests.unit.fakes import FakeOperationRepository, FakeQodSessionRepository + + +def _make_request() -> CreateSession: + return CreateSession( + device=Device(phoneNumber="123456789"), + applicationServer=ApplicationServer(ipv4Address="198.51.100.0/24"), + qosProfile="voice", + duration=3600, + ) + + +class TestCreateSession: + @pytest.fixture() + def operation_repo(self) -> FakeOperationRepository: + return FakeOperationRepository() + + @pytest.fixture() + def qod_session_repo(self) -> FakeQodSessionRepository: + return FakeQodSessionRepository() + + @pytest.fixture() + def service( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> QualityOnDemandService: + return QualityOnDemandService( + srm_client=AsyncMock(), + publisher=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) + + async def test_persists_pending_operation_row( + self, service: QualityOnDemandService, operation_repo: FakeOperationRepository + ) -> None: + await service.create_session( + request=_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.NETWORK_CAPABILITY + assert operation.subject == Subject.TASK_ACTIVATE + assert operation.tenant_id == "tenant-1" + assert operation.app_provider_id == "provider-1" + assert operation.metadata["qos_profile"] == "voice" + + async def test_persists_requested_qod_session_row( + self, service: QualityOnDemandService, qod_session_repo: FakeQodSessionRepository + ) -> None: + result = await service.create_session( + request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1" + ) + stored = await qod_session_repo.get_by_id(result.sessionId) + assert stored is not None + assert stored.state == QodSessionState.REQUESTED + assert stored.qos_profile == "voice" + assert stored.duration_seconds == 3600 + + async def test_returns_session_info_with_requested_status( + self, service: QualityOnDemandService + ) -> None: + result = await service.create_session( + request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1" + ) + assert result.qosStatus == "REQUESTED" + assert result.qosProfile == "voice" + assert result.duration == 3600 + assert result.expiresAt - result.startedAt == 3600 + + async def test_raises_when_repositories_unavailable(self) -> None: + service = QualityOnDemandService(srm_client=AsyncMock(), publisher=AsyncMock()) + with pytest.raises(RuntimeError, match="Operation/QodSession repositories"): + await service.create_session( + request=_make_request(), tenant_id="t", app_provider_id="p" + ) + + async def test_raises_when_publisher_unavailable( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=None, + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) + with pytest.raises(RuntimeError, match="DataBus publisher is not available"): + await service.create_session( + request=_make_request(), tenant_id="t", app_provider_id="p" + ) + + async def test_publish_failure_maps_to_downstream_exception( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + publisher = AsyncMock() + publisher.publish = AsyncMock(side_effect=RuntimeError("nats down")) + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=publisher, + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) + with pytest.raises(DownstreamServiceException): + await service.create_session( + request=_make_request(), tenant_id="t", app_provider_id="p" + ) + + +class TestSessionIdMinting: + async def test_each_request_gets_distinct_session_and_operation_ids(self) -> None: + operation_repo = FakeOperationRepository() + qod_session_repo = FakeQodSessionRepository() + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) + first = await service.create_session( + request=_make_request(), tenant_id="t", app_provider_id="p" + ) + second = await service.create_session( + request=_make_request(), tenant_id="t", app_provider_id="p" + ) + assert first.sessionId != second.sessionId + assert len(operation_repo.rows) == 2 + assert len({str(uuid) for uuid in operation_repo.rows}) == 2 -- GitLab From a705b4a6e5572f4815fd6e3363883f7201904582 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 13:55:29 +0300 Subject: [PATCH 06/32] feat: implement event.srm.operation.completed flow (uc9) --- .../adapters/database/repos/qod_sessions.py | 5 + .../adapters/databus/nats_adapter.py | 9 +- .../services/quality_on_demand_service.py | 58 +++++++ .../domain/edge_application_management.py | 51 +----- .../domain/quality_on_demand.py | 1 + .../domain/srm_events.py | 56 +++++++ src/open_exposure_gateway/main.py | 42 ++++- .../ports/database/qod_sessions.py | 4 + tests/unit/conftest.py | 12 ++ tests/unit/fakes.py | 36 ++++ tests/unit/test_qod_flows.py | 37 +++- tests/unit/test_qod_service.py | 158 +++++++++++++++++- 12 files changed, 416 insertions(+), 53 deletions(-) create mode 100644 src/open_exposure_gateway/domain/srm_events.py diff --git a/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py b/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py index 50e2215..0d84c64 100644 --- a/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py +++ b/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py @@ -18,6 +18,11 @@ class SqlQodSessionRepository(QodSessionRepository): row = await self._session.scalar(stmt) return QodSessionMapper.to_domain(row) if row is not None else None + async def get_by_operation_id(self, operation_id: UUID) -> QodSession | None: + stmt = select(QodSessionRow).where(QodSessionRow.operation_id == operation_id) + row = await self._session.scalar(stmt) + return QodSessionMapper.to_domain(row) if row is not None else None + async def save(self, qod_session: QodSession) -> QodSession: merged = await self._session.merge(QodSessionMapper.to_row(qod_session)) await self._session.flush() diff --git a/src/open_exposure_gateway/adapters/databus/nats_adapter.py b/src/open_exposure_gateway/adapters/databus/nats_adapter.py index 51ce781..2312d8b 100644 --- a/src/open_exposure_gateway/adapters/databus/nats_adapter.py +++ b/src/open_exposure_gateway/adapters/databus/nats_adapter.py @@ -9,7 +9,7 @@ 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.domain.srm_events import SRMOperationCompleted from open_exposure_gateway.ports.databus_port import DataBusPort logger: structlog.BoundLogger = structlog.get_logger(__name__) @@ -78,7 +78,7 @@ class NatsOperationConsumer: self, client: Client, subject: str, - handler: Callable[[SRMOperationCompleted], Awaitable[None]], + handler: Callable[[SRMOperationCompleted], Awaitable[None]] | None = None, ) -> None: self._client = client self._subject = subject @@ -99,6 +99,11 @@ class NatsOperationConsumer: logger.warning("invalid_json", subject=msg.subject) return + if self._handler is None: + logger.info("operation_completed_received", subject=msg.subject, payload=raw) + return + + try: event = SRMOperationCompleted.model_validate(raw) except ValidationError as exc: diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index 57fcfa1..b05d4b7 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone from typing import Optional from uuid import UUID, uuid4 +import structlog from pydantic import BaseModel from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( @@ -21,11 +22,20 @@ from open_exposure_gateway.domain.models import ( QodSessionState, ) from open_exposure_gateway.domain.quality_on_demand import Subject +from open_exposure_gateway.domain.srm_events import SRMOperationCompleted from open_exposure_gateway.ports.database.operations import OperationRepository from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository from open_exposure_gateway.ports.databus_port import DataBusPort from open_exposure_gateway.ports.srm_port import SRMClientPort +logger: structlog.BoundLogger = structlog.get_logger(__name__) + +_OPERATION_COMPLETION_STATUS_MAP: dict[str, OperationStatus] = { + "completed": OperationStatus.COMPLETED, + "partially_completed": OperationStatus.PARTIALLY_COMPLETED, + "failed": OperationStatus.FAILED, +} + class QualityOnDemandService: def __init__( @@ -124,6 +134,54 @@ class QualityOnDemandService: qosStatus=QosStatus.REQUESTED, ) + async def handle_completed(self, event: SRMOperationCompleted) -> None: + if self._operation_repo is None or self._qod_session_repo is None: + raise RuntimeError("Operation/QodSession repositories are 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 + + # Not ours: event.srm.operation.completed is shared across every domain + # (deploy/terminate and network-capability activate/deactivate all land + # here). operation_type is how a domain recognizes its own operations. + if operation.operation_type != OperationType.NETWORK_CAPABILITY: + return + + status = _OPERATION_COMPLETION_STATUS_MAP[event.status] + result = None + if status != OperationStatus.FAILED: + result = {"instances": [i.model_dump(mode="json") for i in event.instances]} + await self._operation_repo.save( + operation.model_copy( + update={ + "status": status, + "result": result, + "error": event.error, + "completed_at": datetime.fromisoformat(event.completed_at), + } + ) + ) + + qod_session = await self._qod_session_repo.get_by_operation_id(operation_id) + if qod_session is None: + logger.warning( + "qod_session_completed_for_unknown_operation", operation_id=event.operation_id + ) + return + + instance = event.instances[0] if event.instances else None + update: dict[str, object] + if instance is not None and instance.status == "completed": + update = {"state": QodSessionState.AVAILABLE, "external_ref": instance.external_ref} + else: + update = {"state": QodSessionState.UNAVAILABLE} + await self._qod_session_repo.save(qod_session.model_copy(update=update)) + async def get_session( self, session_id: str, diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index 592b0d6..f04876b 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -1,10 +1,17 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, Literal +from typing import Any from uuid import UUID -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field + +from open_exposure_gateway.domain.srm_events import ( + SRMCompletedInstance, + SRMOperationCompleted, +) + +__all__ = ["SRMCompletedInstance", "SRMOperationCompleted"] class Subject(StrEnum): @@ -275,46 +282,6 @@ class SRMTerminateCommand(BaseModel): terminate: SRMTerminatePayload -class SRMCompletedInstance(BaseModel): - service_instance_id: str - zone_id: str - status: Literal["completed", "failed"] - external_ref: str | None = None - error: dict[str, Any] | None = None - - @model_validator(mode="after") - def _require_error_when_failed(self) -> SRMCompletedInstance: - if self.status == "failed" and self.error is None: - raise ValueError("error is required when instance status is failed") - return self - - -class SRMOperationCompleted(BaseModel): - schema_version: str - operation_id: str - status: Literal["completed", "partially_completed", "failed"] - service_order_id: str | None = None - instances: list[SRMCompletedInstance] = [] - metadata: dict[str, Any] | None = None - error: dict[str, Any] | None = None - correlation_id: str - completed_at: str - - @model_validator(mode="after") - def _require_error_when_failed(self) -> SRMOperationCompleted: - 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") - def _require_instances_unless_failed(self) -> SRMOperationCompleted: - if self.status != "failed" and not self.instances: - raise ValueError("instances is required when status != failed") - return self - - class SRMCapabilityEndpoint(BaseModel): interface_id: str fqdn: str | None = None diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index d169cb6..86c40a7 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field class Subject(StrEnum): TASK_ACTIVATE = "command.srm.network.capability.activate" + OPERATION_COMPLETED = "event.srm.operation.completed" class NetworkCapabilityTargetDevice(BaseModel): diff --git a/src/open_exposure_gateway/domain/srm_events.py b/src/open_exposure_gateway/domain/srm_events.py new file mode 100644 index 0000000..8d90f1f --- /dev/null +++ b/src/open_exposure_gateway/domain/srm_events.py @@ -0,0 +1,56 @@ +"""Generic `event.srm.*` shapes shared by every domain (EAM, QoD, ...). + +Lives outside any single feature's domain module (`edge_application_management.py`, +`quality_on_demand.py`, ...) so infrastructure adapters (`adapters/databus/nats_adapter.py`) +can depend on the shared event contract without importing a feature-specific module — +`event.srm.operation.completed` is one subject shared across all domains +(srm/interface-contract.md §C.2/§D.3), not owned by any one of them. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, model_validator + + +class SRMCompletedInstance(BaseModel): + service_instance_id: str + zone_id: str + status: Literal["completed", "failed"] + external_ref: str | None = None + error: dict[str, Any] | None = None + + @model_validator(mode="after") + def _require_error_when_failed(self) -> SRMCompletedInstance: + if self.status == "failed" and self.error is None: + raise ValueError("error is required when instance status is failed") + return self + + +class SRMOperationCompleted(BaseModel): + schema_version: str + operation_id: str + status: Literal["completed", "partially_completed", "failed"] + service_order_id: str | None = None + # One entry per resulting realization (one per targeted zone for deploy; + # a single entry for network-capability activate); 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 + correlation_id: str + completed_at: str + + @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") + return self + + @model_validator(mode="after") + def _require_instances_unless_failed(self) -> SRMOperationCompleted: + if self.status != "failed" and not self.instances: + raise ValueError("instances is required when status != failed") + return self diff --git a/src/open_exposure_gateway/main.py b/src/open_exposure_gateway/main.py index fb4967d..808c319 100644 --- a/src/open_exposure_gateway/main.py +++ b/src/open_exposure_gateway/main.py @@ -22,6 +22,7 @@ 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.database.repos.qod_sessions import SqlQodSessionRepository from open_exposure_gateway.adapters.databus.nats_adapter import ( NatsMessagePublisher, NatsOperationConsumer, @@ -42,12 +43,13 @@ 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.application.services.quality_on_demand_service import ( + QualityOnDemandService, +) 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 ( - SRMOperationCompleted, - Subject, -) +from open_exposure_gateway.domain.edge_application_management import Subject +from open_exposure_gateway.domain.srm_events import SRMOperationCompleted from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort @@ -78,6 +80,27 @@ def _build_operation_completed_handler( return handle +def _build_qod_operation_completed_handler( + session_maker: async_sessionmaker[AsyncSession], + srm_client: SRMClientPort, +) -> Callable[[SRMOperationCompleted], Awaitable[None]]: + async def handle(event: SRMOperationCompleted) -> None: + async with session_maker() as session: + try: + service = QualityOnDemandService( + srm_client=srm_client, + operation_repo=SqlOperationRepository(session), + qod_session_repo=SqlQodSessionRepository(session), + ) + await service.handle_completed(event) + await session.commit() + except Exception: + await session.rollback() + raise + + return handle + + openapi_tags = [ { "name": "Application", @@ -139,12 +162,19 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.error("Failed to connect NATS publisher", error=str(e)) raise - consumer = NatsOperationConsumer( + eam_operation_consumer = NatsOperationConsumer( client=publisher.client, subject=Subject.OPERATION_COMPLETED, handler=_build_operation_completed_handler(session_maker, srm_client, HttpCallbackClient()), ) - await consumer.start() + await eam_operation_consumer.start() + + qod_operation_consumer = NatsOperationConsumer( + client=publisher.client, + subject=Subject.OPERATION_COMPLETED, + handler=_build_qod_operation_completed_handler(session_maker, srm_client), + ) + await qod_operation_consumer.start() logger.info("NATS consumer started", subject=Subject.OPERATION_COMPLETED) app.state.srm_client = srm_client diff --git a/src/open_exposure_gateway/ports/database/qod_sessions.py b/src/open_exposure_gateway/ports/database/qod_sessions.py index 292c49b..e163a7b 100644 --- a/src/open_exposure_gateway/ports/database/qod_sessions.py +++ b/src/open_exposure_gateway/ports/database/qod_sessions.py @@ -11,6 +11,10 @@ class QodSessionRepository(ABC): async def get_by_id(self, session_id: UUID) -> QodSession | None: pass + @abstractmethod + async def get_by_operation_id(self, operation_id: UUID) -> QodSession | None: + pass + @abstractmethod async def save(self, qod_session: QodSession) -> QodSession: pass diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 7f96712..a9c5395 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -32,6 +32,7 @@ from tests.unit.fakes import ( FakeQodSessionRepository, FakeSRMClient, wire_operation_consumer, + wire_qod_operation_consumer, wire_srm_worker, ) @@ -151,6 +152,17 @@ def eam_service( ) +@pytest.fixture() +def live_qod( + fake_bus: FakeDataBus, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, +) -> None: + """Wires QoD's real completion handler behind the fake bus, sharing the same + operation_repo/qod_session_repo instances the api_client's qod_service writes to.""" + wire_qod_operation_consumer(fake_bus, operation_repo, qod_session_repo) + + @pytest.fixture() def qod_service( fake_srm: FakeSRMClient, diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 62cba78..fead870 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -34,6 +34,9 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) +from open_exposure_gateway.application.services.quality_on_demand_service import ( + QualityOnDemandService, +) from open_exposure_gateway.core.exceptions import NotFoundException from open_exposure_gateway.domain.edge_application_management import ( AppInstanceStatusChangeCloudEvent, @@ -53,6 +56,7 @@ from open_exposure_gateway.domain.models import ( Operation, QodSession, ) +from open_exposure_gateway.domain.quality_on_demand import Subject as QodSubject from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, @@ -288,6 +292,32 @@ def wire_operation_consumer( return consumer +def wire_qod_operation_consumer( + bus: FakeDataBus, + operation_repo: OperationRepository, + qod_session_repo: QodSessionRepository, +) -> NatsOperationConsumer: + """Wires QualityOnDemandService.handle_completed behind the fake bus -- pass the + same repo instances used to build the service under test so a completion event + updates the rows the test can see.""" + service = QualityOnDemandService( + srm_client=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) + consumer = NatsOperationConsumer( + client=AsyncMock(), subject=QodSubject.OPERATION_COMPLETED, handler=service.handle_completed + ) + + async def deliver(payload: dict[str, Any]) -> None: + await consumer._handle_message( + FakeMsg(data=json.dumps(payload).encode(), subject=str(QodSubject.OPERATION_COMPLETED)) + ) + + bus.subscribe(QodSubject.OPERATION_COMPLETED, deliver) + return consumer + + class FakeAppRegistrationRepository(AppRegistrationRepository): def __init__(self) -> None: self.rows: dict[UUID, AppRegistration] = {} @@ -398,6 +428,12 @@ class FakeQodSessionRepository(QodSessionRepository): found = self.rows.get(session_id) return found.model_copy(deep=True) if found is not None else None + async def get_by_operation_id(self, operation_id: UUID) -> QodSession | None: + for row in self.rows.values(): + if row.operation_id == operation_id: + return row.model_copy(deep=True) + return None + async def save(self, qod_session: QodSession) -> QodSession: stored = qod_session.model_copy(deep=True) self.rows[stored.session_id] = stored diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 1065e82..2ea88d3 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -18,7 +18,12 @@ from open_exposure_gateway.domain.quality_on_demand import ( SRMNetworkCapabilityActivateCommand, Subject, ) -from tests.unit.fakes import FakeDataBus, FakeOperationRepository, FakeQodSessionRepository +from tests.unit.fakes import ( + FakeDataBus, + FakeOperationRepository, + FakeQodSessionRepository, + completion_payload, +) SESSION_BODY: dict[str, Any] = { "device": { @@ -77,6 +82,36 @@ class TestQodSessionFlow: assert stored_session.state == QodSessionState.REQUESTED assert stored_session.operation_id == operations[0].operation_id + async def test_completion_event_marks_session_available( + self, + api_client: TestClient, + live_qod: None, + fake_bus: FakeDataBus, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY) + session_id = UUID(response.json()["sessionId"]) + operation_id = qod_session_repo.rows[session_id].operation_id + + await fake_bus.publish( + str(Subject.OPERATION_COMPLETED), + completion_payload( + str(operation_id), + instances=[ + { + "service_instance_id": str(uuid4()), + "zone_id": str(uuid4()), + "status": "completed", + "external_ref": "qod-session-nef-123", + } + ], + ), + ) + + updated = qod_session_repo.rows[session_id] + assert updated.state == QodSessionState.AVAILABLE + assert updated.external_ref == "qod-session-nef-123" + @pytest.mark.skip( reason="TODO: GET /sessions/{id} is a synchronous SRM call (unchanged by the async " "POST /sessions work) and OEG's minted sessionId has no reconciled mapping to SRM's " diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index c914351..3e7f1f6 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -1,4 +1,5 @@ from unittest.mock import AsyncMock +from uuid import uuid4 import pytest @@ -11,9 +12,16 @@ from open_exposure_gateway.application.services.quality_on_demand_service import QualityOnDemandService, ) from open_exposure_gateway.core.exceptions import DownstreamServiceException -from open_exposure_gateway.domain.models import OperationStatus, OperationType, QodSessionState +from open_exposure_gateway.domain.models import ( + Operation, + OperationStatus, + OperationType, + QodSession, + QodSessionState, +) from open_exposure_gateway.domain.quality_on_demand import Subject -from tests.unit.fakes import FakeOperationRepository, FakeQodSessionRepository +from open_exposure_gateway.domain.srm_events import SRMOperationCompleted +from tests.unit.fakes import FakeOperationRepository, FakeQodSessionRepository, completion_payload def _make_request() -> CreateSession: @@ -128,6 +136,152 @@ class TestCreateSession: ) +class TestHandleCompleted: + @pytest.fixture() + def operation_repo(self) -> FakeOperationRepository: + return FakeOperationRepository() + + @pytest.fixture() + def qod_session_repo(self) -> FakeQodSessionRepository: + return FakeQodSessionRepository() + + @pytest.fixture() + def service( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> QualityOnDemandService: + return QualityOnDemandService( + srm_client=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) + + async def _seed( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + operation_type: OperationType = OperationType.NETWORK_CAPABILITY, + ) -> tuple[Operation, QodSession]: + operation = await operation_repo.save( + Operation( + operation_id=uuid4(), + correlation_id="corr-1", + tenant_id="tenant-1", + app_provider_id="provider-1", + operation_type=operation_type, + status=OperationStatus.PENDING, + subject=Subject.TASK_ACTIVATE, + ) + ) + qod_session = await qod_session_repo.save( + QodSession( + session_id=uuid4(), + operation_id=operation.operation_id, + service_specification_id=uuid4(), + qos_profile="voice", + duration_seconds=3600, + state=QodSessionState.REQUESTED, + ) + ) + return operation, qod_session + + async def test_completed_event_marks_session_available( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + operation, qod_session = await self._seed(operation_repo, qod_session_repo) + event = SRMOperationCompleted.model_validate( + completion_payload( + str(operation.operation_id), + instances=[ + { + "service_instance_id": str(uuid4()), + "zone_id": str(uuid4()), + "status": "completed", + "external_ref": "qod-session-123", + } + ], + ) + ) + + await service.handle_completed(event) + + updated_operation = await operation_repo.get_by_id(operation.operation_id) + assert updated_operation is not None + assert updated_operation.status == OperationStatus.COMPLETED + + updated_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert updated_session is not None + assert updated_session.state == QodSessionState.AVAILABLE + assert updated_session.external_ref == "qod-session-123" + + async def test_failed_event_marks_session_unavailable( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + operation, qod_session = await self._seed(operation_repo, qod_session_repo) + event = SRMOperationCompleted.model_validate( + completion_payload( + str(operation.operation_id), + status="failed", + instances=[], + error={"status": 500, "code": "INTERNAL", "message": "backend rejected"}, + ) + ) + + await service.handle_completed(event) + + updated_operation = await operation_repo.get_by_id(operation.operation_id) + assert updated_operation is not None + assert updated_operation.status == OperationStatus.FAILED + + updated_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert updated_session is not None + assert updated_session.state == QodSessionState.UNAVAILABLE + + async def test_ignores_completion_for_a_different_domains_operation( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + """event.srm.operation.completed is shared across domains (deploy/terminate + and network-capability activate/deactivate all land on it) -- a DEPLOY + operation must be left untouched, not mistaken for one of QoD's own.""" + operation, qod_session = await self._seed( + operation_repo, qod_session_repo, operation_type=OperationType.DEPLOY + ) + event = SRMOperationCompleted.model_validate( + completion_payload(str(operation.operation_id)) + ) + + await service.handle_completed(event) + + unchanged_operation = await operation_repo.get_by_id(operation.operation_id) + assert unchanged_operation is not None + assert unchanged_operation.status == OperationStatus.PENDING + + unchanged_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert unchanged_session is not None + assert unchanged_session.state == QodSessionState.REQUESTED + + async def test_unknown_operation_id_is_a_noop(self, service: QualityOnDemandService) -> None: + event = SRMOperationCompleted.model_validate(completion_payload(str(uuid4()))) + + await service.handle_completed(event) + + async def test_raises_when_repositories_unavailable(self) -> None: + service = QualityOnDemandService(srm_client=AsyncMock()) + event = SRMOperationCompleted.model_validate(completion_payload(str(uuid4()))) + with pytest.raises(RuntimeError, match="Operation/QodSession repositories"): + await service.handle_completed(event) + + class TestSessionIdMinting: async def test_each_request_gets_distinct_session_and_operation_ids(self) -> None: operation_repo = FakeOperationRepository() -- GitLab From 1bb82d74339df9eb4bc51824f4d592c0b4c27a04 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 15:09:27 +0300 Subject: [PATCH 07/32] feat: add SRM deactivate command mapper --- .../mappers/quality_on_demand_mapper.py | 22 +++++++++++++++++++ .../domain/models/operations/enums.py | 1 + .../domain/quality_on_demand.py | 17 ++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py index e8787b4..9dc650d 100644 --- a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py +++ b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py @@ -2,12 +2,14 @@ from uuid import UUID from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import CreateSession from open_exposure_gateway.domain.quality_on_demand import ( + NetworkCapabilityDeactivateTarget, NetworkCapabilityParameters, NetworkCapabilityPayload, NetworkCapabilityTarget, NetworkCapabilityTargetApplicationServer, NetworkCapabilityTargetDevice, SRMNetworkCapabilityActivateCommand, + SRMNetworkCapabilityDeactivateCommand, ) @@ -47,3 +49,23 @@ def build_activate_command( parameters=NetworkCapabilityParameters(duration_seconds=request.duration), ), ) + + +def build_deactivate_command( + external_ref: str, + operation_id: UUID, + correlation_id: str, + requested_at: str, + app_provider_id: str, + grace_period_seconds: int = 0, +) -> SRMNetworkCapabilityDeactivateCommand: + return SRMNetworkCapabilityDeactivateCommand( + operation_id=str(operation_id), + correlation_id=correlation_id, + requested_at=requested_at, + app_provider_id=app_provider_id, + network_capability=NetworkCapabilityDeactivateTarget( + external_ref=external_ref, + grace_period_seconds=grace_period_seconds, + ), + ) diff --git a/src/open_exposure_gateway/domain/models/operations/enums.py b/src/open_exposure_gateway/domain/models/operations/enums.py index 95399d4..1f19d95 100644 --- a/src/open_exposure_gateway/domain/models/operations/enums.py +++ b/src/open_exposure_gateway/domain/models/operations/enums.py @@ -6,6 +6,7 @@ class OperationType(StrEnum): SCALE = "scale" TERMINATE = "terminate" NETWORK_CAPABILITY = "network_capability" + NETWORK_CAPABILITY_DEACTIVATE = "network_capability_deactivate" class OperationStatus(StrEnum): diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index 86c40a7..a750b92 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field class Subject(StrEnum): TASK_ACTIVATE = "command.srm.network.capability.activate" + TASK_DEACTIVATE = "command.srm.network.capability.deactivate" OPERATION_COMPLETED = "event.srm.operation.completed" @@ -52,3 +53,19 @@ class SRMNetworkCapabilityActivateCommand(BaseModel): resource_zone_id: str | None = None network_domain_id: str | None = None network_capability: NetworkCapabilityPayload + + +class NetworkCapabilityDeactivateTarget(BaseModel): + capability_type: str = "qod_session" + external_ref: str + grace_period_seconds: int = 0 + + +class SRMNetworkCapabilityDeactivateCommand(BaseModel): + schema_version: str = "1.0" + operation_id: str + correlation_id: str + requested_at: str + app_provider_id: str + source: str = "nbi_camara" + network_capability: NetworkCapabilityDeactivateTarget -- GitLab From 25d6ee1e036a5d5940c3983d55b8796cf0f0b13e Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 15:20:42 +0300 Subject: [PATCH 08/32] feat: make DELETE /sessions/{id} async via the deactivate command --- .../quality_on_demand/v0_10_1/router.py | 3 + .../services/quality_on_demand_service.py | 41 +++++- tests/unit/test_qod_flows.py | 53 ++++++-- tests/unit/test_qod_service.py | 126 +++++++++++++++++- 4 files changed, 206 insertions(+), 17 deletions(-) diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py index cfbb5aa..8ea8dc9 100644 --- a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py @@ -104,10 +104,13 @@ async def get_qod_session( async def delete_qod_session( sessionId: str, service: QoDService, + caller: Caller, x_correlator: XCorrelatorHeader = None, ) -> Response: await service.delete_session( session_id=sessionId, + tenant_id=caller.tenant_id, + app_provider_id=caller.app_provider_id, x_correlator=x_correlator, ) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index b05d4b7..0a51798 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -12,8 +12,9 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( ) from open_exposure_gateway.application.mappers.quality_on_demand_mapper import ( build_activate_command, + build_deactivate_command, ) -from open_exposure_gateway.core.exceptions import DownstreamServiceException +from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException from open_exposure_gateway.domain.models import ( Operation, OperationStatus, @@ -195,9 +196,41 @@ class QualityOnDemandService: async def delete_session( self, session_id: str, + tenant_id: str, + app_provider_id: str, x_correlator: Optional[str] = None, ) -> None: - await self.srm_client.delete_qod_session( - session_id=session_id, - x_correlator=x_correlator, + if self._operation_repo is None or self._qod_session_repo is None: + raise RuntimeError("Operation/QodSession repositories are not available") + + qod_session = await self._qod_session_repo.get_by_id(UUID(session_id)) + if qod_session is None or qod_session.external_ref is None: + raise NotFoundException(message=f"Session {session_id} not found") + + operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) + command = build_deactivate_command( + external_ref=qod_session.external_ref, + operation_id=operation_id, + correlation_id=correlation_id, + requested_at=requested_at, + app_provider_id=app_provider_id, + ) + + await self._operation_repo.save( + Operation( + operation_id=operation_id, + correlation_id=correlation_id, + tenant_id=tenant_id, + app_provider_id=app_provider_id, + operation_type=OperationType.NETWORK_CAPABILITY_DEACTIVATE, + status=OperationStatus.PENDING, + subject=Subject.TASK_DEACTIVATE, + metadata={"session_id": session_id}, + ) + ) + + await self._publish( + Subject.TASK_DEACTIVATE, + command, + "Failed to publish QoD session deactivation", ) diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 2ea88d3..4ca65d7 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -16,6 +16,7 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.router import ( from open_exposure_gateway.domain.models import OperationStatus, QodSessionState from open_exposure_gateway.domain.quality_on_demand import ( SRMNetworkCapabilityActivateCommand, + SRMNetworkCapabilityDeactivateCommand, Subject, ) from tests.unit.fakes import ( @@ -130,28 +131,58 @@ class TestQodSessionFlow: response = api_client.get(f"{QOD_BASE}/sessions/{uuid4()}") assert response.status_code == 404 - @pytest.mark.skip( - reason="TODO: DELETE /sessions/{id} still calls srm_client.delete_qod_session " - "unchanged, keyed on OEG's sessionId — same unresolved identity mapping as GET " - "(see test_get_session_returns_created_session). Deletion 404s until that mapping " - "exists and the deactivate command (command.srm.network.capability.deactivate) is " - "implemented." - ) - def test_delete_session_releases_the_downstream_reservation( + def test_delete_session_returns_404_before_activation_confirmed( self, api_client: TestClient ) -> None: - """DELETE must actually tear the session down at SRM: a 204 with the - reservation still alive downstream silently leaks QoS resources.""" + """A session with no confirmed external_ref yet (still REQUESTED) has nothing + for a deactivate command to key on, so deletion 404s rather than silently + no-op-ing or guessing at an id SRM never confirmed.""" session_id = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"] response = api_client.delete(f"{QOD_BASE}/sessions/{session_id}") - assert response.status_code == 204 + assert response.status_code == 404 def test_delete_session_returns_404_when_unknown(self, api_client: TestClient) -> None: response = api_client.delete(f"{QOD_BASE}/sessions/{uuid4()}") assert response.status_code == 404 + async def test_delete_session_publishes_deactivate_command_once_available( + self, + api_client: TestClient, + live_qod: None, + fake_bus: FakeDataBus, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + """DELETE must actually tear the session down at SRM: a 204 with no deactivate + command published would silently leak QoS resources.""" + response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY) + session_id = UUID(response.json()["sessionId"]) + operation_id = qod_session_repo.rows[session_id].operation_id + + await fake_bus.publish( + str(Subject.OPERATION_COMPLETED), + completion_payload( + str(operation_id), + instances=[ + { + "service_instance_id": str(uuid4()), + "zone_id": str(uuid4()), + "status": "completed", + "external_ref": "qod-session-nef-123", + } + ], + ), + ) + + delete_response = api_client.delete(f"{QOD_BASE}/sessions/{session_id}") + + assert delete_response.status_code == 204 + deactivates = [p for s, p in fake_bus.published if s == Subject.TASK_DEACTIVATE] + assert len(deactivates) == 1 + command = SRMNetworkCapabilityDeactivateCommand.model_validate(deactivates[0]) + assert command.network_capability.external_ref == "qod-session-nef-123" + class TestQodSessionValidation: """CAMARA qod-api.yaml constraints the request schema must enforce (400s).""" diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index 3e7f1f6..04a7efd 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -11,7 +11,7 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( from open_exposure_gateway.application.services.quality_on_demand_service import ( QualityOnDemandService, ) -from open_exposure_gateway.core.exceptions import DownstreamServiceException +from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException from open_exposure_gateway.domain.models import ( Operation, OperationStatus, @@ -19,7 +19,10 @@ from open_exposure_gateway.domain.models import ( QodSession, QodSessionState, ) -from open_exposure_gateway.domain.quality_on_demand import Subject +from open_exposure_gateway.domain.quality_on_demand import ( + SRMNetworkCapabilityDeactivateCommand, + Subject, +) from open_exposure_gateway.domain.srm_events import SRMOperationCompleted from tests.unit.fakes import FakeOperationRepository, FakeQodSessionRepository, completion_payload @@ -301,3 +304,122 @@ class TestSessionIdMinting: assert first.sessionId != second.sessionId assert len(operation_repo.rows) == 2 assert len({str(uuid) for uuid in operation_repo.rows}) == 2 + + +class TestDeleteSession: + @pytest.fixture() + def operation_repo(self) -> FakeOperationRepository: + return FakeOperationRepository() + + @pytest.fixture() + def qod_session_repo(self) -> FakeQodSessionRepository: + return FakeQodSessionRepository() + + @pytest.fixture() + def publisher(self) -> AsyncMock: + return AsyncMock() + + @pytest.fixture() + def service( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + publisher: AsyncMock, + ) -> QualityOnDemandService: + return QualityOnDemandService( + srm_client=AsyncMock(), + publisher=publisher, + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) + + async def _seed_available_session( + self, qod_session_repo: FakeQodSessionRepository + ) -> QodSession: + return await qod_session_repo.save( + QodSession( + session_id=uuid4(), + operation_id=uuid4(), + service_specification_id=uuid4(), + qos_profile="voice", + duration_seconds=3600, + state=QodSessionState.AVAILABLE, + external_ref="qod-session-nef-123", + ) + ) + + async def test_publishes_deactivate_command_keyed_on_external_ref( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + publisher: AsyncMock, + ) -> None: + qod_session = await self._seed_available_session(qod_session_repo) + + await service.delete_session( + session_id=str(qod_session.session_id), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + + publisher.publish.assert_awaited_once() + subject, payload = publisher.publish.call_args.args + assert subject == Subject.TASK_DEACTIVATE + command = SRMNetworkCapabilityDeactivateCommand.model_validate(payload) + assert command.network_capability.external_ref == "qod-session-nef-123" + assert command.app_provider_id == "provider-1" + + async def test_persists_pending_deactivate_operation_row( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + operation_repo: FakeOperationRepository, + ) -> None: + qod_session = await self._seed_available_session(qod_session_repo) + + await service.delete_session( + session_id=str(qod_session.session_id), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + + operations = list(operation_repo.rows.values()) + assert len(operations) == 1 + assert operations[0].operation_type == OperationType.NETWORK_CAPABILITY_DEACTIVATE + assert operations[0].status == OperationStatus.PENDING + assert operations[0].subject == Subject.TASK_DEACTIVATE + + async def test_raises_not_found_when_session_unknown( + self, service: QualityOnDemandService + ) -> None: + with pytest.raises(NotFoundException): + await service.delete_session( + session_id=str(uuid4()), tenant_id="t", app_provider_id="p" + ) + + async def test_raises_not_found_when_external_ref_not_yet_confirmed( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + qod_session = await qod_session_repo.save( + QodSession( + session_id=uuid4(), + operation_id=uuid4(), + service_specification_id=uuid4(), + qos_profile="voice", + duration_seconds=3600, + state=QodSessionState.REQUESTED, + ) + ) + with pytest.raises(NotFoundException): + await service.delete_session( + session_id=str(qod_session.session_id), tenant_id="t", app_provider_id="p" + ) + + async def test_raises_when_repositories_unavailable(self) -> None: + service = QualityOnDemandService(srm_client=AsyncMock(), publisher=AsyncMock()) + with pytest.raises(RuntimeError, match="Operation/QodSession repositories"): + await service.delete_session( + session_id=str(uuid4()), tenant_id="t", app_provider_id="p" + ) -- GitLab From ba0b7b4625ef6bcc049138a385eaec65646c8fe3 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 20 Jul 2026 15:26:51 +0300 Subject: [PATCH 09/32] feat: add qos-profiles routes returning not implemented --- .../quality_on_demand/v0_10_1/router.py | 27 +++++++++++++++++-- tests/unit/test_qod_flows.py | 12 +++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py index 8ea8dc9..d0050a6 100644 --- a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py @@ -1,6 +1,6 @@ -from typing import Annotated, Any +from typing import Annotated, Any, Optional -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends, Query, Response, status from open_exposure_gateway.api.camara.common import XCorrelatorHeader from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( @@ -114,3 +114,26 @@ async def delete_qod_session( x_correlator=x_correlator, ) return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get( + "/qos-profiles", + tags=["QoS Profiles"], + summary="Get All QoS Profiles", + responses=_responses(401, 403, 404, 500, 501, 503), +) +async def get_qos_profiles( + name: Annotated[Optional[str], Query()] = None, + status: Annotated[Optional[str], Query()] = None, +) -> Any: + raise NotImplementedException(message="QoS profile catalog is not implemented in this release") + + +@router.get( + "/qos-profiles/{name}", + tags=["QoS Profiles"], + summary="Get QoS Profile for a given name", + responses=_responses(400, 401, 403, 404, 500, 501, 503), +) +async def get_qos_profile(name: str) -> Any: + raise NotImplementedException(message="QoS profile catalog is not implemented in this release") diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 4ca65d7..148aff2 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -184,6 +184,18 @@ class TestQodSessionFlow: assert command.network_capability.external_ref == "qod-session-nef-123" +class TestQosProfiles: + """QoS profile catalog is not implemented yet -- both routes exist but 501.""" + + def test_get_qos_profiles_returns_501(self, api_client: TestClient) -> None: + response = api_client.get(f"{QOD_BASE}/qos-profiles") + assert response.status_code == 501 + + def test_get_qos_profile_by_name_returns_501(self, api_client: TestClient) -> None: + response = api_client.get(f"{QOD_BASE}/qos-profiles/voice") + assert response.status_code == 501 + + class TestQodSessionValidation: """CAMARA qod-api.yaml constraints the request schema must enforce (400s).""" -- GitLab From 71a4c7a6c86b5fe412c358d14818e19ae3eeded0 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 11:36:07 +0300 Subject: [PATCH 10/32] refactor: drop dead synchronous srm session create/delete methods --- .../adapters/http/srm_client.py | 17 ------------ src/open_exposure_gateway/ports/srm_port.py | 6 ----- tests/unit/fakes.py | 26 ------------------- 3 files changed, 49 deletions(-) diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index 6cc0b85..579b4d3 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -124,15 +124,6 @@ class SRMClient: ) return [SRMZone.model_validate(z) for z in data] - async def create_qod_session( - self, - payload: dict[str, Any], - x_correlator: str | None = None, - ) -> SessionInfo: - headers = {"x-correlator": x_correlator} if x_correlator else None - data = await self._request("POST", "/sessions", json=payload, headers=headers) - return SessionInfo.model_validate(data) - async def get_qod_session( self, session_id: str, @@ -142,14 +133,6 @@ class SRMClient: data = await self._request("GET", f"/sessions/{session_id}", headers=headers) return SessionInfo.model_validate(data) - async def delete_qod_session( - self, - session_id: str, - x_correlator: str | None = None, - ) -> None: - headers = {"x-correlator": x_correlator} if x_correlator else None - await self._request("DELETE", f"/sessions/{session_id}", headers=headers) - async def get_apps(self, x_correlator: str | None = None) -> list[SRMCatalogPayload]: headers = {"x-correlator": x_correlator} if x_correlator else None data = await self._request( diff --git a/src/open_exposure_gateway/ports/srm_port.py b/src/open_exposure_gateway/ports/srm_port.py index fe1cfd6..ba406eb 100644 --- a/src/open_exposure_gateway/ports/srm_port.py +++ b/src/open_exposure_gateway/ports/srm_port.py @@ -38,10 +38,4 @@ class SRMClientPort(Protocol): x_correlator: str | None, ) -> list[SRMServiceInstance]: ... - async def create_qod_session( - self, payload: dict[str, Any], x_correlator: str | None - ) -> SessionInfo: ... - async def get_qod_session(self, session_id: str, x_correlator: str | None) -> SessionInfo: ... - - async def delete_qod_session(self, session_id: str, x_correlator: str | None) -> None: ... diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index fead870..73ab227 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -28,7 +28,6 @@ from open_exposure_gateway.adapters.errors import ( DuplicateOperationError, ) from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( - QosStatus, SessionInfo, ) from open_exposure_gateway.application.services.edge_application_management_service import ( @@ -148,26 +147,6 @@ class FakeSRMClient: result = [i for i in result if i.service_instance_id == str(app_instance_id)] return result - async def create_qod_session( - self, payload: dict[str, Any], x_correlator: str | None = None - ) -> SessionInfo: - duration = payload.get("duration", 86400) - session = SessionInfo( - sessionId=uuid4(), - device=payload["device"], - applicationServer=payload["applicationServer"], - qosProfile=payload["qosProfile"], - duration=duration, - startedAt=1_750_000_000, - expiresAt=1_750_000_000 + duration, - qosStatus=QosStatus.REQUESTED, - devicePorts=payload.get("devicePorts"), - applicationServerPorts=payload.get("applicationServerPorts"), - webhook=payload.get("webhook"), - ) - self.qod_sessions[str(session.sessionId)] = session - return session - async def get_qod_session( self, session_id: str, x_correlator: str | None = None ) -> SessionInfo: @@ -176,11 +155,6 @@ class FakeSRMClient: raise NotFoundException(message=f"Session {session_id} not found") return session - async def delete_qod_session(self, session_id: str, x_correlator: str | None = None) -> None: - if session_id not in self.qod_sessions: - raise NotFoundException(message=f"Session {session_id} not found") - del self.qod_sessions[session_id] - def completion_payload(operation_id: str, **overrides: Any) -> dict[str, Any]: payload: dict[str, Any] = { -- GitLab From e513956bf0416fd0110526a039048d80ad258bdb Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:00:45 +0300 Subject: [PATCH 11/32] feat: add Qos status-changed CloudEvent models and operation.status subject --- .../domain/quality_on_demand.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index a750b92..9905255 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -1,7 +1,8 @@ from __future__ import annotations from enum import StrEnum -from typing import Any +from typing import Any, Literal +from uuid import UUID from pydantic import BaseModel, Field @@ -10,6 +11,7 @@ class Subject(StrEnum): TASK_ACTIVATE = "command.srm.network.capability.activate" TASK_DEACTIVATE = "command.srm.network.capability.deactivate" OPERATION_COMPLETED = "event.srm.operation.completed" + OPERATION_STATUS = "event.srm.operation.status" class NetworkCapabilityTargetDevice(BaseModel): @@ -69,3 +71,19 @@ class SRMNetworkCapabilityDeactivateCommand(BaseModel): app_provider_id: str source: str = "nbi_camara" network_capability: NetworkCapabilityDeactivateTarget + + +class EventQosStatusChangedData(BaseModel): + sessionId: UUID + qosStatus: Literal["AVAILABLE", "UNAVAILABLE"] + statusInfo: Literal["DURATION_EXPIRED", "NETWORK_TERMINATED", "DELETE_REQUESTED"] | None = None + + +class QosStatusChangedCloudEvent(BaseModel): + id: UUID + source: str + specversion: str = "1.0" + type: str = "org.camaraproject.qod.v0.qos-status-changed" + time: str + datacontenttype: str = "application/json" + data: EventQosStatusChangedData -- GitLab From 40b6164a5b6cb0ab5fd387e2b41858874d9deb8c Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:02:08 +0300 Subject: [PATCH 12/32] feat add SRMOperationStatus event model --- .../domain/srm_events.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/open_exposure_gateway/domain/srm_events.py b/src/open_exposure_gateway/domain/srm_events.py index 8d90f1f..1a9f80d 100644 --- a/src/open_exposure_gateway/domain/srm_events.py +++ b/src/open_exposure_gateway/domain/srm_events.py @@ -54,3 +54,23 @@ class SRMOperationCompleted(BaseModel): if self.status != "failed" and not self.instances: raise ValueError("instances is required when status != failed") return self + + +class SRMOperationStatus(BaseModel): + """`event.srm.operation.status` (srm/interface-contract.md §C.1). + + Covers both pre-execution states (`accepted`, `failed_before_start`) and, per §C.3's + "backend-driven change" case, post-completion capability condition changes (e.g. a QoD + session dropped by the network) reusing the original realization's `operation_id`. + Backend-specific status (e.g. `qos_status`) lives in `metadata`, never in `state`. + """ + + schema_version: str + operation_id: str + service_order_id: str | None = None + service_instance_id: str | None = None + capability: str | None = None + state: Literal["accepted", "failed_before_start", "completed", "failed", "in_progress"] + metadata: dict[str, Any] | None = None + correlation_id: str + emitted_at: str -- GitLab From bf1adbfbee0bd9eab707335da18155472245e67b Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:04:17 +0300 Subject: [PATCH 13/32] feat add build_qos_status_changed_event_mapper --- .../mappers/quality_on_demand_mapper.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py index 9dc650d..b1b3f67 100644 --- a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py +++ b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py @@ -1,13 +1,16 @@ -from uuid import UUID +from typing import Literal +from uuid import UUID, uuid4 from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import CreateSession from open_exposure_gateway.domain.quality_on_demand import ( + EventQosStatusChangedData, NetworkCapabilityDeactivateTarget, NetworkCapabilityParameters, NetworkCapabilityPayload, NetworkCapabilityTarget, NetworkCapabilityTargetApplicationServer, NetworkCapabilityTargetDevice, + QosStatusChangedCloudEvent, SRMNetworkCapabilityActivateCommand, SRMNetworkCapabilityDeactivateCommand, ) @@ -69,3 +72,22 @@ def build_deactivate_command( grace_period_seconds=grace_period_seconds, ), ) + + +def build_qos_status_changed_event( + session_id: UUID, + qos_status: Literal["AVAILABLE", "UNAVAILABLE"], + status_info: Literal["DURATION_EXPIRED", "NETWORK_TERMINATED", "DELETE_REQUESTED"] | None, + source: str, + occurred_at: str, +) -> QosStatusChangedCloudEvent: + return QosStatusChangedCloudEvent( + id=uuid4(), + source=source, + time=occurred_at, + data=EventQosStatusChangedData( + sessionId=session_id, + qosStatus=qos_status, + statusInfo=status_info, + ), + ) -- GitLab From b6a0e00c2b50754b12cfd2b8b26957caeeb1220c Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:05:48 +0300 Subject: [PATCH 14/32] feat: add QodCallbackDeliveryPort protocol --- src/open_exposure_gateway/ports/qod_callback_port.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/open_exposure_gateway/ports/qod_callback_port.py diff --git a/src/open_exposure_gateway/ports/qod_callback_port.py b/src/open_exposure_gateway/ports/qod_callback_port.py new file mode 100644 index 0000000..46b6856 --- /dev/null +++ b/src/open_exposure_gateway/ports/qod_callback_port.py @@ -0,0 +1,7 @@ +from typing import Protocol + +from open_exposure_gateway.domain.quality_on_demand import QosStatusChangedCloudEvent + + +class QodCallbackDeliveryPort(Protocol): + async def deliver(self, sink: str, event: QosStatusChangedCloudEvent) -> None: ... -- GitLab From 5dfdba1d75f84a8fe71ddd2ecb306855c2c56665 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:13:48 +0300 Subject: [PATCH 15/32] feat add callback_settings and public_base_url to Settings --- .env.example | 3 +++ .../adapters/http/qod_callback_client.py | 19 +++++++++++++++++++ src/open_exposure_gateway/core/config.py | 5 +++++ 3 files changed, 27 insertions(+) create mode 100644 src/open_exposure_gateway/adapters/http/qod_callback_client.py diff --git a/.env.example b/.env.example index e2b3e9a..4baaec0 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ # DEBUG=false # HOST="0.0.0.0" # PORT=8080 +# PUBLIC_BASE_URL="http://localhost:8080" # SRM_SETTINGS__BASE_URL="http://localhost:8081" # SRM_SETTINGS__TIMEOUT=10.0 @@ -20,3 +21,5 @@ # NATS_SETTINGS__MAX_RECONNECT_ATTEMPTS=3 # OBSERVABILITY_SETTINGS__LOG_LEVEL="INFO" + +# CALLBACK_SETTINGS__TIMEOUT=10.0 diff --git a/src/open_exposure_gateway/adapters/http/qod_callback_client.py b/src/open_exposure_gateway/adapters/http/qod_callback_client.py new file mode 100644 index 0000000..6542e98 --- /dev/null +++ b/src/open_exposure_gateway/adapters/http/qod_callback_client.py @@ -0,0 +1,19 @@ +import httpx + +from open_exposure_gateway.core.config import get_settings +from open_exposure_gateway.domain.quality_on_demand import QosStatusChangedCloudEvent + + +class HttpQodCallbackClient: + def __init__(self) -> None: + settings = get_settings() + self.timeout = settings.callback_settings.timeout + + async def deliver(self, sink: str, event: QosStatusChangedCloudEvent) -> 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/core/config.py b/src/open_exposure_gateway/core/config.py index 4335083..9b6fa49 100644 --- a/src/open_exposure_gateway/core/config.py +++ b/src/open_exposure_gateway/core/config.py @@ -29,6 +29,10 @@ class ObservabilitySettings(BaseModel): log_level: str = "INFO" +class CallbackSettings(BaseModel): + timeout: float = 10.0 + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", @@ -43,6 +47,7 @@ class Settings(BaseSettings): debug: bool = False host: str = "0.0.0.0" port: int = 8080 + public_base_url: str = "http://localhost:8080" srm_settings: SRMSettings = SRMSettings() postgresql_settings: PostgreSQLSettings = PostgreSQLSettings() nats_settings: NatsSettings = NatsSettings() -- GitLab From d75989abc171903f6ae5e4c76c1b1d20a28eb044 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:16:45 +0300 Subject: [PATCH 16/32] refactor: generalize NatsOperationConsumer over the event model --- .../adapters/databus/nats_adapter.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/open_exposure_gateway/adapters/databus/nats_adapter.py b/src/open_exposure_gateway/adapters/databus/nats_adapter.py index 2312d8b..6165186 100644 --- a/src/open_exposure_gateway/adapters/databus/nats_adapter.py +++ b/src/open_exposure_gateway/adapters/databus/nats_adapter.py @@ -6,7 +6,7 @@ import nats import structlog from nats.aio.client import Client from nats.aio.subscription import Subscription -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from open_exposure_gateway.core.config import NatsSettings from open_exposure_gateway.domain.srm_events import SRMOperationCompleted @@ -78,11 +78,13 @@ class NatsOperationConsumer: self, client: Client, subject: str, - handler: Callable[[SRMOperationCompleted], Awaitable[None]] | None = None, + handler: Callable[[Any], Awaitable[None]] | None = None, + event_model: type[BaseModel] = SRMOperationCompleted, ) -> None: self._client = client self._subject = subject self._handler = handler + self._event_model = event_model self._subscription: Subscription | None = None async def start(self) -> None: @@ -105,7 +107,7 @@ class NatsOperationConsumer: try: - event = SRMOperationCompleted.model_validate(raw) + event = self._event_model.model_validate(raw) except ValidationError as exc: logger.warning("invalid_operation_completed_event", subject=msg.subject, error=str(exc)) return @@ -113,4 +115,7 @@ class NatsOperationConsumer: try: await self._handler(event) except Exception: - logger.exception("operation_completed_handler_failed", operation_id=event.operation_id) + logger.exception( + "operation_completed_handler_failed", + operation_id=getattr(event, "operation_id", None), + ) -- GitLab From c88e8c2fcab5ad08bfec6d8272c9d2a464b9aba0 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:23:16 +0300 Subject: [PATCH 17/32] feat: wire QOS_STATUS_CHANGED delivery into QualityOnDemandService --- .../services/quality_on_demand_service.py | 134 +++++++++++++++++- tests/unit/conftest.py | 7 +- 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index 0a51798..dabfce7 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import Optional +from typing import Literal, Optional from uuid import UUID, uuid4 import structlog @@ -13,9 +13,13 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( from open_exposure_gateway.application.mappers.quality_on_demand_mapper import ( build_activate_command, build_deactivate_command, + build_qos_status_changed_event, ) +from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException from open_exposure_gateway.domain.models import ( + CallbackDelivery, + CallbackRegistration, Operation, OperationStatus, OperationType, @@ -23,10 +27,15 @@ from open_exposure_gateway.domain.models import ( QodSessionState, ) from open_exposure_gateway.domain.quality_on_demand import Subject -from open_exposure_gateway.domain.srm_events import SRMOperationCompleted +from open_exposure_gateway.domain.srm_events import SRMOperationCompleted, SRMOperationStatus +from open_exposure_gateway.ports.database.callbacks import ( + CallbackDeliveryRepository, + CallbackRegistrationRepository, +) from open_exposure_gateway.ports.database.operations import OperationRepository from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository from open_exposure_gateway.ports.databus_port import DataBusPort +from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort logger: structlog.BoundLogger = structlog.get_logger(__name__) @@ -45,11 +54,17 @@ class QualityOnDemandService: publisher: Optional[DataBusPort] = None, operation_repo: Optional[OperationRepository] = None, qod_session_repo: Optional[QodSessionRepository] = None, + callback_registration_repo: Optional[CallbackRegistrationRepository] = None, + callback_delivery_repo: Optional[CallbackDeliveryRepository] = None, + callback_delivery_port: Optional[QodCallbackDeliveryPort] = None, ) -> None: self.srm_client = srm_client self._publisher = publisher self._operation_repo = operation_repo self._qod_session_repo = qod_session_repo + self._callback_registration_repo = callback_registration_repo + self._callback_delivery_repo = callback_delivery_repo + self._callback_delivery_port = callback_delivery_port def _new_operation_metadata(self, x_correlator: Optional[str]) -> tuple[UUID, str, str]: operation_id = uuid4() @@ -114,6 +129,26 @@ class QualityOnDemandService: ) ) + if request.webhook is not None: + if self._callback_registration_repo is None: + raise RuntimeError("CallbackRegistrationRepository is not available") + await self._callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation_id, + tenant_id=tenant_id, + api_family="quality-on-demand", + sink=request.webhook.notificationUrl, + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + sink_credential_ref=( + f"secret://oeg/{operation_id}/notification-auth-token" + if request.webhook.notificationAuthToken + else None + ), + expires_at=None, + ) + ) + await self._publish( Subject.TASK_ACTIVATE, command, @@ -135,6 +170,54 @@ class QualityOnDemandService: qosStatus=QosStatus.REQUESTED, ) + async def _deliver_qos_status_changed( + self, + operation_id: UUID, + session_id: UUID, + qos_status: Literal["AVAILABLE", "UNAVAILABLE"], + status_info: Literal["DURATION_EXPIRED", "NETWORK_TERMINATED", "DELETE_REQUESTED"] | None, + occurred_at: str, + ) -> None: + if self._callback_registration_repo is None: + return + registration = await self._callback_registration_repo.get_by_operation_id(operation_id) + if registration is None or not registration.is_active: + return + if self._callback_delivery_port is None or self._callback_delivery_repo is None: + raise RuntimeError("CallbackDeliveryPort/CallbackDeliveryRepository is not available") + + source = f"{get_settings().public_base_url}/qod/v0/sessions/{session_id}" + cloud_event = build_qos_status_changed_event( + session_id=session_id, + qos_status=qos_status, + status_info=status_info, + source=source, + occurred_at=occurred_at, + ) + + prior_attempts = await self._callback_delivery_repo.list_by_callback_registration_id( + registration.id + ) + 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("qod_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=len(prior_attempts) + 1, + state=state, + last_error=last_error, + ) + ) + async def handle_completed(self, event: SRMOperationCompleted) -> None: if self._operation_repo is None or self._qod_session_repo is None: raise RuntimeError("Operation/QodSession repositories are not available") @@ -177,12 +260,59 @@ class QualityOnDemandService: instance = event.instances[0] if event.instances else None update: dict[str, object] + qos_status: Literal["AVAILABLE", "UNAVAILABLE"] + status_info: Literal["NETWORK_TERMINATED"] | None if instance is not None and instance.status == "completed": update = {"state": QodSessionState.AVAILABLE, "external_ref": instance.external_ref} + qos_status, status_info = "AVAILABLE", None else: update = {"state": QodSessionState.UNAVAILABLE} + qos_status, status_info = "UNAVAILABLE", "NETWORK_TERMINATED" await self._qod_session_repo.save(qod_session.model_copy(update=update)) + await self._deliver_qos_status_changed( + operation_id=operation_id, + session_id=qod_session.session_id, + qos_status=qos_status, + status_info=status_info, + occurred_at=event.completed_at, + ) + + async def handle_status_changed(self, event: SRMOperationStatus) -> None: + if self._operation_repo is None or self._qod_session_repo is None: + raise RuntimeError("Operation/QodSession repositories are not available") + + operation_id = UUID(event.operation_id) + operation = await self._operation_repo.get_by_id(operation_id) + if operation is None or operation.operation_type != OperationType.NETWORK_CAPABILITY: + return + + qod_session = await self._qod_session_repo.get_by_operation_id(operation_id) + if qod_session is None: + return + + qos_status = (event.metadata or {}).get("qos_status") + if qos_status not in ("AVAILABLE", "UNAVAILABLE"): + logger.warning( + "operation_status_unrecognized_qos_status", + operation_id=event.operation_id, + metadata=event.metadata, + ) + return + + status = ( + QodSessionState.AVAILABLE if qos_status == "AVAILABLE" else QodSessionState.UNAVAILABLE + ) + await self._qod_session_repo.save(qod_session.model_copy(update={"state": status})) + + await self._deliver_qos_status_changed( + operation_id=operation_id, + session_id=qod_session.session_id, + qos_status=qos_status, + status_info="NETWORK_TERMINATED" if qos_status == "UNAVAILABLE" else None, + occurred_at=event.emitted_at, + ) + async def get_session( self, session_id: str, diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index a9c5395..25d46d3 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -123,11 +123,6 @@ def live_srm( return fake_srm -@pytest.fixture() -def operation_repo() -> FakeOperationRepository: - return FakeOperationRepository() - - @pytest.fixture() def qod_session_repo() -> FakeQodSessionRepository: return FakeQodSessionRepository() @@ -169,12 +164,14 @@ def qod_service( fake_bus: FakeDataBus, operation_repo: FakeOperationRepository, qod_session_repo: FakeQodSessionRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, ) -> QualityOnDemandService: return QualityOnDemandService( srm_client=fake_srm, publisher=fake_bus, operation_repo=operation_repo, qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, ) -- GitLab From 458ae0eb5bcfc7cafc39b7b45b25a189c2eced02 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:25:50 +0300 Subject: [PATCH 18/32] feat: wire callback repos/port into dependency ijection --- src/open_exposure_gateway/core/state.py | 2 ++ src/open_exposure_gateway/dependencies.py | 32 +++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/open_exposure_gateway/core/state.py b/src/open_exposure_gateway/core/state.py index 63090ac..8af4926 100644 --- a/src/open_exposure_gateway/core/state.py +++ b/src/open_exposure_gateway/core/state.py @@ -3,6 +3,7 @@ from typing import Protocol from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from open_exposure_gateway.ports.databus_port import DataBusPort +from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort @@ -11,3 +12,4 @@ class AppState(Protocol): publisher: DataBusPort db_engine: AsyncEngine session_maker: async_sessionmaker[AsyncSession] + qod_callback_client: QodCallbackDeliveryPort diff --git a/src/open_exposure_gateway/dependencies.py b/src/open_exposure_gateway/dependencies.py index 86b7682..b5cb845 100644 --- a/src/open_exposure_gateway/dependencies.py +++ b/src/open_exposure_gateway/dependencies.py @@ -12,6 +12,9 @@ from open_exposure_gateway.adapters.database.repos.app_instances import ( 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, ) @@ -29,12 +32,16 @@ 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.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.qod_sessions import QodSessionRepository from open_exposure_gateway.ports.database.registration import AppRegistrationRepository from open_exposure_gateway.ports.databus_port import DataBusPort +from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort @@ -119,6 +126,14 @@ def get_qod_session_repo(session: SessionDep) -> QodSessionRepository: return SqlQodSessionRepository(session) +def get_callback_delivery_repo(session: SessionDep) -> CallbackDeliveryRepository: + return SqlCallbackDeliveryRepository(session) + + +def get_qod_callback_client(request: Request) -> QodCallbackDeliveryPort: + return get_app_state(request=request).qod_callback_client + + def get_edge_app_service( srm: SRMClientPort = Depends(get_client), publisher: DataBusPort = Depends(get_publisher), @@ -144,5 +159,18 @@ def get_qod_service( publisher: DataBusPort = Depends(get_publisher), operation_repo: OperationRepository = Depends(get_operation_repo), qod_session_repo: QodSessionRepository = Depends(get_qod_session_repo), + callback_registration_repo: CallbackRegistrationRepository = Depends( + get_callback_registration_repo + ), + callback_delivery_repo: CallbackDeliveryRepository = Depends(get_callback_delivery_repo), + callback_delivery_port: QodCallbackDeliveryPort = Depends(get_qod_callback_client), ) -> QualityOnDemandService: - return QualityOnDemandService(srm, publisher, operation_repo, qod_session_repo) + return QualityOnDemandService( + srm, + publisher, + operation_repo, + qod_session_repo, + callback_registration_repo, + callback_delivery_repo, + callback_delivery_port, + ) -- GitLab From 4b7afb76c17341457941d77891d77fa7b0008a0e Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:28:16 +0300 Subject: [PATCH 19/32] feat: start event.srm.operation.status consumer, wire callback client --- .../domain/srm_events.py | 6 +- src/open_exposure_gateway/main.py | 65 ++++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/open_exposure_gateway/domain/srm_events.py b/src/open_exposure_gateway/domain/srm_events.py index 1a9f80d..f878ff5 100644 --- a/src/open_exposure_gateway/domain/srm_events.py +++ b/src/open_exposure_gateway/domain/srm_events.py @@ -45,8 +45,10 @@ class SRMOperationCompleted(BaseModel): @model_validator(mode="after") def _require_error_when_failed(self) -> SRMOperationCompleted: - if self.status == "failed" and self.error is None: - raise ValueError("error is required when status is failed") + if self.status == "failed" and not self.instances and self.error is None: + raise ValueError( + "error is required when status is failed and no instances were produced" + ) return self @model_validator(mode="after") diff --git a/src/open_exposure_gateway/main.py b/src/open_exposure_gateway/main.py index 808c319..402b860 100644 --- a/src/open_exposure_gateway/main.py +++ b/src/open_exposure_gateway/main.py @@ -28,6 +28,7 @@ from open_exposure_gateway.adapters.databus.nats_adapter import ( NatsOperationConsumer, ) from open_exposure_gateway.adapters.http.callback_client import HttpCallbackClient +from open_exposure_gateway.adapters.http.qod_callback_client import HttpQodCallbackClient 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, @@ -49,8 +50,10 @@ from open_exposure_gateway.application.services.quality_on_demand_service import 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.srm_events import SRMOperationCompleted +from open_exposure_gateway.domain.quality_on_demand import Subject as QodSubject +from open_exposure_gateway.domain.srm_events import SRMOperationCompleted, SRMOperationStatus from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort +from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort @@ -80,18 +83,30 @@ def _build_operation_completed_handler( return handle +def _build_qod_service( + session: AsyncSession, + srm_client: SRMClientPort, + qod_callback_client: QodCallbackDeliveryPort, +) -> QualityOnDemandService: + return QualityOnDemandService( + srm_client=srm_client, + operation_repo=SqlOperationRepository(session), + qod_session_repo=SqlQodSessionRepository(session), + callback_registration_repo=SqlCallbackRegistrationRepository(session), + callback_delivery_repo=SqlCallbackDeliveryRepository(session), + callback_delivery_port=qod_callback_client, + ) + + def _build_qod_operation_completed_handler( session_maker: async_sessionmaker[AsyncSession], srm_client: SRMClientPort, + qod_callback_client: QodCallbackDeliveryPort, ) -> Callable[[SRMOperationCompleted], Awaitable[None]]: async def handle(event: SRMOperationCompleted) -> None: async with session_maker() as session: try: - service = QualityOnDemandService( - srm_client=srm_client, - operation_repo=SqlOperationRepository(session), - qod_session_repo=SqlQodSessionRepository(session), - ) + service = _build_qod_service(session, srm_client, qod_callback_client) await service.handle_completed(event) await session.commit() except Exception: @@ -101,6 +116,24 @@ def _build_qod_operation_completed_handler( return handle +def _build_qod_operation_status_handler( + session_maker: async_sessionmaker[AsyncSession], + srm_client: SRMClientPort, + qod_callback_client: QodCallbackDeliveryPort, +) -> Callable[[SRMOperationStatus], Awaitable[None]]: + async def handle(event: SRMOperationStatus) -> None: + async with session_maker() as session: + try: + service = _build_qod_service(session, srm_client, qod_callback_client) + await service.handle_status_changed(event) + await session.commit() + except Exception: + await session.rollback() + raise + + return handle + + openapi_tags = [ { "name": "Application", @@ -154,6 +187,8 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.error("Failed to initialize SRM client", error=str(e)) raise + qod_callback_client = HttpQodCallbackClient() + try: publisher = NatsMessagePublisher(settings.nats_settings) await publisher.connect() @@ -169,18 +204,30 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: ) await eam_operation_consumer.start() - qod_operation_consumer = NatsOperationConsumer( + operation_completed_consumer = NatsOperationConsumer( client=publisher.client, subject=Subject.OPERATION_COMPLETED, - handler=_build_qod_operation_completed_handler(session_maker, srm_client), + handler=_build_qod_operation_completed_handler( + session_maker, srm_client, qod_callback_client + ), ) - await qod_operation_consumer.start() + await operation_completed_consumer.start() logger.info("NATS consumer started", subject=Subject.OPERATION_COMPLETED) + operation_status_consumer = NatsOperationConsumer( + client=publisher.client, + subject=QodSubject.OPERATION_STATUS, + event_model=SRMOperationStatus, + handler=_build_qod_operation_status_handler(session_maker, srm_client, qod_callback_client), + ) + await operation_status_consumer.start() + logger.info("NATS consumer started", subject=QodSubject.OPERATION_STATUS) + app.state.srm_client = srm_client app.state.publisher = publisher app.state.db_engine = db_engine app.state.session_maker = session_maker + app.state.qod_callback_client = qod_callback_client yield -- GitLab From 5087b3c99f7ba717a4228d02698c9def7ca69f71 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:40:18 +0300 Subject: [PATCH 20/32] test: add FakeQodCallbackDeliveryPort wire status consumer in tests --- tests/unit/fakes.py | 47 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 73ab227..b22a5b7 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -55,7 +55,9 @@ from open_exposure_gateway.domain.models import ( Operation, QodSession, ) +from open_exposure_gateway.domain.quality_on_demand import QosStatusChangedCloudEvent from open_exposure_gateway.domain.quality_on_demand import Subject as QodSubject +from open_exposure_gateway.domain.srm_events import SRMOperationStatus from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, @@ -65,6 +67,7 @@ from open_exposure_gateway.ports.database.instances import AppInstanceRepository from open_exposure_gateway.ports.database.operations import OperationRepository from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository from open_exposure_gateway.ports.database.registration import AppRegistrationRepository +from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort Handler = Callable[[dict[str, Any]], Awaitable[None]] @@ -270,26 +273,46 @@ def wire_qod_operation_consumer( bus: FakeDataBus, operation_repo: OperationRepository, qod_session_repo: QodSessionRepository, + callback_registration_repo: CallbackRegistrationRepository | None = None, + callback_delivery_repo: CallbackDeliveryRepository | None = None, + callback_delivery_port: QodCallbackDeliveryPort | None = None, ) -> NatsOperationConsumer: - """Wires QualityOnDemandService.handle_completed behind the fake bus -- pass the - same repo instances used to build the service under test so a completion event - updates the rows the test can see.""" + """Wires QualityOnDemandService.handle_completed and handle_status_changed behind + the fake bus -- pass the same repo instances used to build the service under test + so a completion/status event updates the rows the test can see.""" service = QualityOnDemandService( srm_client=AsyncMock(), operation_repo=operation_repo, qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_repo=callback_delivery_repo, + callback_delivery_port=callback_delivery_port, ) - consumer = NatsOperationConsumer( + completed_consumer = NatsOperationConsumer( client=AsyncMock(), subject=QodSubject.OPERATION_COMPLETED, handler=service.handle_completed ) - async def deliver(payload: dict[str, Any]) -> None: - await consumer._handle_message( + async def deliver_completed(payload: dict[str, Any]) -> None: + await completed_consumer._handle_message( FakeMsg(data=json.dumps(payload).encode(), subject=str(QodSubject.OPERATION_COMPLETED)) ) - bus.subscribe(QodSubject.OPERATION_COMPLETED, deliver) - return consumer + bus.subscribe(QodSubject.OPERATION_COMPLETED, deliver_completed) + + status_consumer = NatsOperationConsumer( + client=AsyncMock(), + subject=QodSubject.OPERATION_STATUS, + event_model=SRMOperationStatus, + handler=service.handle_status_changed, + ) + + async def deliver_status(payload: dict[str, Any]) -> None: + await status_consumer._handle_message( + FakeMsg(data=json.dumps(payload).encode(), subject=str(QodSubject.OPERATION_STATUS)) + ) + + bus.subscribe(QodSubject.OPERATION_STATUS, deliver_status) + return completed_consumer class FakeAppRegistrationRepository(AppRegistrationRepository): @@ -455,3 +478,11 @@ class FakeCallbackDeliveryPort: async def deliver(self, sink: str, event: AppInstanceStatusChangeCloudEvent) -> None: self.delivered.append((sink, event)) + + +class FakeQodCallbackDeliveryPort: + def __init__(self) -> None: + self.delivered: list[tuple[str, QosStatusChangedCloudEvent]] = [] + + async def deliver(self, sink: str, event: QosStatusChangedCloudEvent) -> None: + self.delivered.append((sink, event)) -- GitLab From dbc6fe1d204d219334d871bb8a785fc5387e0bb9 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:44:00 +0300 Subject: [PATCH 21/32] test: wire callback_delivery_repo/port fixtures inf conftest.py --- tests/unit/conftest.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 25d46d3..5e2b0dc 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -29,6 +29,7 @@ from tests.unit.fakes import ( FakeCallbackRegistrationRepository, FakeDataBus, FakeOperationRepository, + FakeQodCallbackDeliveryPort, FakeQodSessionRepository, FakeSRMClient, wire_operation_consumer, @@ -128,6 +129,11 @@ def qod_session_repo() -> FakeQodSessionRepository: return FakeQodSessionRepository() +@pytest.fixture() +def qod_callback_delivery_port() -> FakeQodCallbackDeliveryPort: + return FakeQodCallbackDeliveryPort() + + @pytest.fixture() def eam_service( fake_srm: FakeSRMClient, @@ -152,10 +158,20 @@ def live_qod( fake_bus: FakeDataBus, operation_repo: FakeOperationRepository, qod_session_repo: FakeQodSessionRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_repo: FakeCallbackDeliveryRepository, + qod_callback_delivery_port: FakeQodCallbackDeliveryPort, ) -> None: - """Wires QoD's real completion handler behind the fake bus, sharing the same - operation_repo/qod_session_repo instances the api_client's qod_service writes to.""" - wire_qod_operation_consumer(fake_bus, operation_repo, qod_session_repo) + """Wires QoD's real completion/status handlers behind the fake bus, sharing the + same repo/port instances the api_client's qod_service writes to and delivers with.""" + wire_qod_operation_consumer( + fake_bus, + operation_repo, + qod_session_repo, + callback_registration_repo, + callback_delivery_repo, + qod_callback_delivery_port, + ) @pytest.fixture() @@ -165,6 +181,8 @@ def qod_service( operation_repo: FakeOperationRepository, qod_session_repo: FakeQodSessionRepository, callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_repo: FakeCallbackDeliveryRepository, + qod_callback_delivery_port: FakeQodCallbackDeliveryPort, ) -> QualityOnDemandService: return QualityOnDemandService( srm_client=fake_srm, @@ -172,6 +190,8 @@ def qod_service( operation_repo=operation_repo, qod_session_repo=qod_session_repo, callback_registration_repo=callback_registration_repo, + callback_delivery_repo=callback_delivery_repo, + callback_delivery_port=qod_callback_delivery_port, ) -- GitLab From 8337c0d6b3cd488abe713c4ccb9098f74aea429f Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Thu, 30 Jul 2026 12:52:04 +0300 Subject: [PATCH 22/32] test: add unit and flow tests for QOS_STATUS_CHANGED delivery --- tests/unit/fakes.py | 13 + tests/unit/test_qod_flows.py | 50 ++++ tests/unit/test_qod_service.py | 499 ++++++++++++++++++++++++++++++++- 3 files changed, 559 insertions(+), 3 deletions(-) diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index b22a5b7..e099a2f 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -178,6 +178,19 @@ def completion_payload(operation_id: str, **overrides: Any) -> dict[str, Any]: return payload +def operation_status_payload(operation_id: str, **overrides: Any) -> dict[str, Any]: + payload: dict[str, Any] = { + "schema_version": "1.0", + "operation_id": operation_id, + "state": "completed", + "metadata": {"qos_status": "UNAVAILABLE"}, + "correlation_id": str(uuid4()), + "emitted_at": "2026-07-03T12:00:00+00:00", + } + payload.update(overrides) + return payload + + def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None: async def on_deploy(command: dict[str, Any]) -> None: # POST /appinstances always carries exactly one targets[] entry diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 148aff2..4899eca 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -20,10 +20,13 @@ from open_exposure_gateway.domain.quality_on_demand import ( Subject, ) from tests.unit.fakes import ( + FakeCallbackDeliveryRepository, FakeDataBus, FakeOperationRepository, + FakeQodCallbackDeliveryPort, FakeQodSessionRepository, completion_payload, + operation_status_payload, ) SESSION_BODY: dict[str, Any] = { @@ -113,6 +116,53 @@ class TestQodSessionFlow: assert updated.state == QodSessionState.AVAILABLE assert updated.external_ref == "qod-session-nef-123" + async def test_webhook_receives_qos_status_changed_on_completion_and_status_events( + self, + api_client: TestClient, + live_qod: None, + fake_bus: FakeDataBus, + qod_session_repo: FakeQodSessionRepository, + callback_delivery_repo: FakeCallbackDeliveryRepository, + callback_delivery_port: FakeQodCallbackDeliveryPort, + ) -> None: + body = {**SESSION_BODY, "webhook": {"notificationUrl": "https://client.example.com/cb"}} + response = api_client.post(f"{QOD_BASE}/sessions", json=body) + session_id = UUID(response.json()["sessionId"]) + operation_id = qod_session_repo.rows[session_id].operation_id + + await fake_bus.publish( + str(Subject.OPERATION_COMPLETED), + completion_payload( + str(operation_id), + instances=[ + { + "service_instance_id": str(uuid4()), + "zone_id": str(uuid4()), + "status": "completed", + "external_ref": "qod-session-nef-123", + } + ], + ), + ) + + assert len(callback_delivery_port.delivered) == 1 + sink, first_event = callback_delivery_port.delivered[0] + assert sink == "https://client.example.com/cb" + assert first_event.data.qosStatus == "AVAILABLE" + assert qod_session_repo.rows[session_id].state == QodSessionState.AVAILABLE + + await fake_bus.publish( + str(Subject.OPERATION_STATUS), + operation_status_payload(str(operation_id)), + ) + + assert len(callback_delivery_port.delivered) == 2 + _, second_event = callback_delivery_port.delivered[1] + assert second_event.data.qosStatus == "UNAVAILABLE" + assert second_event.data.statusInfo == "NETWORK_TERMINATED" + assert qod_session_repo.rows[session_id].state == QodSessionState.UNAVAILABLE + assert len(callback_delivery_repo.rows) == 2 + @pytest.mark.skip( reason="TODO: GET /sessions/{id} is a synchronous SRM call (unchanged by the async " "POST /sessions work) and OEG's minted sessionId has no reconciled mapping to SRM's " diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index 04a7efd..ca29734 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -7,12 +7,15 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( ApplicationServer, CreateSession, Device, + Webhook, ) from open_exposure_gateway.application.services.quality_on_demand_service import ( QualityOnDemandService, ) from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException from open_exposure_gateway.domain.models import ( + CallbackDelivery, + CallbackRegistration, Operation, OperationStatus, OperationType, @@ -23,16 +26,25 @@ from open_exposure_gateway.domain.quality_on_demand import ( SRMNetworkCapabilityDeactivateCommand, Subject, ) -from open_exposure_gateway.domain.srm_events import SRMOperationCompleted -from tests.unit.fakes import FakeOperationRepository, FakeQodSessionRepository, completion_payload +from open_exposure_gateway.domain.srm_events import SRMOperationCompleted, SRMOperationStatus +from tests.unit.fakes import ( + FakeCallbackDeliveryRepository, + FakeCallbackRegistrationRepository, + FakeOperationRepository, + FakeQodCallbackDeliveryPort, + FakeQodSessionRepository, + completion_payload, + operation_status_payload, +) -def _make_request() -> CreateSession: +def _make_request(webhook: Webhook | None = None) -> CreateSession: return CreateSession( device=Device(phoneNumber="123456789"), applicationServer=ApplicationServer(ipv4Address="198.51.100.0/24"), qosProfile="voice", duration=3600, + webhook=webhook, ) @@ -138,6 +150,102 @@ class TestCreateSession: request=_make_request(), tenant_id="t", app_provider_id="p" ) + async def test_persists_callback_registration_when_webhook_present( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + callback_registration_repo = FakeCallbackRegistrationRepository() + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + ) + result = await service.create_session( + request=_make_request(webhook=Webhook(notificationUrl="https://client.example.com/cb")), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + + registrations = list(callback_registration_repo.rows.values()) + assert len(registrations) == 1 + registration = registrations[0] + assert registration.sink == "https://client.example.com/cb" + assert registration.api_family == "quality-on-demand" + assert registration.event_types == ["org.camaraproject.qod.v0.qos-status-changed"] + stored_session = await qod_session_repo.get_by_id(result.sessionId) + assert stored_session is not None + assert registration.operation_id == stored_session.operation_id + + async def test_no_callback_registration_when_webhook_absent( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + callback_registration_repo = FakeCallbackRegistrationRepository() + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + ) + await service.create_session( + request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1" + ) + assert callback_registration_repo.rows == {} + + async def test_sink_credential_ref_never_stores_raw_token( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + callback_registration_repo = FakeCallbackRegistrationRepository() + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + ) + raw_token = "a" * 32 + await service.create_session( + request=_make_request( + webhook=Webhook( + notificationUrl="https://client.example.com/cb", + notificationAuthToken=raw_token, + ) + ), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + + registration = next(iter(callback_registration_repo.rows.values())) + assert registration.sink_credential_ref is not None + assert raw_token not in registration.sink_credential_ref + + async def test_raises_when_webhook_present_but_callback_registration_repo_unavailable( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) + with pytest.raises(RuntimeError, match="CallbackRegistrationRepository"): + await service.create_session( + request=_make_request( + webhook=Webhook(notificationUrl="https://client.example.com/cb") + ), + tenant_id="t", + app_provider_id="p", + ) + class TestHandleCompleted: @pytest.fixture() @@ -284,6 +392,391 @@ class TestHandleCompleted: with pytest.raises(RuntimeError, match="Operation/QodSession repositories"): await service.handle_completed(event) + async def test_delivers_qos_status_changed_on_success( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + callback_registration_repo = FakeCallbackRegistrationRepository() + callback_delivery_repo = FakeCallbackDeliveryRepository() + callback_delivery_port = FakeQodCallbackDeliveryPort() + service = QualityOnDemandService( + srm_client=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_repo=callback_delivery_repo, + callback_delivery_port=callback_delivery_port, + ) + operation, qod_session = await self._seed(operation_repo, qod_session_repo) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + event = SRMOperationCompleted.model_validate( + completion_payload( + str(operation.operation_id), + instances=[ + { + "service_instance_id": str(uuid4()), + "zone_id": str(uuid4()), + "status": "completed", + "external_ref": "qod-session-123", + } + ], + ) + ) + + await service.handle_completed(event) + + assert len(callback_delivery_port.delivered) == 1 + sink, cloud_event = callback_delivery_port.delivered[0] + assert sink == "https://client.example.com/cb" + assert cloud_event.data.sessionId == qod_session.session_id + assert cloud_event.data.qosStatus == "AVAILABLE" + assert cloud_event.data.statusInfo is None + + deliveries = list(callback_delivery_repo.rows.values()) + assert len(deliveries) == 1 + assert deliveries[0].state == "delivered" + assert deliveries[0].attempt == 1 + + async def test_delivers_qos_status_changed_unavailable_on_failure( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + callback_registration_repo = FakeCallbackRegistrationRepository() + callback_delivery_repo = FakeCallbackDeliveryRepository() + callback_delivery_port = FakeQodCallbackDeliveryPort() + service = QualityOnDemandService( + srm_client=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_repo=callback_delivery_repo, + callback_delivery_port=callback_delivery_port, + ) + operation, _ = await self._seed(operation_repo, qod_session_repo) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + event = SRMOperationCompleted.model_validate( + completion_payload( + str(operation.operation_id), + status="failed", + instances=[], + error={"status": 500, "code": "INTERNAL", "message": "backend rejected"}, + ) + ) + + await service.handle_completed(event) + + _, cloud_event = callback_delivery_port.delivered[0] + assert cloud_event.data.qosStatus == "UNAVAILABLE" + assert cloud_event.data.statusInfo == "NETWORK_TERMINATED" + + async def test_failed_delivery_still_persists_session_update( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + callback_registration_repo = FakeCallbackRegistrationRepository() + callback_delivery_repo = FakeCallbackDeliveryRepository() + callback_delivery_port = AsyncMock() + callback_delivery_port.deliver = AsyncMock(side_effect=RuntimeError("sink unreachable")) + service = QualityOnDemandService( + srm_client=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_repo=callback_delivery_repo, + callback_delivery_port=callback_delivery_port, + ) + operation, qod_session = await self._seed(operation_repo, qod_session_repo) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + event = SRMOperationCompleted.model_validate( + completion_payload( + str(operation.operation_id), + instances=[ + { + "service_instance_id": str(uuid4()), + "zone_id": str(uuid4()), + "status": "completed", + "external_ref": "qod-session-123", + } + ], + ) + ) + + await service.handle_completed(event) + + updated_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert updated_session is not None + assert updated_session.state == QodSessionState.AVAILABLE + + deliveries = list(callback_delivery_repo.rows.values()) + assert len(deliveries) == 1 + assert deliveries[0].state == "failed" + assert deliveries[0].last_error is not None + + async def test_no_delivery_when_no_registration_exists( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + """service fixture has no callback repos/port wired at all -- handle_completed + must not error just because nobody registered a webhook.""" + operation, _ = await self._seed(operation_repo, qod_session_repo) + event = SRMOperationCompleted.model_validate( + completion_payload(str(operation.operation_id)) + ) + + await service.handle_completed(event) + + async def test_raises_when_registration_exists_but_delivery_port_unavailable( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + callback_registration_repo = FakeCallbackRegistrationRepository() + service = QualityOnDemandService( + srm_client=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + ) + operation, _ = await self._seed(operation_repo, qod_session_repo) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + event = SRMOperationCompleted.model_validate( + completion_payload(str(operation.operation_id)) + ) + with pytest.raises(RuntimeError, match="CallbackDeliveryPort"): + await service.handle_completed(event) + + +class TestHandleStatusChanged: + @pytest.fixture() + def operation_repo(self) -> FakeOperationRepository: + return FakeOperationRepository() + + @pytest.fixture() + def qod_session_repo(self) -> FakeQodSessionRepository: + return FakeQodSessionRepository() + + @pytest.fixture() + def callback_registration_repo(self) -> FakeCallbackRegistrationRepository: + return FakeCallbackRegistrationRepository() + + @pytest.fixture() + def callback_delivery_repo(self) -> FakeCallbackDeliveryRepository: + return FakeCallbackDeliveryRepository() + + @pytest.fixture() + def callback_delivery_port(self) -> FakeQodCallbackDeliveryPort: + return FakeQodCallbackDeliveryPort() + + @pytest.fixture() + def service( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_repo: FakeCallbackDeliveryRepository, + callback_delivery_port: FakeQodCallbackDeliveryPort, + ) -> QualityOnDemandService: + return QualityOnDemandService( + srm_client=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_repo=callback_delivery_repo, + callback_delivery_port=callback_delivery_port, + ) + + async def _seed_available( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + operation_type: OperationType = OperationType.NETWORK_CAPABILITY, + ) -> tuple[Operation, QodSession]: + operation = await operation_repo.save( + Operation( + operation_id=uuid4(), + correlation_id="corr-1", + tenant_id="tenant-1", + app_provider_id="provider-1", + operation_type=operation_type, + status=OperationStatus.COMPLETED, + subject=Subject.TASK_ACTIVATE, + ) + ) + qod_session = await qod_session_repo.save( + QodSession( + session_id=uuid4(), + operation_id=operation.operation_id, + service_specification_id=uuid4(), + qos_profile="voice", + duration_seconds=3600, + state=QodSessionState.AVAILABLE, + external_ref="qod-session-123", + ) + ) + return operation, qod_session + + async def test_updates_session_and_delivers_second_event_on_network_drop( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_repo: FakeCallbackDeliveryRepository, + callback_delivery_port: FakeQodCallbackDeliveryPort, + ) -> None: + operation, qod_session = await self._seed_available(operation_repo, qod_session_repo) + registration = await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + # A prior delivery already happened (the AVAILABLE notification from + # handle_completed) -- this event's delivery must be attempt 2, not 1. + await callback_delivery_repo.save( + CallbackDelivery( + id=uuid4(), + callback_registration_id=registration.id, + operation_id=operation.operation_id, + attempt=1, + state="delivered", + ) + ) + event = SRMOperationStatus.model_validate( + operation_status_payload(str(operation.operation_id)) + ) + + await service.handle_status_changed(event) + + updated_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert updated_session is not None + assert updated_session.state == QodSessionState.UNAVAILABLE + + assert len(callback_delivery_port.delivered) == 1 + _, cloud_event = callback_delivery_port.delivered[0] + assert cloud_event.data.sessionId == qod_session.session_id + assert cloud_event.data.qosStatus == "UNAVAILABLE" + assert cloud_event.data.statusInfo == "NETWORK_TERMINATED" + + deliveries = list(callback_delivery_repo.rows.values()) + assert len(deliveries) == 2 + second_delivery = next(d for d in deliveries if d.attempt == 2) + assert second_delivery.state == "delivered" + + async def test_ignores_status_for_a_different_domains_operation( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + operation, qod_session = await self._seed_available( + operation_repo, qod_session_repo, operation_type=OperationType.DEPLOY + ) + event = SRMOperationStatus.model_validate( + operation_status_payload(str(operation.operation_id)) + ) + + await service.handle_status_changed(event) + + unchanged_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert unchanged_session is not None + assert unchanged_session.state == QodSessionState.AVAILABLE + + async def test_ignores_unrecognized_metadata( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + operation, qod_session = await self._seed_available(operation_repo, qod_session_repo) + event = SRMOperationStatus.model_validate( + operation_status_payload(str(operation.operation_id), metadata={"foo": "bar"}) + ) + + await service.handle_status_changed(event) + + unchanged_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert unchanged_session is not None + assert unchanged_session.state == QodSessionState.AVAILABLE + + async def test_noop_when_no_qod_session_matches_operation( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + ) -> None: + operation = await operation_repo.save( + Operation( + operation_id=uuid4(), + correlation_id="corr-1", + tenant_id="tenant-1", + app_provider_id="provider-1", + operation_type=OperationType.NETWORK_CAPABILITY, + status=OperationStatus.COMPLETED, + subject=Subject.TASK_ACTIVATE, + ) + ) + event = SRMOperationStatus.model_validate( + operation_status_payload(str(operation.operation_id)) + ) + + await service.handle_status_changed(event) + + async def test_unknown_operation_id_is_a_noop(self, service: QualityOnDemandService) -> None: + event = SRMOperationStatus.model_validate(operation_status_payload(str(uuid4()))) + + await service.handle_status_changed(event) + + async def test_raises_when_repositories_unavailable(self) -> None: + service = QualityOnDemandService(srm_client=AsyncMock()) + event = SRMOperationStatus.model_validate(operation_status_payload(str(uuid4()))) + with pytest.raises(RuntimeError, match="Operation/QodSession repositories"): + await service.handle_status_changed(event) + class TestSessionIdMinting: async def test_each_request_gets_distinct_session_and_operation_ids(self) -> None: -- GitLab From e98bf7f58473a29a9313dcf13c6c9be13b8e7229 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Fri, 31 Jul 2026 12:12:35 +0300 Subject: [PATCH 23/32] feat: send DELETE_REQUESTED notification when deleting an available QoD session --- .../services/quality_on_demand_service.py | 9 ++ tests/unit/test_qod_service.py | 91 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index dabfce7..733a33a 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -364,3 +364,12 @@ class QualityOnDemandService: command, "Failed to publish QoD session deactivation", ) + + if qod_session.state == QodSessionState.AVAILABLE: + await self._deliver_qos_status_changed( + operation_id=qod_session.operation_id, + session_id=qod_session.session_id, + qos_status="UNAVAILABLE", + status_info="DELETE_REQUESTED", + occurred_at=requested_at, + ) diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index ca29734..0091d25 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -916,3 +916,94 @@ class TestDeleteSession: await service.delete_session( session_id=str(uuid4()), tenant_id="t", app_provider_id="p" ) + + async def test_delivers_delete_requested_notification_when_session_available( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + publisher: AsyncMock, + ) -> None: + callback_registration_repo = FakeCallbackRegistrationRepository() + callback_delivery_repo = FakeCallbackDeliveryRepository() + callback_delivery_port = FakeQodCallbackDeliveryPort() + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=publisher, + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_repo=callback_delivery_repo, + callback_delivery_port=callback_delivery_port, + ) + qod_session = await self._seed_available_session(qod_session_repo) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=qod_session.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + + await service.delete_session( + session_id=str(qod_session.session_id), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + + assert len(callback_delivery_port.delivered) == 1 + sink, cloud_event = callback_delivery_port.delivered[0] + assert sink == "https://client.example.com/cb" + assert cloud_event.data.sessionId == qod_session.session_id + assert cloud_event.data.qosStatus == "UNAVAILABLE" + assert cloud_event.data.statusInfo == "DELETE_REQUESTED" + + async def test_no_notification_when_session_already_unavailable( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + publisher: AsyncMock, + ) -> None: + callback_registration_repo = FakeCallbackRegistrationRepository() + callback_delivery_repo = FakeCallbackDeliveryRepository() + callback_delivery_port = FakeQodCallbackDeliveryPort() + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=publisher, + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_repo=callback_delivery_repo, + callback_delivery_port=callback_delivery_port, + ) + qod_session = await qod_session_repo.save( + QodSession( + session_id=uuid4(), + operation_id=uuid4(), + service_specification_id=uuid4(), + qos_profile="voice", + duration_seconds=3600, + state=QodSessionState.UNAVAILABLE, + external_ref="qod-session-nef-123", + ) + ) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=qod_session.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + + await service.delete_session( + session_id=str(qod_session.session_id), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + + assert callback_delivery_port.delivered == [] -- GitLab From a1f88b031c01a1afb4eeb0acaca18bc5e8112ee3 Mon Sep 17 00:00:00 2001 From: gpapathan87 Date: Mon, 3 Aug 2026 13:08:14 +0300 Subject: [PATCH 24/32] refactor: align nats consumer and srm events models with latest changes on develop branch --- .../adapters/databus/nats_adapter.py | 52 +++++++++---- .../services/quality_on_demand_service.py | 4 +- src/open_exposure_gateway/core/config.py | 4 - .../domain/edge_application_management.py | 51 +++++++++--- .../domain/quality_on_demand.py | 20 +++++ .../domain/srm_events.py | 78 ------------------- src/open_exposure_gateway/main.py | 11 ++- tests/unit/fakes.py | 9 ++- tests/unit/test_nats_adapter.py | 62 +++++++++++++++ tests/unit/test_qod_flows.py | 10 +-- tests/unit/test_qod_service.py | 3 +- 11 files changed, 184 insertions(+), 120 deletions(-) delete mode 100644 src/open_exposure_gateway/domain/srm_events.py diff --git a/src/open_exposure_gateway/adapters/databus/nats_adapter.py b/src/open_exposure_gateway/adapters/databus/nats_adapter.py index 6165186..2d54fd1 100644 --- a/src/open_exposure_gateway/adapters/databus/nats_adapter.py +++ b/src/open_exposure_gateway/adapters/databus/nats_adapter.py @@ -6,10 +6,11 @@ import nats import structlog from nats.aio.client import Client from nats.aio.subscription import Subscription -from pydantic import BaseModel, ValidationError +from pydantic import ValidationError from open_exposure_gateway.core.config import NatsSettings -from open_exposure_gateway.domain.srm_events import SRMOperationCompleted +from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted +from open_exposure_gateway.domain.quality_on_demand import SRMOperationStatus from open_exposure_gateway.ports.databus_port import DataBusPort logger: structlog.BoundLogger = structlog.get_logger(__name__) @@ -78,13 +79,11 @@ class NatsOperationConsumer: self, client: Client, subject: str, - handler: Callable[[Any], Awaitable[None]] | None = None, - event_model: type[BaseModel] = SRMOperationCompleted, + handler: Callable[[SRMOperationCompleted], Awaitable[None]], ) -> None: self._client = client self._subject = subject self._handler = handler - self._event_model = event_model self._subscription: Subscription | None = None async def start(self) -> None: @@ -101,21 +100,48 @@ class NatsOperationConsumer: logger.warning("invalid_json", subject=msg.subject) return - if self._handler is None: - 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) + + +class NatsOperationStatusConsumer: + def __init__( + self, + client: Client, + subject: str, + handler: Callable[[SRMOperationStatus], Awaitable[None]], + ) -> None: + self._client = client + self._subject = subject + self._handler = handler + self._subscription: Subscription | None = None + + async def start(self) -> None: + # TODO: same at-most-once caveat as NatsOperationConsumer.start() — see there. + self._subscription = await self._client.subscribe(self._subject, cb=self._handle_message) + + async def _handle_message(self, msg: _Msg) -> None: + try: + raw: Any = json.loads(msg.data.decode()) + except (json.JSONDecodeError, UnicodeDecodeError): + logger.warning("invalid_json", subject=msg.subject) + return try: - event = self._event_model.model_validate(raw) + event = SRMOperationStatus.model_validate(raw) except ValidationError as exc: - logger.warning("invalid_operation_completed_event", subject=msg.subject, error=str(exc)) + logger.warning("invalid_operation_status_event", subject=msg.subject, error=str(exc)) return try: await self._handler(event) except Exception: - logger.exception( - "operation_completed_handler_failed", - operation_id=getattr(event, "operation_id", None), - ) + logger.exception("operation_status_handler_failed", operation_id=event.operation_id) diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index 733a33a..52dff36 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -17,6 +17,7 @@ from open_exposure_gateway.application.mappers.quality_on_demand_mapper import ( ) from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException +from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted from open_exposure_gateway.domain.models import ( CallbackDelivery, CallbackRegistration, @@ -26,8 +27,7 @@ from open_exposure_gateway.domain.models import ( QodSession, QodSessionState, ) -from open_exposure_gateway.domain.quality_on_demand import Subject -from open_exposure_gateway.domain.srm_events import SRMOperationCompleted, SRMOperationStatus +from open_exposure_gateway.domain.quality_on_demand import SRMOperationStatus, Subject from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, CallbackRegistrationRepository, diff --git a/src/open_exposure_gateway/core/config.py b/src/open_exposure_gateway/core/config.py index 9b6fa49..885de72 100644 --- a/src/open_exposure_gateway/core/config.py +++ b/src/open_exposure_gateway/core/config.py @@ -21,10 +21,6 @@ class NatsSettings(BaseModel): max_reconnect_attempts: int = 3 -class CallbackSettings(BaseModel): - timeout: float = 10.0 - - class ObservabilitySettings(BaseModel): log_level: str = "INFO" diff --git a/src/open_exposure_gateway/domain/edge_application_management.py b/src/open_exposure_gateway/domain/edge_application_management.py index f04876b..592b0d6 100644 --- a/src/open_exposure_gateway/domain/edge_application_management.py +++ b/src/open_exposure_gateway/domain/edge_application_management.py @@ -1,17 +1,10 @@ from __future__ import annotations from enum import StrEnum -from typing import Any +from typing import Any, Literal from uuid import UUID -from pydantic import BaseModel, Field - -from open_exposure_gateway.domain.srm_events import ( - SRMCompletedInstance, - SRMOperationCompleted, -) - -__all__ = ["SRMCompletedInstance", "SRMOperationCompleted"] +from pydantic import BaseModel, Field, model_validator class Subject(StrEnum): @@ -282,6 +275,46 @@ class SRMTerminateCommand(BaseModel): terminate: SRMTerminatePayload +class SRMCompletedInstance(BaseModel): + service_instance_id: str + zone_id: str + status: Literal["completed", "failed"] + external_ref: str | None = None + error: dict[str, Any] | None = None + + @model_validator(mode="after") + def _require_error_when_failed(self) -> SRMCompletedInstance: + if self.status == "failed" and self.error is None: + raise ValueError("error is required when instance status is failed") + return self + + +class SRMOperationCompleted(BaseModel): + schema_version: str + operation_id: str + status: Literal["completed", "partially_completed", "failed"] + service_order_id: str | None = None + instances: list[SRMCompletedInstance] = [] + metadata: dict[str, Any] | None = None + error: dict[str, Any] | None = None + correlation_id: str + completed_at: str + + @model_validator(mode="after") + def _require_error_when_failed(self) -> SRMOperationCompleted: + 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") + def _require_instances_unless_failed(self) -> SRMOperationCompleted: + if self.status != "failed" and not self.instances: + raise ValueError("instances is required when status != failed") + return self + + class SRMCapabilityEndpoint(BaseModel): interface_id: str fqdn: str | None = None diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index 9905255..f06e78a 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -73,6 +73,26 @@ class SRMNetworkCapabilityDeactivateCommand(BaseModel): network_capability: NetworkCapabilityDeactivateTarget +class SRMOperationStatus(BaseModel): + """`event.srm.operation.status` (srm/interface-contract.md §C.1). + + Covers both pre-execution states (`accepted`, `failed_before_start`) and, per §C.3's + "backend-driven change" case, post-completion capability condition changes (e.g. a QoD + session dropped by the network) reusing the original realization's `operation_id`. + Backend-specific status (e.g. `qos_status`) lives in `metadata`, never in `state`. + """ + + schema_version: str + operation_id: str + service_order_id: str | None = None + service_instance_id: str | None = None + capability: str | None = None + state: Literal["accepted", "failed_before_start", "completed", "failed", "in_progress"] + metadata: dict[str, Any] | None = None + correlation_id: str + emitted_at: str + + class EventQosStatusChangedData(BaseModel): sessionId: UUID qosStatus: Literal["AVAILABLE", "UNAVAILABLE"] diff --git a/src/open_exposure_gateway/domain/srm_events.py b/src/open_exposure_gateway/domain/srm_events.py deleted file mode 100644 index f878ff5..0000000 --- a/src/open_exposure_gateway/domain/srm_events.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Generic `event.srm.*` shapes shared by every domain (EAM, QoD, ...). - -Lives outside any single feature's domain module (`edge_application_management.py`, -`quality_on_demand.py`, ...) so infrastructure adapters (`adapters/databus/nats_adapter.py`) -can depend on the shared event contract without importing a feature-specific module — -`event.srm.operation.completed` is one subject shared across all domains -(srm/interface-contract.md §C.2/§D.3), not owned by any one of them. -""" - -from __future__ import annotations - -from typing import Any, Literal - -from pydantic import BaseModel, model_validator - - -class SRMCompletedInstance(BaseModel): - service_instance_id: str - zone_id: str - status: Literal["completed", "failed"] - external_ref: str | None = None - error: dict[str, Any] | None = None - - @model_validator(mode="after") - def _require_error_when_failed(self) -> SRMCompletedInstance: - if self.status == "failed" and self.error is None: - raise ValueError("error is required when instance status is failed") - return self - - -class SRMOperationCompleted(BaseModel): - schema_version: str - operation_id: str - status: Literal["completed", "partially_completed", "failed"] - service_order_id: str | None = None - # One entry per resulting realization (one per targeted zone for deploy; - # a single entry for network-capability activate); 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 - correlation_id: str - completed_at: str - - @model_validator(mode="after") - def _require_error_when_failed(self) -> SRMOperationCompleted: - 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") - def _require_instances_unless_failed(self) -> SRMOperationCompleted: - if self.status != "failed" and not self.instances: - raise ValueError("instances is required when status != failed") - return self - - -class SRMOperationStatus(BaseModel): - """`event.srm.operation.status` (srm/interface-contract.md §C.1). - - Covers both pre-execution states (`accepted`, `failed_before_start`) and, per §C.3's - "backend-driven change" case, post-completion capability condition changes (e.g. a QoD - session dropped by the network) reusing the original realization's `operation_id`. - Backend-specific status (e.g. `qos_status`) lives in `metadata`, never in `state`. - """ - - schema_version: str - operation_id: str - service_order_id: str | None = None - service_instance_id: str | None = None - capability: str | None = None - state: Literal["accepted", "failed_before_start", "completed", "failed", "in_progress"] - metadata: dict[str, Any] | None = None - correlation_id: str - emitted_at: str diff --git a/src/open_exposure_gateway/main.py b/src/open_exposure_gateway/main.py index 402b860..c11cd22 100644 --- a/src/open_exposure_gateway/main.py +++ b/src/open_exposure_gateway/main.py @@ -26,6 +26,7 @@ from open_exposure_gateway.adapters.database.repos.qod_sessions import SqlQodSes from open_exposure_gateway.adapters.databus.nats_adapter import ( NatsMessagePublisher, NatsOperationConsumer, + NatsOperationStatusConsumer, ) from open_exposure_gateway.adapters.http.callback_client import HttpCallbackClient from open_exposure_gateway.adapters.http.qod_callback_client import HttpQodCallbackClient @@ -49,9 +50,12 @@ from open_exposure_gateway.application.services.quality_on_demand_service import ) 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.domain.quality_on_demand import SRMOperationStatus from open_exposure_gateway.domain.quality_on_demand import Subject as QodSubject -from open_exposure_gateway.domain.srm_events import SRMOperationCompleted, SRMOperationStatus from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort @@ -214,10 +218,9 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await operation_completed_consumer.start() logger.info("NATS consumer started", subject=Subject.OPERATION_COMPLETED) - operation_status_consumer = NatsOperationConsumer( + operation_status_consumer = NatsOperationStatusConsumer( client=publisher.client, subject=QodSubject.OPERATION_STATUS, - event_model=SRMOperationStatus, handler=_build_qod_operation_status_handler(session_maker, srm_client, qod_callback_client), ) await operation_status_consumer.start() diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index e099a2f..48987a4 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -22,7 +22,10 @@ from typing import Any from unittest.mock import AsyncMock from uuid import UUID, uuid4 -from open_exposure_gateway.adapters.databus.nats_adapter import NatsOperationConsumer +from open_exposure_gateway.adapters.databus.nats_adapter import ( + NatsOperationConsumer, + NatsOperationStatusConsumer, +) from open_exposure_gateway.adapters.errors import ( DuplicateAppRegistrationError, DuplicateOperationError, @@ -57,7 +60,6 @@ from open_exposure_gateway.domain.models import ( ) from open_exposure_gateway.domain.quality_on_demand import QosStatusChangedCloudEvent from open_exposure_gateway.domain.quality_on_demand import Subject as QodSubject -from open_exposure_gateway.domain.srm_events import SRMOperationStatus from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, @@ -312,10 +314,9 @@ def wire_qod_operation_consumer( bus.subscribe(QodSubject.OPERATION_COMPLETED, deliver_completed) - status_consumer = NatsOperationConsumer( + status_consumer = NatsOperationStatusConsumer( client=AsyncMock(), subject=QodSubject.OPERATION_STATUS, - event_model=SRMOperationStatus, handler=service.handle_status_changed, ) diff --git a/tests/unit/test_nats_adapter.py b/tests/unit/test_nats_adapter.py index b6355e8..c0caae7 100644 --- a/tests/unit/test_nats_adapter.py +++ b/tests/unit/test_nats_adapter.py @@ -8,6 +8,7 @@ import pytest from open_exposure_gateway.adapters.databus.nats_adapter import ( NatsMessagePublisher, NatsOperationConsumer, + NatsOperationStatusConsumer, ) from open_exposure_gateway.core.config import NatsSettings @@ -195,3 +196,64 @@ async def test_handler_exception_is_caught() -> None: handler.side_effect = RuntimeError("db down") await consumer._handle_message(Msg(data=json.dumps(_valid_completed_payload()).encode())) + + +def make_status_consumer() -> tuple[NatsOperationStatusConsumer, AsyncMock]: + handler = AsyncMock() + consumer = NatsOperationStatusConsumer( + client=AsyncMock(), + subject="operation.status", + handler=handler, + ) + return consumer, handler + + +def _valid_status_payload() -> dict[str, Any]: + return { + "schema_version": "1.0", + "operation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "state": "accepted", + "correlation_id": "corr-1", + "emitted_at": "2026-07-03T12:00:00+00:00", + } + + +async def test_status_invalid_json_is_ignored() -> None: + consumer, handler = make_status_consumer() + + await consumer._handle_message(Msg(data=b"not json", subject="operation.status")) + + handler.assert_not_awaited() + + +async def test_status_schema_invalid_message_is_ignored() -> None: + consumer, handler = make_status_consumer() + payload = {"state": "accepted"} + + await consumer._handle_message( + Msg(data=json.dumps(payload).encode(), subject="operation.status") + ) + + handler.assert_not_awaited() + + +async def test_status_valid_message_is_handled() -> None: + consumer, handler = make_status_consumer() + + await consumer._handle_message( + Msg(data=json.dumps(_valid_status_payload()).encode(), subject="operation.status") + ) + + 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_status_handler_exception_is_caught() -> None: + consumer, handler = make_status_consumer() + handler.side_effect = RuntimeError("db down") + + await consumer._handle_message( + Msg(data=json.dumps(_valid_status_payload()).encode(), subject="operation.status") + ) diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 4899eca..3dde154 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -123,7 +123,7 @@ class TestQodSessionFlow: fake_bus: FakeDataBus, qod_session_repo: FakeQodSessionRepository, callback_delivery_repo: FakeCallbackDeliveryRepository, - callback_delivery_port: FakeQodCallbackDeliveryPort, + qod_callback_delivery_port: FakeQodCallbackDeliveryPort, ) -> None: body = {**SESSION_BODY, "webhook": {"notificationUrl": "https://client.example.com/cb"}} response = api_client.post(f"{QOD_BASE}/sessions", json=body) @@ -145,8 +145,8 @@ class TestQodSessionFlow: ), ) - assert len(callback_delivery_port.delivered) == 1 - sink, first_event = callback_delivery_port.delivered[0] + assert len(qod_callback_delivery_port.delivered) == 1 + sink, first_event = qod_callback_delivery_port.delivered[0] assert sink == "https://client.example.com/cb" assert first_event.data.qosStatus == "AVAILABLE" assert qod_session_repo.rows[session_id].state == QodSessionState.AVAILABLE @@ -156,8 +156,8 @@ class TestQodSessionFlow: operation_status_payload(str(operation_id)), ) - assert len(callback_delivery_port.delivered) == 2 - _, second_event = callback_delivery_port.delivered[1] + assert len(qod_callback_delivery_port.delivered) == 2 + _, second_event = qod_callback_delivery_port.delivered[1] assert second_event.data.qosStatus == "UNAVAILABLE" assert second_event.data.statusInfo == "NETWORK_TERMINATED" assert qod_session_repo.rows[session_id].state == QodSessionState.UNAVAILABLE diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index 0091d25..cb0bdb2 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -13,6 +13,7 @@ from open_exposure_gateway.application.services.quality_on_demand_service import QualityOnDemandService, ) from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException +from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted from open_exposure_gateway.domain.models import ( CallbackDelivery, CallbackRegistration, @@ -24,9 +25,9 @@ from open_exposure_gateway.domain.models import ( ) from open_exposure_gateway.domain.quality_on_demand import ( SRMNetworkCapabilityDeactivateCommand, + SRMOperationStatus, Subject, ) -from open_exposure_gateway.domain.srm_events import SRMOperationCompleted, SRMOperationStatus from tests.unit.fakes import ( FakeCallbackDeliveryRepository, FakeCallbackRegistrationRepository, -- GitLab From 679d6fda9f308d2bbf1c35b559cc55e8c1e6de92 Mon Sep 17 00:00:00 2001 From: dgogos Date: Wed, 5 Aug 2026 16:51:10 +0300 Subject: [PATCH 25/32] refactor: update QoD service specification handling and integrate network capability retrieval --- .env.example | 1 + .../adapters/http/srm_client.py | 16 +- .../mappers/quality_on_demand_mapper.py | 60 ++++- .../services/quality_on_demand_service.py | 26 +- src/open_exposure_gateway/core/config.py | 18 ++ src/open_exposure_gateway/dependencies.py | 2 + .../domain/quality_on_demand.py | 14 ++ src/open_exposure_gateway/main.py | 15 ++ src/open_exposure_gateway/ports/srm_port.py | 8 +- tests/unit/fakes.py | 46 +++- tests/unit/test_config.py | 32 ++- tests/unit/test_qod_flows.py | 23 +- tests/unit/test_qod_service.py | 223 +++++++++++++++++- 13 files changed, 442 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index 4baaec0..444f099 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,4 @@ # OBSERVABILITY_SETTINGS__LOG_LEVEL="INFO" # CALLBACK_SETTINGS__TIMEOUT=10.0 +# QOD_SETTINGS__SERVICE_SPECIFICATION_ID="7608e902-b927-559f-b448-e7e9061dfa5c" diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index 579b4d3..ce9ccc1 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -4,9 +4,6 @@ from uuid import UUID import httpx import structlog -from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( - SessionInfo, -) from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.exceptions import ( DownstreamServiceException, @@ -18,6 +15,7 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMServiceInstance, SRMZone, ) +from open_exposure_gateway.domain.quality_on_demand import SRMNetworkCapability logger = structlog.get_logger(__name__) @@ -124,14 +122,16 @@ class SRMClient: ) return [SRMZone.model_validate(z) for z in data] - async def get_qod_session( + async def get_network_capability( self, - session_id: str, + service_instance_id: str, x_correlator: str | None = None, - ) -> SessionInfo: + ) -> SRMNetworkCapability: headers = {"x-correlator": x_correlator} if x_correlator else None - data = await self._request("GET", f"/sessions/{session_id}", headers=headers) - return SessionInfo.model_validate(data) + data = await self._request( + "GET", f"/internal/network-capabilities/{service_instance_id}", headers=headers + ) + return SRMNetworkCapability.model_validate(data) async def get_apps(self, x_correlator: str | None = None) -> list[SRMCatalogPayload]: headers = {"x-correlator": x_correlator} if x_correlator else None diff --git a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py index b1b3f67..bf9fd02 100644 --- a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py +++ b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py @@ -1,7 +1,16 @@ +from datetime import datetime, timezone from typing import Literal from uuid import UUID, uuid4 -from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import CreateSession +from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( + ApplicationServer, + CreateSession, + Device, + DeviceIpv4Addr, + QosStatus, + SessionInfo, +) +from open_exposure_gateway.domain.models import QodSession from open_exposure_gateway.domain.quality_on_demand import ( EventQosStatusChangedData, NetworkCapabilityDeactivateTarget, @@ -11,6 +20,7 @@ from open_exposure_gateway.domain.quality_on_demand import ( NetworkCapabilityTargetApplicationServer, NetworkCapabilityTargetDevice, QosStatusChangedCloudEvent, + SRMNetworkCapability, SRMNetworkCapabilityActivateCommand, SRMNetworkCapabilityDeactivateCommand, ) @@ -22,6 +32,7 @@ def build_activate_command( correlation_id: str, requested_at: str, service_specification_id: UUID, + service_instance_id: UUID, app_provider_id: str, ) -> SRMNetworkCapabilityActivateCommand: device = request.device @@ -45,6 +56,7 @@ def build_activate_command( correlation_id=correlation_id, requested_at=requested_at, app_provider_id=app_provider_id, + service_instance_id=str(service_instance_id), service_specification_id=str(service_specification_id), network_capability=NetworkCapabilityPayload( target=target, @@ -54,6 +66,52 @@ def build_activate_command( ) +def build_session_info(qod_session: QodSession, capability: SRMNetworkCapability) -> SessionInfo: + """Combines `qod_sessions` (identity, ownership, cached `qosStatus`) with SRM's live + capability detail (target device/application server) into the CAMARA response -- the + read-side counterpart of `build_activate_command`, and the boundary that keeps SRM's + internal shape from leaking onto the CAMARA surface directly. + """ + device_target = capability.parameters_snapshot.target.device + server_target = capability.parameters_snapshot.target.application_server + + ipv4_address = None + if device_target.ipv4: + # Only the bare address survives on SRM's side (build_activate_command never sends + # privateAddress/publicPort), so this can't fully round-trip a CAMARA DeviceIpv4Addr. + # model_construct bypasses DeviceIpv4Addr's "publicAddress + one of private/port" + # validator, which exists to constrain client input, not this internal reconstruction. + # Device.model_construct is required alongside it: pydantic revalidates nested model + # fields against their own validators even when a pre-built instance is passed in, so + # the bypass has to hold at every level enclosing the incomplete address. + ipv4_address = DeviceIpv4Addr.model_construct( + publicAddress=device_target.ipv4, privateAddress=None, publicPort=None + ) + + device = Device.model_construct( + phoneNumber=device_target.phone_number, + networkAccessIdentifier=device_target.network_access_id, + ipv4Address=ipv4_address, + ipv6Address=device_target.ipv6, + ) + application_server = ApplicationServer( + ipv4Address=server_target.ipv4, + ipv6Address=server_target.ipv6, + ) + + started_at = int((qod_session.created_at or datetime.now(timezone.utc)).timestamp()) + return SessionInfo( + sessionId=qod_session.session_id, + device=device, + applicationServer=application_server, + qosProfile=qod_session.qos_profile, + duration=qod_session.duration_seconds, + startedAt=started_at, + expiresAt=started_at + qod_session.duration_seconds, + qosStatus=QosStatus(qod_session.state.value), + ) + + def build_deactivate_command( external_ref: str, operation_id: UUID, diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index 52dff36..8e999c3 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -14,8 +14,12 @@ from open_exposure_gateway.application.mappers.quality_on_demand_mapper import ( build_activate_command, build_deactivate_command, build_qos_status_changed_event, + build_session_info, +) +from open_exposure_gateway.core.config import ( + DEFAULT_QOD_SERVICE_SPECIFICATION_ID, + get_settings, ) -from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted from open_exposure_gateway.domain.models import ( @@ -57,6 +61,7 @@ class QualityOnDemandService: callback_registration_repo: Optional[CallbackRegistrationRepository] = None, callback_delivery_repo: Optional[CallbackDeliveryRepository] = None, callback_delivery_port: Optional[QodCallbackDeliveryPort] = None, + service_specification_id: UUID = DEFAULT_QOD_SERVICE_SPECIFICATION_ID, ) -> None: self.srm_client = srm_client self._publisher = publisher @@ -65,6 +70,7 @@ class QualityOnDemandService: self._callback_registration_repo = callback_registration_repo self._callback_delivery_repo = callback_delivery_repo self._callback_delivery_port = callback_delivery_port + self._service_specification_id = service_specification_id def _new_operation_metadata(self, x_correlator: Optional[str]) -> tuple[UUID, str, str]: operation_id = uuid4() @@ -92,7 +98,10 @@ class QualityOnDemandService: operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) session_id = uuid4() - service_specification_id = uuid4() + # The platform-seeded QoD specification, identical on every session (ADR-0035). + # Stored per row so the offering a session was provisioned under stays auditable + # once per-offering specifications exist. + service_specification_id = self._service_specification_id command = build_activate_command( request=request, @@ -100,6 +109,7 @@ class QualityOnDemandService: correlation_id=correlation_id, requested_at=requested_at, service_specification_id=service_specification_id, + service_instance_id=session_id, app_provider_id=app_provider_id, ) @@ -318,10 +328,18 @@ class QualityOnDemandService: session_id: str, x_correlator: Optional[str] = None, ) -> SessionInfo: - return await self.srm_client.get_qod_session( - session_id=session_id, + if self._qod_session_repo is None: + raise RuntimeError("QodSession repository is not available") + + qod_session = await self._qod_session_repo.get_by_id(UUID(session_id)) + if qod_session is None: + raise NotFoundException(message=f"Session {session_id} not found") + + capability = await self.srm_client.get_network_capability( + service_instance_id=session_id, x_correlator=x_correlator, ) + return build_session_info(qod_session, capability) async def delete_session( self, diff --git a/src/open_exposure_gateway/core/config.py b/src/open_exposure_gateway/core/config.py index 885de72..4f6066d 100644 --- a/src/open_exposure_gateway/core/config.py +++ b/src/open_exposure_gateway/core/config.py @@ -1,8 +1,17 @@ from functools import lru_cache +from uuid import UUID from pydantic import BaseModel, HttpUrl from pydantic_settings import BaseSettings, SettingsConfigDict +# The platform seeds one QoD service specification at deployment (ADR-0035); every +# activate carries its id. There is no CAMARA registration step to resolve it from, +# so the id cannot be derived per request -- minting one would reference no catalog +# row and SRM would answer failed_before_start. This is the well-known id used when +# nobody sets one at deploy time; SRM's topology bootstrap must seed the same value. + +DEFAULT_QOD_SERVICE_SPECIFICATION_ID = UUID("7608e902-b927-559f-b448-e7e9061dfa5c") + class SRMSettings(BaseModel): base_url: HttpUrl = HttpUrl("http://localhost:8081") @@ -29,6 +38,14 @@ class CallbackSettings(BaseModel): timeout: float = 10.0 +class QodSettings(BaseModel): + service_specification_id: UUID = DEFAULT_QOD_SERVICE_SPECIFICATION_ID + + @property + def uses_default_service_specification_id(self) -> bool: + return self.service_specification_id == DEFAULT_QOD_SERVICE_SPECIFICATION_ID + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", @@ -49,6 +66,7 @@ class Settings(BaseSettings): nats_settings: NatsSettings = NatsSettings() observability_settings: ObservabilitySettings = ObservabilitySettings() callback_settings: CallbackSettings = CallbackSettings() + qod_settings: QodSettings = QodSettings() @lru_cache diff --git a/src/open_exposure_gateway/dependencies.py b/src/open_exposure_gateway/dependencies.py index b5cb845..067e16b 100644 --- a/src/open_exposure_gateway/dependencies.py +++ b/src/open_exposure_gateway/dependencies.py @@ -31,6 +31,7 @@ from open_exposure_gateway.application.services.edge_application_management_serv from open_exposure_gateway.application.services.quality_on_demand_service import ( QualityOnDemandService, ) +from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.state import AppState from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, @@ -173,4 +174,5 @@ def get_qod_service( callback_registration_repo, callback_delivery_repo, callback_delivery_port, + get_settings().qod_settings.service_specification_id, ) diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index f06e78a..98683c9 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -51,6 +51,7 @@ class SRMNetworkCapabilityActivateCommand(BaseModel): requested_at: str app_provider_id: str source: str = "nbi_camara" + service_instance_id: str service_specification_id: str resource_zone_id: str | None = None network_domain_id: str | None = None @@ -73,6 +74,19 @@ class SRMNetworkCapabilityDeactivateCommand(BaseModel): network_capability: NetworkCapabilityDeactivateTarget +class SRMNetworkCapability(BaseModel): + service_instance_id: str + capability_type: str + state: str + zone_id: str | None = None + app_provider_id: str + external_ref: str | None = None + parameters_snapshot: NetworkCapabilityPayload + result_summary: dict[str, Any] | None = None + created_at: str | None = None + updated_at: str | None = None + + class SRMOperationStatus(BaseModel): """`event.srm.operation.status` (srm/interface-contract.md §C.1). diff --git a/src/open_exposure_gateway/main.py b/src/open_exposure_gateway/main.py index c11cd22..d593791 100644 --- a/src/open_exposure_gateway/main.py +++ b/src/open_exposure_gateway/main.py @@ -99,6 +99,7 @@ def _build_qod_service( callback_registration_repo=SqlCallbackRegistrationRepository(session), callback_delivery_repo=SqlCallbackDeliveryRepository(session), callback_delivery_port=qod_callback_client, + service_specification_id=get_settings().qod_settings.service_specification_id, ) @@ -170,6 +171,20 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger = structlog.get_logger() logger.info("Starting", app=settings.app_name, version=settings.app_version) + if settings.qod_settings.uses_default_service_specification_id: + logger.warning( + "qod_service_specification_id_defaulted", + service_specification_id=str(settings.qod_settings.service_specification_id), + detail=( + "QOD_SETTINGS__SERVICE_SPECIFICATION_ID is unset; using a hardcoded id." + ), + ) + else: + logger.info( + "qod_service_specification_id_configured", + service_specification_id=str(settings.qod_settings.service_specification_id), + ) + try: db_engine, session_maker = await build_engine_and_session_maker( url=settings.postgresql_settings.url, diff --git a/src/open_exposure_gateway/ports/srm_port.py b/src/open_exposure_gateway/ports/srm_port.py index ba406eb..e37fc33 100644 --- a/src/open_exposure_gateway/ports/srm_port.py +++ b/src/open_exposure_gateway/ports/srm_port.py @@ -1,15 +1,13 @@ from typing import Any, Protocol from uuid import UUID -from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( - SessionInfo, -) from open_exposure_gateway.domain.edge_application_management import ( SRMCatalogPayload, SRMCatalogServiceSpecificationCreated, SRMServiceInstance, SRMZone, ) +from open_exposure_gateway.domain.quality_on_demand import SRMNetworkCapability class SRMClientPort(Protocol): @@ -38,4 +36,6 @@ class SRMClientPort(Protocol): x_correlator: str | None, ) -> list[SRMServiceInstance]: ... - async def get_qod_session(self, session_id: str, x_correlator: str | None) -> SessionInfo: ... + async def get_network_capability( + self, service_instance_id: str, x_correlator: str | None + ) -> SRMNetworkCapability: ... diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 48987a4..809930f 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -30,9 +30,6 @@ from open_exposure_gateway.adapters.errors import ( DuplicateAppRegistrationError, DuplicateOperationError, ) -from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( - SessionInfo, -) from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) @@ -58,7 +55,11 @@ from open_exposure_gateway.domain.models import ( Operation, QodSession, ) -from open_exposure_gateway.domain.quality_on_demand import QosStatusChangedCloudEvent +from open_exposure_gateway.domain.quality_on_demand import ( + NetworkCapabilityPayload, + QosStatusChangedCloudEvent, + SRMNetworkCapability, +) from open_exposure_gateway.domain.quality_on_demand import Subject as QodSubject from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort from open_exposure_gateway.ports.database.callbacks import ( @@ -109,7 +110,7 @@ class FakeSRMClient: self.zones: list[SRMZone] = [] self.catalog: dict[str, dict[str, Any]] = {} self.instances: dict[str, SRMServiceInstance] = {} - self.qod_sessions: dict[str, SessionInfo] = {} + self.network_capabilities: dict[str, SRMNetworkCapability] = {} async def get_zones( self, @@ -152,13 +153,13 @@ class FakeSRMClient: result = [i for i in result if i.service_instance_id == str(app_instance_id)] return result - async def get_qod_session( - self, session_id: str, x_correlator: str | None = None - ) -> SessionInfo: - session = self.qod_sessions.get(session_id) - if session is None: - raise NotFoundException(message=f"Session {session_id} not found") - return session + async def get_network_capability( + self, service_instance_id: str, x_correlator: str | None = None + ) -> SRMNetworkCapability: + capability = self.network_capabilities.get(service_instance_id) + if capability is None: + raise NotFoundException(message=f"Network capability {service_instance_id} not found") + return capability def completion_payload(operation_id: str, **overrides: Any) -> dict[str, Any]: @@ -244,8 +245,29 @@ def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None: ), ) + async def on_qod_activate(command: dict[str, Any]) -> None: + service_instance_id = command["service_instance_id"] + srm.network_capabilities[service_instance_id] = SRMNetworkCapability( + service_instance_id=service_instance_id, + capability_type=command["network_capability"]["capability_type"], + state="active", + zone_id=command.get("resource_zone_id"), + app_provider_id=command["app_provider_id"], + parameters_snapshot=NetworkCapabilityPayload.model_validate( + command["network_capability"] + ), + ) + + async def on_qod_deactivate(command: dict[str, Any]) -> None: + external_ref = command["network_capability"]["external_ref"] + for key, capability in list(srm.network_capabilities.items()): + if capability.external_ref == external_ref: + del srm.network_capabilities[key] + bus.subscribe(Subject.TASK_DEPLOY, on_deploy) bus.subscribe(Subject.TASK_TERMINATE, on_terminate) + bus.subscribe(QodSubject.TASK_ACTIVATE, on_qod_activate) + bus.subscribe(QodSubject.TASK_DEACTIVATE, on_qod_deactivate) def wire_operation_consumer( diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 777a930..fff5fbd 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,8 +1,13 @@ import os +from uuid import NAMESPACE_URL, UUID, uuid5 import pytest -from open_exposure_gateway.core.config import Settings, get_settings +from open_exposure_gateway.core.config import ( + DEFAULT_QOD_SERVICE_SPECIFICATION_ID, + Settings, + get_settings, +) class TestNestedEnvironmentVariables: @@ -47,6 +52,16 @@ class TestNestedEnvironmentVariables: settings = Settings(_env_file=None) # type: ignore[call-arg] assert settings.observability_settings.log_level == "DEBUG" + def test_qod_service_specification_id(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv( + "QOD_SETTINGS__SERVICE_SPECIFICATION_ID", "11111111-2222-3333-4444-555555555555" + ) + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.qod_settings.service_specification_id == UUID( + "11111111-2222-3333-4444-555555555555" + ) + assert settings.qod_settings.uses_default_service_specification_id is False + def test_flat_top_level_fields(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PORT", "9000") monkeypatch.setenv("DEBUG", "true") @@ -66,6 +81,21 @@ class TestDefaults: assert settings.port == 8080 assert settings.postgresql_settings.echo is False assert settings.postgresql_settings.create_schema_on_startup is False + assert ( + settings.qod_settings.service_specification_id == DEFAULT_QOD_SERVICE_SPECIFICATION_ID + ) + assert settings.qod_settings.uses_default_service_specification_id is True + + +class TestWellKnownQodServiceSpecificationId: + def test_constant_matches_its_documented_derivation(self) -> None: + """The default is a cross-component contract: SRM's bootstrap must seed this + exact id. Both sides derive it from the same URL rather than copying a literal, + so this pins the recipe -- changing the constant without changing the URL (or + the reverse) is a silent break that only shows up as failed_before_start.""" + assert DEFAULT_QOD_SERVICE_SPECIFICATION_ID == uuid5( + NAMESPACE_URL, "https://etsi.org/sdg/oop/service-specification/qod-session/1" + ) class TestGetSettingsCaching: diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 3dde154..4b2ac00 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -7,7 +7,6 @@ A failing test here means a real hole in the flow, not a broken test. from typing import Any from uuid import UUID, uuid4 -import pytest from fastapi.testclient import TestClient from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.router import ( @@ -25,8 +24,10 @@ from tests.unit.fakes import ( FakeOperationRepository, FakeQodCallbackDeliveryPort, FakeQodSessionRepository, + FakeSRMClient, completion_payload, operation_status_payload, + wire_srm_worker, ) SESSION_BODY: dict[str, Any] = { @@ -163,19 +164,19 @@ class TestQodSessionFlow: assert qod_session_repo.rows[session_id].state == QodSessionState.UNAVAILABLE assert len(callback_delivery_repo.rows) == 2 - @pytest.mark.skip( - reason="TODO: GET /sessions/{id} is a synchronous SRM call (unchanged by the async " - "POST /sessions work) and OEG's minted sessionId has no reconciled mapping to SRM's " - "future external_ref (SrmNetworkCapabilityActivateV1 has no client-supplied session id " - "field for SRM to adopt, unlike appInstanceId — ADR-0005 doesn't apply here). A session " - "created via the new async POST is therefore not retrievable via GET until that mapping " - "is designed (likely as part of the event.srm.operation.completed consumer)." - ) - def test_get_session_returns_created_session(self, api_client: TestClient) -> None: + def test_get_session_returns_created_session( + self, api_client: TestClient, fake_bus: FakeDataBus, fake_srm: FakeSRMClient + ) -> None: + wire_srm_worker(fake_bus, fake_srm) + session_id = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"] response = api_client.get(f"{QOD_BASE}/sessions/{session_id}") + assert response.status_code == 200 - assert response.json()["sessionId"] == session_id + body = response.json() + assert body["sessionId"] == session_id + assert body["qosStatus"] == "REQUESTED" + assert body["applicationServer"]["ipv4Address"] == "192.168.0.1" def test_get_session_returns_404_when_unknown(self, api_client: TestClient) -> None: response = api_client.get(f"{QOD_BASE}/sessions/{uuid4()}") diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index cb0bdb2..db6166d 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -1,5 +1,6 @@ +from typing import Any from unittest.mock import AsyncMock -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest @@ -12,6 +13,7 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( from open_exposure_gateway.application.services.quality_on_demand_service import ( QualityOnDemandService, ) +from open_exposure_gateway.core.config import DEFAULT_QOD_SERVICE_SPECIFICATION_ID from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted from open_exposure_gateway.domain.models import ( @@ -24,6 +26,8 @@ from open_exposure_gateway.domain.models import ( QodSessionState, ) from open_exposure_gateway.domain.quality_on_demand import ( + SRMNetworkCapability, + SRMNetworkCapabilityActivateCommand, SRMNetworkCapabilityDeactivateCommand, SRMOperationStatus, Subject, @@ -779,6 +783,100 @@ class TestHandleStatusChanged: await service.handle_status_changed(event) +class TestServiceSpecificationId: + @pytest.fixture() + def operation_repo(self) -> FakeOperationRepository: + return FakeOperationRepository() + + @pytest.fixture() + def qod_session_repo(self) -> FakeQodSessionRepository: + return FakeQodSessionRepository() + + def _service( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + publisher: AsyncMock, + service_specification_id: UUID | None = None, + ) -> QualityOnDemandService: + kwargs: dict[str, Any] = {} + if service_specification_id is not None: + kwargs["service_specification_id"] = service_specification_id + return QualityOnDemandService( + srm_client=AsyncMock(), + publisher=publisher, + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + **kwargs, + ) + + async def test_configured_id_is_sent_on_the_activate_command( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + configured = uuid4() + publisher = AsyncMock() + service = self._service(operation_repo, qod_session_repo, publisher, configured) + + await service.create_session( + request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1" + ) + + _, payload = publisher.publish.call_args.args + command = SRMNetworkCapabilityActivateCommand.model_validate(payload) + assert command.service_specification_id == str(configured) + + async def test_configured_id_is_persisted_on_the_session_row( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + configured = uuid4() + service = self._service(operation_repo, qod_session_repo, AsyncMock(), configured) + + result = await service.create_session( + request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1" + ) + + stored = await qod_session_repo.get_by_id(result.sessionId) + assert stored is not None + assert stored.service_specification_id == configured + + async def test_every_session_carries_the_same_id( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + """The regression guard: a per-request id is what ADR-0035 exists to remove.""" + configured = uuid4() + service = self._service(operation_repo, qod_session_repo, AsyncMock(), configured) + + for _ in range(3): + await service.create_session( + request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1" + ) + + stored_ids = {row.service_specification_id for row in qod_session_repo.rows.values()} + assert stored_ids == {configured} + + async def test_falls_back_to_the_well_known_id_when_unconfigured( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + """A deployment that sets nothing still sends an id SRM's bootstrap can seed.""" + service = self._service(operation_repo, qod_session_repo, AsyncMock()) + + result = await service.create_session( + request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1" + ) + + stored = await qod_session_repo.get_by_id(result.sessionId) + assert stored is not None + assert stored.service_specification_id == DEFAULT_QOD_SERVICE_SPECIFICATION_ID + + class TestSessionIdMinting: async def test_each_request_gets_distinct_session_and_operation_ids(self) -> None: operation_repo = FakeOperationRepository() @@ -1008,3 +1106,126 @@ class TestDeleteSession: ) assert callback_delivery_port.delivered == [] + + +class TestGetSession: + @pytest.fixture() + def qod_session_repo(self) -> FakeQodSessionRepository: + return FakeQodSessionRepository() + + @pytest.fixture() + def srm_client(self) -> AsyncMock: + return AsyncMock() + + @pytest.fixture() + def service( + self, + srm_client: AsyncMock, + qod_session_repo: FakeQodSessionRepository, + ) -> QualityOnDemandService: + return QualityOnDemandService(srm_client=srm_client, qod_session_repo=qod_session_repo) + + def _capability( + self, + service_instance_id: UUID, + phone_number: str = "123456789", + application_server_ipv4: str = "192.168.0.1", + ) -> SRMNetworkCapability: + return SRMNetworkCapability.model_validate( + { + "service_instance_id": str(service_instance_id), + "capability_type": "qod_session", + "state": "active", + "app_provider_id": "provider-1", + "external_ref": "qod-session-nef-123", + "parameters_snapshot": { + "target": { + "device": {"phone_number": phone_number}, + "application_server": {"ipv4": application_server_ipv4}, + }, + "profile_ref": "voice", + "parameters": {"duration_seconds": 3600}, + }, + } + ) + + async def _seed_session( + self, qod_session_repo: FakeQodSessionRepository, **overrides: Any + ) -> QodSession: + fields: dict[str, Any] = { + "session_id": uuid4(), + "operation_id": uuid4(), + "service_specification_id": uuid4(), + "qos_profile": "voice", + "duration_seconds": 3600, + "state": QodSessionState.AVAILABLE, + "external_ref": "qod-session-nef-123", + } + fields.update(overrides) + return await qod_session_repo.save(QodSession(**fields)) + + async def test_raises_not_found_when_session_unknown( + self, service: QualityOnDemandService, srm_client: AsyncMock + ) -> None: + with pytest.raises(NotFoundException): + await service.get_session(session_id=str(uuid4())) + srm_client.get_network_capability.assert_not_awaited() + + async def test_raises_when_repository_unavailable(self) -> None: + service = QualityOnDemandService(srm_client=AsyncMock()) + with pytest.raises(RuntimeError, match="QodSession repository"): + await service.get_session(session_id=str(uuid4())) + + async def test_queries_srm_keyed_on_the_session_id( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + srm_client: AsyncMock, + ) -> None: + qod_session = await self._seed_session(qod_session_repo) + srm_client.get_network_capability.return_value = self._capability(qod_session.session_id) + + await service.get_session(session_id=str(qod_session.session_id), x_correlator="corr-1") + + srm_client.get_network_capability.assert_awaited_once_with( + service_instance_id=str(qod_session.session_id), x_correlator="corr-1" + ) + + async def test_returns_session_info_combining_local_status_and_srm_target( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + srm_client: AsyncMock, + ) -> None: + qod_session = await self._seed_session(qod_session_repo, state=QodSessionState.AVAILABLE) + srm_client.get_network_capability.return_value = self._capability( + qod_session.session_id, + phone_number="5550100", + application_server_ipv4="10.0.0.5", + ) + + result = await service.get_session(session_id=str(qod_session.session_id)) + + assert result.sessionId == qod_session.session_id + assert result.qosStatus == "AVAILABLE" + assert result.qosProfile == "voice" + assert result.duration == 3600 + assert result.device.phoneNumber == "5550100" + assert result.applicationServer.ipv4Address == "10.0.0.5" + assert result.expiresAt - result.startedAt == 3600 + + async def test_qos_status_reflects_cached_local_state_not_srm( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + srm_client: AsyncMock, + ) -> None: + """qosStatus is the row's cached status (persistence-model.md); SRM's + `state` on the capability projection is a different vocabulary and is + not the source of `qosStatus`.""" + qod_session = await self._seed_session(qod_session_repo, state=QodSessionState.REQUESTED) + srm_client.get_network_capability.return_value = self._capability(qod_session.session_id) + + result = await service.get_session(session_id=str(qod_session.session_id)) + + assert result.qosStatus == "REQUESTED" -- GitLab From 81e460e32cec4434dbdd0d5aba61e0419123e116 Mon Sep 17 00:00:00 2001 From: dgogos Date: Wed, 5 Aug 2026 17:11:57 +0300 Subject: [PATCH 26/32] fix: update QoD session handling to use service_instance_id and manage terminal states --- .../mappers/quality_on_demand_mapper.py | 4 +- .../services/quality_on_demand_service.py | 50 +++++-- .../domain/models/qod_sessions/enums.py | 3 + .../domain/quality_on_demand.py | 3 +- tests/unit/test_qod_flows.py | 81 ++++++++++- tests/unit/test_qod_service.py | 129 +++++++++++++++++- 6 files changed, 246 insertions(+), 24 deletions(-) diff --git a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py index bf9fd02..44152a3 100644 --- a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py +++ b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py @@ -113,7 +113,7 @@ def build_session_info(qod_session: QodSession, capability: SRMNetworkCapability def build_deactivate_command( - external_ref: str, + service_instance_id: UUID, operation_id: UUID, correlation_id: str, requested_at: str, @@ -126,7 +126,7 @@ def build_deactivate_command( requested_at=requested_at, app_provider_id=app_provider_id, network_capability=NetworkCapabilityDeactivateTarget( - external_ref=external_ref, + service_instance_id=str(service_instance_id), grace_period_seconds=grace_period_seconds, ), ) diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index 8e999c3..a167f0e 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -50,6 +50,10 @@ _OPERATION_COMPLETION_STATUS_MAP: dict[str, OperationStatus] = { "failed": OperationStatus.FAILED, } +_TERMINAL_STATES = frozenset( + {QodSessionState.DELETION_REQUESTED, QodSessionState.DELETED, QodSessionState.ERROR} +) + class QualityOnDemandService: def __init__( @@ -239,11 +243,10 @@ class QualityOnDemandService: "operation_completed_for_unknown_operation", operation_id=event.operation_id ) return - - # Not ours: event.srm.operation.completed is shared across every domain - # (deploy/terminate and network-capability activate/deactivate all land - # here). operation_type is how a domain recognizes its own operations. - if operation.operation_type != OperationType.NETWORK_CAPABILITY: + if operation.operation_type not in ( + OperationType.NETWORK_CAPABILITY, + OperationType.NETWORK_CAPABILITY_DEACTIVATE, + ): return status = _OPERATION_COMPLETION_STATUS_MAP[event.status] @@ -261,6 +264,10 @@ class QualityOnDemandService: ) ) + if operation.operation_type == OperationType.NETWORK_CAPABILITY_DEACTIVATE: + await self._handle_deactivate_completed(operation, status) + return + qod_session = await self._qod_session_repo.get_by_operation_id(operation_id) if qod_session is None: logger.warning( @@ -288,6 +295,29 @@ class QualityOnDemandService: occurred_at=event.completed_at, ) + async def _handle_deactivate_completed( + self, operation: Operation, status: OperationStatus + ) -> None: + assert self._qod_session_repo is not None + session_id_raw = (operation.metadata or {}).get("session_id") + if session_id_raw is None: + logger.warning( + "deactivate_completed_without_session_id", operation_id=str(operation.operation_id) + ) + return + + qod_session = await self._qod_session_repo.get_by_id(UUID(session_id_raw)) + if qod_session is None: + logger.warning( + "deactivate_completed_for_unknown_session", session_id=session_id_raw + ) + return + + terminal_state = ( + QodSessionState.ERROR if status == OperationStatus.FAILED else QodSessionState.DELETED + ) + await self._qod_session_repo.save(qod_session.model_copy(update={"state": terminal_state})) + async def handle_status_changed(self, event: SRMOperationStatus) -> None: if self._operation_repo is None or self._qod_session_repo is None: raise RuntimeError("Operation/QodSession repositories are not available") @@ -332,7 +362,7 @@ class QualityOnDemandService: raise RuntimeError("QodSession repository is not available") qod_session = await self._qod_session_repo.get_by_id(UUID(session_id)) - if qod_session is None: + if qod_session is None or qod_session.state in _TERMINAL_STATES: raise NotFoundException(message=f"Session {session_id} not found") capability = await self.srm_client.get_network_capability( @@ -352,12 +382,12 @@ class QualityOnDemandService: raise RuntimeError("Operation/QodSession repositories are not available") qod_session = await self._qod_session_repo.get_by_id(UUID(session_id)) - if qod_session is None or qod_session.external_ref is None: + if qod_session is None: raise NotFoundException(message=f"Session {session_id} not found") operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) command = build_deactivate_command( - external_ref=qod_session.external_ref, + service_instance_id=qod_session.session_id, operation_id=operation_id, correlation_id=correlation_id, requested_at=requested_at, @@ -383,6 +413,10 @@ class QualityOnDemandService: "Failed to publish QoD session deactivation", ) + await self._qod_session_repo.save( + qod_session.model_copy(update={"state": QodSessionState.DELETION_REQUESTED}) + ) + if qod_session.state == QodSessionState.AVAILABLE: await self._deliver_qos_status_changed( operation_id=qod_session.operation_id, diff --git a/src/open_exposure_gateway/domain/models/qod_sessions/enums.py b/src/open_exposure_gateway/domain/models/qod_sessions/enums.py index f326d61..f3b517e 100644 --- a/src/open_exposure_gateway/domain/models/qod_sessions/enums.py +++ b/src/open_exposure_gateway/domain/models/qod_sessions/enums.py @@ -5,3 +5,6 @@ class QodSessionState(StrEnum): REQUESTED = "REQUESTED" AVAILABLE = "AVAILABLE" UNAVAILABLE = "UNAVAILABLE" + DELETION_REQUESTED = "DELETION_REQUESTED" + DELETED = "DELETED" + ERROR = "ERROR" diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index 98683c9..36a45c1 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -60,7 +60,8 @@ class SRMNetworkCapabilityActivateCommand(BaseModel): class NetworkCapabilityDeactivateTarget(BaseModel): capability_type: str = "qod_session" - external_ref: str + service_instance_id: str | None = None + external_ref: str | None = None grace_period_seconds: int = 0 diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 4b2ac00..1287311 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -182,16 +182,27 @@ class TestQodSessionFlow: response = api_client.get(f"{QOD_BASE}/sessions/{uuid4()}") assert response.status_code == 404 - def test_delete_session_returns_404_before_activation_confirmed( - self, api_client: TestClient + def test_delete_session_succeeds_before_activation_confirmed( + self, api_client: TestClient, fake_bus: FakeDataBus ) -> None: - """A session with no confirmed external_ref yet (still REQUESTED) has nothing - for a deactivate command to key on, so deletion 404s rather than silently - no-op-ing or guessing at an id SRM never confirmed.""" session_id = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"] response = api_client.delete(f"{QOD_BASE}/sessions/{session_id}") + assert response.status_code == 204 + deactivates = [p for s, p in fake_bus.published if s == Subject.TASK_DEACTIVATE] + assert len(deactivates) == 1 + command = SRMNetworkCapabilityDeactivateCommand.model_validate(deactivates[0]) + assert command.network_capability.service_instance_id == session_id + + def test_get_session_returns_404_after_delete_requested( + self, api_client: TestClient + ) -> None: + session_id = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"] + api_client.delete(f"{QOD_BASE}/sessions/{session_id}") + + response = api_client.get(f"{QOD_BASE}/sessions/{session_id}") + assert response.status_code == 404 def test_delete_session_returns_404_when_unknown(self, api_client: TestClient) -> None: @@ -232,7 +243,65 @@ class TestQodSessionFlow: deactivates = [p for s, p in fake_bus.published if s == Subject.TASK_DEACTIVATE] assert len(deactivates) == 1 command = SRMNetworkCapabilityDeactivateCommand.model_validate(deactivates[0]) - assert command.network_capability.external_ref == "qod-session-nef-123" + assert command.network_capability.service_instance_id == str(session_id) + + async def test_deactivate_completion_drives_session_to_deleted( + self, + api_client: TestClient, + live_qod: None, + fake_bus: FakeDataBus, + qod_session_repo: FakeQodSessionRepository, + operation_repo: FakeOperationRepository, + ) -> None: + session_id = UUID( + api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"] + ) + api_client.delete(f"{QOD_BASE}/sessions/{session_id}") + assert qod_session_repo.rows[session_id].state == QodSessionState.DELETION_REQUESTED + + deactivate_operation_id = next( + op.operation_id + for op in operation_repo.rows.values() + if op.metadata == {"session_id": str(session_id)} + ) + + await fake_bus.publish( + str(Subject.OPERATION_COMPLETED), + completion_payload(str(deactivate_operation_id)), + ) + + assert qod_session_repo.rows[session_id].state == QodSessionState.DELETED + + async def test_failed_deactivate_completion_drives_session_to_error( + self, + api_client: TestClient, + live_qod: None, + fake_bus: FakeDataBus, + qod_session_repo: FakeQodSessionRepository, + operation_repo: FakeOperationRepository, + ) -> None: + session_id = UUID( + api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"] + ) + api_client.delete(f"{QOD_BASE}/sessions/{session_id}") + + deactivate_operation_id = next( + op.operation_id + for op in operation_repo.rows.values() + if op.metadata == {"session_id": str(session_id)} + ) + + await fake_bus.publish( + str(Subject.OPERATION_COMPLETED), + completion_payload( + str(deactivate_operation_id), + status="failed", + instances=[], + error={"status": 500, "code": "INTERNAL", "message": "backend rejected"}, + ), + ) + + assert qod_session_repo.rows[session_id].state == QodSessionState.ERROR class TestQosProfiles: diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index db6166d..47e8595 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -391,6 +391,77 @@ class TestHandleCompleted: await service.handle_completed(event) + async def _seed_deactivate( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> tuple[Operation, QodSession]: + qod_session = await qod_session_repo.save( + QodSession( + session_id=uuid4(), + operation_id=uuid4(), + service_specification_id=uuid4(), + qos_profile="voice", + duration_seconds=3600, + state=QodSessionState.DELETION_REQUESTED, + external_ref="qod-session-nef-123", + ) + ) + operation = await operation_repo.save( + Operation( + operation_id=uuid4(), + correlation_id="corr-1", + tenant_id="tenant-1", + app_provider_id="provider-1", + operation_type=OperationType.NETWORK_CAPABILITY_DEACTIVATE, + status=OperationStatus.PENDING, + subject=Subject.TASK_DEACTIVATE, + metadata={"session_id": str(qod_session.session_id)}, + ) + ) + return operation, qod_session + + async def test_completed_deactivate_marks_session_deleted( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + operation, qod_session = await self._seed_deactivate(operation_repo, qod_session_repo) + event = SRMOperationCompleted.model_validate(completion_payload(str(operation.operation_id))) + + await service.handle_completed(event) + + updated_operation = await operation_repo.get_by_id(operation.operation_id) + assert updated_operation is not None + assert updated_operation.status == OperationStatus.COMPLETED + + updated_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert updated_session is not None + assert updated_session.state == QodSessionState.DELETED + + async def test_failed_deactivate_marks_session_error( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + operation, qod_session = await self._seed_deactivate(operation_repo, qod_session_repo) + event = SRMOperationCompleted.model_validate( + completion_payload( + str(operation.operation_id), + status="failed", + instances=[], + error={"status": 500, "code": "INTERNAL", "message": "backend rejected"}, + ) + ) + + await service.handle_completed(event) + + updated_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert updated_session is not None + assert updated_session.state == QodSessionState.ERROR + async def test_raises_when_repositories_unavailable(self) -> None: service = QualityOnDemandService(srm_client=AsyncMock()) event = SRMOperationCompleted.model_validate(completion_payload(str(uuid4()))) @@ -940,7 +1011,7 @@ class TestDeleteSession: ) ) - async def test_publishes_deactivate_command_keyed_on_external_ref( + async def test_publishes_deactivate_command_keyed_on_service_instance_id( self, service: QualityOnDemandService, qod_session_repo: FakeQodSessionRepository, @@ -958,9 +1029,27 @@ class TestDeleteSession: subject, payload = publisher.publish.call_args.args assert subject == Subject.TASK_DEACTIVATE command = SRMNetworkCapabilityDeactivateCommand.model_validate(payload) - assert command.network_capability.external_ref == "qod-session-nef-123" + assert command.network_capability.service_instance_id == str(qod_session.session_id) + assert command.network_capability.external_ref is None assert command.app_provider_id == "provider-1" + async def test_moves_session_to_deletion_requested( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + qod_session = await self._seed_available_session(qod_session_repo) + + await service.delete_session( + session_id=str(qod_session.session_id), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + + stored = await qod_session_repo.get_by_id(qod_session.session_id) + assert stored is not None + assert stored.state == QodSessionState.DELETION_REQUESTED + async def test_persists_pending_deactivate_operation_row( self, service: QualityOnDemandService, @@ -989,10 +1078,11 @@ class TestDeleteSession: session_id=str(uuid4()), tenant_id="t", app_provider_id="p" ) - async def test_raises_not_found_when_external_ref_not_yet_confirmed( + async def test_succeeds_when_external_ref_not_yet_confirmed( self, service: QualityOnDemandService, qod_session_repo: FakeQodSessionRepository, + publisher: AsyncMock, ) -> None: qod_session = await qod_session_repo.save( QodSession( @@ -1004,10 +1094,18 @@ class TestDeleteSession: state=QodSessionState.REQUESTED, ) ) - with pytest.raises(NotFoundException): - await service.delete_session( - session_id=str(qod_session.session_id), tenant_id="t", app_provider_id="p" - ) + + await service.delete_session( + session_id=str(qod_session.session_id), tenant_id="t", app_provider_id="p" + ) + + subject, payload = publisher.publish.call_args.args + assert subject == Subject.TASK_DEACTIVATE + command = SRMNetworkCapabilityDeactivateCommand.model_validate(payload) + assert command.network_capability.service_instance_id == str(qod_session.session_id) + stored = await qod_session_repo.get_by_id(qod_session.session_id) + assert stored is not None + assert stored.state == QodSessionState.DELETION_REQUESTED async def test_raises_when_repositories_unavailable(self) -> None: service = QualityOnDemandService(srm_client=AsyncMock(), publisher=AsyncMock()) @@ -1176,6 +1274,23 @@ class TestGetSession: with pytest.raises(RuntimeError, match="QodSession repository"): await service.get_session(session_id=str(uuid4())) + @pytest.mark.parametrize( + "state", + [QodSessionState.DELETION_REQUESTED, QodSessionState.DELETED, QodSessionState.ERROR], + ) + async def test_raises_not_found_for_terminal_states( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + srm_client: AsyncMock, + state: QodSessionState, + ) -> None: + qod_session = await self._seed_session(qod_session_repo, state=state) + + with pytest.raises(NotFoundException): + await service.get_session(session_id=str(qod_session.session_id)) + srm_client.get_network_capability.assert_not_awaited() + async def test_queries_srm_keyed_on_the_session_id( self, service: QualityOnDemandService, -- GitLab From cd6927e6a32e4b13b89ecc4670b820ba5aa1e2ca Mon Sep 17 00:00:00 2001 From: dgogos Date: Wed, 5 Aug 2026 17:15:59 +0300 Subject: [PATCH 27/32] fix: rename resource_zone_id and network_domain_id to zone_id and domain_id in SRMNetworkCapabilityActivateCommand --- src/open_exposure_gateway/domain/quality_on_demand.py | 4 ++-- tests/unit/fakes.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index 36a45c1..d667471 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -53,8 +53,8 @@ class SRMNetworkCapabilityActivateCommand(BaseModel): source: str = "nbi_camara" service_instance_id: str service_specification_id: str - resource_zone_id: str | None = None - network_domain_id: str | None = None + zone_id: str | None = None + domain_id: str | None = None network_capability: NetworkCapabilityPayload diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 809930f..631f2ad 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -251,7 +251,7 @@ def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None: service_instance_id=service_instance_id, capability_type=command["network_capability"]["capability_type"], state="active", - zone_id=command.get("resource_zone_id"), + zone_id=command.get("zone_id"), app_provider_id=command["app_provider_id"], parameters_snapshot=NetworkCapabilityPayload.model_validate( command["network_capability"] -- GitLab From 86306f5fbb24bf4cff2dc1aedf65bd552278d67b Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 6 Aug 2026 08:39:05 +0300 Subject: [PATCH 28/32] fix: streamline logging and enhance NetworkCapability handling in QoD service --- .../services/quality_on_demand_service.py | 6 +- .../domain/quality_on_demand.py | 8 +- src/open_exposure_gateway/main.py | 4 +- tests/unit/fakes.py | 18 ++-- tests/unit/test_qod_flows.py | 4 +- tests/unit/test_qod_service.py | 82 ++++++++++++++++++- 6 files changed, 100 insertions(+), 22 deletions(-) diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index a167f0e..5a69678 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -308,9 +308,7 @@ class QualityOnDemandService: qod_session = await self._qod_session_repo.get_by_id(UUID(session_id_raw)) if qod_session is None: - logger.warning( - "deactivate_completed_for_unknown_session", session_id=session_id_raw - ) + logger.warning("deactivate_completed_for_unknown_session", session_id=session_id_raw) return terminal_state = ( @@ -382,7 +380,7 @@ class QualityOnDemandService: raise RuntimeError("Operation/QodSession repositories are not available") qod_session = await self._qod_session_repo.get_by_id(UUID(session_id)) - if qod_session is None: + if qod_session is None or qod_session.state in _TERMINAL_STATES: raise NotFoundException(message=f"Session {session_id} not found") operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index d667471..058e46c 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -75,6 +75,12 @@ class SRMNetworkCapabilityDeactivateCommand(BaseModel): network_capability: NetworkCapabilityDeactivateTarget +class NetworkCapabilityParametersSnapshot(BaseModel): + schema_version: str | None = None + target: NetworkCapabilityTarget + parameters: dict[str, Any] = Field(default_factory=dict) + + class SRMNetworkCapability(BaseModel): service_instance_id: str capability_type: str @@ -82,7 +88,7 @@ class SRMNetworkCapability(BaseModel): zone_id: str | None = None app_provider_id: str external_ref: str | None = None - parameters_snapshot: NetworkCapabilityPayload + parameters_snapshot: NetworkCapabilityParametersSnapshot result_summary: dict[str, Any] | None = None created_at: str | None = None updated_at: str | None = None diff --git a/src/open_exposure_gateway/main.py b/src/open_exposure_gateway/main.py index d593791..364e1f2 100644 --- a/src/open_exposure_gateway/main.py +++ b/src/open_exposure_gateway/main.py @@ -175,9 +175,7 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.warning( "qod_service_specification_id_defaulted", service_specification_id=str(settings.qod_settings.service_specification_id), - detail=( - "QOD_SETTINGS__SERVICE_SPECIFICATION_ID is unset; using a hardcoded id." - ), + detail=("QOD_SETTINGS__SERVICE_SPECIFICATION_ID is unset; using a hardcoded id."), ) else: logger.info( diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 631f2ad..346960f 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -56,7 +56,8 @@ from open_exposure_gateway.domain.models import ( QodSession, ) from open_exposure_gateway.domain.quality_on_demand import ( - NetworkCapabilityPayload, + NetworkCapabilityParametersSnapshot, + NetworkCapabilityTarget, QosStatusChangedCloudEvent, SRMNetworkCapability, ) @@ -247,22 +248,23 @@ def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None: async def on_qod_activate(command: dict[str, Any]) -> None: service_instance_id = command["service_instance_id"] + payload = command["network_capability"] srm.network_capabilities[service_instance_id] = SRMNetworkCapability( service_instance_id=service_instance_id, - capability_type=command["network_capability"]["capability_type"], + capability_type=payload["capability_type"], state="active", zone_id=command.get("zone_id"), app_provider_id=command["app_provider_id"], - parameters_snapshot=NetworkCapabilityPayload.model_validate( - command["network_capability"] + parameters_snapshot=NetworkCapabilityParametersSnapshot( + schema_version="srm.params/v1", + target=NetworkCapabilityTarget.model_validate(payload["target"]), + parameters={**payload["parameters"], "profile_ref": payload["profile_ref"]}, ), ) async def on_qod_deactivate(command: dict[str, Any]) -> None: - external_ref = command["network_capability"]["external_ref"] - for key, capability in list(srm.network_capabilities.items()): - if capability.external_ref == external_ref: - del srm.network_capabilities[key] + service_instance_id = command["network_capability"]["service_instance_id"] + srm.network_capabilities.pop(service_instance_id, None) bus.subscribe(Subject.TASK_DEPLOY, on_deploy) bus.subscribe(Subject.TASK_TERMINATE, on_terminate) diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 1287311..7f3c51f 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -195,9 +195,7 @@ class TestQodSessionFlow: command = SRMNetworkCapabilityDeactivateCommand.model_validate(deactivates[0]) assert command.network_capability.service_instance_id == session_id - def test_get_session_returns_404_after_delete_requested( - self, api_client: TestClient - ) -> None: + def test_get_session_returns_404_after_delete_requested(self, api_client: TestClient) -> None: session_id = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"] api_client.delete(f"{QOD_BASE}/sessions/{session_id}") diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index 47e8595..a32d447 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -428,7 +428,9 @@ class TestHandleCompleted: qod_session_repo: FakeQodSessionRepository, ) -> None: operation, qod_session = await self._seed_deactivate(operation_repo, qod_session_repo) - event = SRMOperationCompleted.model_validate(completion_payload(str(operation.operation_id))) + event = SRMOperationCompleted.model_validate( + completion_payload(str(operation.operation_id)) + ) await service.handle_completed(event) @@ -1107,6 +1109,39 @@ class TestDeleteSession: assert stored is not None assert stored.state == QodSessionState.DELETION_REQUESTED + @pytest.mark.parametrize( + "state", + [QodSessionState.DELETION_REQUESTED, QodSessionState.DELETED, QodSessionState.ERROR], + ) + async def test_repeat_delete_neither_republishes_nor_renotifies( + self, + state: QodSessionState, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + publisher: AsyncMock, + ) -> None: + callback_delivery_port = FakeQodCallbackDeliveryPort() + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=publisher, + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=FakeCallbackRegistrationRepository(), + callback_delivery_repo=FakeCallbackDeliveryRepository(), + callback_delivery_port=callback_delivery_port, + ) + qod_session = await self._seed_available_session(qod_session_repo) + await qod_session_repo.save(qod_session.model_copy(update={"state": state})) + + with pytest.raises(NotFoundException): + await service.delete_session( + session_id=str(qod_session.session_id), tenant_id="t", app_provider_id="p" + ) + + publisher.publish.assert_not_awaited() + assert operation_repo.rows == {} + assert callback_delivery_port.delivered == [] + async def test_raises_when_repositories_unavailable(self) -> None: service = QualityOnDemandService(srm_client=AsyncMock(), publisher=AsyncMock()) with pytest.raises(RuntimeError, match="Operation/QodSession repositories"): @@ -1237,12 +1272,17 @@ class TestGetSession: "app_provider_id": "provider-1", "external_ref": "qod-session-nef-123", "parameters_snapshot": { + "schema_version": "srm.params/v1", "target": { "device": {"phone_number": phone_number}, "application_server": {"ipv4": application_server_ipv4}, }, - "profile_ref": "voice", - "parameters": {"duration_seconds": 3600}, + "parameters": {"profile_ref": "voice", "duration_seconds": 3600}, + "source_spec": { + "family": "camara", + "api": "quality-on-demand", + "version": "0.10.1", + }, }, } ) @@ -1344,3 +1384,39 @@ class TestGetSession: result = await service.get_session(session_id=str(qod_session.session_id)) assert result.qosStatus == "REQUESTED" + + +class TestNetworkCapabilitySnapshotShape: + def _snapshot(self, **overrides: Any) -> dict[str, Any]: + payload: dict[str, Any] = { + "service_instance_id": str(uuid4()), + "capability_type": "qod_session", + "state": "active", + "app_provider_id": "provider-1", + "parameters_snapshot": { + "schema_version": "srm.params/v1", + "target": { + "device": {"phone_number": "123456789"}, + "application_server": {"ipv4": "192.168.0.1"}, + }, + "parameters": {"profile_ref": "voice", "duration_seconds": 3600}, + "source_spec": {"family": "camara", "api": "quality-on-demand"}, + }, + } + payload.update(overrides) + return payload + + def test_accepts_canonical_snapshot_with_profile_ref_nested_under_parameters(self) -> None: + capability = SRMNetworkCapability.model_validate(self._snapshot()) + + assert capability.parameters_snapshot.parameters["profile_ref"] == "voice" + assert capability.parameters_snapshot.target.device.phone_number == "123456789" + + def test_accepts_snapshot_without_the_commands_top_level_profile_ref(self) -> None: + """The regression guard: §B.5 says the top-level field never reaches the snapshot.""" + snapshot = self._snapshot() + assert "profile_ref" not in snapshot["parameters_snapshot"] + + capability = SRMNetworkCapability.model_validate(snapshot) + + assert capability.parameters_snapshot.target.application_server.ipv4 == "192.168.0.1" -- GitLab From b1f59c294c5ae818469d5a6b60b20f23a36f6911 Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 6 Aug 2026 15:19:30 +0300 Subject: [PATCH 29/32] feat: add device_ports and application_server_ports to QodSession and related mappers --- .../adapters/database/mappers.py | 4 + .../adapters/database/sql.py | 2 + .../mappers/quality_on_demand_mapper.py | 31 ++++++ .../services/quality_on_demand_service.py | 12 ++- .../domain/models/qod_sessions/models.py | 3 + .../domain/quality_on_demand.py | 17 +++ tests/unit/test_qod_service.py | 100 +++++++++++++++++- 7 files changed, 167 insertions(+), 2 deletions(-) diff --git a/src/open_exposure_gateway/adapters/database/mappers.py b/src/open_exposure_gateway/adapters/database/mappers.py index b18dde2..85a4c79 100644 --- a/src/open_exposure_gateway/adapters/database/mappers.py +++ b/src/open_exposure_gateway/adapters/database/mappers.py @@ -121,6 +121,8 @@ class QodSessionMapper: duration_seconds=row.duration_seconds, state=row.state, external_ref=row.external_ref, + device_ports=row.device_ports, + application_server_ports=row.application_server_ports, created_at=row.created_at, updated_at=row.updated_at, ) @@ -135,6 +137,8 @@ class QodSessionMapper: duration_seconds=domain.duration_seconds, state=domain.state, external_ref=domain.external_ref, + device_ports=domain.device_ports, + application_server_ports=domain.application_server_ports, ) diff --git a/src/open_exposure_gateway/adapters/database/sql.py b/src/open_exposure_gateway/adapters/database/sql.py index 0c26b97..857b9f3 100644 --- a/src/open_exposure_gateway/adapters/database/sql.py +++ b/src/open_exposure_gateway/adapters/database/sql.py @@ -200,3 +200,5 @@ class QodSessionRow(AuditedMixin, Base): duration_seconds: Mapped[int] = mapped_column(Integer, nullable=False) state: Mapped[QodSessionState] = mapped_column(_enum_type(QodSessionState), nullable=False) external_ref: Mapped[str | None] = mapped_column(String(255)) + device_ports: Mapped[dict[str, object] | None] = mapped_column(JSONB) + application_server_ports: Mapped[dict[str, object] | None] = mapped_column(JSONB) diff --git a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py index 44152a3..1b1fd4c 100644 --- a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py +++ b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py @@ -7,6 +7,7 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( CreateSession, Device, DeviceIpv4Addr, + PortsSpec, QosStatus, SessionInfo, ) @@ -16,6 +17,8 @@ from open_exposure_gateway.domain.quality_on_demand import ( NetworkCapabilityDeactivateTarget, NetworkCapabilityParameters, NetworkCapabilityPayload, + NetworkCapabilityPortRange, + NetworkCapabilityPorts, NetworkCapabilityTarget, NetworkCapabilityTargetApplicationServer, NetworkCapabilityTargetDevice, @@ -26,6 +29,20 @@ from open_exposure_gateway.domain.quality_on_demand import ( ) +def _to_capability_ports(spec: PortsSpec | None) -> NetworkCapabilityPorts | None: + """CAMARA `PortsSpec` -> canonical `target.*.ports`.""" + if spec is None: + return None + return NetworkCapabilityPorts( + ranges=( + [NetworkCapabilityPortRange(**{"from": r.from_, "to": r.to}) for r in spec.ranges] + if spec.ranges is not None + else None + ), + ports=spec.ports, + ) + + def build_activate_command( request: CreateSession, operation_id: UUID, @@ -44,10 +61,12 @@ def build_activate_command( ipv4=device.ipv4Address.publicAddress if device.ipv4Address else None, ipv6=device.ipv6Address, network_access_id=device.networkAccessIdentifier, + ports=_to_capability_ports(request.devicePorts), ), application_server=NetworkCapabilityTargetApplicationServer( ipv4=application_server.ipv4Address, ipv6=application_server.ipv6Address, + ports=_to_capability_ports(request.applicationServerPorts), ), ) @@ -98,12 +117,24 @@ def build_session_info(qod_session: QodSession, capability: SRMNetworkCapability ipv4Address=server_target.ipv4, ipv6Address=server_target.ipv6, ) + device_ports = ( + PortsSpec.model_validate(qod_session.device_ports) + if qod_session.device_ports is not None + else None + ) + application_server_ports = ( + PortsSpec.model_validate(qod_session.application_server_ports) + if qod_session.application_server_ports is not None + else None + ) started_at = int((qod_session.created_at or datetime.now(timezone.utc)).timestamp()) return SessionInfo( sessionId=qod_session.session_id, device=device, applicationServer=application_server, + devicePorts=device_ports, + applicationServerPorts=application_server_ports, qosProfile=qod_session.qos_profile, duration=qod_session.duration_seconds, startedAt=started_at, diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index 5a69678..1c384d4 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -86,7 +86,7 @@ class QualityOnDemandService: if self._publisher is None: raise RuntimeError("DataBus publisher is not available") try: - await self._publisher.publish(subject, command.model_dump(mode="json")) + await self._publisher.publish(subject, command.model_dump(mode="json", by_alias=True)) except Exception as exc: raise DownstreamServiceException(error_msg) from exc @@ -140,6 +140,16 @@ class QualityOnDemandService: qos_profile=request.qosProfile, duration_seconds=request.duration, state=QodSessionState.REQUESTED, + device_ports=( + request.devicePorts.model_dump(mode="json") + if request.devicePorts is not None + else None + ), + application_server_ports=( + request.applicationServerPorts.model_dump(mode="json") + if request.applicationServerPorts is not None + else None + ), ) ) diff --git a/src/open_exposure_gateway/domain/models/qod_sessions/models.py b/src/open_exposure_gateway/domain/models/qod_sessions/models.py index 7053b6c..f96e68d 100644 --- a/src/open_exposure_gateway/domain/models/qod_sessions/models.py +++ b/src/open_exposure_gateway/domain/models/qod_sessions/models.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any from uuid import UUID from pydantic import BaseModel @@ -14,5 +15,7 @@ class QodSession(BaseModel): duration_seconds: int state: QodSessionState external_ref: str | None = None + device_ports: dict[str, Any] | None = None + application_server_ports: dict[str, Any] | None = None created_at: datetime | None = None updated_at: datetime | None = None diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index 058e46c..7f5a310 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -14,16 +14,33 @@ class Subject(StrEnum): OPERATION_STATUS = "event.srm.operation.status" +class NetworkCapabilityPortRange(BaseModel): + from_: int = Field(alias="from") + to: int + + model_config = {"populate_by_name": True} + + +class NetworkCapabilityPorts(BaseModel): + """`target.device.ports` / `target.application_server.ports` (ADR-0037) — generic across + any capability with a device/application-server target, not a QoD-only extra.""" + + ranges: list[NetworkCapabilityPortRange] | None = None + ports: list[int] | None = None + + class NetworkCapabilityTargetDevice(BaseModel): phone_number: str | None = None ipv4: str | None = None ipv6: str | None = None network_access_id: str | None = None + ports: NetworkCapabilityPorts | None = None class NetworkCapabilityTargetApplicationServer(BaseModel): ipv4: str | None = None ipv6: str | None = None + ports: NetworkCapabilityPorts | None = None class NetworkCapabilityTarget(BaseModel): diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index a32d447..884f4bf 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -8,6 +8,8 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( ApplicationServer, CreateSession, Device, + PortRange, + PortsSpec, Webhook, ) from open_exposure_gateway.application.services.quality_on_demand_service import ( @@ -43,10 +45,16 @@ from tests.unit.fakes import ( ) -def _make_request(webhook: Webhook | None = None) -> CreateSession: +def _make_request( + webhook: Webhook | None = None, + device_ports: PortsSpec | None = None, + application_server_ports: PortsSpec | None = None, +) -> CreateSession: return CreateSession( device=Device(phoneNumber="123456789"), applicationServer=ApplicationServer(ipv4Address="198.51.100.0/24"), + devicePorts=device_ports, + applicationServerPorts=application_server_ports, qosProfile="voice", duration=3600, webhook=webhook, @@ -103,6 +111,62 @@ class TestCreateSession: assert stored.qos_profile == "voice" assert stored.duration_seconds == 3600 + async def test_persists_ports_on_the_session_row( + self, service: QualityOnDemandService, qod_session_repo: FakeQodSessionRepository + ) -> None: + result = await service.create_session( + request=_make_request( + device_ports=PortsSpec(ports=[80, 443]), + application_server_ports=PortsSpec( + ranges=[PortRange(**{"from": 5000, "to": 5010})] + ), + ), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + stored = await qod_session_repo.get_by_id(result.sessionId) + assert stored is not None + assert stored.device_ports == {"ranges": None, "ports": [80, 443]} + assert stored.application_server_ports["ranges"][0]["to"] == 5010 + + async def test_sends_ports_to_srm_on_the_activate_command( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + """ADR-0037: ports are canonical target.device.ports / target.application_server.ports, + not dropped before reaching SRM.""" + publisher = AsyncMock() + service = QualityOnDemandService( + srm_client=AsyncMock(), + publisher=publisher, + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + ) + + await service.create_session( + request=_make_request( + device_ports=PortsSpec(ports=[80, 443]), + application_server_ports=PortsSpec( + ranges=[PortRange(**{"from": 5000, "to": 5010})] + ), + ), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + + _, payload = publisher.publish.call_args.args + command = SRMNetworkCapabilityActivateCommand.model_validate(payload) + target = command.network_capability.target + assert target.device.ports is not None + assert target.device.ports.ports == [80, 443] + assert target.application_server.ports is not None + assert target.application_server.ports.ranges[0].to == 5010 + # Wire shape uses "from"/"to" (canonical schema), not the Python-side "from_" alias. + assert payload["network_capability"]["target"]["application_server"]["ports"]["ranges"][ + 0 + ]["from"] == 5000 + async def test_returns_session_info_with_requested_status( self, service: QualityOnDemandService ) -> None: @@ -1369,6 +1433,40 @@ class TestGetSession: assert result.applicationServer.ipv4Address == "10.0.0.5" assert result.expiresAt - result.startedAt == 3600 + async def test_get_session_echoes_ports_persisted_at_creation( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + srm_client: AsyncMock, + ) -> None: + qod_session = await self._seed_session( + qod_session_repo, + device_ports={"ranges": None, "ports": [80, 443]}, + application_server_ports={"ranges": [{"from_": 5000, "to": 5010}], "ports": None}, + ) + srm_client.get_network_capability.return_value = self._capability(qod_session.session_id) + + result = await service.get_session(session_id=str(qod_session.session_id)) + + assert result.devicePorts is not None + assert result.devicePorts.ports == [80, 443] + assert result.applicationServerPorts is not None + assert result.applicationServerPorts.ranges[0].to == 5010 + + async def test_get_session_omits_ports_when_none_were_set( + self, + service: QualityOnDemandService, + qod_session_repo: FakeQodSessionRepository, + srm_client: AsyncMock, + ) -> None: + qod_session = await self._seed_session(qod_session_repo) + srm_client.get_network_capability.return_value = self._capability(qod_session.session_id) + + result = await service.get_session(session_id=str(qod_session.session_id)) + + assert result.devicePorts is None + assert result.applicationServerPorts is None + async def test_qos_status_reflects_cached_local_state_not_srm( self, service: QualityOnDemandService, -- GitLab From c94448109e203b3990c6fd3659bb35580dbcab59 Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 6 Aug 2026 15:42:45 +0300 Subject: [PATCH 30/32] fix: prevent duplicate notifications on redelivery of QoD events --- .../services/quality_on_demand_service.py | 9 ++ tests/integration/test_postgres.py | 98 +++++++++++++++++++ tests/unit/test_qod_service.py | 92 +++++++++++++++++ 3 files changed, 199 insertions(+) diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index 1c384d4..70377ee 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -295,8 +295,13 @@ class QualityOnDemandService: else: update = {"state": QodSessionState.UNAVAILABLE} qos_status, status_info = "UNAVAILABLE", "NETWORK_TERMINATED" + + prior_state = qod_session.state await self._qod_session_repo.save(qod_session.model_copy(update=update)) + if prior_state == update["state"]: + return + await self._deliver_qos_status_changed( operation_id=operation_id, session_id=qod_session.session_id, @@ -351,8 +356,12 @@ class QualityOnDemandService: status = ( QodSessionState.AVAILABLE if qos_status == "AVAILABLE" else QodSessionState.UNAVAILABLE ) + prior_state = qod_session.state await self._qod_session_repo.save(qod_session.model_copy(update={"state": status})) + if prior_state == status: + return + await self._deliver_qos_status_changed( operation_id=operation_id, session_id=qod_session.session_id, diff --git a/tests/integration/test_postgres.py b/tests/integration/test_postgres.py index ad620be..5abe6ff 100644 --- a/tests/integration/test_postgres.py +++ b/tests/integration/test_postgres.py @@ -17,6 +17,7 @@ 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.database.repos.qod_sessions import SqlQodSessionRepository from open_exposure_gateway.adapters.errors import ( DuplicateAppRegistrationError, DuplicateOperationError, @@ -32,6 +33,8 @@ from open_exposure_gateway.domain.models import ( OperationStatus, OperationType, PackageType, + QodSession, + QodSessionState, ) @@ -85,6 +88,17 @@ def _callback_registration(operation_id: UUID) -> CallbackRegistration: ) +def _qod_session(operation_id: UUID) -> QodSession: + return QodSession( + session_id=uuid4(), + operation_id=operation_id, + service_specification_id=uuid4(), + qos_profile="QOS_E", + duration_seconds=3600, + state=QodSessionState.REQUESTED, + ) + + def _callback_delivery(callback_registration_id: UUID, operation_id: UUID) -> CallbackDelivery: return CallbackDelivery( id=uuid4(), @@ -339,3 +353,87 @@ async def test_callback_delivery_repo_persists_and_lists(db_session: AsyncSessio assert len(deliveries) == 1 assert deliveries[0].id == saved.id assert deliveries[0].state == "delivered" + + +async def test_qod_session_repo_persists_and_loads(db_session: AsyncSession) -> None: + operation = await SqlOperationRepository(db_session).save(_operation()) + repo = SqlQodSessionRepository(db_session) + qod_session = _qod_session(operation_id=operation.operation_id) + + saved = await repo.save(qod_session) + + assert saved.session_id == qod_session.session_id + assert saved.created_at is not None + assert saved.updated_at is not None + + by_id = await repo.get_by_id(saved.session_id) + by_operation_id = await repo.get_by_operation_id(operation.operation_id) + + assert by_id is not None + assert by_operation_id is not None + assert by_id == by_operation_id + assert by_id.service_specification_id == qod_session.service_specification_id + assert by_id.qos_profile == qod_session.qos_profile + assert by_id.duration_seconds == qod_session.duration_seconds + assert by_id.state == QodSessionState.REQUESTED + assert by_id.external_ref is None + assert by_id.device_ports is None + assert by_id.application_server_ports is None + + +async def test_qod_session_repo_persists_optional_fields(db_session: AsyncSession) -> None: + operation = await SqlOperationRepository(db_session).save(_operation()) + repo = SqlQodSessionRepository(db_session) + qod_session = _qod_session(operation_id=operation.operation_id) + qod_session.external_ref = "external-ref-1" + qod_session.device_ports = {"ports": [1000, 2000]} + qod_session.application_server_ports = {"ports": [3000]} + + saved = await repo.save(qod_session) + reloaded = await repo.get_by_id(saved.session_id) + + assert reloaded is not None + assert reloaded.external_ref == "external-ref-1" + assert reloaded.device_ports == {"ports": [1000, 2000]} + assert reloaded.application_server_ports == {"ports": [3000]} + + +async def test_qod_session_repo_get_by_id_returns_none_when_missing( + db_session: AsyncSession, +) -> None: + repo = SqlQodSessionRepository(db_session) + + assert await repo.get_by_id(uuid4()) is None + + +async def test_qod_session_repo_get_by_operation_id_returns_none_when_missing( + db_session: AsyncSession, +) -> None: + repo = SqlQodSessionRepository(db_session) + + assert await repo.get_by_operation_id(uuid4()) is None + + +async def test_qod_session_repo_updates_updated_at_on_second_save(db_engine: AsyncEngine) -> None: + session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) + + async with session_factory() as session: + operation = await SqlOperationRepository(session).save(_operation()) + saved = await SqlQodSessionRepository(session).save( + _qod_session(operation_id=operation.operation_id) + ) + await session.commit() + + await asyncio.sleep(0.01) + + async with session_factory() as session: + repo = SqlQodSessionRepository(session) + saved.state = QodSessionState.AVAILABLE + updated = await repo.save(saved) + await session.commit() + + assert updated.created_at == saved.created_at + assert updated.created_at is not None + assert updated.updated_at is not None + assert updated.updated_at > updated.created_at + assert updated.state == QodSessionState.AVAILABLE diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index 884f4bf..fb32adb 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -631,6 +631,59 @@ class TestHandleCompleted: assert cloud_event.data.qosStatus == "UNAVAILABLE" assert cloud_event.data.statusInfo == "NETWORK_TERMINATED" + async def test_redelivery_of_same_event_does_not_duplicate_notification( + self, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + ) -> None: + """The databus can redeliver event.srm.operation.completed at least + once (e.g. handler crashes after acting but before acking). Replaying + the exact same event must not re-notify a session that is already + UNAVAILABLE -- the CAMARA QoD spec forbids sending a qos-status-changed + event when qosStatus was already UNAVAILABLE.""" + callback_registration_repo = FakeCallbackRegistrationRepository() + callback_delivery_repo = FakeCallbackDeliveryRepository() + callback_delivery_port = FakeQodCallbackDeliveryPort() + service = QualityOnDemandService( + srm_client=AsyncMock(), + operation_repo=operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=callback_registration_repo, + callback_delivery_repo=callback_delivery_repo, + callback_delivery_port=callback_delivery_port, + ) + operation, qod_session = await self._seed(operation_repo, qod_session_repo) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + event = SRMOperationCompleted.model_validate( + completion_payload( + str(operation.operation_id), + status="failed", + instances=[], + error={"status": 500, "code": "INTERNAL", "message": "backend rejected"}, + ) + ) + + await service.handle_completed(event) + await service.handle_completed(event) # simulated redelivery of the same message + + updated_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert updated_session is not None + assert updated_session.state == QodSessionState.UNAVAILABLE + + assert len(callback_delivery_port.delivered) == 1 + deliveries = list(callback_delivery_repo.rows.values()) + assert len(deliveries) == 1 + assert deliveries[0].attempt == 1 + async def test_failed_delivery_still_persists_session_update( self, operation_repo: FakeOperationRepository, @@ -850,6 +903,45 @@ class TestHandleStatusChanged: second_delivery = next(d for d in deliveries if d.attempt == 2) assert second_delivery.state == "delivered" + async def test_redelivery_of_same_event_does_not_duplicate_notification( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_repo: FakeCallbackDeliveryRepository, + callback_delivery_port: FakeQodCallbackDeliveryPort, + ) -> None: + """event.srm.operation.status can be redelivered at least once (ADR-0036 + notes redelivery/clock-skew can cause duplicate processing). Replaying + the same UNAVAILABLE status a second time must not send a second + qos-status-changed notification -- the session is already UNAVAILABLE.""" + operation, qod_session = await self._seed_available(operation_repo, qod_session_repo) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + event = SRMOperationStatus.model_validate( + operation_status_payload(str(operation.operation_id)) + ) + + await service.handle_status_changed(event) + await service.handle_status_changed(event) # simulated redelivery of the same message + + updated_session = await qod_session_repo.get_by_id(qod_session.session_id) + assert updated_session is not None + assert updated_session.state == QodSessionState.UNAVAILABLE + + assert len(callback_delivery_port.delivered) == 1 + deliveries = list(callback_delivery_repo.rows.values()) + assert len(deliveries) == 1 + async def test_ignores_status_for_a_different_domains_operation( self, service: QualityOnDemandService, -- GitLab From b7f9a4923e5bd2c515b3ced7c95bd3d2542088d5 Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 20 Aug 2026 15:34:08 +0300 Subject: [PATCH 31/32] feat: add EAM deployment stub endpoints and QoD session-extend endpoint with request validation --- .../vwip/router.py | 90 ++++++++++++++++ .../quality_on_demand/v0_10_1/router.py | 11 ++ .../quality_on_demand/v0_10_1/schemas.py | 4 + .../services/quality_on_demand_service.py | 30 +++++- tests/conformance/conftest.py | 28 ++++- tests/conformance/test_eam_conformance.py | 24 ++--- tests/conformance/test_qod_conformance.py | 46 ++++++++ tests/unit/test_qod_service.py | 102 +++++++++++++++++- tests/unit/test_vendored_spec_refs.py | 27 ++--- 9 files changed, 328 insertions(+), 34 deletions(-) create mode 100644 tests/conformance/test_qod_conformance.py 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 346f10b..a04ec2e 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 @@ -281,3 +281,93 @@ async def delete_app_instance( x_correlator=caller.x_correlator, ) return Response(status_code=status.HTTP_202_ACCEPTED) + + +@router.post( + "/deployments", + tags=["Application"], + summary="Deploy an Application", + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + AlreadyExistsException(), + NotImplementedException(), + DownstreamServiceException(), + ), +) +async def create_app_deployment() -> Any: + raise NotImplementedException(message="Multi-zone deployment is not implemented") + + +@router.get( + "/deployments", + tags=["Application"], + summary="Retrieve a list of Application Deployment for a given App", + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + NotImplementedException(), + DownstreamServiceException(), + ), +) +async def get_app_deployments( + appId: Annotated[Optional[UUID], Query()] = None, + appDeploymentId: Annotated[Optional[UUID], Query()] = None, +) -> Any: + raise NotImplementedException(message="Multi-zone deployment is not implemented") + + +@router.delete( + "/deployments/{appDeploymentId}", + tags=["Application"], + summary="Terminate an Application Deployment", + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + NotFoundException(), + NotImplementedException(), + DownstreamServiceException(), + ), +) +async def delete_app_deployment(appDeploymentId: UUID) -> Any: + raise NotImplementedException(message="Multi-zone deployment is not implemented") + + +@router.patch( + "/deployments/{appDeploymentId}", + tags=["Application"], + summary="Update an Application Deployment", + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + NotFoundException(), + NotImplementedException(), + DownstreamServiceException(), + ), +) +async def update_app_deployment(appDeploymentId: UUID) -> Any: + raise NotImplementedException(message="Multi-zone deployment is not implemented") + + +@router.get( + "/clusters", + tags=["Cluster"], + summary="Retrieve a list of the available clusters filtered by the optional query parameters", + responses=_responses( + BadRequestException(), + UnauthorizedException(), + ForbiddenException(), + NotImplementedException(), + DownstreamServiceException(), + ), +) +async def get_clusters( + region: Annotated[Optional[str], Query(max_length=64)] = None, + clusterRef: Annotated[Optional[UUID], Query()] = None, + edgeCloudZoneId: Annotated[Optional[UUID], Query()] = None, +) -> Any: + raise NotImplementedException(message="Cluster catalog is not implemented") diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py index d0050a6..6ed649f 100644 --- a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, Query, Response, status from open_exposure_gateway.api.camara.common import XCorrelatorHeader from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import ( CreateSession, + ExtendSessionDuration, SessionInfo, ) from open_exposure_gateway.application.services.quality_on_demand_service import ( @@ -116,6 +117,16 @@ async def delete_qod_session( return Response(status_code=status.HTTP_204_NO_CONTENT) +@router.post( + "/sessions/{sessionId}/extend", + tags=["QoS Sessions"], + summary="Extend the duration of an active session", + responses=_responses(400, 401, 403, 404, 500, 501, 503), +) +async def extend_qod_session_duration(sessionId: str, request: ExtendSessionDuration) -> Any: + raise NotImplementedException(message="Session duration extension is not implemented") + + @router.get( "/qos-profiles", tags=["QoS Profiles"], diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py index 7c0ad29..904b33b 100644 --- a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py @@ -101,3 +101,7 @@ class SessionInfo(BaseSessionInfo): expiresAt: int qosStatus: QosStatus messages: Optional[list[Message]] = None + + +class ExtendSessionDuration(BaseModel): + requestedAdditionalDuration: int = Field(ge=1, le=86399) diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py index 70377ee..7c73645 100644 --- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py +++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py @@ -20,7 +20,11 @@ from open_exposure_gateway.core.config import ( DEFAULT_QOD_SERVICE_SPECIFICATION_ID, get_settings, ) -from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException +from open_exposure_gateway.core.exceptions import ( + BadRequestException, + DownstreamServiceException, + NotFoundException, +) from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted from open_exposure_gateway.domain.models import ( CallbackDelivery, @@ -50,6 +54,11 @@ _OPERATION_COMPLETION_STATUS_MAP: dict[str, OperationStatus] = { "failed": OperationStatus.FAILED, } +_QOS_STATUS_INFO_MAP: dict[str, Literal["DURATION_EXPIRED", "NETWORK_TERMINATED"]] = { + "duration_expired": "DURATION_EXPIRED", + "network_terminated": "NETWORK_TERMINATED", +} + _TERMINAL_STATES = frozenset( {QodSessionState.DELETION_REQUESTED, QodSessionState.DELETED, QodSessionState.ERROR} ) @@ -76,6 +85,12 @@ class QualityOnDemandService: self._callback_delivery_port = callback_delivery_port self._service_specification_id = service_specification_id + def _parse_session_id(self, session_id: str) -> UUID: + try: + return UUID(session_id) + except ValueError as exc: + raise BadRequestException(message=f"Malformed session ID: {session_id}") from exc + def _new_operation_metadata(self, x_correlator: Optional[str]) -> tuple[UUID, str, str]: operation_id = uuid4() correlation_id = x_correlator or str(uuid4()) @@ -362,11 +377,18 @@ class QualityOnDemandService: if prior_state == status: return + status_info: Literal["DURATION_EXPIRED", "NETWORK_TERMINATED"] | None = None + if qos_status == "UNAVAILABLE": + reason = (event.metadata or {}).get("qos_status_info") + status_info = _QOS_STATUS_INFO_MAP.get( + reason if isinstance(reason, str) else "", "NETWORK_TERMINATED" + ) + await self._deliver_qos_status_changed( operation_id=operation_id, session_id=qod_session.session_id, qos_status=qos_status, - status_info="NETWORK_TERMINATED" if qos_status == "UNAVAILABLE" else None, + status_info=status_info, occurred_at=event.emitted_at, ) @@ -378,7 +400,7 @@ class QualityOnDemandService: if self._qod_session_repo is None: raise RuntimeError("QodSession repository is not available") - qod_session = await self._qod_session_repo.get_by_id(UUID(session_id)) + qod_session = await self._qod_session_repo.get_by_id(self._parse_session_id(session_id)) if qod_session is None or qod_session.state in _TERMINAL_STATES: raise NotFoundException(message=f"Session {session_id} not found") @@ -398,7 +420,7 @@ class QualityOnDemandService: if self._operation_repo is None or self._qod_session_repo is None: raise RuntimeError("Operation/QodSession repositories are not available") - qod_session = await self._qod_session_repo.get_by_id(UUID(session_id)) + qod_session = await self._qod_session_repo.get_by_id(self._parse_session_id(session_id)) if qod_session is None or qod_session.state in _TERMINAL_STATES: raise NotFoundException(message=f"Session {session_id} not found") diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py index 4885ac5..e0918d7 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -30,11 +30,15 @@ from tests.conformance.harness import app from tests.unit.fakes import ( FakeAppInstanceRepository, FakeAppRegistrationRepository, + FakeCallbackDeliveryRepository, FakeCallbackRegistrationRepository, FakeDataBus, FakeOperationRepository, + FakeQodCallbackDeliveryPort, + FakeQodSessionRepository, FakeSRMClient, wire_operation_consumer, + wire_qod_operation_consumer, wire_srm_worker, ) @@ -66,7 +70,29 @@ def service_overrides() -> Generator[None, None, None]: app_instance_repo=app_instance_repo, callback_registration_repo=FakeCallbackRegistrationRepository(), ) - app.dependency_overrides[get_qod_service] = lambda: QualityOnDemandService(srm_client=srm) + + qod_operation_repo = FakeOperationRepository() + qod_session_repo = FakeQodSessionRepository() + qod_callback_registration_repo = FakeCallbackRegistrationRepository() + qod_callback_delivery_repo = FakeCallbackDeliveryRepository() + qod_callback_delivery_port = FakeQodCallbackDeliveryPort() + wire_qod_operation_consumer( + bus, + qod_operation_repo, + qod_session_repo, + qod_callback_registration_repo, + qod_callback_delivery_repo, + qod_callback_delivery_port, + ) + app.dependency_overrides[get_qod_service] = lambda: QualityOnDemandService( + srm_client=srm, + publisher=bus, + operation_repo=qod_operation_repo, + qod_session_repo=qod_session_repo, + callback_registration_repo=qod_callback_registration_repo, + callback_delivery_repo=qod_callback_delivery_repo, + callback_delivery_port=qod_callback_delivery_port, + ) app.dependency_overrides[get_publisher] = lambda: bus yield app.dependency_overrides.clear() diff --git a/tests/conformance/test_eam_conformance.py b/tests/conformance/test_eam_conformance.py index 5caaeac..d87e7c9 100644 --- a/tests/conformance/test_eam_conformance.py +++ b/tests/conformance/test_eam_conformance.py @@ -33,20 +33,18 @@ SPEC = ( schema = schemathesis.openapi.from_path(SPEC) schema.app = app -# Keep runtime bounded; raise max_examples (and drop no_shrink) when digging -# into a specific failure. schema.config.generation.update(max_examples=10, no_shrink=True) -# 501 is a deliberate, spec-documented response (ADR-0009: unsupported -# packageType/infraKind variants), not a server crash; Schemathesis's -# 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") +# 501 is spec-documented, not a crash; not_a_server_error flags all 5xx. +schema.config.checks.not_a_server_error.expected_statuses = [ + *schema.config.checks.not_a_server_error.expected_statuses, + "501", +] +# 400 for a schema-valid but unregistered appId stays within createAppInstance's +# documented codes (no 404 listed); positive_data_acceptance otherwise flags it. +schema.config.checks.positive_data_acceptance.expected_statuses = [ + *schema.config.checks.positive_data_acceptance.expected_statuses, + "400", +] @schema.parametrize() diff --git a/tests/conformance/test_qod_conformance.py b/tests/conformance/test_qod_conformance.py new file mode 100644 index 0000000..b159931 --- /dev/null +++ b/tests/conformance/test_qod_conformance.py @@ -0,0 +1,46 @@ +"""CAMARA Quality on Demand conformance. + +Every operation in the vendored upstream spec is exercised with generated +requests against the ASGI app; responses are validated against the spec +(status codes, response schemas, headers). A failure here means the +northbound interface diverges from CAMARA. +""" + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import schemathesis + +from tests.conformance.harness import app + +if TYPE_CHECKING: + from schemathesis.specs.openapi.schemas import OpenApiCase + +pytestmark = pytest.mark.conformance + +SPEC = ( + Path(__file__).parents[2] + / "src" + / "open_exposure_gateway" + / "api" + / "camara" + / "quality_on_demand" + / "API_definitions" + / "qod-api.yaml" +) + +schema = schemathesis.openapi.from_path(SPEC) +schema.app = app +schema.config.generation.update(max_examples=10, no_shrink=True) +# ApplicationServer accepts empty ipv4/ipv6 strings at the schema level; our +# validator correctly rejects them, which positive_data_acceptance otherwise flags. +schema.config.checks.positive_data_acceptance.expected_statuses = [ + *schema.config.checks.positive_data_acceptance.expected_statuses, + "400", +] + + +@schema.parametrize() +def test_qod_conformance(case: "OpenApiCase") -> None: + case.call_and_validate() diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py index fb32adb..29e0e3e 100644 --- a/tests/unit/test_qod_service.py +++ b/tests/unit/test_qod_service.py @@ -127,6 +127,7 @@ class TestCreateSession: stored = await qod_session_repo.get_by_id(result.sessionId) assert stored is not None assert stored.device_ports == {"ranges": None, "ports": [80, 443]} + assert stored.application_server_ports is not None assert stored.application_server_ports["ranges"][0]["to"] == 5010 async def test_sends_ports_to_srm_on_the_activate_command( @@ -161,11 +162,15 @@ class TestCreateSession: assert target.device.ports is not None assert target.device.ports.ports == [80, 443] assert target.application_server.ports is not None + assert target.application_server.ports.ranges is not None assert target.application_server.ports.ranges[0].to == 5010 # Wire shape uses "from"/"to" (canonical schema), not the Python-side "from_" alias. - assert payload["network_capability"]["target"]["application_server"]["ports"]["ranges"][ - 0 - ]["from"] == 5000 + assert ( + payload["network_capability"]["target"]["application_server"]["ports"]["ranges"][0][ + "from" + ] + == 5000 + ) async def test_returns_session_info_with_requested_status( self, service: QualityOnDemandService @@ -851,6 +856,96 @@ class TestHandleStatusChanged: ) return operation, qod_session + @pytest.mark.parametrize( + ("metadata", "expected_status_info"), + [ + ( + {"qos_status": "UNAVAILABLE", "qos_status_info": "duration_expired"}, + "DURATION_EXPIRED", + ), + ( + {"qos_status": "UNAVAILABLE", "qos_status_info": "network_terminated"}, + "NETWORK_TERMINATED", + ), + ({"qos_status": "UNAVAILABLE"}, "NETWORK_TERMINATED"), + ( + {"qos_status": "UNAVAILABLE", "qos_status_info": "something_new"}, + "NETWORK_TERMINATED", + ), + ], + ids=["duration_expired", "network_terminated", "absent", "unknown"], + ) + async def test_maps_qos_status_info_to_camara_status_info( + self, + metadata: dict[str, Any], + expected_status_info: str, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeQodCallbackDeliveryPort, + ) -> None: + operation, qod_session = await self._seed_available(operation_repo, qod_session_repo) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + + await service.handle_status_changed( + SRMOperationStatus.model_validate( + operation_status_payload(str(operation.operation_id), metadata=metadata) + ) + ) + + assert len(callback_delivery_port.delivered) == 1 + _, cloud_event = callback_delivery_port.delivered[0] + assert cloud_event.data.qosStatus == "UNAVAILABLE" + assert cloud_event.data.statusInfo == expected_status_info + + async def test_available_carries_no_status_info( + self, + service: QualityOnDemandService, + operation_repo: FakeOperationRepository, + qod_session_repo: FakeQodSessionRepository, + callback_registration_repo: FakeCallbackRegistrationRepository, + callback_delivery_port: FakeQodCallbackDeliveryPort, + ) -> None: + """CAMARA: statusInfo is only applicable when qosStatus is UNAVAILABLE.""" + operation, qod_session = await self._seed_available(operation_repo, qod_session_repo) + await qod_session_repo.save( + qod_session.model_copy(update={"state": QodSessionState.UNAVAILABLE}) + ) + await callback_registration_repo.save( + CallbackRegistration( + id=uuid4(), + operation_id=operation.operation_id, + tenant_id="tenant-1", + api_family="quality-on-demand", + sink="https://client.example.com/cb", + event_types=["org.camaraproject.qod.v0.qos-status-changed"], + ) + ) + + await service.handle_status_changed( + SRMOperationStatus.model_validate( + operation_status_payload( + str(operation.operation_id), + metadata={"qos_status": "AVAILABLE", "qos_status_info": "duration_expired"}, + ) + ) + ) + + assert len(callback_delivery_port.delivered) == 1 + _, cloud_event = callback_delivery_port.delivered[0] + assert cloud_event.data.qosStatus == "AVAILABLE" + assert cloud_event.data.statusInfo is None + async def test_updates_session_and_delivers_second_event_on_network_drop( self, service: QualityOnDemandService, @@ -1543,6 +1638,7 @@ class TestGetSession: assert result.devicePorts is not None assert result.devicePorts.ports == [80, 443] assert result.applicationServerPorts is not None + assert result.applicationServerPorts.ranges is not None assert result.applicationServerPorts.ranges[0].to == 5010 async def test_get_session_omits_ports_when_none_were_set( diff --git a/tests/unit/test_vendored_spec_refs.py b/tests/unit/test_vendored_spec_refs.py index 67f22d5..9b51c56 100644 --- a/tests/unit/test_vendored_spec_refs.py +++ b/tests/unit/test_vendored_spec_refs.py @@ -1,29 +1,29 @@ -"""Every $ref in the vendored CAMARA EAM spec must resolve. +"""Every $ref in the vendored CAMARA specs 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 +This test walks every $ref in each 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 pytest import yaml -SPEC = ( - Path(__file__).parents[2] - / "src" - / "open_exposure_gateway" - / "api" - / "camara" +_CAMARA = Path(__file__).parents[2] / "src" / "open_exposure_gateway" / "api" / "camara" + +SPECS = { + "eam": _CAMARA / "edge_application_management" / "vwip" / "API_definitions" - / "edge-application-management.yaml" -) + / "edge-application-management.yaml", + "qod": _CAMARA / "quality_on_demand" / "API_definitions" / "qod-api.yaml", +} def _load(path: Path) -> Any: @@ -65,6 +65,7 @@ def _walk(node: Any, path: Path, document: Any, seen: set[tuple[Path, str]]) -> _walk(item, path, document, seen) -def test_every_ref_in_the_vendored_eam_spec_resolves() -> None: - document = _load(SPEC) - _walk(document, SPEC, document, set()) +@pytest.mark.parametrize("spec", SPECS.values(), ids=SPECS.keys()) +def test_every_ref_in_the_vendored_spec_resolves(spec: Path) -> None: + document = _load(spec) + _walk(document, spec, document, set()) -- GitLab From 917b6ba5d32b4f3e49ac0ca830ac8f12c98b14ac Mon Sep 17 00:00:00 2001 From: dgogos Date: Thu, 20 Aug 2026 15:39:44 +0300 Subject: [PATCH 32/32] fix: streamline CI configuration by consolidating build and push stages --- .gitlab-ci.yml | 44 ++++++++++++-------------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4a0d4de..cd2724b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,4 @@ default: - tags: - - docker image: python:3.12-slim cache: paths: @@ -18,12 +16,10 @@ stages: - lint - format - test - - build - - push + - build-and-push variables: UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv" - GIT_STRATEGY: clone type: stage: type @@ -47,13 +43,6 @@ format: test: stage: test - # Integration tests (tests/integration) need a Docker daemon for testcontainers. - # A docker:dind service provides it; DOCKER_HOST/DOCKER_TLS_CERTDIR point the - # docker-py client (used by testcontainers) at it without requiring TLS certs - # to be shared into this job's (non-docker) image. - # test/conformance stays out of this job's scope: it's collected/run separately - # (see the `conformance` job) since its module-level schemathesis schema - # loading is expensive and would cost minutes on every pipeline otherwise. variables: DOCKER_HOST: tcp://docker:2375 DOCKER_TLS_CERTDIR: "" @@ -63,36 +52,27 @@ test: - pytest tests/unit tests/integration --cov=src/open_exposure_gateway --cov-branch --cov-report=term-missing || [ $? -eq 5 ] #bypass no tests found error. coverage: '/TOTAL.+ ([0-9]{1,3}(?:\.[0-9]+)?%)/' + conformance: stage: test allow_failure: true script: - pytest tests/conformance -build: - stage: build - tags: - - shell - before_script: - - docker info - script: - - export TEST_IMAGE_TAG="ci-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}" - - docker build --network=host -t "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" . - rules: - - if: '$CI_COMMIT_BRANCH' - -push: - stage: push - tags: - - shell - needs: - - build +build-and-push: + stage: build-and-push + image: docker:cli + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + services: + - docker:dind before_script: - docker info script: - export TEST_IMAGE_TAG="ci-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}" - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin - - docker push "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" + - docker buildx build --provenance=false --network=host -t "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" --push . - docker logout "$CI_REGISTRY" rules: - - if: '$CI_COMMIT_BRANCH' + - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"' -- GitLab