diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..474b4f7d565a3c93b3963b7c284dcf8a50e4a585 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Copy this file to .env and adjust as needed. Every value below is optional +# -- open_exposure_gateway/app/core/config.py already has a working default +# for each one, shown here. Uncomment and edit only what you need to change. + +# APP_NAME="Open Exposure Gateway" +# APP_VERSION="1.0.0" +# DEBUG=false +# HOST="0.0.0.0" +# PORT=8080 + +# SRM_SETTINGS__BASE_URL="http://localhost:8081" +# SRM_SETTINGS__TIMEOUT=10.0 + +# POSTGRESQL_SETTINGS__URL="postgresql+asyncpg://postgres:postgres@localhost:5432/oeg" +# POSTGRESQL_SETTINGS__ECHO=false +# POSTGRESQL_SETTINGS__CREATE_SCHEMA_ON_STARTUP=false + +# NATS_SETTINGS__URL="nats://localhost:4222" +# NATS_SETTINGS__CONNECT_TIMEOUT=10 +# NATS_SETTINGS__MAX_RECONNECT_ATTEMPTS=3 + +# OBSERVABILITY_SETTINGS__LOG_LEVEL="INFO" diff --git a/.gitignore b/.gitignore index fc2253d378c92c97c5730a2a6d2cbe4cc037c702..633612177fe34d216a242741e005b172d5c74cee 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ wheels/ # code editors .vscode/ +# AI software tools +.claude +CLAUDE.md + .tox/ htmlcov/ .mypy_cache/ @@ -28,3 +32,4 @@ uv.lock # Runtime scratch files .tmp/ + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9ce3251181c648733fdce207d4fe956095ebfb22..d33a8d1fc91b2377f4a121ca20f696bbec0935e9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,20 +1,76 @@ +default: + tags: + - docker + image: python:3.12-slim + cache: + paths: + - .cache/uv + before_script: + - cd "$CI_PROJECT_DIR/${BACKEND_DIR:-.}" + - pip install uv + - export UV_SYSTEM_CERTS=1 + - uv sync --locked --extra dev + - . .venv/bin/activate + stages: + - type + - architecture + - lint + - format + - test - build - push -before_script: - - docker info +variables: + UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv" + +type: + stage: type + script: + - mypy open_exposure_gateway/app/ open_exposure_gateway/test/ + +architecture: + stage: architecture + script: + - lint-imports + +lint: + stage: lint + script: + - ruff check open_exposure_gateway/ + +format: + stage: format + script: + - ruff format --check open_exposure_gateway/ + +test: + stage: test + # TODO: add integration tests when we move this to ETSI gitlab + # Scoped to test/unit explicitly (not just -m deselection): pytest must still + # import every collected module to read its markers, and test/conformance's + # module-level schemathesis schema loading is expensive — collecting it here + # would cost minutes on every pipeline even though none of its tests run. + script: + - pytest open_exposure_gateway/test/unit || [ $? -eq 5 ] #bypass no tests found error. + +conformance: + stage: test + allow_failure: true + script: + - pytest open_exposure_gateway/test/conformance build: stage: build tags: - shell + before_script: + - docker info script: - - docker build - -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG - -t $CI_REGISTRY_IMAGE:latest . + - 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_TAG =~ /^\d+\.\d+\.\d+$/ + - if: '$CI_COMMIT_BRANCH' push: stage: push @@ -22,10 +78,12 @@ push: - shell needs: - build + before_script: + - docker info script: - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin - - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG - - docker push $CI_REGISTRY_IMAGE:latest - - docker logout $CI_REGISTRY + - 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 logout "$CI_REGISTRY" rules: - - if: $CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/ \ No newline at end of file + - if: '$CI_COMMIT_BRANCH' \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c6a95ba071a7fc497e1eb6a2dfb422356074ca89 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-yaml + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.15.6 + hooks: + # Run the linter. + - id: ruff-check + args: ["--cache-dir", "open_exposure_gateway/.cache/ruff"] + # Run the formatter. + - id: ruff-format + args: ["--cache-dir", "open_exposure_gateway/.cache/ruff"] + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.19.1 # Use the sha / tag you want to point at + hooks: + - id: mypy + files: ^open_exposure_gateway/app/|^open_exposure_gateway/test/ + args: [--strict, --ignore-missing-imports, --cache-dir, open_exposure_gateway/.cache/mypy] + additional_dependencies: ["fastapi[standard]>=0.135.1", "pydantic>=2.0", "pydantic-settings>=2.0", "httpx>=0.27", "pytest>=9.0.2", "sqlalchemy>=2.0.48", "pytest-asyncio>=0.24", "testcontainers>=4.0.0"] #TODO, always add necessary diff --git a/Dockerfile b/Dockerfile index 1744f1b96c648dc29f2b08c468064710337581ce..adf22866ec3b71e83029bafd3bba041a0fd895be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,58 @@ -FROM python:3.12-alpine +FROM python:3.12-slim AS builder -RUN mkdir -p /usr/src/app -WORKDIR /usr/src/app +WORKDIR /app -COPY requirements.txt /usr/src/app/ +# DB build arg: base | postgres | mongo +ARG DB=base -RUN pip install --no-cache-dir --trusted-host pypi.org --trusted-host files.pythonhosted.org -r requirements.txt +ENV PIP_DEFAULT_TIMEOUT=120 -COPY . /usr/src/app +COPY pyproject.toml ./ +COPY README.md ./ +COPY open_exposure_gateway/ ./open_exposure_gateway/ -EXPOSE 8080 +RUN python -m venv .venv && \ + .venv/bin/pip install \ + --no-cache-dir \ + --default-timeout=120 \ + --trusted-host pypi.org \ + --trusted-host files.pythonhosted.org \ + --upgrade pip setuptools wheel && \ + if [ "$DB" = "postgres" ]; then \ + .venv/bin/pip install \ + --no-cache-dir \ + --default-timeout=120 \ + --trusted-host pypi.org \ + --trusted-host files.pythonhosted.org \ + ".[postgres]"; \ + elif [ "$DB" = "mongo" ]; then \ + .venv/bin/pip install \ + --no-cache-dir \ + --default-timeout=120 \ + --trusted-host pypi.org \ + --trusted-host files.pythonhosted.org \ + ".[mongo]"; \ + else \ + .venv/bin/pip install \ + --no-cache-dir \ + --default-timeout=120 \ + --trusted-host pypi.org \ + --trusted-host files.pythonhosted.org \ + "."; \ + fi + +FROM python:3.12-slim AS runtime + +WORKDIR /app -ENTRYPOINT ["python"] +COPY --from=builder /app/.venv /app/.venv +COPY open_exposure_gateway/ ./open_exposure_gateway/ + +ENV PATH="/app/.venv/bin:$PATH" +ENV PYTHONPATH="/app" +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +EXPOSE 8080 -CMD ["-m", "edge_cloud_management_api"] +CMD ["uvicorn", "open_exposure_gateway.app.main:app", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..b3f736b44067fad0a0f877227674fa006461e132 --- /dev/null +++ b/Makefile @@ -0,0 +1,80 @@ +.PHONY: install clean lint format precommit type test conformance run + +SRC := open_exposure_gateway/app +TEST := open_exposure_gateway/test +DB ?= base +DEV ?= false + +# Trust the OS certificate store (uv doesn't by default, unlike pip) so +# installs work behind corporate TLS-inspecting proxies. +export UV_SYSTEM_CERTS := 1 + +## Database Package Handling +ifeq ($(DB),postgres) + EXTRA := postgres +else ifeq ($(DB),mongo) + EXTRA := mongo +else + EXTRA := +endif + +## Dev tools Packages Handling +ifeq ($(DEV),true) + ifneq ($(EXTRA),) + EXTRAS := --extra $(EXTRA) --extra dev + else + EXTRAS := --extra dev + endif +else + ifneq ($(EXTRA),) + EXTRAS := --extra $(EXTRA) + else + EXTRAS := + endif +endif + +## Uv sync command +# --locked fails fast if uv.lock is out of sync with pyproject.toml, instead of +# silently re-resolving, so local dev and CI always install the same versions. +UV_SYNC = uv sync --locked $(EXTRAS) + +## Make Commands +install: + $(UV_SYNC) + +clean: + rm -rf .venv + rm -rf .cache + rm -rf open_exposure_gateway/*.egg-info + +lint: + make install DEV=true + .venv/bin/ruff check --fix $(SRC) + +format: + make install DEV=true + .venv/bin/ruff format $(SRC) + +precommit: + make install DEV=true + .venv/bin/pre-commit run --all-files + +architecture: + make install DEV=true + .venv/bin/lint-imports --cache-dir .cache/import-linter + +type: + make install DEV=true + .venv/bin/mypy $(SRC) $(TEST) + +test: + make install DEV=true + .venv/bin/pytest $(TEST)/unit $(TEST)/integration -m "not conformance" + +conformance: + make install DEV=true + .venv/bin/pytest $(TEST)/conformance + +run: + make install + .venv/bin/python3 open_exposure_gateway/app/main.py diff --git a/README.md b/README.md index 322f073437b37d994a3d0b7dbd13e998da617516..e364e609ecd8f3ddaaa078835d883475832f50b4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Open Exposure Gateway -The **Open Exposure Gateway (OEG)** is a Python web service implementation. It implements the **Open Exposure Gateway** role of the Operator Platform, defined by the [GSMA Operator Platform Group (OPG)](https://www.gsma.com/solutions-and-impact/technologies/networks/gsma-operator-platform-group-september-2024-publications/). +The **Open Exposure Gateway (OEG)** is a Python web service implementation. It implements the **Open Exposure Gateway** role of the Operator Platform, defined by the [GSMA Operator Platform Group (OPG)](https://www.gsma.com/solutions-and-impact/technologies/networks/gsma-operator-platform-group-september-2024-publications/). ### Overview @@ -21,9 +21,9 @@ OEG acts as a middleware layer between the CAMARA APIs and the Service Resource
| Edge Cloud Management API | Federation Management API | Network Exposure API (QoD & Traffic Influence)| -| ------------- | ------------- | ------------- | -| Application Metadata registration | Create one direction federation | Create QoD Session | -| App Metadata Removal | Retrieve details about federation with the partner OP | Remove QoD Session | +| ------------- | ------------- | ------------- | +| Application Metadata registration | Create one direction federation | Create QoD Session | +| App Metadata Removal | Retrieve details about federation with the partner OP | Remove QoD Session | | App Metadata Retrieval | Retrieve the existing federationContextId with partner | Retrieve QoD Session | | Application Instantiation | Remove existing federation with partner OP | Create TrafficeInfluence Resource | | Application Instance Retrieval | | Remove TrafficeInfluence Resource | @@ -32,7 +32,7 @@ OEG acts as a middleware layer between the CAMARA APIs and the Service Resource ## Deployment -OEG can be deployed in a Kubernetes cluster by executing the file _oeg-deployment.yaml_ located in the root folder. This file will create a OEG Deployment resource and its supporting native K8s Service. The following table contains the necesssary environment variables for the Kubernetes adapter. +OEG can be deployed in a Kubernetes cluster by executing the file _oeg-deployment.yaml_ located in the root folder. This file will create a OEG Deployment resource and its supporting native K8s Service. The following table contains the necesssary environment variables for the Kubernetes adapter. | **Variable** | **Description** | |-----------------------------|-------------------------------------------------------------| @@ -47,7 +47,7 @@ Before running the server you need to `mv env.sample .env` and update variables To run the server, please execute the following from the root directory: -Using uv: +Using uv: ```bash # run the server as module - uv will automatically take care of any dependencies installation uv run edge_cloud_management_api @@ -61,6 +61,44 @@ To launch the integration tests, use tox: uv run tox ``` +### CAMARA specs and conformance testing + +The northbound API implements pinned versions of the CAMARA specs. Each pin is +a folder under `open_exposure_gateway/app/api/camara///`, named after +the upstream commit or tag it tracks. A pin that serves as a conformance target +carries the upstream OpenAPI YAML verbatim (upstream's `code/` layout is +mirrored so relative `$ref`s resolve). + +The conformance suite (`pytest -m conformance`) drives every operation of the +vendored spec against the app with schemathesis and validates responses against +the spec. A failure means the northbound interface diverges from CAMARA. The +suite runs as a separate, non-blocking CI job; the regular test job excludes it +via the `conformance` marker. + +#### Vendored spec pins (update this list when bumping a pin) + +**Edge Application Management** — [camaraproject/EdgeApplicationManagement](https://github.com/camaraproject/EdgeApplicationManagement) +at commit `0a64e153e8b860f9a8b7c045fe2e840e7278dada` (API version `wip`, served +under `{apiRoot}/edge-application-management/vwip`), vendored in +`open_exposure_gateway/app/api/camara/edge_application_management/vwip/`: + +| Vendored file | Upstream file | +|---|---| +| `API_definitions/edge-application-management.yaml` | `code/API_definitions/edge-application-management.yaml` | +| `common/CAMARA_common.yaml` | `code/common/CAMARA_common.yaml` | + +To refresh or bump the pin, fetch **both files from the same commit** (a +mismatched pair breaks the spec's relative `$ref`s or silently changes its +meaning), from the pin folder: + +```sh +SHA=0a64e153e8b860f9a8b7c045fe2e840e7278dada +curl -sL -o API_definitions/edge-application-management.yaml \ + "https://raw.githubusercontent.com/camaraproject/EdgeApplicationManagement/$SHA/code/API_definitions/edge-application-management.yaml" +curl -sL -o common/CAMARA_common.yaml \ + "https://raw.githubusercontent.com/camaraproject/EdgeApplicationManagement/$SHA/code/common/CAMARA_common.yaml" +``` + ### Running with Docker To run the server on a Docker container, please execute the following from the root directory: diff --git a/deploy/oeg.yaml b/deploy/oeg.yaml deleted file mode 100644 index c34f664bdc22a88e9c0270671900e66fba2c573a..0000000000000000000000000000000000000000 --- a/deploy/oeg.yaml +++ /dev/null @@ -1,179 +0,0 @@ ---- -kind: PersistentVolume -apiVersion: v1 -metadata: - name: mongodb-oeg-pv-volume # Sets PV's name - labels: - type: local # Sets PV's type to local - app: oegmongo -spec: - storageClassName: manual - capacity: - storage: 50Mi # Sets PV Volume - accessModes: - - ReadWriteOnce - hostPath: - path: "/mnt/data/mongodb_oeg" ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - creationTimestamp: null - labels: - io.kompose.service: mongo-oeg - name: mongo-oeg -spec: - storageClassName: manual - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 50Mi -status: {} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - annotations: - kompose.cmd: kompose convert - kompose.version: 1.26.0 (40646f47) - creationTimestamp: null - labels: - io.kompose.service: oegmongo - name: oegmongo -spec: - replicas: 1 - selector: - matchLabels: - io.kompose.service: oegmongo - strategy: - type: Recreate - template: - metadata: - annotations: - kompose.cmd: kompose convert - kompose.version: 1.26.0 (40646f47) - creationTimestamp: null - labels: - #io.kompose.network/netEMPkub: "true" - io.kompose.service: oegmongo - spec: - containers: - - image: mongo - name: oegmongo - ports: - - containerPort: 27017 - resources: {} - volumeMounts: - - mountPath: /data/db - name: mongo-db - restartPolicy: Always - volumes: - - name: mongo-db - persistentVolumeClaim: - claimName: mongo-oeg -status: {} ---- -apiVersion: v1 -kind: Service -metadata: - annotations: - kompose.cmd: kompose convert - kompose.version: 1.26.0 (40646f47) - creationTimestamp: null - labels: - io.kompose.service: oegmongo - name: oegmongo -spec: - type: ClusterIP - ports: - - name: "27017" - port: 27017 - targetPort: 27017 - selector: - io.kompose.service: oegmongo -status: - loadBalancer: {} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - annotations: - kompose.cmd: kompose convert - kompose.version: 1.26.0 (40646f47) - creationTimestamp: null - labels: - io.kompose.service: oegcontroller - name: oegcontroller -spec: - replicas: 1 - selector: - matchLabels: - io.kompose.service: oegcontroller - strategy: {} - template: - metadata: - annotations: - kompose.cmd: kompose convert - kompose.version: 1.26.0 (40646f47) - creationTimestamp: null - labels: - io.kompose.service: oegcontroller - spec: - containers: - - env: - - name: MONGO_URI - value: mongodb://oegmongo:27017 - - name: SRM_HOST - value: http://srm:8080/srm/1.0.0 - - name: FEDERATION_MANAGER_HOST - value: http://federation-manager.federation-manager.svc.cluster.local:8989/operatorplatform/federation/v1 - - name: PARTNER_API_ROOT - value: http://10.8.0.1:31002 - - name: TOKEN_ENDPOINT - value: http://federation-manager.federation-manager.svc.cluster.local:8080/realms/federation/protocol/openid-connect/token - image: ghcr.io/sunriseopenoperatorplatform/oeg/oeg:1.0.1 - name: oegcontroller - ports: - - containerPort: 8080 - resources: {} - imagePullPolicy: Always - restartPolicy: Always - -status: {} ---- -apiVersion: v1 -kind: Service -metadata: - name: oeg - namespace: sunrise6g -spec: - type: ClusterIP - selector: - io.kompose.service: oegcontroller - ports: - - name: http - port: 80 - targetPort: 8080 ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: oegcontroller-ingress - namespace: sunrise6g - annotations: - traefik.ingress.kubernetes.io/router.entrypoints: web - # traefik.ingress.kubernetes.io/rewrite-target: /$2 -spec: - ingressClassName: traefik - rules: - - host: isiath.duckdns.org - http: - paths: - - path: /oeg - pathType: Prefix - backend: - service: - name: oeg - port: - number: 80 \ No newline at end of file diff --git a/edge_cloud_management_api/__main__.py b/edge_cloud_management_api/__main__.py deleted file mode 100644 index 41bd405efecd8664a1afc1eb87301d22ae46304b..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from edge_cloud_management_api.app import get_app_instance - -if __name__ == "__main__": - app = get_app_instance() - app.run(host="0.0.0.0", port=8080) diff --git a/edge_cloud_management_api/app.py b/edge_cloud_management_api/app.py deleted file mode 100644 index 7c0fa0813433ce7e334445abbd18e794711e0479..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/app.py +++ /dev/null @@ -1,20 +0,0 @@ -from pathlib import Path -from connexion import FlaskApp -from connexion.options import SwaggerUIOptions - - -def get_app_instance() -> FlaskApp: - file_path = Path(__file__).resolve().parent - swagger_options = SwaggerUIOptions(swagger_ui_path="/docs") - app = FlaskApp(__name__, specification_dir=file_path / "specification") - app.add_api( - "openapi.yaml", - swagger_ui_options=swagger_options, - strict_validation=True, - ) - return app - - -if __name__ == "__main__": - app = get_app_instance() - app.run(host="0.0.0.0", port=8080) diff --git a/edge_cloud_management_api/configs/env_config.py b/edge_cloud_management_api/configs/env_config.py deleted file mode 100644 index 2b161fb9c1801a36dcbadb68b64d53cd2e56b2b2..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/configs/env_config.py +++ /dev/null @@ -1,20 +0,0 @@ -import os -from pydantic.v1 import BaseSettings -from dotenv import load_dotenv - -load_dotenv() - - -class Configuration(BaseSettings): - MONGO_URI: str = os.getenv("MONGO_URI") - SRM_HOST: str = os.getenv("SRM_HOST") - SRM_USERNAME: str = os.getenv("SRM_USERNAME") - SRM_PASSWORD: str = os.getenv("SRM_PASSWORD") - HTTP_PROXY: str = os.getenv("HTTP_PROXY") - FEDERATION_MANAGER_HOST=os.getenv("FEDERATION_MANAGER_HOST") - TOKEN_ENDPOINT = os.getenv('TOKEN_ENDPOINT') - PARTNER_API_ROOT = os.getenv('PARTNER_API_ROOT') - AVAIL_ZONE_NOTIF_LINK = os.getenv('AVAIL_ZONE_NOTIF_LINK') - - -config = Configuration() diff --git a/edge_cloud_management_api/controllers/app_controllers.py b/edge_cloud_management_api/controllers/app_controllers.py deleted file mode 100644 index 065041a52f9965232a73a9b3b7acee924aa402f2..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/controllers/app_controllers.py +++ /dev/null @@ -1,511 +0,0 @@ -from flask import jsonify, request -from pydantic import ValidationError -from edge_cloud_management_api.models.application_models import AppManifest -from edge_cloud_management_api.managers.log_manager import logger -from edge_cloud_management_api.controllers.app_federation_helpers import ensure_gsma_id -from edge_cloud_management_api.controllers.app_federation_helpers import ensure_res_pool -from edge_cloud_management_api.controllers.app_federation_helpers import ensure_service_name -from edge_cloud_management_api.controllers.app_federation_helpers import get_catalog_app_provider_map -from edge_cloud_management_api.controllers.app_federation_helpers import iter_federated_instances -from edge_cloud_management_api.controllers.app_federation_helpers import normalize_federated_app_id -from edge_cloud_management_api.controllers.app_federation_helpers import normalize_federated_artefact_id -from edge_cloud_management_api.controllers.app_federation_helpers import normalize_federated_app_provider_id -from edge_cloud_management_api.controllers.app_federation_helpers import resolve_app_provider -from edge_cloud_management_api.controllers.app_federation_helpers import resolve_federated_app_identity -from edge_cloud_management_api.controllers.app_federation_helpers import resolve_federated_app_provider_id -from edge_cloud_management_api.controllers.app_instance_helpers import dedupe_app_instances -from edge_cloud_management_api.controllers.app_instance_helpers import enrich_instance_zone_from_catalog -from edge_cloud_management_api.controllers.app_instance_helpers import normalize_federated_app_instances -from edge_cloud_management_api.controllers.app_instance_helpers import normalize_local_app_instance -from edge_cloud_management_api.controllers.app_partner_orchestration import cleanup_federated_app -from edge_cloud_management_api.controllers.app_partner_orchestration import deploy_to_partner -from edge_cloud_management_api.controllers.app_partner_orchestration import resolve_target_zone -from edge_cloud_management_api.services.edge_cloud_services import SRMAPIClientFactory -from edge_cloud_management_api.services.federation_services import FederationManagerClientFactory -from edge_cloud_management_api.services.storage_service import get_zone -from edge_cloud_management_api.services.storage_service import get_fed, get_all_feds -import json -import re -import uuid -from urllib.parse import urlsplit - -factory = FederationManagerClientFactory() -federation_client = factory.create_federation_client() - -class NotFound404Exception(Exception): - pass - - -_ensure_gsma_id = ensure_gsma_id -_ensure_res_pool = ensure_res_pool -_ensure_service_name = ensure_service_name -_get_catalog_app_provider_map = get_catalog_app_provider_map -_normalize_federated_app_id = normalize_federated_app_id -_normalize_federated_artefact_id = normalize_federated_artefact_id -_normalize_federated_app_provider_id = normalize_federated_app_provider_id -_normalize_federated_app_instances = normalize_federated_app_instances -_normalize_local_app_instance = normalize_local_app_instance -_dedupe_app_instances = dedupe_app_instances -_enrich_instance_zone_from_catalog = enrich_instance_zone_from_catalog -_resolve_app_provider = resolve_app_provider -_resolve_federated_app_identity = resolve_federated_app_identity -_resolve_federated_app_provider_id = resolve_federated_app_provider_id - - -def _get_app_cleanup_metadata(api_client, app_id): - app_response = api_client.get_app(app_id) - if not isinstance(app_response, dict): - return None, None - - app_provider_id = app_response.get("appProvider") or app_response.get("appProviderId") - artefact_id = None - manifest = app_response.get("appManifest") - if isinstance(manifest, dict) and not app_provider_id: - app_provider_id = manifest.get("appProvider") or manifest.get("appProviderId") - app_component_specs = app_response.get("appComponentSpecs") - if isinstance(app_component_specs, list) and app_component_specs: - artefact_id = app_component_specs[0].get("artefactId") - if artefact_id is None and isinstance(manifest, dict): - manifest_component_specs = manifest.get("appComponentSpecs") - if isinstance(manifest_component_specs, list) and manifest_component_specs: - artefact_id = manifest_component_specs[0].get("artefactId") - return app_provider_id, artefact_id - - -def _cleanup_federated_app_before_local_delete(api_client, app_id): - feds = get_all_feds() - if not feds: - return None - - app_provider_id, artefact_id = _get_app_cleanup_metadata(api_client, app_id) - cleanup_response = cleanup_federated_app( - federation_client=federation_client, - feds=feds, - app_id=app_id, - app_provider_id=app_provider_id, - normalize_federated_app_id=_normalize_federated_app_id, - artefact_id=artefact_id, - normalize_federated_artefact_id=_normalize_federated_artefact_id, - resolve_federated_app_identity=_resolve_federated_app_identity, - ) - if cleanup_response is None: - return None - - _, cleanup_status = cleanup_response - if cleanup_status in (200, 202, 204): - return None - return cleanup_response - - -def _delete_local_app(api_client, app_id): - response = api_client.delete_app(appId=app_id) - if isinstance(response, dict): - status = int(response.get("status_code", 500)) - if status == 404: - return None - if status >= 400: - return jsonify(response), status - return "", 204 - - -def _iter_federated_instances(feds, app_id_provider_pairs): - return iter_federated_instances(federation_client, feds, app_id_provider_pairs) - - -def _split_image_reference(image_path): - if not image_path: - return None, None - if str(image_path).startswith(("http://", "https://")): - parsed = urlsplit(str(image_path)) - path = parsed.path.rstrip("/") - if not path: - return f"{parsed.scheme}://{parsed.netloc}", None - repo_path, _, image_ref = path.rpartition("/") - repo_url = f"{parsed.scheme}://{parsed.netloc}{repo_path or path}" - if not image_ref: - return repo_url, None - return repo_url, image_ref - clean_path = str(image_path).lstrip("/") - parts = clean_path.split("/", 1) - if len(parts) == 1: - return "docker.io/library", clean_path - registry_candidate = parts[0] - if "." in registry_candidate or ":" in registry_candidate: - return registry_candidate, parts[1] - return "docker.io", clean_path - - -def _split_image_name_tag(image_ref): - if not image_ref: - return None, None - if "@" in image_ref: - name, digest = image_ref.split("@", 1) - return name, digest - if ":" in image_ref: - name, tag = image_ref.rsplit(":", 1) - return name, tag - return image_ref, "latest" - - - - -def submit_app(body: dict): - """ - Controller for submitting application metadata. - """ - try: - AppManifest(**body) - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.submit_app(body) - return response - - except ValidationError as e: - return jsonify({"error": "Invalid input", "details": e.errors()}), 400 - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - - -def get_apps(x_correlator=None): - """Retrieve metadata information of all applications""" - try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - registered_apps = api_client.get_service_functions_catalogue() - return registered_apps - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - - -def get_app(appId, x_correlator=None): - """Retrieve the information of an Application""" - try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.get_app(appId) - if isinstance(response, dict) and int(response.get("status_code", 200)) >= 400: - return jsonify(response), int(response.get("status_code", 500)) - return response - - except NotFound404Exception: - return ( - jsonify({"status": 404, "code": "NOT_FOUND", "message": "Resource does not exist"}), - 404, - ) - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - - -def delete_app(appId, x_correlator=None): - """Delete Application metadata from an Edge Cloud Provider""" - try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - cleanup_response = _cleanup_federated_app_before_local_delete(api_client, appId) - if cleanup_response is not None: - return cleanup_response - - result = _delete_local_app(api_client, appId) - if result is not None: - return result - return "", 204 - - except NotFound404Exception: - return ( - jsonify({"status": 404, "code": "NOT_FOUND", "message": "Resource does not exist"}), - 404, - ) - - except Exception as e: - return ( - jsonify({"status": 500, "code": "INTERNAL", "message": f"Internal server error: {str(e)}"}), - 500, - ) - - -def create_app_instance(): - logger.info("Received request to create app instance") - - try: - body = request.get_json() - logger.debug(f"Request body: {body}") - - app_id = body.get("appId") - app_zones = body.get("appZones") - - if not app_id or not app_zones: - return jsonify({ - "error": "Missing required fields: appId, appZones" - }), 400 - - srm_client_factory = SRMAPIClientFactory() - srm_client = srm_client_factory.create_srm_api_client() - - first_zone = app_zones[0] if isinstance(app_zones, list) and app_zones else {} - if not isinstance(first_zone, dict): - first_zone = {} - zone_payload = ( - first_zone.get("EdgeCloudZone", {}) - if isinstance(first_zone.get("EdgeCloudZone", {}), dict) - else first_zone - ) - edge_cloud_zone_id = None - edge_cloud_provider = None - if isinstance(zone_payload, dict): - edge_cloud_zone_id = zone_payload.get("edgeCloudZoneId") or zone_payload.get("zoneId") - edge_cloud_provider = zone_payload.get("edgeCloudProvider") - - zone = resolve_target_zone( - srm_client=srm_client, - edge_cloud_zone_id=edge_cloud_zone_id, - edge_cloud_provider=edge_cloud_provider, - zone_payload=zone_payload, - ) - - if not zone: - return jsonify({ - "error": "Edge Cloud Zone not found", - "edgeCloudZoneId": edge_cloud_zone_id - }), 404 - - # ============================================================ - # PARTNER DEPLOYMENT (Federation path) - # ============================================================ - if zone.get("isLocal") == "false": - app_response = srm_client.get_app(appId=app_id) - appData = app_response.get("appManifest") if isinstance(app_response, dict) else None - if not isinstance(appData, dict): - appData = None - - if not appData: - return jsonify({ - "error": "Application manifest not found", - "appId": app_id - }), 404 - return deploy_to_partner( - federation_client=federation_client, - zone=zone, - app_id=app_id, - app_data=appData, - split_image_reference=_split_image_reference, - split_image_name_tag=_split_image_name_tag, - ensure_gsma_id=_ensure_gsma_id, - ensure_service_name=_ensure_service_name, - ensure_res_pool=_ensure_res_pool, - normalize_federated_app_id=_normalize_federated_app_id, - normalize_federated_app_provider_id=_normalize_federated_app_provider_id, - normalize_federated_artefact_id=_normalize_federated_artefact_id, - ) - - # ============================================================ - # LOCAL DEPLOYMENT (SRM path) - # ============================================================ - logger.info(f"Proceeding with LOCAL deployment for appId={app_id}") - - try: - logger.debug("Sending deployment request to SRM") - local_deploy_body = dict(body) - local_zone = { - "edgeCloudZoneId": zone.get("edgeCloudZoneId"), - "edgeCloudZoneName": zone.get("edgeCloudZoneName"), - "edgeCloudProvider": zone.get("edgeCloudProvider"), - "edgeCloudZoneStatus": zone.get("edgeCloudZoneStatus"), - "edgeCloudRegion": zone.get("edgeCloudRegion"), - } - local_deploy_body["appZones"] = [{"EdgeCloudZone": local_zone}] - response = srm_client.deploy_service_function(data=local_deploy_body) - - if isinstance(response, dict) and "error" in response: - logger.warning( - "SRM returned an error, deployment not completed" - ) - return jsonify({ - "warning": "Deployment request accepted but not completed", - "details": response - }), 202 - - logger.info("Local deployment request successfully sent to SRM") - return jsonify({ - "message": "Application deployed locally", - "appId": app_id, - "response": response - }), 202 - - except Exception as e: - logger.error(f"SRM deployment failed: {str(e)}") - return jsonify({ - "warning": "SRM backend unavailable", - "details": str(e) - }), 202 - - except Exception as e: - logger.exception("Unexpected error in create_app_instance") - return jsonify({ - "error": "Unexpected error", - "details": str(e) - }), 500 - -def get_app_instance(app_id=None, appId=None, x_correlator=None, xCorrelator=None, app_instance_id=None, appInstanceId=None, region=None): - """ - Retrieve application instances from the database. - Supports filtering by app_id, app_instance_id, and region. - """ - try: - app_id = app_id or appId - app_instance_id = app_instance_id or appInstanceId - instances = [] - srm_client_factory = SRMAPIClientFactory() - srm_client = srm_client_factory.create_srm_api_client() - - local_response = srm_client.get_app_instances() - local_instances = [] - if isinstance(local_response, dict): - local_instances = local_response.get("appInstances", []) - elif isinstance(local_response, list): - local_instances = local_response - - for instance in local_instances: - normalized_instance = _normalize_local_app_instance(instance) - if normalized_instance: - if app_id and normalized_instance.get("appId") and normalized_instance.get("appId") != app_id: - continue - instances.append(normalized_instance) - - feds = get_all_feds() - if app_id: - app_provider_id = _resolve_app_provider(srm_client, app_id) - if app_provider_id: - for app_id_value, _federated_app_id, _federation_context_id, _fed_token, zone_provider, fed_instances in _iter_federated_instances( - feds, - [(app_id, app_provider_id)], - ): - instances.extend( - _normalize_federated_app_instances( - fed_instances, - zone_provider=zone_provider, - region=region, - ) - ) - else: - logger.info("Skipping federated lookup; no appProviderId for appId=%s", app_id) - else: - app_provider_map = _get_catalog_app_provider_map(srm_client) - for app_id_value, _federated_app_id, _federation_context_id, _fed_token, zone_provider, fed_instances in _iter_federated_instances( - feds, - app_provider_map.items(), - ): - instances.extend( - _normalize_federated_app_instances( - fed_instances, - zone_provider=zone_provider, - region=region, - ) - ) - - if app_instance_id: - instances = [ - instance for instance in instances - if instance.get("appInstanceId") == app_instance_id - ] - if region: - instances = [ - instance for instance in instances - if isinstance(instance.get("edgeCloudZone"), dict) - and instance["edgeCloudZone"].get("edgeCloudRegion") == region - ] - - instances = _dedupe_app_instances(instances) - instances = [_enrich_instance_zone_from_catalog(instance) for instance in instances] - - return jsonify(instances), 200 - - except Exception as e: - logger.exception("Failed to retrieve app instances") - return jsonify({ - "status": 500, - "code": "INTERNAL", - "message": f"Internal server error: {str(e)}" - }), 500 - - -def delete_app_instance(appInstanceId: str, x_correlator=None): - """ - Terminate an Application Instance - - - Removes a specific app instance from the database. - - Returns 204 if deleted, 404 if not found. - """ - try: - srm_client_factory = SRMAPIClientFactory() - srm_client = srm_client_factory.create_srm_api_client() - response = srm_client.delete_app_instance(appInstanceId) - if isinstance(response, dict) and response.get("status_code") != 404: - status_code = response.get("status_code", 500) - return jsonify(response), status_code - if not isinstance(response, dict) and response.status_code != 404: - return jsonify({ - "result": response.text, - "status": response.status_code - }), response.status_code - - if not isinstance(response, dict): - status_code = response.status_code - else: - status_code = response.get("status_code", 404) - - app_provider_map = _get_catalog_app_provider_map(srm_client) - - feds = get_all_feds() - for app_id_value, federated_app_id, federation_context_id, fed_token, _zone_provider, fed_instances in _iter_federated_instances( - feds, - app_provider_map.items(), - ): - for zone_info in fed_instances: - if not isinstance(zone_info, dict): - continue - zone_id = zone_info.get("zoneId") - instances_list = zone_info.get("appInstanceInfo", []) - if not zone_id or not isinstance(instances_list, list): - continue - for instance in instances_list: - if not isinstance(instance, dict): - continue - instance_id = instance.get("appInstIdentifier") or instance.get("appInstanceId") - if instance_id != appInstanceId: - continue - remove_response, remove_status = federation_client.remove_app_instance( - federation_context_id=federation_context_id, - app_id=federated_app_id, - app_instance_id=appInstanceId, - zone_id=zone_id, - token=fed_token, - ) - if remove_status >= 400 and "App instance not found" in str(remove_response): - return "", 204 - return jsonify(remove_response), remove_status - - return jsonify({ - "error": response.get("error") if isinstance(response, dict) else response.text, - "status_code": status_code - }), status_code - - except Exception as e: - return ( - jsonify({ - "status": 500, - "code": "INTERNAL", - "message": f"Internal server error: {str(e)}" - }), - 500, - ) diff --git a/edge_cloud_management_api/controllers/app_federation_helpers.py b/edge_cloud_management_api/controllers/app_federation_helpers.py deleted file mode 100644 index 608fbdb5230d1cacbeeb828be1fa813c3d483590..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/controllers/app_federation_helpers.py +++ /dev/null @@ -1,165 +0,0 @@ -import re -import uuid - - -def ensure_gsma_id(value, pattern, prefix, min_len, max_len, fallback_source): - if value and re.match(pattern, value): - return value - base = re.sub(r"[^A-Za-z0-9_]", "", str(fallback_source or "")) - candidate = f"{prefix}{base}" - if not re.match(r"^[A-Za-z]", candidate): - candidate = f"{prefix}{candidate}" - candidate = candidate[:max_len] - if len(candidate) < min_len: - candidate = (candidate + uuid.uuid4().hex)[:max_len] - if not re.match(r"^[A-Za-z]", candidate): - candidate = f"{prefix}{candidate}" - candidate = candidate[:max_len] - return candidate - - -def ensure_service_name(value, prefix, fallback_source): - pattern = r"^[A-Za-z0-9][A-Za-z0-9_]{6,62}[A-Za-z0-9]$" - if value and re.match(pattern, value): - return value - base = re.sub(r"[^A-Za-z0-9_]", "", str(fallback_source or "")) - candidate = f"{prefix}{base}" - candidate = re.sub(r"^_+", "", candidate) - candidate = candidate[:64] - if len(candidate) < 8: - candidate = (candidate + uuid.uuid4().hex)[:64] - if not re.match(r"^[A-Za-z0-9]", candidate): - candidate = f"s{candidate}" - if not re.match(r"[A-Za-z0-9]$", candidate): - candidate = f"{candidate}0" - return candidate[:64] - - -def ensure_res_pool(value, fallback_source): - pattern = r"^[A-Za-z0-9][A-Za-z0-9_]{6,30}[A-Za-z0-9]$" - if value and re.match(pattern, value): - return value - base = re.sub(r"[^A-Za-z0-9_]", "", str(fallback_source or "")) - candidate = f"respool{base}"[:32] - if len(candidate) < 8: - candidate = (candidate + uuid.uuid4().hex)[:32] - if not re.match(r"^[A-Za-z0-9]", candidate): - candidate = f"r{candidate}" - if not re.match(r"[A-Za-z0-9]$", candidate): - candidate = f"{candidate}0" - return candidate[:32] - - -def normalize_federated_app_id(app_id): - pattern = ( - r"^(?:[A-Za-z][A-Za-z0-9_]{7,63}|" - r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" - r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$" - ) - if app_id and re.match(pattern, str(app_id)): - return app_id - return str(uuid.uuid5(uuid.NAMESPACE_URL, f"federated-app:{app_id}")) - - -def normalize_federated_app_provider_id(app_provider_id, fallback_source): - return ensure_gsma_id( - app_provider_id, - r"^[A-Za-z][A-Za-z0-9_]{7,63}$", - "provider", - 8, - 64, - fallback_source, - ) - - -def resolve_federated_app_provider_id(app_id_value, app_provider_id): - if not app_provider_id: - return None - return normalize_federated_app_provider_id(app_provider_id, app_id_value) - - -def resolve_federated_app_identity(app_id_value, app_provider_id): - if not app_id_value or not app_provider_id: - return None, None - return ( - normalize_federated_app_id(app_id_value), - resolve_federated_app_provider_id(app_id_value, app_provider_id), - ) - - -def normalize_federated_artefact_id(federation_context_id, artefact_id, fallback_source): - if artefact_id and re.match( - r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" - r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", - str(artefact_id), - ): - return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{federation_context_id}:{artefact_id}")) - return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{federation_context_id}:{fallback_source}")) - - -def resolve_app_provider(srm_client, app_id_value, app_payload=None): - if isinstance(app_payload, dict): - provider = app_payload.get("appProvider") or app_payload.get("appProviderId") - if provider: - return provider - manifest = app_payload.get("appManifest") - if isinstance(manifest, dict): - provider = manifest.get("appProvider") or manifest.get("appProviderId") - if provider: - return provider - - app_response = srm_client.get_app(app_id_value) - if isinstance(app_response, dict): - manifest = app_response.get("appManifest") - if isinstance(manifest, dict): - provider = manifest.get("appProvider") or manifest.get("appProviderId") - if provider: - return provider - provider = app_response.get("appProvider") or app_response.get("appProviderId") - if provider: - return provider - return None - - -def get_catalog_app_provider_map(srm_client): - app_provider_map = {} - apps = srm_client.get_service_functions_catalogue() - if not isinstance(apps, list): - return app_provider_map - - for app in apps: - if not isinstance(app, dict): - continue - app_id_value = app.get("appId") or app.get("id") - if not app_id_value: - continue - provider = resolve_app_provider(srm_client, app_id_value, app_payload=app) - if provider: - app_provider_map[app_id_value] = provider - return app_provider_map - - -def iter_federated_instances(federation_client, feds, app_id_provider_pairs): - for fed in feds: - fed_token = fed.get("token") - federation_context_id = fed.get("_id") - if not federation_context_id or not fed_token: - continue - - zone_provider = fed.get("partnerOPFederationId") or "unknown" - for app_id_value, app_provider_id in app_id_provider_pairs: - federated_app_id, federated_app_provider_id = resolve_federated_app_identity( - app_id_value, - app_provider_id, - ) - if not federated_app_id or not federated_app_provider_id: - continue - fed_instances, fed_code = federation_client.get_all_app_instances( - federation_context_id=federation_context_id, - app_id=federated_app_id, - app_provider_id=federated_app_provider_id, - token=fed_token, - ) - if fed_code != 200 or not isinstance(fed_instances, list): - continue - yield app_id_value, federated_app_id, federation_context_id, fed_token, zone_provider, fed_instances diff --git a/edge_cloud_management_api/controllers/app_instance_helpers.py b/edge_cloud_management_api/controllers/app_instance_helpers.py deleted file mode 100644 index 77e2ab409f262b9654839ce4b39b716f28f50a45..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/controllers/app_instance_helpers.py +++ /dev/null @@ -1,186 +0,0 @@ -from edge_cloud_management_api.services.storage_service import get_zone -from edge_cloud_management_api.controllers.edge_cloud_controller import get_local_zones - - -def _find_local_zone(zone_id, zone_provider=None): - for zone in get_local_zones(): - if not isinstance(zone, dict): - continue - if zone.get("edgeCloudZoneId") != zone_id: - continue - if zone_provider and zone.get("edgeCloudProvider") != zone_provider: - continue - return zone - return None - - -def normalize_local_app_instance(instance): - if not isinstance(instance, dict): - return None - - app_instance_id = instance.get("appInstanceId") or instance.get("appInstIdentifier") - if not app_instance_id: - return None - - status = instance.get("status") or instance.get("appInstanceState") or "unknown" - edge_cloud_zone = instance.get("edgeCloudZone") - if not isinstance(edge_cloud_zone, dict): - zone_id = instance.get("edgeCloudZoneId") or instance.get("zoneId") - zone_name = instance.get("edgeCloudZoneName") or instance.get("zoneName") or "unknown" - edge_cloud_provider = instance.get("edgeCloudProvider") or "unknown" - if zone_id: - edge_cloud_zone = { - "edgeCloudZoneId": zone_id, - "edgeCloudZoneName": zone_name, - "edgeCloudProvider": edge_cloud_provider, - "edgeCloudZoneStatus": instance.get("edgeCloudZoneStatus") or "unknown", - "edgeCloudRegion": instance.get("edgeCloudRegion") or "unknown", - } - - normalized = { - "appInstanceId": app_instance_id, - "status": status, - } - if instance.get("appId"): - normalized["appId"] = instance.get("appId") - if edge_cloud_zone: - normalized["edgeCloudZone"] = edge_cloud_zone - if instance.get("componentEndpointInfo"): - normalized["componentEndpointInfo"] = instance.get("componentEndpointInfo") - elif instance.get("accesspointInfo"): - normalized["componentEndpointInfo"] = instance.get("accesspointInfo") - if instance.get("kubernetesClusterRef"): - normalized["kubernetesClusterRef"] = instance.get("kubernetesClusterRef") - return normalized - - -def enrich_instance_zone_from_catalog(instance): - if not isinstance(instance, dict): - return instance - - zone = instance.get("edgeCloudZone") - if not isinstance(zone, dict): - return instance - - zone_id = zone.get("edgeCloudZoneId") - if not zone_id: - return instance - - zone_provider = zone.get("edgeCloudProvider") - stored_zone = None - if zone_provider and zone_provider != "unknown": - stored_zone = get_zone(zone_id, zone_provider) - else: - stored_zone = _find_local_zone(zone_id) - if not isinstance(stored_zone, dict): - stored_zone = get_zone(zone_id) - if not isinstance(stored_zone, dict): - return instance - - enriched = dict(instance) - enriched_zone = dict(zone) - for key in ( - "edgeCloudZoneId", - "edgeCloudZoneName", - "edgeCloudProvider", - "edgeCloudZoneStatus", - "edgeCloudRegion", - ): - if enriched_zone.get(key) in (None, "unknown") and stored_zone.get(key) is not None: - enriched_zone[key] = stored_zone.get(key) - enriched["edgeCloudZone"] = enriched_zone - return enriched - - -def normalize_federated_app_instances(fed_instances, zone_provider=None, region=None): - normalized_instances = [] - if not isinstance(fed_instances, list): - return normalized_instances - - for zone_info in fed_instances: - if not isinstance(zone_info, dict): - continue - zone_id = zone_info.get("zoneId") - for instance in zone_info.get("appInstanceInfo", []) or []: - if not isinstance(instance, dict): - continue - app_instance_id = instance.get("appInstIdentifier") or instance.get("appInstanceId") - if not app_instance_id: - continue - status = instance.get("appInstanceState") or "unknown" - if isinstance(status, str) and status.startswith("Error 404"): - continue - normalized = { - "appInstanceId": app_instance_id, - "status": status, - } - if instance.get("appId"): - normalized["appId"] = instance.get("appId") - if zone_id: - normalized["edgeCloudZone"] = { - "edgeCloudZoneId": zone_id, - "edgeCloudZoneName": "unknown", - "edgeCloudProvider": zone_provider or "unknown", - "edgeCloudZoneStatus": "unknown", - "edgeCloudRegion": region or "unknown", - } - normalized_instances.append(normalized) - return normalized_instances - - -def dedupe_app_instances(instances): - deduped = [] - by_instance_id = {} - - def zone_quality(instance): - zone = instance.get("edgeCloudZone") - if not isinstance(zone, dict): - return 0 - - score = 0 - if zone.get("edgeCloudProvider") and zone.get("edgeCloudProvider") != "unknown": - score += 4 - if zone.get("edgeCloudZoneName") and zone.get("edgeCloudZoneName") != "unknown": - score += 2 - if zone.get("edgeCloudZoneStatus") and zone.get("edgeCloudZoneStatus") != "unknown": - score += 1 - return score - - def merge_instances(existing, incoming): - merged = dict(existing) - - for key, value in incoming.items(): - if key == "edgeCloudZone" and isinstance(value, dict): - existing_zone = merged.get("edgeCloudZone") - if not isinstance(existing_zone, dict) or zone_quality(incoming) > zone_quality(existing): - merged["edgeCloudZone"] = dict(value) - else: - zone = dict(existing_zone) - for zone_key, zone_value in value.items(): - if zone_key not in zone or zone.get(zone_key) in (None, "unknown"): - zone[zone_key] = zone_value - merged["edgeCloudZone"] = zone - continue - - if key not in merged or merged.get(key) in (None, "unknown", []): - merged[key] = value - - return merged - - for instance in instances: - if not isinstance(instance, dict): - continue - app_instance_id = instance.get("appInstanceId") - if app_instance_id: - existing = by_instance_id.get(app_instance_id) - if existing is not None: - merged = merge_instances(existing, instance) - by_instance_id[app_instance_id] = merged - for index, deduped_instance in enumerate(deduped): - if deduped_instance.get("appInstanceId") == app_instance_id: - deduped[index] = merged - break - continue - by_instance_id[app_instance_id] = instance - deduped.append(instance) - return deduped diff --git a/edge_cloud_management_api/controllers/app_partner_orchestration.py b/edge_cloud_management_api/controllers/app_partner_orchestration.py deleted file mode 100644 index 3d94ff26a9d7fc3d066aa3df92dd2bd39c45eed8..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/controllers/app_partner_orchestration.py +++ /dev/null @@ -1,415 +0,0 @@ -import json - -from flask import jsonify - -from edge_cloud_management_api.controllers.edge_cloud_controller import get_local_zones -from edge_cloud_management_api.managers.log_manager import logger -from edge_cloud_management_api.services.storage_service import get_fed -from edge_cloud_management_api.services.storage_service import get_zone -from edge_cloud_management_api.services.storage_service import insert_zones - - -def resolve_target_zone(srm_client, edge_cloud_zone_id, edge_cloud_provider, zone_payload): - zone = get_zone(edge_cloud_zone_id, edge_cloud_provider) if edge_cloud_zone_id else None - if not zone and edge_cloud_zone_id: - try: - zones = get_local_zones() - if isinstance(zones, list): - matching_local_zones = [] - for candidate_zone in zones: - if not isinstance(candidate_zone, dict): - continue - if edge_cloud_provider and candidate_zone.get("edgeCloudProvider") == edge_cloud_provider: - matching_local_zones.append(candidate_zone) - if candidate_zone.get("edgeCloudZoneId") != edge_cloud_zone_id: - continue - if edge_cloud_provider and candidate_zone.get("edgeCloudProvider") != edge_cloud_provider: - continue - if candidate_zone.get("edgeCloudProvider") == edge_cloud_provider or not edge_cloud_provider: - zone = candidate_zone - break - if not zone and edge_cloud_zone_id == "default" and len(matching_local_zones) == 1: - zone = matching_local_zones[0] - except Exception as exc: - logger.info(f"Failed to refresh zones from SRM: {exc}") - - if not zone and edge_cloud_zone_id and isinstance(zone_payload, dict): - zone = dict(zone_payload) - zone.setdefault("isLocal", "true") - - return zone - - -def cleanup_federated_app( - federation_client, - feds, - app_id, - app_provider_id, - normalize_federated_app_id, - artefact_id, - normalize_federated_artefact_id, - resolve_federated_app_identity, -): - if not feds: - return None - - cleanup_performed = False - federated_app_id = normalize_federated_app_id(app_id) - for fed in feds: - fed_token = fed.get("token") - federation_context_id = fed.get("_id") - if not federation_context_id or not fed_token: - continue - if app_provider_id: - federated_app_id, federated_app_provider_id = resolve_federated_app_identity(app_id, app_provider_id) - fed_instances, fed_code = federation_client.get_all_app_instances( - federation_context_id=federation_context_id, - app_id=federated_app_id, - app_provider_id=federated_app_provider_id, - token=fed_token, - ) - if fed_code == 200 and isinstance(fed_instances, list): - cleanup_performed = True - for zone_info in fed_instances: - if not isinstance(zone_info, dict): - continue - zone_id = zone_info.get("zoneId") - instances_list = zone_info.get("appInstanceInfo", []) - if not zone_id or not isinstance(instances_list, list): - continue - for instance in instances_list: - if not isinstance(instance, dict): - continue - instance_id = instance.get("appInstIdentifier") - if not instance_id: - continue - federation_client.remove_app_instance( - federation_context_id=federation_context_id, - app_id=federated_app_id, - app_instance_id=instance_id, - zone_id=zone_id, - token=fed_token, - ) - elif fed_code not in (404, 422): - return jsonify(fed_instances), fed_code - - remove_response = federation_client.delete_onboarded_app( - federation_context_id, - federated_app_id, - fed_token, - ) - remove_status = int(remove_response.get("status_code", 500)) if isinstance(remove_response, dict) else 500 - if remove_status in (200, 202, 204, 404): - cleanup_performed = True - if app_provider_id: - federated_artefact_id = normalize_federated_artefact_id( - federation_context_id, - artefact_id, - app_id, - ) - artefact_response = federation_client.delete_artefact( - federation_context_id, - federated_artefact_id, - fed_token, - ) - artefact_status = int(artefact_response.get("status_code", 500)) if isinstance(artefact_response, dict) else 500 - if artefact_status not in (200, 202, 204, 404): - return jsonify(artefact_response), artefact_status - continue - return jsonify(remove_response), remove_status - - if cleanup_performed: - return "", 204 - return None - - -def deploy_to_partner( - federation_client, - zone, - app_id, - app_data, - split_image_reference, - split_image_name_tag, - ensure_gsma_id, - ensure_service_name, - ensure_res_pool, - normalize_federated_app_id, - normalize_federated_app_provider_id, - normalize_federated_artefact_id, -): - federated_app_id = normalize_federated_app_id(app_id) - - artefact_id = None - app_provider_id = app_data.get("appProvider") or app_data.get("appProviderId") - app_name = app_data.get("name") or app_data.get("appName") or app_id - app_version = app_data.get("version") or "v1" - app_repo = app_data.get("appRepo", {}) - if not isinstance(app_repo, dict): - app_repo = {} - image_path = app_repo.get("imagePath") - repo_type = app_repo.get("type", "PUBLICREPO") - component_specs = app_data.get("componentSpec", []) - network_interfaces = [] - required_resources = app_data.get("requiredResources") or {} - if component_specs and isinstance(component_specs, list): - network_interfaces = component_specs[0].get("networkInterfaces", []) or [] - app_component_specs = app_data.get("appComponentSpecs", []) - if isinstance(app_component_specs, list) and app_component_specs: - artefact_id = app_component_specs[0].get("artefactId") - component_name = None - if component_specs and isinstance(component_specs, list): - component_name = component_specs[0].get("componentName") - component_name = component_name or app_name - component_name = ensure_service_name(component_name, "cmp", app_name) - artefact_id = normalize_federated_artefact_id(zone.get("fedContextId"), artefact_id, app_id) - - service_name_nb = ensure_service_name( - component_specs[0].get("serviceNameNB") if component_specs else None, - "nb", - component_name, - ) - service_name_ew = ensure_service_name( - component_specs[0].get("serviceNameEW") if component_specs else None, - "ew", - component_name, - ) - - app_provider_id = ensure_gsma_id( - app_provider_id, - r"^[A-Za-z][A-Za-z0-9_]{7,63}$", - "provider", - 8, - 64, - app_id, - ) - federated_app_provider_id = normalize_federated_app_provider_id(app_provider_id, app_id) - app_name = ensure_gsma_id( - app_name, - r"^[A-Za-z][A-Za-z0-9_]{7,31}$", - "app", - 8, - 32, - app_id, - ) - access_token = ensure_gsma_id( - app_data.get("accessToken"), - r"^[A-Za-z][A-Za-z0-9_]{31,63}$", - "token", - 32, - 64, - app_id, - ) - package_type = (app_data.get("packageType") or "CONTAINER").upper() - artefact_descriptor_type = "HELM" if package_type == "HELM" else "COMPONENTSPEC" - - repo_url, image_ref = split_image_reference(image_path) - if not repo_url or not image_ref: - return jsonify({ - "error": "Application manifest missing imagePath", - "appId": app_id, - }), 400 - image_name, image_tag = split_image_name_tag(image_ref) - edge_cloud_zone_id = zone.get("edgeCloudZoneId") - app_deployment_zones = app_data.get("appDeploymentZones") - if not isinstance(app_deployment_zones, list) or not app_deployment_zones: - app_deployment_zones = [edge_cloud_zone_id] - app_qos = app_data.get("appQoSProfile", {}) - if not isinstance(app_qos, dict): - app_qos = {} - bandwidth_required = app_qos.get("bandwidthRequired", 1) - multi_user_clients = app_qos.get("multiUserClients", "APP_TYPE_SINGLE_USER") - no_of_users = app_qos.get("noOfUsersPerAppInst", 1) - app_provisioning = app_qos.get("appProvisioning", True) - latency_constraints = app_qos.get("latencyConstraints", "NONE") - app_status_callback_link = ( - app_data.get("appStatusCallbackLink") - or app_data.get("statusCallbackLink") - or "http://callback.local" - ) - edge_app_fqdn = f"{app_name.lower().replace('_', '-')}.edge.local" - - artefact = { - "artefactId": artefact_id, - "appProviderId": app_provider_id, - "artefactName": image_name, - "artefactVersionInfo": image_tag, - "artefactVirtType": "CONTAINER_TYPE", - "artefactDescriptorType": artefact_descriptor_type, - "repoType": repo_type, - "artefactRepoLocation": {"repoURL": repo_url}, - "componentSpec": [{ - "componentName": component_name or app_name, - "images": [image_ref], - "numOfInstances": 1, - "restartPolicy": "RESTART_POLICY_ALWAYS", - "computeResourceProfile": { - "cpuArchType": "ISA_X86_64", - "numCPU": "100m", - "memory": 128, - }, - }], - } - - fed_record = get_fed(zone.get("fedContextId")) - if not fed_record or "token" not in fed_record: - return jsonify({ - "error": "Federation token not found", - "federationContextId": zone.get("fedContextId"), - }), 404 - fed_token = fed_record.get("token") - - print("\n========== OEG → FM ARTEFACT PAYLOAD ==========") - print(json.dumps(artefact, indent=2)) - print("================================================\n") - - artefact_body, artefact_status = federation_client.create_artefact( - artefact=artefact, - federation_context_id=zone.get("fedContextId"), - token=fed_token, - ) - print("\n========== ARTEFACT CREATION RESPONSE ==========") - print(f"Status: {artefact_status}") - print(f"Response: {json.dumps(artefact_body, indent=2)}") - print("================================================\n") - - if artefact_status == 422 and "duplicate key" in str(artefact_body): - logger.info("Artefact already exists in FM, continuing") - artefact_status = 200 - - if artefact_status not in (200, 409): - return jsonify({ - "error": "Artefact creation failed", - "fm_response": artefact_body, - }), artefact_status - - fed_context_id = zone.get("fedContextId") - logger.info(f"Federation Context ID from zone: {fed_context_id}") - logger.info(f"Federation Manager URL: {federation_client.base_url}") - print(f"\n========== FEDERATION CONTEXT DEBUG ==========") - print(f"Using Federation Context ID: {fed_context_id}") - print(f"Federation Manager Base URL: {federation_client.base_url}") - print(f"Full Onboard URL: {federation_client.base_url}/{fed_context_id}/application/onboarding") - print("===============================================\n") - print("\n========== CHECKING FEDERATION IDS ==========") - fed_ids_body, fed_ids_status = federation_client.get_federation_context_ids(token=fed_token) - print(f"Status: {fed_ids_status}") - print(f"Federation IDs: {json.dumps(fed_ids_body, indent=2)}") - print(f"Looking for: {zone.get('fedContextId')}") - print("=============================================\n") - print(f"\n========== HEADERS DEBUG ==========") - print(f"X-Partner-API-Root: {federation_client.partner_root}") - print(f"Authorization: Bearer {fed_token[:20]}...") - print("===================================\n") - print("DEBUG: About to check partner status...") - try: - print("\n========== CHECKING PARTNER STATUS ==========") - partner_body, partner_status = federation_client.get_partner( - federation_context_id=zone.get("fedContextId"), - token=fed_token, - ) - print(f"Partner Status: {partner_status}") - print(f"Partner Info: {json.dumps(partner_body, indent=2)}") - print("=============================================\n") - except Exception as exc: - print("\n========== PARTNER CHECK ERROR ==========") - print(f"Error getting partner status: {str(exc)}") - print("=========================================\n") - - onboard_app = { - "appId": federated_app_id, - "appProviderId": federated_app_provider_id, - "appMetaData": { - "appName": app_name, - "version": app_version, - "accessToken": access_token, - }, - "appQoSProfile": { - "latencyConstraints": latency_constraints, - "bandwidthRequired": bandwidth_required, - "multiUserClients": multi_user_clients, - "noOfUsersPerAppInst": no_of_users, - "appProvisioning": app_provisioning, - }, - "appComponentSpecs": [{ - "artefactId": artefact_id, - "componentName": component_name, - "serviceNameNB": service_name_nb, - "serviceNameEW": service_name_ew, - "exposedInterfaces": [ - { - "interfaceId": interface.get("interfaceId") or f"{component_name}_{index}", - "commProtocol": interface.get("protocol", "TCP"), - "commPort": interface.get("port"), - "visibilityType": interface.get("visibilityType", "VISIBILITY_EXTERNAL"), - } - for index, interface in enumerate(network_interfaces) - if interface.get("port") is not None - ], - "computeResourceProfile": { - "cpuArchType": "ISA_X86_64", - "numCPU": required_resources.get("numCPU") or "100m", - "memory": required_resources.get("memory") or 128, - "diskStorage": required_resources.get("storage"), - "gpu": required_resources.get("gpu") or [], - }, - }], - "appStatusCallbackLink": app_status_callback_link, - "appDeploymentZones": app_deployment_zones, - "edgeAppFQDN": edge_app_fqdn, - } - - print("\n========== OEG → FM ONBOARD PAYLOAD ==========") - print(json.dumps(onboard_app, indent=2)) - print("==============================================\n") - - onboard_app_body, onboard_app_status = federation_client.onboard_application( - federation_context_id=zone.get("fedContextId"), - body=onboard_app, - token=fed_token, - ) - print("\n========== ONBOARD RESPONSE DEBUG ==========") - print(f"Status: {onboard_app_status}") - print(f"Response Body: {json.dumps(onboard_app_body, indent=2)}") - print("============================================\n") - - if onboard_app_status not in (200, 202, 409): - return jsonify({ - "error": "Application onboarding failed", - "fm_response": onboard_app_body, - }), onboard_app_status - - res_pool_value = ensure_res_pool(zone.get("resPool"), zone.get("edgeCloudZoneId")) - deploy_app = { - "appId": federated_app_id, - "appProviderId": federated_app_provider_id, - "appVersion": app_version, - "appInstCallbackLink": app_data.get("appInstCallbackLink", ""), - "zoneInfo": { - "zoneId": zone.get("edgeCloudZoneId"), - "flavourId": zone.get("flavourId") or zone.get("edgeCloudZoneId") or "default", - "resPool": res_pool_value, - "resourceConsumption": "RESERVED_RES_AVOID", - }, - } - - print("\n========== OEG → FM DEPLOY PAYLOAD ==========") - print(json.dumps(deploy_app, indent=2)) - print("=============================================\n") - - deploy_app_body, deploy_app_status = federation_client.deploy_app_partner( - federation_context_id=zone.get("fedContextId"), - body=deploy_app, - token=fed_token, - ) - - if deploy_app_status in (200, 201, 202): - return jsonify({ - "message": "Application deployed successfully at partner OP", - "appId": app_id, - "deployment_response": deploy_app_body, - }), deploy_app_status - - return jsonify({ - "error": "Application deployment failed", - "fm_response": deploy_app_body, - }), deploy_app_status diff --git a/edge_cloud_management_api/controllers/edge_cloud_controller.py b/edge_cloud_management_api/controllers/edge_cloud_controller.py deleted file mode 100644 index 12c299d169bd4d6e75f0bc5b8a9f4bd49def2482..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/controllers/edge_cloud_controller.py +++ /dev/null @@ -1,134 +0,0 @@ -from flask import jsonify -from pydantic import BaseModel, Field, ValidationError -from typing import List -from edge_cloud_management_api.configs.env_config import config -from edge_cloud_management_api.managers.log_manager import logger -from edge_cloud_management_api.services.edge_cloud_services import SRMAPIClientFactory -from edge_cloud_management_api.services.storage_service import get_zones -from edge_cloud_management_api.services.federation_services import FederationManagerClientFactory - - -factory = FederationManagerClientFactory() -federation_client = factory.create_federation_client() - -class EdgeCloudZone(BaseModel): - edgeCloudZoneId: str = Field(..., description="Unique identifier of the Edge Cloud Zone") - edgeCloudZoneName: str = Field(..., description="Name of the Edge Cloud Zone") - edgeCloudZoneStatus: str = Field( - ..., - description="Status of the Edge Cloud Zone", - pattern="^(active|inactive|unknown)$", - ) - edgeCloudProvider: str = Field(..., description="Name of the Edge Cloud Provider") - edgeCloudRegion: str | None = Field(None, description="Region of the Edge Cloud Zone") - - -class EdgeCloudQueryParams(BaseModel): - x_correlator: str | None - region: str | None - status: str | None = Field( - None, - description="Status of the Edge Cloud Zone", - pattern="^(active|inactive|unknown)$", - ) - - -def get_local_zones() -> list[dict]: - """ - Get local Operator Platform available zones from the Service Resource Manager. - """ - try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - result = api_client.edge_cloud_zones() - - if isinstance(result, dict) and "error" in result: - logger.error(f"SRM error: {result['error']}") - return [] - if isinstance(result, list): - return result - return [] - - except Exception as e: - logger.exception("Unexpected error while retrieving local zones from SRM: %s", e) - return [] - - -def get_federated_zones() -> List[EdgeCloudZone]: - """get partner/federated Operator Platform available zones from Federation Manager""" - return [] - -def get_partner_zones() -> list[dict]: - """Retrieve persisted partner zones only.""" - try: - zones = get_zones() - except Exception as e: - logger.warning("Failed to read partner zones: %s", e) - return [] - return [ - zone for zone in (zones or []) - if isinstance(zone, dict) and zone.get("isLocal") == "false" - ] - -def get_all_cloud_zones() -> List[EdgeCloudZone]: - """Get all available zones from local and federated Operator Platforms""" - - return get_local_zones() + get_partner_zones() + get_federated_zones() - -def get_edge_cloud_zones(x_correlator: str | None = None, region=None, status=None): # noqa: E501 - """Retrieve a list of the operators Edge Cloud Zones and their status - - List of the operators Edge Cloud Zones and their status, ordering the results by location and filtering by status (active/inactive/unknown) # noqa: E501 - - :param x_correlator: Correlation id for the different services - :type x_correlator: str - :param region: Human readable name of the geographical Edge Cloud Region of the Edge Cloud. Defined by the Edge Cloud Provider. - :type region: str - :param status: Human readable status of the Edge Cloud Zone - :type status: str - - :rtype: list[EdgeCloudZone] - """ - try: - query_params = EdgeCloudQueryParams( - x_correlator=x_correlator, - region=region, - status=status, - ) - - #def query_region_matches(zone: str) -> bool: - # return query_params.region is None or zone["edgeCloudRegion"] == query_params.region - def query_region_matches(zone: EdgeCloudZone) -> bool: - return query_params.region is None or zone.edgeCloudRegion == query_params.region - #def query_status_matches(zone: str) -> bool: - # return (query_params.status is None) or (zone["edgeCloudZoneStatus"] == query_params.status) - def query_status_matches(zone: EdgeCloudZone) -> bool: - return query_params.status is None or zone.edgeCloudZoneStatus == query_params.status - - response = [ - zone.model_dump() - for zone in (EdgeCloudZone(**zone_dict) for zone_dict in get_all_cloud_zones()) - if query_region_matches(zone) and query_status_matches(zone) - ] - return jsonify(response), 200 - - except ValidationError as e: - return ( - jsonify({"status": 400, "code": "VALIDATION_ERROR", "message": e.errors()}), - 400, - ) - - except Exception as e: - error_info = { - "status": 500, - "code": "INTERNAL_ERROR", - "message": f"An error occurred: {str(e)}", - } - return jsonify(error_info), 500 - - -def edge_cloud_zone_details(zoneId: str) -> dict: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - result = api_client.edge_cloud_zone_details(zone_id=zoneId) - return result diff --git a/edge_cloud_management_api/controllers/federation_manager_controller.py b/edge_cloud_management_api/controllers/federation_manager_controller.py deleted file mode 100644 index 7cf8afe7e427a6c1a435312156bea4105d1273b0..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/controllers/federation_manager_controller.py +++ /dev/null @@ -1,179 +0,0 @@ -from flask import request, jsonify -import logging -import connexion -from requests.exceptions import Timeout, ConnectionError -from pydantic import ValidationError -from edge_cloud_management_api.managers.log_manager import logger -import requests -from edge_cloud_management_api.configs.env_config import config -from edge_cloud_management_api.services.storage_service import insert_zones -from edge_cloud_management_api.services.storage_service import insert_federation, get_fed, get_all_feds - -from edge_cloud_management_api.services.federation_services import FederationManagerClientFactory -from edge_cloud_management_api.models.federation_manager_models import FederationRequestData -from edge_cloud_management_api.models.federation_manager_models import ZoneRegistrationRequestData - -token_headers = {'Authorization': 'Basic b3JpZ2luYXRpbmctb3AtMTpkZDd2TndGcWpOcFl3YWdobEV3TWJ3MTBnMGtsV0RIYg==', - 'Content-Type': 'application/x-www-form-urlencoded' - } -data = {'grant_type': 'client_credentials', - 'scope': 'fed-mgmt'} -TOKEN_ENDPOINT = config.TOKEN_ENDPOINT - - -# Factory pattern -factory = FederationManagerClientFactory() -federation_client = factory.create_federation_client() - - -def _store_partner_zones(federation_context_id, provider, availability_zones, accepted_zone_ids=None): - zones_to_insert = [] - accepted_zone_ids = set(accepted_zone_ids or []) - for zone in availability_zones or []: - zone_id = zone.get('zoneId') if isinstance(zone, dict) else None - if not zone_id: - continue - if accepted_zone_ids and zone_id not in accepted_zone_ids: - continue - inserted_item = { - '_id': zone_id, - 'edgeCloudProvider': provider, - 'edgeCloudZoneId': zone_id, - 'edgeCloudZoneName': zone.get('geographyDetails'), - 'edgeCloudZoneStatus': 'unknown', - 'isLocal': 'false', - 'fedContextId': federation_context_id, - } - zones_to_insert.append(inserted_item) - if zones_to_insert: - insert_zones(zones_to_insert) - - -def create_federation(): - """POST /partner - Create federation with partner OP.""" - - body = request.get_json() - try: - FederationRequestData(**body) - except ValidationError as error: - return jsonify({"error": "Invalid input", "details": error.errors()}), 400 - token = __get_token() - if not token: - return jsonify({"error": "Unable to obtain access token"}), 500 - response, code = federation_client.post_partner(body, token) - fed = { - '_id': response.get('federationContextId'), - 'token': token, - 'partnerOPFederationId': response.get('partnerOPFederationId'), - } - if code==200: - insert_federation(fed) - return response, code - -def get_federation(federationContextId): - """GET /{federationContextId}/partner - Get federation info.""" - fed = get_fed(federationContextId) - if not fed: - return 'Federation not found', 404 - else: - token = fed.get('token') - response, code = federation_client.get_partner(federationContextId, token) - return response, code - -def delete_federation(federationContextId): - """DELETE /{federationContextId}/partner - Delete federation.""" - fed = get_fed(federationContextId) - if not fed: - return 'Federation not found', 404 - else: - token = fed.get('token') - response, code = federation_client.delete_partner(federationContextId, token) - return response, code - -def get_federation_context_ids(): - """GET /fed-context-id - Fetch federationContextId(s).""" - feds = get_all_feds() - if not feds: - return 'Federation not found', 404 - else: - token = feds[len(feds)-1].get('token') - response, code = federation_client.get_federation_context_ids(token) - return response, code - - -def onboard_application_to_partner(federationContextId): - """POST /{federationContextId}/application/onboarding - Onboard app.""" - body = request.get_json() - token = __get_token() - result = federation_client.onboard_application(federationContextId, body, token) - return jsonify(result) - - -def get_onboarded_app(federationContextId, appId): - """GET /{federationContextId}/application/onboarding/app/{appId}""" - - token = __get_token() - result = federation_client.get_onboarded_app(federationContextId, appId, token) - return jsonify(result) - - -def delete_onboarded_app(federationContextId, appId): - """DELETE /{federationContextId}/application/onboarding/app/{appId}""" - - token = __get_token() - result = federation_client.delete_onboarded_app(federationContextId, appId, token) - return jsonify(result) - -'''---AVAILABILITY ZONE INFO SYNCHRONIZATION---''' - -def request_zone_synch(federationContextId): - token = __get_token() - body = request.get_json() - if not body: - body = {} - if not body.get("availZoneNotifLink"): - body["availZoneNotifLink"] = config.AVAIL_ZONE_NOTIF_LINK - try: - ZoneRegistrationRequestData(**body) - except ValidationError as error: - return jsonify({"error": "Invalid input", "details": error.errors()}), 400 - response, code = federation_client.request_zone_sync( - federation_context_id=federationContextId, body=body, token=token - ) - if code == 200: - federation_response, federation_code = federation_client.get_partner(federationContextId, token) - if federation_code == 200: - fed = get_fed(federationContextId) or {} - _store_partner_zones( - federationContextId, - federation_response.get('partnerOPFederationId') or fed.get('partnerOPFederationId'), - federation_response.get('offeredAvailabilityZones'), - body.get('acceptedAvailabilityZones'), - ) - else: - logger.warning( - "Unable to refresh partner zones after subscription: %s - %s", - federation_code, - federation_response, - ) - return jsonify(response), code - -def get_zone_resource_info(federationContextId, zoneId): - token = __get_token() - response = federation_client.get_zone_resource_info(federation_context_id=federationContextId, zone_id=zoneId, token=token) - return jsonify(response) - -def remove_zone_sync(federationContextId, zoneId): - token = __get_token() - response = federation_client.remove_zone_sync(federation_context_id=federationContextId, zone_id=zoneId, token=token) - return jsonify(response) - -def __get_token(): - bearer = connexion.request.headers.get('Authorization') - if bearer: - parts = bearer.split() - if len(parts) == 2 and parts[0].lower() == "bearer": - return parts[1] - if TOKEN_ENDPOINT: - return requests.post(TOKEN_ENDPOINT, headers=token_headers, data=data).json().get('access_token') - return None diff --git a/edge_cloud_management_api/controllers/network_functions_controller.py b/edge_cloud_management_api/controllers/network_functions_controller.py deleted file mode 100644 index eba9aafbb1d9e592e422b39fa72428fd15d2662c..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/controllers/network_functions_controller.py +++ /dev/null @@ -1,166 +0,0 @@ -from flask import jsonify -from pydantic import ValidationError #Field -from edge_cloud_management_api.managers.log_manager import logger -from edge_cloud_management_api.services.edge_cloud_services import SRMAPIClientFactory - -def create_qod_session(body: dict): - """ - Creates a new QoD session - """ - try: - # Validate the input data using Pydantic - # validated_data = AppManifest(**body) - # validated_data_dict = validated_data.model_dump(mode="json") - # validated_data_dict["_id"] = str(uuid.uuid4()) - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.create_qod_session(body) - return response - - except ValidationError as e: - return jsonify({"error": "Invalid input", "details": e.errors()}), 400 - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - -def delete_qod_session(sessionId: str): - """ - Creates a new QoD session - """ - try: - # Validate the input data using Pydantic - # validated_data = AppManifest(**body) - # validated_data_dict = validated_data.model_dump(mode="json") - # validated_data_dict["_id"] = str(uuid.uuid4()) - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.delete_qod_session(sessionId=sessionId) - - return response - - except ValidationError as e: - return jsonify({"error": "Invalid input", "details": e.errors()}), 400 - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) -def get_qod_session(sessionId: str): - """ - Creates a new QoD session - """ - try: - # Validate the input data using Pydantic - # validated_data = AppManifest(**body) - # validated_data_dict = validated_data.model_dump(mode="json") - # validated_data_dict["_id"] = str(uuid.uuid4()) - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.get_qod_session(sessionId=sessionId) - # Insert into MongoDB - # with MongoManager() as db: - # document_id = db.insert_document("apps", validated_data_dict) - # return ( - # jsonify({"appId": str(document_id)}), - # 201, - # ) - return response - - except ValidationError as e: - return jsonify({"error": "Invalid input", "details": e.errors()}), 400 - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - -def create_traffic_influence_resource(body: dict): - try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.create_traffic_influence_resource(body) - return response - except ValidationError as e: - return jsonify({"error": "Invalid input", "details": e.errors()}), 400 - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - -def get_traffic_influence_resource(id: str): - try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.get_traffic_influence_resource(id) - return response - except ValidationError as e: - return jsonify({"error": "Invalid input", "details": e.errors()}), 400 - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - - -def delete_traffic_influence_resource(id: str): - try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.delete_traffic_influence_resource(id) - return response - except ValidationError as e: - return jsonify({"error": "Invalid input", "details": e.errors()}), 400 - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - -def get_all_traffic_influence_resources(): - try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.get_all_traffic_influence_resources() - return response - except ValidationError as e: - return jsonify({"error": "Invalid input", "details": e.errors()}), 400 - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - -def retrieve_location(body: dict): - """ - Retrieve the location of a device via the CAMARA Location Retrieval API. - Forwards the request to the SRM which delegates to the configured network adapter. - """ - try: - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - response = api_client.retrieve_location(body) - return response - - except ValidationError as e: - return jsonify({"error": "Invalid input", "details": e.errors()}), 400 - - except Exception as e: - return ( - jsonify({"error": "An unexpected error occurred", "details": str(e)}), - 500, - ) - - -def retrieve_location_legacy(body: dict): - """Deprecated legacy alias for location retrieval.""" - return retrieve_location(body) diff --git a/edge_cloud_management_api/controllers/security_controller.py b/edge_cloud_management_api/controllers/security_controller.py deleted file mode 100644 index 55040f33e63a929c32f3935be09b399f4259f73e..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/controllers/security_controller.py +++ /dev/null @@ -1,18 +0,0 @@ -import os -from jose import JWTError, jwt -from werkzeug.exceptions import Unauthorized - -def decode_token(token:str): - JWT_ISSUER = os.environ.get('JWT_ISSUER') - PUBLIC_KEY = os.environ.get('JWT_PUBLIC_KEY') - try: - return jwt.decode(token, key=PUBLIC_KEY, algorithms=["RS256"], issuer=JWT_ISSUER) - except JWTError as e: - raise Unauthorized from e - -def check_oAuth2ClientCredentials(token): - return {'scopes': ['fed-mgmt'], 'uid': 'test_value'} - - -def validate_scope_oAuth2ClientCredentials(required_scopes, token_scopes): - return set(required_scopes).issubset(set(token_scopes)) diff --git a/edge_cloud_management_api/managers/db_manager.py b/edge_cloud_management_api/managers/db_manager.py deleted file mode 100644 index e13aefeaf1fd2c95cf80cf4a8b49e0ea9c4f0fc9..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/managers/db_manager.py +++ /dev/null @@ -1,83 +0,0 @@ -from pymongo import MongoClient -from edge_cloud_management_api.configs.env_config import config - - -class MongoManager: - """ - A utility class for managing MongoDB operations. - The class implements the context manager protocol to ensure that the connection is closed after use. - - Methods: - insert_document: Inserts a document into a collection. - find_document: Finds a single document in a collection. - find_documents: Finds multiple documents in a collection. - update_document: Updates a single document in a collection. - delete_document: Deletes a single document in a collection. - close_connection: Closes the MongoDB connection. - - Example: - with MongoManager() as db: - db.insert_document("users", {"name": "Test User", "email": "test-user@sunrise6g.eu"}) - - """ - - def __init__(self, mongo_uri=config.MONGO_URI): - """ - Initializes the MongoDB connection using the URI from Config. - """ - if not mongo_uri: - raise ValueError("MONGO_URI is not set in the environment configuration.") - - self.client = MongoClient(mongo_uri, maxPoolSize=50) - mongo_db_name: str = mongo_uri.split("/")[-1].split("?")[0] - self.db = self.client[mongo_db_name] - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close_connection() - - def insert_document(self, collection_name, document): - """ - Inserts a document into the specified collection. - """ - collection = self.db[collection_name] - result = collection.insert_one(document) - return result.inserted_id - - def find_document(self, collection_name, query): - """ - Finds a single document based on the query. - """ - collection = self.db[collection_name] - return collection.find_one(query) - - def find_documents(self, collection_name, query): - """ - Finds multiple documents based on the query. - """ - collection = self.db[collection_name] - return collection.find(query) - - def update_document(self, collection_name, query, update_data): - """ - Updates a single document based on the query. - """ - collection = self.db[collection_name] - result = collection.update_one(query, {"$set": update_data}) - return result.modified_count - - def delete_document(self, collection_name, query): - """ - Deletes a single document based on the query. - """ - collection = self.db[collection_name] - result = collection.delete_one(query) - return result.deleted_count - - def close_connection(self): - """ - Closes the MongoDB connection. - """ - self.client.close() diff --git a/edge_cloud_management_api/managers/log_manager.py b/edge_cloud_management_api/managers/log_manager.py deleted file mode 100644 index e154e6d5c3df159663c3ea9f1ae6765406fbacff..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/managers/log_manager.py +++ /dev/null @@ -1,9 +0,0 @@ -import logging -import sys - -# logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("Edge Cloud Management API") -logger.setLevel(logging.INFO) -formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") -handler = logging.StreamHandler(sys.stdout) -handler.setFormatter(formatter) diff --git a/edge_cloud_management_api/models/application_models.py b/edge_cloud_management_api/models/application_models.py deleted file mode 100644 index cbfeca8fa584e366e358197627375220697e5b39..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/models/application_models.py +++ /dev/null @@ -1,137 +0,0 @@ -#from pydantic import BaseModel, HttpUrl, Field , UUID4 -#from typing import Any, List, Optional -#from enum import Enum -# from ipaddress import IPv4Address, IPv6Address -#from edge_cloud_management_api.models.edge_cloud_models import EdgeCloudZone - - -from pydantic import BaseModel, Field, UUID4 -from typing import Any, List, Literal, Optional, Union -from enum import Enum -from edge_cloud_management_api.models.edge_cloud_models import EdgeCloudZone # <-- you should IMPORT this properly - -# --- Enums --- - -class VisibilityType(str, Enum): - VISIBILITY_EXTERNAL = "VISIBILITY_EXTERNAL" - VISIBILITY_INTERNAL = "VISIBILITY_INTERNAL" - - -class AppInstanceStatus(str, Enum): - ready = "ready" - instantiating = "instantiating" - failed = "failed" - terminating = "terminating" - unknown = "unknown" - - -class Protocol(str, Enum): - TCP = "TCP" - UDP = "UDP" - ANY = "ANY" - -# --- Model Definitions --- - -class NetworkInterface(BaseModel): - interfaceId: str = Field(..., pattern="^[A-Za-z][A-Za-z0-9_]{3,31}$") - protocol: Protocol - port: int # 1-65535 - visibilityType: VisibilityType - - -class ComponentSpec(BaseModel): - componentName: str - networkInterfaces: List[NetworkInterface] - - -class AppRepo(BaseModel): - class AppRepoAuthType(str, Enum): - DOCKER = "DOCKER" - HTTP_BASIC = "HTTP_BASIC" - HTTP_BEARER = "HTTP_BEARER" - NONE = "NONE" - - type: Literal["PRIVATEREPO", "PUBLICREPO"] - imagePath: str = Field(..., max_length=2048) - userName: Optional[str] = Field(default=None, max_length=64) - credentials: Optional[str] = Field(default=None, max_length=2048) - authType: Optional[AppRepoAuthType] = None - checksum: Optional[str] = Field(default=None, max_length=128) - - -class ContainerResources(BaseModel): - infraKind: Literal["container"] - numCPU: Any - memory: int = Field(..., ge=1, le=16384) - storage: Optional[Any] = None - gpu: Optional[Any] = None - - -class VmResources(BaseModel): - infraKind: Literal["virtualMachine"] - numCPU: int = Field(..., ge=1, le=256) - memory: int = Field(..., ge=1, le=32768) - additionalStorages: Optional[Any] = None - gpu: Optional[Any] = None - - -class DockerComposeResources(BaseModel): - infraKind: Literal["dockerCompose"] - numCPU: int = Field(..., ge=1, le=256) - memory: int = Field(..., ge=1, le=16384) - storage: Optional[Any] = None - gpu: Optional[Any] = None - - -class KubernetesResources(BaseModel): - infraKind: Literal["kubernetes"] - applicationResources: Any - isStandalone: bool - version: Optional[str] = None - additionalStorage: Optional[str] = None - networking: Optional[Any] = None - addons: Optional[Any] = None - - -RequiredResources = Union[ - KubernetesResources, - VmResources, - ContainerResources, - DockerComposeResources, -] - - -class AppManifest(BaseModel): - class PackageType(str, Enum): - QCOW2 = "QCOW2" - OVA = "OVA" - CONTAINER = "CONTAINER" - HELM = "HELM" - CSAR = "CSAR" - - class OperatingSystem(BaseModel): - architecture: str # x86_64, x86 - family: str # UBUNTU, RHEL, COREOS, etc - version: str - license: str - - appId: UUID4 - name: str = Field(..., pattern="^[A-Za-z][A-Za-z0-9_]{1,63}$") - appProvider: str = Field(..., pattern="^[A-Za-z][A-Za-z0-9_]{7,63}$") - version: str - packageType: PackageType - operatingSystem: Optional[OperatingSystem] = None - appRepo: AppRepo - requiredResources: RequiredResources - componentSpec: List[ComponentSpec] - - -class AppZones(BaseModel): - kubernetesClusterRef: Optional[UUID4] - EdgeCloudZone: EdgeCloudZone - - -class AppInstance(BaseModel): - appId: UUID4 - appZones: List[AppZones] - diff --git a/edge_cloud_management_api/models/edge_cloud_models.py b/edge_cloud_management_api/models/edge_cloud_models.py deleted file mode 100644 index 61b419fed7c989b8bcf9580088930e02df729c01..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/models/edge_cloud_models.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Optional -from pydantic import BaseModel, UUID4 -from enum import Enum - - -class EdgeCloudZoneStatus(str, Enum): - active = "active" - inactive = "inactive" - unknown = "unknown" - - -class EdgeCloudZone(BaseModel): - edgeCloudZoneId: UUID4 - edgeCloudZoneName: str - edgeCloudZoneStatus: Optional[EdgeCloudZoneStatus] - edgeCloudProvider: str - edgeCloudRegion: Optional[str] - diff --git a/edge_cloud_management_api/models/error_models.py b/edge_cloud_management_api/models/error_models.py deleted file mode 100644 index f679c8caeca3816f4445346aacbf35e69509c325..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/models/error_models.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel - - -class ErrorInfo(BaseModel): - status: int # HTTP status code - code: str # error code, e.g. UNAVAILABLE, INTERNAL, NOT_FOUND, PERMISSION_DENIED, - message: str diff --git a/edge_cloud_management_api/models/federation_manager_models.py b/edge_cloud_management_api/models/federation_manager_models.py deleted file mode 100644 index 6e6601773ab85df3fdd21c7aab3f8409609b3c14..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/models/federation_manager_models.py +++ /dev/null @@ -1,68 +0,0 @@ -from pydantic import BaseModel, RootModel #Field -from typing import List, Optional - - -class MobileNetworkIds(BaseModel): - mncs: List[str] - mcc: str - - -class FixedNetworkIds(RootModel[List[str]]): - pass - - -class CallbackCredentials(BaseModel): - tokenUrl: str - clientId: str - clientSecret: str - - -class ServiceEndpoint(BaseModel): - ipv4Addresses: Optional[List[str]] = None - ipv6Addresses: Optional[List[str]] = None - port: Optional[int] = None - fqdn: Optional[str] = None - - -class ZoneDetails(BaseModel): - geographyDetails: str - zoneId: str - geolocation: str - - -class ZoneRegistrationRequestData(BaseModel): - acceptedAvailabilityZones: List[str] - availZoneNotifLink: str - - -class ZoneResourceInfo(BaseModel): - zoneId: str - computeResourceQuotaLimits: Optional[List[dict]] = None - reservedComputeResources: Optional[List[dict]] = None - flavoursSupported: Optional[List[dict]] = None - - -class ZoneRegistrationResponseData(BaseModel): - acceptedZoneResourceInfo: List[ZoneResourceInfo] - - -class FederationRequestData(BaseModel): - origOPFederationId: Optional[str] = None - origOPCountryCode: Optional[str] = None - origOPMobileNetworkCodes: Optional[MobileNetworkIds] = None - origOPFixedNetworkCodes: Optional[List[str]] = None - initialDate: str - partnerStatusLink: str - partnerCallbackCredentials: Optional[CallbackCredentials] = None - - -class FederationResponseData(BaseModel): - federationContextId: str - partnerOPFederationId: Optional[str] = None - partnerOPCountryCode: Optional[str] = None - partnerOPMobileNetworkCodes: Optional[MobileNetworkIds] = None - partnerOPFixedNetworkCodes: Optional[List[str]] = None - offeredAvailabilityZones: Optional[List[ZoneDetails]] = None - platformCaps: List[str] - edgeDiscoveryServiceEndPoint: Optional[ServiceEndpoint] = None - lcmServiceEndPoint: Optional[ServiceEndpoint] = None diff --git a/edge_cloud_management_api/services/edge_cloud_services.py b/edge_cloud_management_api/services/edge_cloud_services.py deleted file mode 100644 index c1bee7d0ac6a6af34cc48640bbc496a2f56806cc..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/services/edge_cloud_services.py +++ /dev/null @@ -1,390 +0,0 @@ -import requests -from edge_cloud_management_api.managers.log_manager import logger -from requests.exceptions import Timeout, ConnectionError -from edge_cloud_management_api.configs.env_config import config - - -proxies = { - "http": config.HTTP_PROXY, - "https": config.HTTP_PROXY, -} - -class SRMAPIClient: - def __init__(self, base_url, username, password): - self.base_url = base_url - self.username = username - self.password = password - self.token = None - - - def _get_proxy_session(self, session_proxies): - session = requests.Session() - session.proxies.update(session_proxies) - return session - - def _authenticate(self): - """ - Private method to login and obtain an authentication token. - This is automatically called when headers are required and token is missing. - """ - login_url = f"{self.base_url}/authentication" - credentials = {"username": self.username, "password": self.password} - - try: - response = self.requests_session.post( - login_url, - json=credentials, - # proxies=proxies, - ) - response.raise_for_status() - - self.token = response.json().get("token") - if not self.token: - raise ValueError("Login failed: No token found") - except requests.exceptions.HTTPError as http_err: - logger.error(f"HTTP error occurred: {http_err}") - except Exception as err: - logger.error(f"Error occurred: {err}") - - def _get_headers(self): - """ - Helper function to return the authorization headers with token. - If token is not available, automatically login. - """ - - return { - # "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json", - } - - def get_service_functions_catalogue(self): - """ - Get service function catalogue from the /serviceFunction endpoint. - """ - url = f"{self.base_url}/serviceFunction" - try: - request_headers = self._get_headers() - response = requests.get(url, headers=request_headers, verify=False) - response.raise_for_status() - service_functions = response.json() - if isinstance(service_functions, list): - return service_functions - raise ValueError("Unexpected response from Service Resource manager") - - except Timeout: - return {"error": "The request to the external API timed out. Please try again later."} - - except ConnectionError: - return {"error": "Failed to connect to the external API service. Service might be unavailable."} - - except requests.exceptions.HTTPError as http_err: - return { - "error": f"HTTP error occurred: {http_err}.", - "status_code": response.status_code, - } - - except ValueError as val_err: - return {"error": str(val_err)} - - except Exception as err: - return {"error": f"An unexpected error occurred: {err}"} - - - def submit_app(self, body): - """ - Register app metadata to SRM - """ - url = f"{self.base_url}/serviceFunction" - try: - request_headers = self._get_headers() - response = requests.post(url, headers=request_headers, verify=False, json=body) - response.raise_for_status() - return response.json() - except Timeout: - return {"error": "The request to the external API timed out. Please try again later."} - - except ConnectionError: - return {"error": "Failed to connect to the external API service. Service might be unavailable."} - - except requests.exceptions.HTTPError as http_err: - return { - "error": f"HTTP error occurred: {http_err}.", - "status_code": response.status_code, - } - - def get_app(self, appId): - """ - Get app metadata from SRM - """ - url = f"{self.base_url}/serviceFunction/"+appId - try: - request_headers = self._get_headers() - response = requests.get(url, headers=request_headers, verify=False) - response.raise_for_status() - return response.json() - except Timeout: - return {"error": "The request to the external API timed out. Please try again later."} - - except ConnectionError: - return {"error": "Failed to connect to the external API service. Service might be unavailable."} - - except requests.exceptions.HTTPError as http_err: - return { - "error": f"HTTP error occurred: {http_err}.", - "status_code": response.status_code, - } - - def delete_app(self, appId:str): - """ - Remove app metadata from SRM - """ - url = f"{self.base_url}/serviceFunction/"+appId - try: - response = requests.delete(url,headers=self._get_headers(), verify=False) - response.raise_for_status() - return response.text - except Timeout: - return {"error": "The request to the external API timed out. Please try again later."} - - except ConnectionError: - return {"error": "Failed to connect to the external API service. Service might be unavailable."} - - except requests.exceptions.HTTPError as http_err: - return { - "error": f"HTTP error occurred: {http_err}.", - "status_code": response.status_code, - } - - except Exception as err: - return {"error": f"An unexpected error occurred: {err}"} - - def deploy_service_function(self, data: dict): - """ - Post data to the /deployedServiceFunction endpoint. - """ - url = f"{self.base_url}/deployedServiceFunction" - try: - response = requests.post(url, json=data, headers=self._get_headers(), verify=False) - response.raise_for_status() - return response.json() - except Timeout: - return {"error": "The request to the external API timed out. Please try again later."} - - except ConnectionError: - return {"error": "Failed to connect to the external API service. Service might be unavailable."} - - except requests.exceptions.HTTPError as http_err: - return { - "error": f"HTTP error occurred: {http_err}.", - "status_code": response.status_code, - } - - except Exception as err: - return {"error": f"An unexpected error occurred: {err}"} - - - def get_app_instances(self): - """ - Retrieve all app instances. - """ - url = f"{self.base_url}/deployedServiceFunction" - try: - response = requests.get(url, headers=self._get_headers(), verify=False) - response.raise_for_status() - return response.json() - except Timeout: - return {"error": "The request to the external API timed out. Please try again later."} - - except ConnectionError: - return {"error": "Failed to connect to the external API service. Service might be unavailable."} - - except requests.exceptions.HTTPError as http_err: - return { - "error": f"HTTP error occurred: {http_err}.", - "status_code": response.status_code, - } - - - def delete_app_instance(self, app_instance_id:str): - """ - Remove app instance. - """ - url = f"{self.base_url}/deployedServiceFunction/"+app_instance_id - try: - response = requests.delete(url, headers=self._get_headers(), verify=False) - response.raise_for_status() - return response - except Timeout: - return {"error": "The request to the external API timed out. Please try again later."} - - except ConnectionError: - return {"error": "Failed to connect to the external API service. Service might be unavailable."} - - except requests.exceptions.HTTPError as http_err: - return { - "error": f"HTTP error occurred: {http_err}.", - "status_code": response.status_code, - } - - - def edge_cloud_zones(self): - """ - Get list of edge zones from /node endpoint. - """ - url = f"{self.base_url}/node" - #try: - request_headers = self._get_headers() - response = requests.get(url, headers=request_headers, verify=False) - response.raise_for_status() - nodes = response.json() - if not nodes: - raise ValueError("No edge nodes found") - return nodes - - - def edge_cloud_zone_details(self, zone_id): - """ - Get list of edge zones from /node endpoint. - """ - url = f"{self.base_url}/node/"+zone_id - #try: - request_headers = self._get_headers() - response = requests.get(url, headers=request_headers, verify=False) - response.raise_for_status() - nodes = response.json() - if not nodes: - raise ValueError("No edge nodes found") - return nodes - - def create_qod_session(self, body:dict): - - url = f"{self.base_url}/sessions" - request_headers = self._get_headers() - response = requests.post(url, json=body, headers=request_headers,verify=False) - response.raise_for_status() - if response.status_code==200: - return response.json() - elif response.status_code==500: - return response.content - - - - def get_qod_session(self, sessionId: str): - - url = f"{self.base_url}/sessions/"+sessionId - request_headers = self._get_headers() - response = requests.get(url, headers=request_headers,verify=False) - response.raise_for_status() - if response.status_code==200: - return response.json() - elif response.status_code==500: - return response.content - - - def delete_qod_session(self, sessionId: str): - - url = f"{self.base_url}/sessions/"+sessionId - request_headers = self._get_headers() - response = requests.delete(url, headers=request_headers,verify=False) - response.raise_for_status() - if response.status_code==200: - return response.text - elif response.status_code==500: - return response.content - - def create_traffic_influence_resource(self, body_dict): - url = f"{self.base_url}/traffic-influences" - request_headers = self._get_headers() - response = requests.post(url, json=body_dict, headers=request_headers,verify=False) - response.raise_for_status() - if response.status_code==200: - return response.json() - elif response.status_code==500: - return response.content - - def delete_traffic_influence_resource(self, id: str): - url = f"{self.base_url}/traffic-influences/"+id - request_headers = self._get_headers() - response = requests.delete(url, headers=request_headers,verify=False) - response.raise_for_status() - if response.status_code==200: - return response.json() - elif response.status_code==500: - return response.content - - def get_traffic_influence_resource(self, id: str): - url = f"{self.base_url}/traffic-influences/"+id - request_headers = self._get_headers() - response = requests.get(url, headers=request_headers,verify=False) - response.raise_for_status() - if response.status_code==200: - return response.json() - elif response.status_code==500: - return response.content - - def get_all_traffic_influence_resources(self): - url = f"{self.base_url}/traffic-influences/" - request_headers = self._get_headers() - response = requests.get(url, headers=request_headers,verify=False) - response.raise_for_status() - if response.status_code==200: - return response.json() - elif response.status_code==500: - return response.content - - def retrieve_location(self, body: dict): - """ - Retrieve the location of a device via the CAMARA Location Retrieval API. - Forwards the request to the SRM's /location/retrieve endpoint. - """ - url = f"{self.base_url}/location/retrieve" - request_headers = self._get_headers() - response = requests.post(url, json=body, headers=request_headers, verify=False) - if response.status_code == 200: - return response.json() - else: - # Relay the SRM error response with its status code - try: - error_body = response.json() - except Exception: - error_body = {"error": response.text} - return error_body, response.status_code - -class SRMAPIClientFactory: - """ - Factory class to create instances of SRMAPIClient. - """ - - def __init__(self): - self.default_base_url = config.SRM_HOST - self.default_username = config.SRM_USERNAME - self.default_password = config.SRM_PASSWORD - - def create_srm_api_client(self, base_url=None, username=None, password=None): - """ - Factory method to create a new SRMAPIClient instance. - - Args: - base_url (str): The base URL for the SRM API. If None, the default is used. - username (str): The username for authentication. If None, the default is used. - password (str): The password for authentication. If None, the default is used. - - Returns: - SRMAPIClient: A new instance of the SRMAPIClient. - """ - if base_url is None: - base_url = self.default_base_url - if username is None: - username = self.default_username - if password is None: - password = self.default_password - - return SRMAPIClient(base_url=base_url, username=username, password=password) - - -if __name__ == "__main__": - srm_factory = SRMAPIClientFactory() - api_client = srm_factory.create_srm_api_client() - - edge_zones = api_client.edge_cloud_zones() - logger.error("Edge zones:", edge_zones) diff --git a/edge_cloud_management_api/services/federation_services.py b/edge_cloud_management_api/services/federation_services.py deleted file mode 100644 index d23b245f9ddfb381b5f28dad42ca3e37d82ff949..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/services/federation_services.py +++ /dev/null @@ -1,524 +0,0 @@ -import os -import requests -from requests.exceptions import Timeout, ConnectionError -from edge_cloud_management_api.configs.env_config import config -from edge_cloud_management_api.managers.log_manager import logger -from edge_cloud_management_api.services.edge_cloud_services import SRMAPIClientFactory -from edge_cloud_management_api.services.storage_service import delete_fed, delete_partner_zones - -class FederationManagerClient: - def __init__(self, base_url=None): - self.base_url = base_url or config.FEDERATION_MANAGER_HOST - self.partner_root = config.PARTNER_API_ROOT - - def _get_headers(self, token): - headers = {} - if token is not None: - headers['Authorization'] = 'Bearer '+token - - headers['X-Partner-API-Root'] = self.partner_root - headers['X-Internal']='true' - headers['Content-Type'] = 'application/json' - headers['Accept'] = 'application/json' - return headers - - '''---FEDERATION ESTABLISHMENT---''' - - def post_partner(self, data: dict, token: str): - url = f"{self.base_url}/partner" - headers=self._get_headers(token) - - try: - response = requests.post(url, json=data, headers=headers, timeout=20) - try: - body = response.json() - except ValueError: - body = response.text - response.raise_for_status() - return body, response.status_code - except Timeout: - logger.error("POST /partner timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error("POST /partner connection error") - return {"error": "Connection error"}, 504 - except requests.exceptions.HTTPError as http_err: - logger.error(f"POST /partner HTTP error: {http_err}") - try: - body = http_err.response.json() - except ValueError: - body = http_err.response.text - return {"error": body}, http_err.response.status_code - except Exception as e: - logger.error(f"POST /partner unexpected error: {e}") - return {"error": str(e)}, 500 - - def get_partner(self, federation_context_id: str, token: str): - url = f"{self.base_url}/{federation_context_id}/partner" - try: - response = requests.get(url, headers=self._get_headers(token), timeout=10) - response.raise_for_status() - return response.json(), 200 - except Timeout: - logger.error("GET /{id}/partner timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error("GET /{id}/partner connection error") - return {"error": "Connection error"}, 504 - except requests.exceptions.HTTPError as http_err: - logger.error(f"GET /{id}/partner HTTP error: {http_err}") - return {'Error': http_err.response.json().get('detail')}, response.status_code - except Exception as e: - logger.error(f"GET /{id}/partner unexpected error: {e}") - return {"error": str(e)}, 500 - - def delete_partner(self, federation_context_id: str, token: str): - url = f"{self.base_url}/{federation_context_id}/partner" - try: - response = requests.delete(url, headers=self._get_headers(token), timeout=10) - response.raise_for_status() - if response.content: - delete_fed(federation_context_id) - delete_partner_zones() - return response.json(), 200 - return {"status": response.status_code} - except Timeout: - logger.error("DELETE /{id}/partner timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error("DELETE /{id}/partner connection error") - return {"error": "Connection error"}, 504 - except requests.exceptions.HTTPError as http_err: - logger.error(f"DELETE /{id}/partner HTTP error: {http_err}") - return {'Error': http_err.response.json().get('detail')}, response.status_code - except Exception as e: - logger.error(f"DELETE /{id}/partner unexpected error: {e}") - return {"error": str(e)}, 500 - - - def get_federation_context_ids(self, token: str): - url = f"{self.base_url}/fed-context-id" - try: - response = requests.get(url, headers=self._get_headers(token), timeout=10) - response.raise_for_status() - return response.json(), 200 - except Timeout: - logger.error("GET /fed-context-id timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error("GET /fed-context-id connection error") - return {"error": "Connection error"}, 504 - except requests.exceptions.HTTPError as http_err: - logger.error(f"GET /fed-context-id HTTP error: {http_err}") - return {'Error': http_err.response.json().get('detail')}, response.status_code - except Exception as e: - logger.error(f"GET /fed-context-id unexpected error: {e}") - return {"error": str(e)}, 500 - - def get_federation(self, federation_context_id: str, token: str): - """Verify that a federation context exists""" - url = f"{self.base_url}/{federation_context_id}" - try: - response = requests.get( - url, - headers=self._get_headers(token), - timeout=10 - ) - try: - response_body = response.json() - except ValueError: - response_body = response.text - return response_body, response.status_code - except Timeout: - logger.error(f"GET /{federation_context_id} timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error(f"GET /{federation_context_id} connection error") - return {"error": "Connection error"}, 503 - except requests.exceptions.HTTPError as http_err: - logger.error(f"GET /{federation_context_id} HTTP error: {http_err}") - return {"error": str(http_err)}, response.status_code - except Exception as e: - logger.error(f"GET /{federation_context_id} unexpected error: {e}") - return {"error": str(e)}, 500 - - - '''---PARTNER APP ONBOARDING---''' - - def onboard_application(self, federation_context_id: str, body: dict, token: str): - url = f"{self.base_url}/{federation_context_id}/application/onboarding" - try: - response = requests.post( - url, - headers=self._get_headers(token), - json=body, - timeout=10 - ) - try: - response_body = response.json() - except ValueError: - response_body = response.text - return response_body, response.status_code - except Timeout: - logger.error("POST /application/onboarding timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error("POST /application/onboarding connection error") - return {"error": "Connection error"}, 503 - except requests.exceptions.HTTPError as http_err: - logger.error(f"POST /application/onboarding HTTP error: {http_err}") - return {"error": str(http_err)}, response.status_code - except Exception as e: - logger.error(f"POST /application/onboarding unexpected error: {e}") - return {"error": str(e)}, 500 - - - def get_onboarded_app(self, federation_context_id: str, app_id: str, token: str): - url = f"{self.base_url}/{federation_context_id}/application/onboarding/app/{app_id}" - try: - response = requests.get(url, headers=self._get_headers(token), timeout=10) - response.raise_for_status() - return response.json() - except Timeout: - logger.error("GET onboarded app timed out") - return {"error": "Request timed out", "status_code": 408} - except ConnectionError: - logger.error("GET onboarded app connection error") - return {"error": "Connection error", "status_code": 503} - except requests.exceptions.HTTPError as http_err: - logger.error(f"GET onboarded app HTTP error: {http_err}") - return {"error": str(http_err), "status_code": response.status_code} - except Exception as e: - logger.error(f"GET onboarded app unexpected error: {e}") - return {"error": str(e), "status_code": 500} - - def delete_onboarded_app(self, federation_context_id: str, app_id: str, token: str): - url = f"{self.base_url}/{federation_context_id}/application/onboarding/app/{app_id}" - try: - response = requests.delete(url, headers=self._get_headers(token), timeout=10) - response.raise_for_status() - return {"message": "Deleted successfully", "status_code": response.status_code} - except Timeout: - logger.error("DELETE onboarding app timed out") - return {"error": "Request timed out", "status_code": 408} - except ConnectionError: - logger.error("DELETE onboarding app connection error") - return {"error": "Connection error", "status_code": 503} - except requests.exceptions.HTTPError as http_err: - logger.error(f"DELETE onboarding app HTTP error: {http_err}") - return {"error": str(http_err), "status_code": response.status_code} - except Exception as e: - logger.error(f"DELETE onboarding app unexpected error: {e}") - return {"error": str(e), "status_code": 500} - - '''---PARTNER APP DEPLOYMENT---''' - - def deploy_app_partner(self, federation_context_id: str, body: dict, token: str): - url = f"{self.base_url}/{federation_context_id}/application/lcm" - try: - response = requests.post( - url, - headers=self._get_headers(token), - json=body, - timeout=10 - ) - try: - response_body = response.json() - except ValueError: - response_body = response.text - return response_body, response.status_code - except Timeout: - logger.error("POST /application/lcm timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error("POST /application/lcm connection error") - return {"error": "Connection error"}, 503 - except requests.exceptions.HTTPError as http_err: - logger.error(f"POST /application/lcm HTTP error: {http_err}") - return {"error": str(http_err)}, response.status_code - except Exception as e: - logger.error(f"POST /application/lcm unexpected error: {e}") - return {"error": str(e)}, 500 - - def get_all_app_instances(self, federation_context_id: str, app_id: str, app_provider_id: str, token: str): - url = ( - f"{self.base_url}/{federation_context_id}/application/lcm/app/{app_id}" - f"/appProvider/{app_provider_id}" - ) - try: - response = requests.get(url, headers=self._get_headers(token), timeout=10) - response.raise_for_status() - return response.json(), response.status_code - except Timeout: - logger.error("GET /application/lcm/app/{appId}/appProvider/{appProviderId} timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error("GET /application/lcm/app/{appId}/appProvider/{appProviderId} connection error") - return {"error": "Connection error"}, 503 - except requests.exceptions.HTTPError as http_err: - logger.error(f"GET /application/lcm/app/{app_id}/appProvider/{app_provider_id} HTTP error: {http_err}") - try: - body = http_err.response.json() - except ValueError: - body = http_err.response.text - return {"error": body}, http_err.response.status_code - except Exception as e: - logger.error( - "GET /application/lcm/app/%s/appProvider/%s unexpected error: %s", - app_id, - app_provider_id, - e, - ) - return {"error": str(e)}, 500 - - def remove_app_instance( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - token: str, - ): - url = ( - f"{self.base_url}/{federation_context_id}/application/lcm/app/{app_id}" - f"/instance/{app_instance_id}/zone/{zone_id}" - ) - try: - response = requests.delete(url, headers=self._get_headers(token), timeout=10) - try: - body = response.json() - except ValueError: - body = response.text - response.raise_for_status() - return body, response.status_code - except Timeout: - logger.error("DELETE /application/lcm/app/{appId}/instance/{appInstanceId}/zone/{zoneId} timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error( - "DELETE /application/lcm/app/{appId}/instance/{appInstanceId}/zone/{zoneId} connection error" - ) - return {"error": "Connection error"}, 503 - except requests.exceptions.HTTPError as http_err: - logger.error( - "DELETE /application/lcm/app/%s/instance/%s/zone/%s HTTP error: %s", - app_id, - app_instance_id, - zone_id, - http_err, - ) - try: - body = http_err.response.json() - except ValueError: - body = http_err.response.text - return {"error": body}, http_err.response.status_code - except Exception as e: - logger.error( - "DELETE /application/lcm/app/%s/instance/%s/zone/%s unexpected error: %s", - app_id, - app_instance_id, - zone_id, - e, - ) - return {"error": str(e)}, 500 - - '''---AVAILABILITY ZONE INFO SYNCHRONIZATION---''' - - def request_zone_sync(self, federation_context_id: str, body: dict, token: str): - - url = f"{self.base_url}/{federation_context_id}/zones" - try: - response = requests.post(url, headers=self._get_headers(token), json=body, timeout=10) - try: - response_body = response.json() - except ValueError: - response_body = response.text - return response_body, response.status_code - except Timeout: - logger.error("Zone synchronization timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error("Zone synchronization connection error") - return {"error": "Connection error"}, 503 - except requests.exceptions.HTTPError as http_err: - logger.error(f"Zone synchronization HTTP error: {http_err}") - return {"error": str(http_err)}, response.status_code - except Exception as e: - logger.error(f"Zone synchronization unexpected error: {e}") - return {"error": str(e)}, 500 - - def subscribe_to_zones(self, federation_context_id: str, accepted_zone_ids: list, token: str, - callback_url: str = None): - - url = f"{self.base_url}/{federation_context_id}/zones" - - body = { - 'acceptedAvailabilityZones': accepted_zone_ids - } - - if callback_url: - body['availZoneNotifLink'] = callback_url - - try: - response = requests.post(url, headers=self._get_headers(token), json=body, timeout=10) - try: - response_body = response.json() - except ValueError: - response_body = response.text - return response_body, response.status_code - except Timeout: - logger.error("Subscribe to zones timed out") - return {"error": "Request timed out"}, 408 - except ConnectionError: - logger.error("Subscribe to zones connection error") - return {"error": "Connection error"}, 503 - except requests.exceptions.HTTPError as http_err: - logger.error(f"Subscribe to zones HTTP error: {http_err}") - return {"error": str(http_err)}, response.status_code - except Exception as e: - logger.error(f"Subscribe to zones unexpected error: {e}") - return {"error": str(e)}, 500 - - def get_zone_resource_info(self, federation_context_id: str, zone_id: str, token: str): - - url = f"{self.base_url}/{federation_context_id}/zones/{zone_id}" - try: - response = requests.get(url, headers=self._get_headers(token), timeout=10) - return response.json() - except Timeout: - logger.error("Zone resource info timed out") - return {"error": "Request timed out", "status_code": 408} - except ConnectionError: - logger.error("Zone resource info connection error") - return {"error": "Connection error", "status_code": 503} - except requests.exceptions.HTTPError as http_err: - logger.error(f"Zone resource info HTTP error: {http_err}") - return {"error": str(http_err), "status_code": response.status_code} - except Exception as e: - logger.error(f"Zone resource info unexpected error: {e}") - return {"error": str(e), "status_code": 500} - - def remove_zone_sync(self, federation_context_id: str, zone_id: str, token: str): - - url = f"{self.base_url}/{federation_context_id}/zones/{zone_id}" - try: - response = requests.delete(url, headers=self._get_headers(token), timeout=10) - return response.json() - except Timeout: - logger.error("Remove Zone sync timed out") - return {"error": "Request timed out", "status_code": 408} - except ConnectionError: - logger.error("Remove Zone sync connection error") - return {"error": "Connection error", "status_code": 503} - except requests.exceptions.HTTPError as http_err: - logger.error(f"Remove Zone sync HTTP error: {http_err}") - return {"error": str(http_err), "status_code": response.status_code} - except Exception as e: - logger.error(f"Remove Zone sync unexpected error: {e}") - return {"error": str(e), "status_code": 500} - - '''---ARTEFACT API---''' - - def create_artefact(self, artefact: dict, federation_context_id: str, token: str): - url = f"{self.base_url}/{federation_context_id}/artefact" - try: - response = requests.post( - url, - headers=self._get_headers(token), - json=artefact, - timeout=120 - ) - try: - body = response.json() - except ValueError: - body = response.text - return body, response.status_code - except Exception as e: - logger.error(f"Create artefact unexpected error: {e}") - return {"error": str(e)}, 500 - - def delete_artefact(self, federation_context_id: str, artefact_id: str, token: str): - url = f"{self.base_url}/{federation_context_id}/artefact/{artefact_id}" - try: - response = requests.delete(url, headers=self._get_headers(token), timeout=120) - try: - body = response.json() - except ValueError: - body = response.text - response.raise_for_status() - return {"message": body, "status_code": response.status_code} - except Timeout: - logger.error("Delete artefact timed out") - return {"error": "Request timed out", "status_code": 408} - except ConnectionError: - logger.error("Delete artefact connection error") - return {"error": "Connection error", "status_code": 503} - except requests.exceptions.HTTPError as http_err: - logger.error(f"Delete artefact HTTP error: {http_err}") - return {"error": str(http_err), "status_code": response.status_code} - except Exception as e: - logger.error(f"Delete artefact unexpected error: {e}") - return {"error": str(e), "status_code": 500} - -class FederationManagerClientFactory: - def __init__(self): - self.default_base_url = config.FEDERATION_MANAGER_HOST - - def create_federation_client(self, base_url=None): - base_url = base_url or self.default_base_url - return FederationManagerClient(base_url=base_url) - - def onboard_application_to_partners(app_id, zones): - SOURCE_OP = os.getenv("SOURCE_OP_ID") - federation_context_id = os.getenv("FEDERATION_CONTEXT_ID") - callback_url = os.getenv("STATUS_CALLBACK_URL", "http://your-callback/api/status") - - partner_zones = [zone for zone in zones if zone.get("edgeCloudProvider") != SOURCE_OP] - if not partner_zones: - return {"message": "No partner zones to onboard"}, 200 - - srm_client = SRMAPIClientFactory().create_srm_api_client() - app_data = srm_client.get_app(app_id) - - if not app_data or "error" in app_data: - return {"error": "Failed to retrieve application metadata from SRM"}, 500 - - app_manifest = app_data.get("appManifest", {}) - onboarding_payload = { - "appId": app_id, - "appManifest": app_manifest, - "zones": partner_zones, - "appStatusCallbackLink": callback_url - } - - factory = FederationManagerClientFactory() - federation_client = factory.create_federation_client() - - results = [] - for zone in partner_zones: - partner_op = zone["edgeCloudProvider"] - try: - response = federation_client.onboard_application(federation_context_id, onboarding_payload) - results.append({ - "zoneId": zone.get("edgeCloudZoneId"), - "provider": partner_op, - "status": response.get("status", "success"), - "detail": response - }) - except Exception as e: - results.append({ - "zoneId": zone.get("edgeCloudZoneId"), - "provider": partner_op, - "status": "error", - "detail": str(e) - }) - - return {"onboardingResults": results}, 202 - - -if __name__ == "__main__": - factory = FederationManagerClientFactory() - client = factory.create_federation_client() - - result = client.get_federation_context_ids() - logger.info("Federation Context IDs: %s", result) diff --git a/edge_cloud_management_api/services/storage_service.py b/edge_cloud_management_api/services/storage_service.py deleted file mode 100644 index a6feda9a296598eb763744852b63cc7d49194826..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/services/storage_service.py +++ /dev/null @@ -1,87 +0,0 @@ -from edge_cloud_management_api.configs.env_config import config -import pymongo - -storage_url = mongo_host = config.MONGO_URI -mydb_mongo = 'oeg_storage' - - -def _zone_storage_id(zone: dict): - provider = zone.get('edgeCloudProvider') or 'unknown' - zone_id = zone.get('edgeCloudZoneId') or zone.get('zoneId') - if not zone_id: - return zone.get('_id') - return f"{provider}::{zone_id}" - -def insert_zones(zone_list: list): - collection = "zones" - myclient = pymongo.MongoClient(storage_url) - mydbmongo = myclient[mydb_mongo] - col = mydbmongo[collection] - for zone in zone_list: - normalized_zone = dict(zone) - normalized_zone['_id'] = _zone_storage_id(normalized_zone) - col.replace_one({'_id': normalized_zone['_id']}, normalized_zone, upsert=True) - -def get_zone(zone_id: str, provider: str | None = None): - collection = "zones" - myclient = pymongo.MongoClient(storage_url) - mydbmongo = myclient[mydb_mongo] - col = mydbmongo[collection] - query = {'edgeCloudZoneId': zone_id} - if provider: - query['edgeCloudProvider'] = provider - zone = col.find_one(query) - else: - zone = col.find_one({**query, 'isLocal': 'true'}) - if zone is None: - zone = col.find_one(query) - if zone is None and provider: - zone = col.find_one({'_id': f"{provider}::{zone_id}"}) - if zone is None and not provider: - zone = col.find_one({'_id': zone_id}) - return zone - -def get_zones(): - collection = "zones" - myclient = pymongo.MongoClient(storage_url) - mydbmongo = myclient[mydb_mongo] - col = mydbmongo[collection] - zones = col.find() - return list(zones) - -def delete_partner_zones(): - collection = "zones" - myclient = pymongo.MongoClient(storage_url) - mydbmongo = myclient[mydb_mongo] - col = mydbmongo[collection] - col.delete_many({'isLocal': 'false'}) - -def insert_federation(fed: dict): - collection = 'federations' - myclient = pymongo.MongoClient(storage_url) - mydbmongo = myclient[mydb_mongo] - col = mydbmongo[collection] - col.insert_one(fed) - -def get_fed(fed_context_id: str): - collection = 'federations' - myclient = pymongo.MongoClient(storage_url) - mydbmongo = myclient[mydb_mongo] - col = mydbmongo[collection] - fed = col.find_one({'_id': fed_context_id}) - return fed - -def get_all_feds(): - collection = 'federations' - myclient = pymongo.MongoClient(storage_url) - mydbmongo = myclient[mydb_mongo] - col = mydbmongo[collection] - feds = col.find() - return feds.to_list() - -def delete_fed(fed_context_id: str): - collection = 'federations' - myclient = pymongo.MongoClient(storage_url) - mydbmongo = myclient[mydb_mongo] - col = mydbmongo[collection] - col.delete_one({'_id': fed_context_id}) diff --git a/edge_cloud_management_api/specification/openapi.yaml b/edge_cloud_management_api/specification/openapi.yaml deleted file mode 100644 index 72a2679ab73b29a3db30693fcc32f57c35c426cc..0000000000000000000000000000000000000000 --- a/edge_cloud_management_api/specification/openapi.yaml +++ /dev/null @@ -1,3111 +0,0 @@ ---- -openapi: 3.0.3 -info: - title: Open Exposure Gateway API - version: 1.0.1-wip - description: | - Open Exposure Gateway API allows API consumers to manage the - Life Cycle of an Application, Discover Edge Cloud Zones and request Network Resources. - # Overview - The reference scenario foresees a distributed Telco Edge Cloud where any - Application Delevoper, known as an Application Provider, can host and - deploy their application according to their specifications and operational - criteria (e.g. within an specific geographical zone for data protection - purposes, ensure a minimum QoS for the application clients, etc). - Through Telco Edge Cloud services Developers around the globe can be - benefit from the traditional Cloud strengths but expertise and advantages - of the Mobile Network Operators offering to their users an evolved - experience for XR, V2X, Holographic and other new services. - - # Introduction - The Edge Application Management API provides capabilities for lifecycle - management of application, instances and edge cloud zone discovery. - Lifecycle Management allows Application Provider to onboard - their application to the Edge Cloud Platform which do bookkeeping, - resource validation and other pre-deployment operations. - Application details can contain components network specification, - package type (QCOW2, OVA, CONTAINER, HELM), operating system details and - respository to download the image of the desired application. - Once the application is available on the Edge Cloud - Platform, the Application Provider can instantiate the application. - Edge Cloud Provider helps Application Provider to decide where to - instantiate the applications allowing them to retrieve a list of - Edge Cloud Zones that meets the provided criteria. - - This discovery can be filtered by an specific geographical region - (e.g when data residency is need) and by status (active, inactive, unknown) - Application Provider can ask the Edge Cloud Platform to instantiate the - application to one or several Edge Cloud Zones that meet the criteria. - Typically when more than one Edge Cloud Zone is required in the same - geographic boundary, Application Provider can define instead - the entire Edge Cloud Region. - - Application Provider can retrieve the information of the instances - for a given application, the information could be the Edge Cloud Zone - where the instance is, status (ready, instantiating, failed, - terminating, unknown) and endpoint (ip, port, fqdn). - Application Provider can terminate an instance of an application - (appInstanceId) or all the instances for a given appId. - - # Quick Start - The usage of this API is based on several resources including GSMA - Edge Platform, Public Cloud and SDOs, to define a first approach on the - lifecycle management of application instances and edge cloud zones discovery - - Before starting to use the API, the developer needs to know about - the below specified details. - - __Application Management__ - * __submitApp__ - Submits application details to an Edge Cloud Provider. - Based on the details provided, Edge Cloud Provider shall do bookkeeping, - resource validation and other pre-deployment operations. - * __deleteApp__ - Removes an application from an Edge Cloud Provider, - if there is a running instance of the given application, - the request cannot be done. - * __getApp__ - Retrieves the information of a given application. - - __Application Instance Management__ - * __createAppInstance__ Request the Edge Cloud Provider to instatiate - an instance of an application in a given Edge Cloud Zone, - if this parameter is not set, the Edge Cloud Provider will instantiate - the applications in all the Edge Cloud Zones. - * __getAppInstance__ Retrieves the list with information of the instances - related to a given application. - * __deleteAppInstance__ - Removes a given application instance from an Edge - Cloud Zone. - - __Edge Cloud information__ - * __getEdgeCloudZones__ List of the operators Edge Cloud Zones and their - status, ordering the results by location and filtering by status - (active/inactive/unknown) - - # Authentication and Authorization - CAMARA guidelines defines a set of authorization flows which can grant API - clients access to the API functionality, as outlined in the document - [CAMARA-API-access-and-user-consent.md](https://github.com/camaraproject\ - /IdentityAndConsentManagement/blob/main/documentation/CAMARA-API-access\ - -and-user-consent.md). - Which specific authorization flows are to be used will be determined during - onboarding process, happening between the API Client and the Telco Edge - exposing the API, taking into account the declared purpose for accessing the - API, while also being subject to the prevailing legal framework dictated by - local legislation. - - It is important to remark that in cases where personal user data is - processed by the API, and users can exercise their rights through mechanisms - such as opt-in and/or opt-out, the use of 3-legged access tokens becomes - mandatory. This measure ensures that the API remains in strict compliance - with user privacy preferences and regulatory obligations, upholding the - principles of transparency and user-centric data control. - # API documentation - Two operations have been defined in Edge Application Management API. - - *__Application__* - The Application Provider submit application metadata to - the Edge Cloud Platform. The Edge Cloud Platform generates an appId for that - metadata that will be used to instantiate the application within - the Edge Cloud Zone. - - *__Edge Cloud__* - Retrieves all the Edge Cloud Zones available according to - some defined parameters where an application can be instantiated. - - Definitions of terminologies commonly referred - to throughout the API descriptions. - * __Application Provider__ - The provider of the application that accesses - an Edge Cloud Provider to deploy its application on the Edge Cloud. - An Application Provider may be part of a larger organisation, - like an enterprise, enterprise customer of an Edge Cloud Provider, - or be an independent entity. - * __Application__ - Contains the information about the application to be - instantiated. Descriptor, binary image, charts or any other package - assosiated with the application. The Application Provider request contains - mandatory criteria (e.g. required CPU, memory, storage, bandwidth) defined - in an Application. The Edge Cloud Platform generates a unique ID - for an Application that is ready to be instantiated. - * __Application Instance__ - Is an instance (VM or Container based) running - in an Edge Cloud Zone. The Edge Cloud Platform generates a unique ID - for each instance. - * __Edge Cloud__ - Cloud-like capabilities located at the network edge - including, from the Application Provider's perspective, access to - elastically allocated compute, data storage and network resources, - this access is provided through the Edge Cloud Platform. - * __Edge Cloud Provider__ - Company name of the provider offering the - Edge Services through the Edge Cloud Platform. - Could be an Operator or a Cloud Provider. - * __Edge Cloud Region__ - An Edge Cloud Region is equivalent - to a Region on a Public Cloud. - The higher construct in the hierarchy exposed to an Application - Provider who wishes to deploy an Application on the Edge Cloud and broadly - represents a geography. An Edge CloudRegion typically contains one or - multiple Edge Cloud Zones. - An Edge Cloud Region exists within an Edge Cloud. - * __Edge Cloud Zone__ - An Edge Cloud Zone is the lowest level of - abstraction exposed to an Application Provider who wants to deploy - an Application on Edge Cloud. - Edge Cloud Zones exists within a Edge Cloud Region. - --- - contact: - email: sp-edc@lists.camaraproject.org - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html -externalDocs: - description: Product documentation at Camara0 - url: https://github.com/camaraproject/EdgeCloud - -servers: -- url: http://vitrualserver:8080/oeg/1.0.0 - -paths: - /apps: - post: - # security: - # - openId: - # - edge-application-management:apps:write - tags: - - Application - summary: Submit application metadata to the Edge Cloud Provider. - description: | - Contains the information about the application to be - instantiated in the Edge Cloud - operationId: edge_cloud_management_api.controllers.app_controllers.submit_app - parameters: - - $ref: "#/components/parameters/x-correlator" - requestBody: - description: | - The Application Provider request contains mandatory - criteria (e.g. required CPU, memory, storage, bandwidth) and - optional parameters. - content: - application/json: - schema: - $ref: "#/components/schemas/AppManifest" - examples: - KubernetesExample: - summary: Example for Kubernetes app - value: - appId: 3fa85f64-5717-4562-b3fc-2c963f66afa6 - name: nginx_web_app - appProvider: nginx_inc - version: 1.0.0 - packageType: HELM - operatingSystem: - architecture: x86_64 - family: UBUNTU - version: OS_VERSION_UBUNTU_2204_LTS - license: OS_LICENSE_TYPE_FREE - appRepo: - type: PRIVATEREPO - imagePath: "https://charts.bitnami.com/bitnami/nginx:1.25.2" - userName: helm-user - credentials: secure-token - authType: DOCKER - checksum: sha256:4d67e4c1a4b4c8... - requiredResources: - infraKind: kubernetes - applicationResources: - cpuPool: - numCPU: 2 - memory: 4096 - topology: - minNumberOfNodes: 3 - minNodeCpu: 2 - minNodeMemory: 4096 - gpuPool: - numCPU: 4 - memory: 8192 - gpuMemory: 32 - topology: - minNumberOfNodes: 2 - minNodeCpu: 4 - minNodeMemory: 8192 - minNodeGpuMemory: 16 - isStandalone: false - version: "1.29" - additionalStorage: 100GB - networking: - primaryNetwork: - provider: cilium - version: "1.13" - additionalNetworks: - - name: net1 - interfaceType: vfio-pci - - name: backend-net - interfaceType: netdevice - - name: db-net - interfaceType: interface - addons: - monitoring: true - ingress: true - componentSpec: - - componentName: nginx_server - networkInterfaces: - - interfaceId: eth0 - protocol: TCP - port: 80 - visibilityType: VISIBILITY_EXTERNAL - - interfaceId: eth1 - protocol: TCP - port: 443 - visibilityType: VISIBILITY_EXTERNAL - - required: true - responses: - "200": - description: Application created successfully - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/SubmittedApp" - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "409": - description: Conflict - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 409 - code: CONFLICT - message: "App already exists" - "500": - $ref: "#/components/responses/500" - "501": - $ref: "#/components/responses/501" - "503": - $ref: "#/components/responses/503" - get: - # security: - # - openId: - # - edge-application-management:apps:read - tags: - - Application - summary: Retrieve a list of existing Applications - description: | - Get the list of all existing Application definitions from the - Edge Cloud Provider that the user has permission to view. - operationId: edge_cloud_management_api.controllers.app_controllers.get_apps - parameters: - - $ref: "#/components/parameters/x-correlator" - responses: - "200": - description: List of existing applications - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/AppManifest" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - "503": - $ref: "#/components/responses/503" - - /apps/{appId}: - get: - # security: - # - openId: - # - edge-application-management:apps:read - tags: - - Application - summary: Retrieve the information of an Application - description: | - Ask the Edge Cloud Provider the information for a given application - operationId: edge_cloud_management_api.controllers.app_controllers.get_app - parameters: - - $ref: "#/components/parameters/x-correlator" - - name: appId - description: | - A globally unique identifier associated with the - application. - Edge Cloud Provider generates this identifier when the application - is submitted. - in: path - required: true - schema: - $ref: "#/components/schemas/AppId" - responses: - "200": - description: Information of Application - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - type: object - properties: - appManifest: - $ref: "#/components/schemas/AppManifest" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - "503": - $ref: "#/components/responses/503" - delete: - # security: - # - openId: - # - edge-application-management:apps:delete - tags: - - Application - summary: | - Delete an Application from an Edge Cloud Provider - description: Delete all the information and content - related to an Application - operationId: edge_cloud_management_api.controllers.app_controllers.delete_app - parameters: - - $ref: "#/components/parameters/x-correlator" - - name: appId - in: path - description: | - Identificator of the application to be - deleted provided by the Edge Cloud Provider - once the submission was successful - required: true - schema: - $ref: "#/components/schemas/AppId" - responses: - "200": - description: App deleted - "202": - description: Request accepted - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - $ref: "#/components/responses/404" - "409": - description: Conflict - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 409 - code: CONFLICT - message: "App with a running application instance - cannot be deleted" - "500": - $ref: "#/components/responses/500" - "503": - $ref: "#/components/responses/503" - - /appinstances: - post: - # security: - # - openId: - # - edge-application-management:instances:write - tags: - - Application - summary: Instantiation of an Application - description: | - Ask the Edge Cloud Platform to instantiate an application to one - or several Edge Cloud Zones with an Application as an input and an - Application Instance as the output. - operationId: edge_cloud_management_api.controllers.app_controllers.create_app_instance - parameters: - - $ref: "#/components/parameters/x-correlator" - requestBody: - description: | - Information about the application and where to deploy it. - content: - application/json: - schema: - type: object - required: - # - name - - appId - - appZones - # - edgeCloudZoneId - properties: - # name: - # $ref: '#/components/schemas/AppInstanceName' - appId: - $ref: "#/components/schemas/AppId" - # edgeCloudZoneId: - # $ref: "#/components/schemas/EdgeCloudZoneId" - # kubernetesClusterRef: - # $ref: "#/components/schemas/KubernetesClusterRef" - appZones: - $ref: "#/components/schemas/AppZones" - required: true - responses: - "202": - description: Application instantiation accepted - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - Location: - description: Contains the URI of the newly created application. - required: true - schema: - type: string - content: - application/json: - schema: - type: object - properties: - appInstances: - type: array - items: - $ref: "#/components/schemas/AppInstanceInfo" - minItems: 1 - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "409": - description: Conflict - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 409 - code: CONFLICT - message: "Application already instantiated in the given - Edge Cloud Zone or Edge Cloud Region" - "500": - $ref: "#/components/responses/500" - "501": - $ref: "#/components/responses/501" - "503": - $ref: "#/components/responses/503" - get: - # security: - # - openId: - # - edge-application-management:instances:read - tags: - - Application - summary: Retrieve the information of Application Instances for a given App - description: | - Ask the Edge Cloud Provider the information of the instances for a - given application - operationId: edge_cloud_management_api.controllers.app_controllers.get_app_instance - parameters: - - $ref: "#/components/parameters/x-correlator" - - name: appId - description: | - A globally unique identifier associated with - the application. - Edge Cloud Provider generates this identifier when the - application is submitted. - in: query - required: false - schema: - $ref: "#/components/schemas/AppId" - - name: appInstanceId - description: | - A globally unique identifier associated with a running - instance of an application within an specific Edge Cloud Zone. - Edge Cloud Provider generates this identifier. - in: query - required: false - schema: - $ref: "#/components/schemas/AppInstanceId" - - name: region - description: | - Human readable name of the geographical Edge Cloud Region of - the Edge Cloud. Defined by the Edge Cloud Provider. - in: query - required: false - schema: - $ref: "#/components/schemas/EdgeCloudRegion" - responses: - "200": - description: Information of Application Instances - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - type: object - properties: - appInstanceInfo: - type: array - items: - $ref: "#/components/schemas/AppInstanceInfo" - minItems: 1 - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - "503": - $ref: "#/components/responses/503" - - /appinstances/{appInstanceId}: - delete: - # security: - # - openId: - # - edge-application-management:instances:delete - tags: - - Application - summary: Terminate an Application Instance - description: | - Terminate a running instance of an application within - an Edge Cloud Zone - operationId: edge_cloud_management_api.controllers.app_controllers.delete_app_instance - parameters: - - $ref: "#/components/parameters/x-correlator" - - name: appInstanceId - in: path - description: | - Identificator of the specific application instance - that will be terminated - required: true - schema: - $ref: "#/components/schemas/AppInstanceId" - responses: - "202": - description: | - Request accepted to be processed. It applies for async - deletion process - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - "204": - description: Application Instance Deleted - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - "503": - $ref: "#/components/responses/503" - - /edge-cloud-zones: - get: - # security: - # - openId: - # - edge-application-management:edge-cloud-zones:read - tags: - - Edge Cloud Zones - summary: Retrieve a list of the operators Edge Cloud Zones and - their status - description: | - List of the operators Edge Cloud Zones and their - status, ordering the results by location and filtering by - status (active/inactive/unknown) - operationId: edge_cloud_management_api.controllers.edge_cloud_controller.get_edge_cloud_zones - parameters: - - $ref: "#/components/parameters/x-correlator" - - name: region - description: | - Human readable name of the geographical Edge Cloud Region of - the Edge Cloud. Defined by the Edge Cloud Provider. - in: query - required: false - schema: - $ref: "#/components/schemas/EdgeCloudRegion" - - name: status - description: Human readable status of the Edge Cloud Zone - in: query - required: false - schema: - $ref: "#/components/schemas/EdgeCloudZoneStatus" - responses: - "200": - description: | - Successful response, returning the - Available Edge Cloud Zones. - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/EdgeCloudZones" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - "503": - $ref: "#/components/responses/503" - /edge-cloud-zones/{zoneId}: - get: - tags: - - Edge Cloud Zones - summary: Retrieve the details of an Edge Cloud Zone - description: | - List of the operators Edge Cloud Zones and their - status, ordering the results by location and filtering by - status (active/inactive/unknown) - operationId: edge_cloud_management_api.controllers.edge_cloud_controller.edge_cloud_zone_details - parameters: - - $ref: "#/components/parameters/x-correlator" - - name: zoneId - in: path - description: | - UID of the specific edge cloud zone - required: true - style: simple - schema: - type: string - responses: - "200": - description: | - Successful response, returning the Edge Cloud Zone details - "404": - $ref: "#/components/responses/404" - /sessions: - post: - tags: - - Quality on Demand Functions - summary: Creates a new QoD Session - operationId: edge_cloud_management_api.controllers.network_functions_controller.create_qod_session - requestBody: - description: QoD Session body. - content: - application/json: - schema: - $ref: '#/components/schemas/QoDSchema' - responses: - "200": - description: Session created. - /sessions/{sessionId}: - get: - tags: - - Quality on Demand Functions - summary: Retrieve details of a QoD Session - operationId: edge_cloud_management_api.controllers.network_functions_controller.get_qod_session - parameters: - - name: sessionId - in: path - description: Represents a QoD Session. - required: true - style: simple - explode: false - schema: - type: string - responses: - "200": - description: QoD Session found - "405": - description: Method not allowed - "404": - description: Session not found - delete: - tags: - - Quality on Demand Functions - summary: Remove QoD Session - operationId: edge_cloud_management_api.controllers.network_functions_controller.delete_qod_session - parameters: - - name: sessionId - in: path - description: Represents a QoD Session. - required: true - style: simple - explode: false - schema: - type: string - responses: - "201": - description: QoD Session deleted - "405": - description: Method not allowed - "404": - description: Session not found - # /traffic-influences: - # post: - # tags: - # - Traffic Influence Functions - # summary: Creates a new TrafficInfluence resource - # operationId: edge_cloud_management_api.controllers.network_functions_controller.create_traffic_influence_resource - # requestBody: - # description: TrafficInfluence body. - # content: - # application/json: - # schema: - # $ref: '#/components/schemas/TrafficInfluenceSchema' - # responses: - # "200": - # description: Resource created. - # get: - # tags: - # - Traffic Influence Functions - # summary: Retrieves all TrafficInfluence resources - # operationId: edge_cloud_management_api.controllers.network_functions_controller.get_all_traffic_influence_resources - # responses: - # "200": - # description: Resources retrieved. - # /traffic-influences/{id}: - # get: - # tags: - # - Traffic Influence Functions - # summary: Retrieve details of a TrafficInfluence resource - # operationId: edge_cloud_management_api.controllers.network_functions_controller.get_traffic_influence_resource - # parameters: - # - name: id - # in: path - # description: Represents a TrafficInfluence resource. - # required: true - # style: simple - # explode: false - # schema: - # type: string - # responses: - # "200": - # description: TrafficInfluence resource found - # "405": - # description: Method not allowed - # "404": - # description: Session not found - # delete: - # tags: - # - Traffic Influence Functions - # summary: Remove TrafficInfluence resource - # operationId: edge_cloud_management_api.controllers.network_functions_controller.delete_traffic_influence_resource - # parameters: - # - name: id - # in: path - # description: Represents a TrafficInfluence resource - # required: true - # style: simple - # explode: false - # schema: - # type: string - # responses: - # "201": - # description: TrafficInfluence resource deleted - # "405": - # description: Method not allowed - # "404": - # description: Session not found - /location-retrieval/v0.5/retrieve: - post: - tags: - - Location Retrieval Functions - summary: Retrieve the location of a device - description: | - Retrieve the location of a device using the CAMARA Device Location API. - The request is forwarded to the Service Resource Manager which delegates - to the configured network adapter. - operationId: edge_cloud_management_api.controllers.network_functions_controller.retrieve_location - requestBody: - description: Location retrieval request following CAMARA Device Location API. - content: - application/json: - schema: - $ref: '#/components/schemas/RetrievalLocationRequest' - examples: - PhoneNumberExample: - summary: Retrieve location by phone number - value: - device: - phoneNumber: "+123456789" - maxAge: 60 - Ipv4Example: - summary: Retrieve location by IPv4 address - value: - device: - ipv4Address: - publicAddress: "198.51.100.1" - publicPort: 59765 - maxAge: 120 - maxSurface: 10000 - responses: - "200": - description: Device location retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/LocationResponse' - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - "503": - $ref: "#/components/responses/503" - /location/retrieve: - post: - tags: - - Location Retrieval Functions - summary: Deprecated legacy alias for location retrieval - description: | - Deprecated legacy alias. Use /location-retrieval/v0.5/retrieve. - deprecated: true - operationId: edge_cloud_management_api.controllers.network_functions_controller.retrieve_location_legacy - requestBody: - description: Location retrieval request following CAMARA Device Location API. - content: - application/json: - schema: - $ref: '#/components/schemas/RetrievalLocationRequest' - examples: - PhoneNumberExample: - summary: Retrieve location by phone number - value: - device: - phoneNumber: "+123456789" - maxAge: 60 - Ipv4Example: - summary: Retrieve location by IPv4 address - value: - device: - ipv4Address: - publicAddress: "198.51.100.1" - publicPort: 59765 - maxAge: 120 - maxSurface: 10000 - responses: - "200": - description: Device location retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/LocationResponse' - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - "503": - $ref: "#/components/responses/503" - /partner: - post: - tags: - - FederationManagement - summary: Creates one direction federation with partner operator platform. - # security: - # - oAuth2ClientCredentials: - # - fed-mgmt - # security: - # - jwt: [ ] - - operationId: edge_cloud_management_api.controllers.federation_manager_controller.create_federation - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/FederationRequestData' - required: true - responses: - "200": - description: Federation meta-info request accepted - headers: - Location: - description: "Contains the URI of the newly created resource, according\ - \ to the structure: {apiRoot}/operatorplatform/federation/v1/partner/{federationContextId}" - required: true - style: simple - explode: false - schema: - type: string - Accept-Encoding: - description: "Accept-Encoding, described in IETF RFC 7694" - style: simple - explode: false - schema: - type: string - Content-Encoding: - description: "Content-Encoding, described in IETF RFC 7231" - style: simple - explode: false - schema: - type: string - content: - application/json: - schema: - $ref: '#/components/schemas/FederationResponseData' - # "400": - # description: Bad request - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - - # "404": - # description: Unauthorized - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "408": - # description: Timeout - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "409": - # description: Conflict - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "500": - # description: Internal Server Error - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # callbacks: - # onPartnerStatusEvent: - # '{$request.body#/partnerStatusLink }': - # post: - # requestBody: - # description: | - # OP uses this callback api to notify partner OP about change in federation status, federation metadata or offered zone details. Allowed combinations of objectType and operationType are - # - FEDERATION - STATUS: Status specified by parameter 'federationStatus'. - # - ZONES - STATUS: Status specified by parameter 'zoneStatus'. - # - ZONES - ADD: Use parameter 'addZones' to define add new zones - # - ZONES - REMOVE: Use parameter 'removeZones' to define remove zones. - # - EDGE_DISCOVERY_SERVICE - UPDATE: Use parameter 'edgeDiscoverySvcEndPoint' to specify new endpoints - # - LCM_SERVICE - UPDATE: Use parameter 'lcmSvcEndPoint' to specify new endpoints - # - MOBILE_NETWORK_CODES - ADD: Use parameter 'addMobileNetworkIds' to define new mobile network codes. - # - MOBILE_NETWORK_CODES - REMOVE: Use parameter 'removeMobileNetworkIds' to remove mobile network codes. - # - FIXED_NETWORK_CODES - ADD: Use parameter 'addFixedNetworkIds' to define new fixed network codes. - # - FIXED_NETWORK_CODES - REMOVE: Use parameter 'removeFixedNetworkIds' to remove fixed network codes. - # - SERVICE_APIS - ADD/REMOVE: Parameter Usage 'addServiceAPIs / removeServiceAPIs' to add or remove Service APIs support. - # content: - # application/json: - # schema: - # required: - # - federationContextId - # - modificationDate - # - objectType - # - operationType - # type: object - # properties: - # federationContextId: - # $ref: '#/components/schemas/FederationIdentifier' - # objectType: - # type: string - # enum: - # - FEDERATION - # - ZONES - # - EDGE_DISCOVERY_SERVICE - # - LCM_SERVICE - # - MOBILE_NETWORK_CODES - # - FIXED_NETWORK_CODES - # - SERVICE_APIS - # operationType: - # type: string - # enum: - # - STATUS - # - UPDATE - # - ADD - # - REMOVE - # edgeDiscoverySvcEndPoint: - # $ref: '#/components/schemas/ServiceEndpoint' - # lcmSvcEndPoint: - # $ref: '#/components/schemas/ServiceEndpoint' - # addMobileNetworkIds: - # $ref: '#/components/schemas/MobileNetworkIds' - # removeMobileNetworkIds: - # $ref: '#/components/schemas/MobileNetworkIds' - # addFixedNetworkIds: - # $ref: '#/components/schemas/FixedNetworkIds' - # removeFixedNetworkIds: - # $ref: '#/components/schemas/FixedNetworkIds' - # addZones: - # minItems: 1 - # type: array - # description: "List of zones, which the operator platform\ - # \ wishes to make available to developers/ISVs of requesting\ - # \ operator platform." - # items: - # $ref: '#/components/schemas/ZoneDetails' - # removeZones: - # minItems: 1 - # type: array - # description: "List of zones, which the operator platform\ - # \ no longer wishes to share." - # items: - # $ref: '#/components/schemas/ZoneIdentifier' - # addServiceAPIs: - # $ref: '#/components/schemas/serviceAPINames' - # removeServiceAPIs: - # $ref: '#/components/schemas/serviceAPINames' - # zoneStatus: - # minItems: 1 - # type: array - # items: - # required: - # - status - # - zoneId - # type: object - # properties: - # zoneId: - # $ref: '#/components/schemas/ZoneIdentifier' - # status: - # $ref: '#/components/schemas/Status' - # federationStatus: - # $ref: '#/components/schemas/Status' - # modificationDate: - # type: string - # description: Date and time of the federation modification - # by the originating partner OP - # format: date-time - - # responses: - # "204": - # description: Expected response to a successful call back processing - # "400": - # description: Bad request - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "401": - # description: Unauthorized - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "404": - # description: Not Found - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "409": - # description: Conflict - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "422": - # description: Unprocessable Entity - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "500": - # description: Internal Server Error - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "503": - # description: Service Unavailable - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "520": - # description: Web Server Returned an Unknown Error - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # default: - # description: Generic Error - - /{federationContextId}/partner: - get: - tags: - - FederationManagement - summary: "Retrieves details about the federation context with the partner OP.\ - \ The response shall provide info about the zones offered by the partner,\ - \ partner OP network codes, information about edge discovery and LCM service\ - \ etc." - # security: - # - jwt: [ ] - operationId: edge_cloud_management_api.controllers.federation_manager_controller.get_federation - parameters: - - name: federationContextId - in: path - required: true - style: simple - explode: false - schema: - $ref: '#/components/schemas/FederationContextId' - responses: - "200": - description: Federation meta-info request accepted - content: - application/json: - schema: - $ref: '#/components/schemas/inline_response_200_1' - # "401": - # description: Unauthorized - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "404": - # description: Not Found - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "500": - # description: Internal Server Error - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - delete: - tags: - - FederationManagement - summary: Remove existing federation with the partner OP - # security: - # - jwt: [ ] - operationId: edge_cloud_management_api.controllers.federation_manager_controller.delete_federation - parameters: - - name: federationContextId - in: path - required: true - style: simple - explode: false - schema: - $ref: '#/components/schemas/FederationContextId' - responses: - "200": - description: Federation removed successfully - # "401": - # description: Unauthorized - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "404": - # description: Not Found - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "500": - # description: Internal Server Error - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - /{federationContextId}/zones: - post: - tags: - - FederationManagement - summary: Subscribe to availability zones for a federation context. - operationId: edge_cloud_management_api.controllers.federation_manager_controller.request_zone_synch - parameters: - - name: federationContextId - in: path - required: true - style: simple - explode: false - schema: - $ref: '#/components/schemas/FederationContextId' - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Zone subscription accepted - "400": - description: Bad request - "404": - description: Federation not found - /{federationContextId}/zones/{zoneId}: - get: - tags: - - FederationManagement - summary: Retrieve reserved resources for a federated zone. - description: | - Retrieves details about the computation and network resources that the - partner OP has reserved for this zone. - operationId: edge_cloud_management_api.controllers.federation_manager_controller.get_zone_resource_info - parameters: - - name: federationContextId - in: path - required: true - style: simple - explode: false - schema: - $ref: '#/components/schemas/FederationContextId' - - name: zoneId - in: path - required: true - style: simple - explode: false - schema: - type: string - responses: - "200": - description: Zone resource info retrieved - content: - application/json: - schema: - $ref: '#/components/schemas/ZoneResourceInfo' - "404": - description: Zone not found - delete: - tags: - - FederationManagement - summary: Remove availability zone reservation for a federation context. - description: | - Originating OP informs partner OP that it will no longer access the - specified zone. - operationId: edge_cloud_management_api.controllers.federation_manager_controller.remove_zone_sync - parameters: - - name: federationContextId - in: path - required: true - style: simple - explode: false - schema: - $ref: '#/components/schemas/FederationContextId' - - name: zoneId - in: path - required: true - style: simple - explode: false - schema: - type: string - responses: - "200": - description: Zone reservation removed - content: - application/json: - schema: - $ref: '#/components/schemas/ZoneReservationRemoval' - "404": - description: Zone not found - /fed-context-id: - get: - tags: - - FederationManagement - summary: Retrieves the existing federationContextId with partner operator platform. - # security: - # - jwt: [ ] - operationId: edge_cloud_management_api.controllers.federation_manager_controller.get_federation_context_ids - responses: - "200": - description: Federation context identifier retrieval request accepted - content: - application/json: - schema: - $ref: '#/components/schemas/inline_response_200_2' - # "401": - # description: Unauthorized - # content: - # application:/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "404": - # description: Not Found - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "408": - # description: Request timed out - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - # "504": - # description: Connection Error - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - - # "500": - # description: Interal Server Error - # content: - # application/problem+json: - # schema: - # $ref: '#/components/schemas/ProblemDetails' - -components: - # securitySchemes: - # jwt: # arbitrary name for the security scheme - # type: http - # scheme: bearer - # bearerFormat: JWT - # x-bearerInfoFunc: edge_cloud_management_api.controllers.security_controller.decode_token - # x-bearerInfoFunc: edge_cloud_management_api.controllers.security_controller.decode_token - # securitySchemes: - # oAuth2ClientCredentials: - # type: oauth2 - # flows: - # clientCredentials: - # tokenUrl: http://federation-manager.federation-manager.svc.cluster.local:8080/realms/federation/protocol/openid-connect/token - # scopes: - # fed-mgmt: Access to the federation APIs - # x-tokenInfoFunc: edge_cloud_management_api.controllers.security_controller.check_oAuth2ClientCredentials - # x-scopeValidateFunc: edge_cloud_management_api.controllers.security_controller.validate_scope_oAuth2ClientCredentials - - parameters: - x-correlator: - name: x-correlator - in: header - description: | - Correlation id for the different services - schema: - type: string - headers: - x-correlator: - description: | - Correlation id for the different services - required: false - schema: - type: string - format: uuid - - - schemas: - QoDSchema: - type: object - properties: - device: - type: object - properties: - phoneNumber: - type: string - networkAccessIdentifier: - type: string - ipv4Address: - type: object - properties: - publicAddress: - type: string - publicPort: - type: integer - ipv6Address: - type: string - - applicationServer: - type: object - properties: - ipv4Address: - type: string - ipv6Address: - type: string - devicePorts: - type: object - properties: - ranges: - type: array - items: - type: object - required: - - from - - to - properties: - from: - type: integer - to: - type: integer - ports: - type: array - items: - type: integer - applicationServerPorts: - type: object - properties: - ranges: - type: array - items: - type: object - required: - - from - - to - properties: - from: - type: integer - to: - type: integer - ports: - type: array - items: - type: integer - qosProfile: - type: string - sink: - type: string - duration: - type: integer - TrafficInfluenceSchema: - type: object - properties: - apiConsumerId: - type: string - appId: - type: string - appInstanceId: - type: string - edgeCloudRegion: - type: string - edgeCloudZoneId: - type: string - sourceTrafficFilters: - type: object - properties: - sourcePort: - type: integer - destinationTrafficFilters: - type: object - properties: - destinationPort: - type: integer - destinationProtocol: - type: string - notificationUri: - type: string - notificationAuthToken: - type: string - notificationSink: - type: object - properties: - sink: - type: string - sinkCredentials: - type: object - properties: - credentialtype: - type: string - accessToken: - type: string - accessTokenExpireUtc: - type: string - accessTokenType: - type: string - RetrievalLocationRequest: - type: object - properties: - device: - $ref: '#/components/schemas/Device' - maxAge: - type: integer - description: Maximum age of the location information accepted (in seconds). - example: 60 - maxSurface: - type: integer - description: Maximum surface in square meters accepted for the location retrieval. - minimum: 1 - example: 10000 - Device: - type: object - properties: - phoneNumber: - type: string - description: Phone number of the device in E.164 format. - example: "+123456789" - networkAccessIdentifier: - type: string - description: External identifier of the device (e.g. GPSI). - example: "device@testnet.net" - ipv4Address: - $ref: '#/components/schemas/DeviceIpv4Addr' - ipv6Address: - type: string - description: IPv6 address of the device. - example: "2001:db8::1" - DeviceIpv4Addr: - type: object - properties: - publicAddress: - type: string - example: "198.51.100.1" - privateAddress: - type: string - example: "10.0.0.1" - publicPort: - type: integer - example: 59765 - LocationResponse: - type: object - properties: - lastLocationTime: - type: string - format: date-time - description: Timestamp of the last known location. - example: "2024-06-01T12:00:00Z" - area: - $ref: '#/components/schemas/LocationArea' - LocationArea: - type: object - description: Geographic area of the location (Circle or Polygon). - properties: - areaType: - type: string - enum: - - CIRCLE - - POLYGON - example: CIRCLE - center: - $ref: '#/components/schemas/GeoPoint' - radius: - type: number - description: Radius in meters (when areaType is CIRCLE). - example: 800 - boundary: - type: array - description: List of points forming the polygon boundary (when areaType is POLYGON). - items: - $ref: '#/components/schemas/GeoPoint' - GeoPoint: - type: object - properties: - latitude: - type: number - minimum: -90 - maximum: 90 - example: 37.9553 - longitude: - type: number - minimum: -180 - maximum: 180 - example: 23.8522 - AccessEndpoint: - type: object - description: | - Application Endpoint for an especific instance that is - running in an specific Edge Cloud Zone. - required: - - port - anyOf: - - required: - - fqdn - - required: - - ipv4Addresses - - required: - - ipv6Addresses - properties: - port: - $ref: "#/components/schemas/Port" - fqdn: - $ref: "#/components/schemas/Fqdn" - ipv4Addresses: - description: IP version 4 of an application instance - type: array - items: - $ref: "#/components/schemas/Ipv4Addr" - minItems: 1 - ipv6Addresses: - description: IP version 6 of an application instance. - type: array - items: - $ref: "#/components/schemas/Ipv6Addr" - minItems: 1 - - AppId: - type: string - format: uuid - description: | - A globally unique identifier associated with the application. - Edge Cloud Platform generates this identifier when the - Application is submitted. - - AppInstanceId: - type: string - format: uuid - description: | - A globally unique identifier associated with a running - instance of an application. - Edge Cloud Platform generates this identifier when the - instantiation in the Edge Cloud Zone is successful. - - AppInstanceName: - type: string - # pattern: ^[A-Za-z][A-Za-z0-9_]{1,63}$ - description: Name of the App instance, scoped to the AppProvider - - AppInstanceInfo: - description: Information about the application instance. - type: object - properties: - appInstanceId: - $ref: "#/components/schemas/AppInstanceId" - status: - description: Status of the application instance (default is 'unknown') - type: string - enum: - - ready - - instantiating - - failed - - terminating - - unknown - default: unknown - componentEndpointInfo: - description: | - Information about the IP and Port exposed by the - Edge Cloud Platform. - Application Client shall use these access points to reach this - application instance - type: array - items: - type: object - required: - - interfaceId - - accessPoints - properties: - interfaceId: - type: string - #pattern: ^[A-Za-z0-9][A-Za-z0-9_]{6,30}[A-Za-z0-9]$ - description: | - This is the interface Identifier that Application Provider - defines when application is being submitted. - accessPoints: - $ref: "#/components/schemas/AccessEndpoint" - minItems: 1 - kubernetesClusterRef: - $ref: "#/components/schemas/KubernetesClusterRef" - edgeCloudZone: - $ref: "#/components/schemas/EdgeCloudZone" - - AppZones: - description: | - Collection of Edge Cloud Zones and/or Kubernetes cluster reference - where the Application Provider wants to instantiate the application. - type: array - items: - type: object - properties: - kubernetesClusterRef: - $ref: "#/components/schemas/KubernetesClusterRef" - EdgeCloudZone: - $ref: "#/components/schemas/EdgeCloudZone" - required: - - EdgeCloudZone - minItems: 1 - additionalProperties: false - - AppManifest: - description: | - Application information and requirements provided by the - Application Provider - type: object - properties: - appId: - $ref: "#/components/schemas/AppId" - name: - type: string - # pattern: ^[A-Za-z][A-Za-z0-9_]{1,63}$ - description: Name of the application. - appProvider: - $ref: "#/components/schemas/AppProvider" - version: - type: string - description: Application version information - packageType: - description: Format of the application image package - type: string - enum: - - QCOW2 - - OVA - - CONTAINER - - HELM - operatingSystem: - $ref: "#/components/schemas/OperatingSystem" - appRepo: - description: | - Repository where Application Provider stores the application image - type: object - required: - - type - - imagePath - properties: - type: - type: string - enum: - - PRIVATEREPO - - PUBLICREPO - description: | - Application repository and image URI information. - PUBLICREPO is used of public urls like github, helm repo etc. - PRIVATEREPO is used for private repo managed by the application - developer. Private repo can be accessed by using the app - developer provided userName and password. Password is - recommended to be the personal access token created by developer - e.g. in Github repo. - imagePath: - $ref: "#/components/schemas/Uri" - userName: - type: string - description: | - Username to acces the Helm chart, docker-compose - file or VM image repository - credentials: - type: string - maxLength: 128 - description: | - Password or personal access token created by - developer to acces the app repository. API users can generate - a personal access token e.g. docker clients to use them as - password. - authType: - type: string - enum: - - DOCKER - - HTTP_BASIC - - HTTP_BEARER - - NONE - description: | - The credentials can also be formatted as a Basic - auth or Bearer auth in HTTP "Authorization" header. - checksum: - type: string - description: | - MD5 checksum for VM and file-based images, sha256 - digest for containers - requiredResources: - $ref: "#/components/schemas/RequiredResources" - componentSpec: - description: | - Information defined in "appRepo" point to the application - descriptor e.g. Helm chart, docker-compose yaml file etc. - The descriptor may contain one or more containers and their - associated meta-data. A component refers to additional details - about these containers to expose the instances of the containers - to external client applications. App provider can define one or - more components (via the associated network port) in componentSpec - corresponding to the containers in helm charts or docker-compose - yaml file as part of app descriptors. - type: array - items: - type: object - required: - - componentName - - networkInterfaces - properties: - componentName: - type: string - description: Component name must be unique with an application - networkInterfaces: - description: | - Each application component exposes some ports - either for external users or for inter component - communication. - Application provider is required to specify which ports are - to be exposed and the type of traffic that will flow through - these ports.The underlying platform may assign a dynamic port - against the "extPort" that the application clients will use - to connect with edge application instance. - type: array - items: - type: object - required: - - interfaceId - - protocol - - port - - visibilityType - properties: - interfaceId: - type: string - # pattern: ^[A-Za-z][A-Za-z0-9_]{3,31}$ - description: | - Each Port and corresponding traffic protocol - exposed by the component is identified by a name. - Application client on user device requires this to - uniquley idenify the interface. - protocol: - type: string - enum: - - TCP - - UDP - - ANY - description: | - Defines the IP transport communication - protocol i.e., TCP, UDP or ANY - port: - type: integer - format: int32 - minimum: 1 - maximum: 65535 - description: | - Port number exposed by the component. - Edge Cloud Provider may generate a dynamic port - towards the component instance which forwards - external traffic to the component port. - visibilityType: - description: | - Defines whether the interface is exposed - to outer world or not i.e., external, or internal. - If this is set to "external", then it is exposed - to external applications otherwise it is exposed - internally to edge application components within - edge cloud. When exposed to external world, - an external dynamic port is assigned for UC traffic - and mapped to the extPort - type: string - enum: - - VISIBILITY_EXTERNAL - - VISIBILITY_INTERNAL - minItems: 1 - - required: - - name - - version - # - appProvider - - packageType - - appRepo - # - requiredResources - - componentSpec - - AppProvider: - type: string - # pattern: ^[A-Za-z][A-Za-z0-9_]{7,63}$ - description: Human readable name of the Application Provider. - - AppIdentifier: - # pattern: "^[A-Za-z][A-Za-z0-9_]{7,63}$" - type: string - description: Identifier used to refer to an application. - - AppProviderId: - # pattern: "^[A-Za-z][A-Za-z0-9_]{7,63}$" - type: string - description: UserId of the app provider. Identifier is relevant only in context - of this federation. - - ArtefactId: - type: string - description: A globally unique identifier associated with the artefact. Originating - OP generates this identifier when artefact is submitted over NBI. - format: uuid - - CountryCode: - pattern: "^[A-Z]{2}$" - type: string - description: ISO 3166-1 Alpha-2 code for the country of Partner operator - - CPUArchType: - type: string - description: "CPU Instruction Set Architecture (ISA) E.g., Intel, Arm etc." - enum: - - ISA_X86 - - ISA_X86_64 - - ISA_ARM_64 - - CallbackCredentials: - required: - - clientId - - clientSecret - - tokenUrl - type: object - properties: - tokenUrl: - $ref: '#/components/schemas/Uri' - clientId: - type: string - description: Client id for oauth2 client credentials flow. - clientSecret: - type: string - description: Client secret for oauth2 client credentials flow. - description: "Authentication credentials for callbacks. Callbacks use the same\ - \ security scheme, flows, and scopes as the forward path." - - GeoLocation: - pattern: "^([-+]?)([\\d]{1,2})((((\\.)([\\d]{1,4}))?(,)))(([-+]?)([\\d]{1,3})((\\\ - .)([\\d]{1,4}))?)$" - type: string - description: "Latitude,Longitude as decimal fraction up to 4 digit precision" - - InstanceIdentifier: - # pattern: "^[A-Za-z0-9][A-Za-z0-9_]{6,62}[A-Za-z0-9]$" - type: string - description: Unique identifier generated by the partner OP to identify an instance - of the application on a specific zone. - - InstanceState: - type: string - description: Running status of the application instance. - enum: - - PENDING - - READY - - FAILED - - TERMINATING - - inline_response_200: - required: - - federationSupportedAPIs - type: object - properties: - federationSupportedAPIs: - $ref: '#/components/schemas/FederationSupportedAPIs' - example: - federationSupportedAPIs: - availabilityZoneAPI: null - artefactAPI: null - fileAPI: null - resourceMonitoringAPI: null - faultManagementAPI: null - federationBaseAPI: - name: FEDERATION - apiOperations: - - httpMethods: - - POST - - POST - href: href - - httpMethods: - - POST - - POST - href: href - serviceAPIFederation: null - eventManagementAPI: null - edgeApplicationAPI: null - - inline_response_200_1: - type: object - properties: - edgeDiscoveryServiceEndPoint: - $ref: '#/components/schemas/ServiceEndpoint' - lcmServiceEndPoint: - $ref: '#/components/schemas/ServiceEndpoint' - allowedMobileNetworkIds: - $ref: '#/components/schemas/MobileNetworkIds' - allowedFixedNetworkIds: - $ref: '#/components/schemas/FixedNetworkIds' - offeredAvailabilityZones: - minItems: 1 - type: array - items: - $ref: '#/components/schemas/ZoneDetails' - platformCaps: - type: array - items: - type: string - example: - allowedFixedNetworkIds: - - allowedFixedNetworkIds - - allowedFixedNetworkIds - offeredAvailabilityZones: - - geographyDetails: geographyDetails - zoneId: zoneId - geolocation: geolocation - - geographyDetails: geographyDetails - zoneId: zoneId - geolocation: geolocation - lcmServiceEndPoint: null - allowedMobileNetworkIds: - mncs: - - mncs - - mncs - mcc: mcc - edgeDiscoveryServiceEndPoint: - ipv4Addresses: - - 198.51.100.1 - - 198.51.100.1 - port: 0 - fqdn: fqdn - ipv6Addresses: - - 2001:db8:85a3::8a2e:370:7334 - - 2001:db8:85a3::8a2e:370:7334 - - inline_response_200_2: - required: - - federationContextId - type: object - properties: - federationContextId: - $ref: '#/components/schemas/FederationContextId' - example: - federationContextId: federationContextId - - - EdgeCloudProvider: - type: string - description: Human readable name of the Edge Cloud Provider. - - EdgeCloudRegion: - type: string - description: | - Human readable name of the geographical Edge Cloud Region of - the Edge Cloud. Defined by the Edge Cloud Provider. - - EdgeCloudZones: - type: array - items: - $ref: "#/components/schemas/EdgeCloudZone" - description: | - A collection of Edge Cloud Zones where the Application Provider can - instantiate an Application Instance. - - EdgeCloudZoneId: - type: string - format: uuid - description: | - Unique identifier created by the Edge Cloud Platform to identify an - Edge Cloud Zone within an Edge Cloud. - - EdgeCloudZone: - type: object - description: | - An Edge Cloud Zone, uniquely identified by a - combination of the value of the Edge Cloud Zone Id object - and the value of the Edge Cloud Provider - object. This value is used to identify an Edge Cloud zone - between Edge Clouds from different Edge Cloud Providers. - required: - - edgeCloudZoneId - - edgeCloudZoneName - - edgeCloudProvider - properties: - edgeCloudZoneId: - $ref: "#/components/schemas/EdgeCloudZoneId" - edgeCloudZoneName: - $ref: "#/components/schemas/EdgeCloudZoneName" - edgeCloudZoneStatus: - $ref: "#/components/schemas/EdgeCloudZoneStatus" - edgeCloudProvider: - $ref: "#/components/schemas/EdgeCloudProvider" - edgeCloudRegion: - $ref: "#/components/schemas/EdgeCloudRegion" - minItems: 1 - - EdgeCloudZoneName: - type: string - description: | - Human readable name of the geographical zone of - the Edge Cloud. Defined by the Edge Cloud Provider. - - EdgeCloudZoneStatus: - description: Status of the Edge Cloud Zone (default is 'unknown') - type: string - enum: - - active - - inactive - - unknown - - ErrorInfo: - type: object - description: Information about the error - 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 - - FederationAPINames: - type: string - enum: - - FEDERATION - - AVAILZONE - - ARTEFACT - - FILE - - SVSAPEFED - - RESMONITOR - - EVENTMGMT - - FAULTMGMT - HttpMethods: - type: string - enum: - - POST - - PUT - - PATCH - - DELETE - - GET - HttpResources: - required: - - href - - httpMethods - type: object - properties: - href: - $ref: '#/components/schemas/Uri' - httpMethods: - type: array - description: List of HTTP Methods supported for the given API category - items: - $ref: '#/components/schemas/HttpMethods' - example: - httpMethods: - - POST - - POST - href: href - - FederationAPIResources: - required: - - apiOperations - - name - type: object - properties: - name: - $ref: '#/components/schemas/FederationAPINames' - apiOperations: - type: array - description: List of HTTP Methods supported for the given API category - items: - $ref: '#/components/schemas/HttpResources' - example: - name: FEDERATION - apiOperations: - - httpMethods: - - POST - - POST - href: href - - httpMethods: - - POST - - POST - href: href - State: - required: - - alarmState - type: object - properties: - alarmState: - type: string - description: Defines the alarm state during its life cycle (raised | updated - | cleared). - enum: - - RAISED - - UPDATED - - CLEAR - example: - alarmState: RAISED - serviceType: - type: string - description: An identifier to refer to partner OP capabilities for application - providers. - enum: - - api_federation - - FederationSupportedAPIs: - required: - - artefactAPI - - availabilityZoneAPI - - edgeApplicationAPI - - federationBaseAPI - - fileAPI - type: object - properties: - federationBaseAPI: - $ref: '#/components/schemas/FederationAPIResources' - availabilityZoneAPI: - $ref: '#/components/schemas/FederationAPIResources' - edgeApplicationAPI: - $ref: '#/components/schemas/FederationAPIResources' - artefactAPI: - $ref: '#/components/schemas/FederationAPIResources' - fileAPI: - $ref: '#/components/schemas/FederationAPIResources' - serviceAPIFederation: - $ref: '#/components/schemas/FederationAPIResources' - resourceMonitoringAPI: - $ref: '#/components/schemas/FederationAPIResources' - faultManagementAPI: - $ref: '#/components/schemas/FederationAPIResources' - eventManagementAPI: - $ref: '#/components/schemas/FederationAPIResources' - example: - availabilityZoneAPI: null - artefactAPI: null - fileAPI: null - resourceMonitoringAPI: null - faultManagementAPI: null - federationBaseAPI: - name: FEDERATION - apiOperations: - - httpMethods: - - POST - - POST - href: href - - httpMethods: - - POST - - POST - href: href - serviceAPIFederation: null - eventManagementAPI: null - edgeApplicationAPI: null - FederationIdentifier: - pattern: "^[A-Za-z0-9][A-Za-z0-9-]*$" - type: string - description: Globally unique identifier allocated to an operator platform. This - is valid and used only in context of MEC federation interface. - - FederationRequestData: - required: - - initialDate - - partnerStatusLink - type: object - properties: - origOPFederationId: - $ref: '#/components/schemas/FederationIdentifier' - origOPCountryCode: - $ref: '#/components/schemas/CountryCode' - origOPMobileNetworkCodes: - $ref: '#/components/schemas/MobileNetworkIds' - origOPFixedNetworkCodes: - $ref: '#/components/schemas/FixedNetworkIds' - initialDate: - type: string - description: Time zone info of the federation initiated by the originating - OP - format: date-time - partnerStatusLink: - $ref: '#/components/schemas/Uri' - partnerCallbackCredentials: - $ref: '#/components/schemas/CallbackCredentials' - - FederationResponseData: - required: - - federationContextId - - platformCaps - type: object - properties: - partnerOPFederationId: - $ref: '#/components/schemas/FederationIdentifier' - partnerOPCountryCode: - $ref: '#/components/schemas/CountryCode' - federationContextId: - $ref: '#/components/schemas/FederationContextId' - edgeDiscoveryServiceEndPoint: - $ref: '#/components/schemas/ServiceEndpoint' - lcmServiceEndPoint: - $ref: '#/components/schemas/ServiceEndpoint' - partnerOPMobileNetworkCodes: - $ref: '#/components/schemas/MobileNetworkIds' - partnerOPFixedNetworkCodes: - $ref: '#/components/schemas/FixedNetworkIds' - offeredAvailabilityZones: - minItems: 1 - type: array - description: "List of zones, which the operator platform wishes to make\ - \ available to developers/ISVs of requesting operator platform." - items: - $ref: '#/components/schemas/ZoneDetails' - FederationContextId: - pattern: "^[A-Za-z0-9][A-Za-z0-9-]*$" - type: string - description: This identifier shall be provided by the partner OP on successful - verification and validation of the federation create request and is used by - partner op to identify this newly created federation context. Originating - OP shall provide this identifier in any subsequent request towards the partner - op. - readOnly: true - - ProblemDetails: - type: object - properties: - title: - type: string - detail: - type: string - cause: - type: string - invalidParams: - minItems: 1 - type: array - items: - $ref: '#/components/schemas/InvalidParam' - - ServiceEndpoint: - required: - - port - type: object - properties: - port: - $ref: '#/components/schemas/Port' - fqdn: - $ref: '#/components/schemas/Fqdn' - ipv4Addresses: - minItems: 1 - type: array - items: - $ref: '#/components/schemas/Ipv4Addr' - ipv6Addresses: - minItems: 1 - type: array - items: - $ref: '#/components/schemas/Ipv6Addr' - example: - ipv4Addresses: - - 198.51.100.1 - - 198.51.100.1 - port: 0 - fqdn: fqdn - ipv6Addresses: - - 2001:db8:85a3::8a2e:370:7334 - - 2001:db8:85a3::8a2e:370:7334 - anyOf: - - required: - - fqdn - - required: - - ipv4Addresses - - required: - - ipv6Addresses - - InvalidParam: - required: - - param - type: object - properties: - param: - type: string - reason: - type: string - - ZoneDetails: - required: - - geographyDetails - - geolocation - - zoneId - type: object - properties: - zoneId: - $ref: '#/components/schemas/ZoneIdentifier' - geolocation: - $ref: '#/components/schemas/GeoLocation' - geographyDetails: - type: string - description: "Details about cities or state covered by the edge. Details\ - \ about the type of locality for eg rural, urban, industrial etc. This\ - \ information is defined in human readable form." - example: - geographyDetails: geographyDetails - zoneId: zoneId - geolocation: geolocation - - ZoneIdentifier: - pattern: "^[A-Za-z0-9][A-Za-z0-9-]*$" - type: string - description: Human readable name of the zone. - - ZoneRegistrationRequestData: - required: - - acceptedAvailabilityZones - - availZoneNotifLink - type: object - properties: - acceptedAvailabilityZones: - minItems: 1 - type: array - items: - $ref: '#/components/schemas/ZoneIdentifier' - availZoneNotifLink: - $ref: '#/components/schemas/Uri' - - ZoneResourceInfo: - type: object - properties: - zoneId: - $ref: '#/components/schemas/ZoneIdentifier' - computeResourceQuotaLimits: - type: array - items: - $ref: '#/components/schemas/ComputeResource' - reservedComputeResources: - type: array - items: - $ref: '#/components/schemas/ComputeResource' - flavoursSupported: - type: array - items: - $ref: '#/components/schemas/ZoneFlavour' - - ZoneReservationRemoval: - type: object - properties: - status: - type: string - example: - status: deleted - - ComputeResource: - type: object - properties: - cpuArchType: - $ref: '#/components/schemas/CPUArchType' - numCPU: - type: integer - format: int32 - memory: - type: integer - format: int32 - hugepages: - type: array - items: - type: string - - ZoneFlavour: - type: object - properties: - flavourId: - type: string - cpuArchType: - $ref: '#/components/schemas/CPUArchType' - numCPU: - type: integer - format: int32 - memorySize: - type: integer - format: int32 - storageSize: - type: integer - format: int32 - supportedOSTypes: - type: array - items: - $ref: '#/components/schemas/OSType' - - OSType: - type: object - properties: - distribution: - type: string - version: - type: string - architecture: - type: string - license: - type: string - - FixedNetworkIds: - minItems: 1 - type: array - description: List of network identifier associated with the fixed line network - of the operator platform. - items: - type: string - - Fqdn: - type: string - description: | - Full qualified domain name of an application instance - - GpuInfo: - type: object - description: Information about the supported GPUs - required: - - gpuMemory - - numGPU - properties: - gpuMemory: - type: integer - description: GPU memory in mega bytes - numGPU: - type: integer - description: Number of GPUs - - K8sAddons: - description: | - Addons for the Kubernetes cluster. - Additional addons should be defined in application the helm chart - (Service Mesh, Serverless, AI). - type: object - properties: - monitoring: - type: boolean - example: true - default: false - description: Enable monitoring for Kubernetes cluster. - ingress: - type: boolean - example: true - default: false - description: Enable ingress for Kubernetes cluster. - - K8sNetworking: - description: | - Kubernetes networking definition - type: object - properties: - primaryNetwork: - description: Definition of Kubernetes primary Network - type: object - properties: - provider: - description: CNI provider name - type: string - example: cilium - version: - description: CNI provider version - type: string - example: "1.13" - additionalNetworks: - description: Additional Networks for the Kubernetes cluster. - type: array - items: - type: object - description: Additional network interface definition - properties: - name: - description: Additional Network Name - type: string - example: net1 - interfaceType: - description: | - Type of additional Interface: - netdevice: (SR-IOV) A regular kernel network device in the - Network Namespace (netns) of the container - vfio-pci: (SR-IOV) A PCI network interface directly mounted - in the container - interface: Additional interface to be used by cni plugins - such as macvlan, ipvlan - Note: The use of SR-IOV interfaces automatically - configure the required kernel parameters for the nodes. - type: string - example: vfio-pci - enum: - - netdevice - - vfio-pci - - interface - - Mcc: - pattern: "^\\d{3}$" - type: string - Mnc: - pattern: "^\\d{2,3}$" - - MobileNetworkIds: - type: object - properties: - mcc: - $ref: '#/components/schemas/Mcc' - mncs: - minItems: 1 - type: array - items: - $ref: '#/components/schemas/Mnc' - example: - mncs: - - mncs - - mncs - mcc: mcc - - AdditionalStorage: - description: Additional storage for the application. - type: array - items: - type: object - required: - - storageSize - - mountPoint - properties: - name: - type: string - description: Name of additional storage resource. - example: logs - storageSize: - type: string - description: Additional persistent volume for the application. - example: 80GB - pattern: ^\d+(GB|MB)$ - mountPoint: - type: string - description: Location of additional storage resource. - example: /logs - - Vcpu: - type: string - pattern: ^\d+((\.\d{1,3})|(m))?$ - description: | - Number of vcpus in whole (i.e 1), decimal (i.e 0.500) up to - millivcpu, or millivcpu (i.e 500m) format. - example: "500m" - - KubernetesClusterRef: - description: | - A global unique identifier associated with a Kubernetes cluster - infrastructure. - type: string - format: uuid - example: "642f6105-7015-4af1-a4d1-e1ecb8437abc" - - KubernetesResources: - description: Definition of Kubernetes Cluster Infrastructure. - required: - - infraKind - - applicationResources - - isStandalone - properties: - infraKind: - description: Type of infrastructure for the application. - type: string - example: kubernetes - enum: - - kubernetes - applicationResources: - description: | - Application resources define the resources pool required - by the application to be executed in a Kubernetes clusters. - type: object - properties: - cpuPool: - required: - - numCPU - - memory - - topology - type: object - description: | - CPU Pool refers to the amount of application' resources - that is executed in nodes with CPU only. That means the part - of application that doesn't require GPU or other kind of - acceleration. - CPU pool is not mandatory when the application is executed - exclusively in a GPU pool. - A CPU pool is composed by CPU and memory. - properties: - numCPU: - description: | - Total number of vcpus in whole (i.e 1) of CPU pool. - type: integer - example: 1 - memory: - description: Total memory in mega bytes of CPU pool. - type: integer - example: 1024 - topology: - type: object - description: | - CPU pool topology defines an application's CPU-based - architecture. - When deploying for high availability or redundancy, it - allows for clustering with a configurable number of nodes - and minimum CPU/memory resource per Kubernetes node - requirements. - required: - - minNumberOfNodes - - minNodeCpu - - minNodeMemory - properties: - minNumberOfNodes: - description: | - Minimum number of worker nodes required by the - application. - type: integer - example: 5 - minNodeCpu: - description: | - Minimum number of vcpus in whole (i.e 1) per cluster - node in CPU pool. - type: integer - example: 2 - minNodeMemory: - description: | - Minimum memory in mega bytes per cluster node in - CPU pool. - type: integer - example: 1024 - gpuPool: - required: - - numCPU - - memory - - gpuMemory - - topology - type: object - description: | - GPU Pool refers to the amount of resources of the application - that is executed in nodes with GPU. - GPU Pool is not mandatory when the application is executed - exclusively in a CPU pool. - A GPU pool is composed by memory, CPU and GPU memory - properties: - numCPU: - description: | - Total Number of vcpus in whole (i.e 1) of GPU pool. - type: integer - example: 1 - memory: - description: Total memory in mega bytes of GPU pool. - type: integer - example: 1024 - gpuMemory: - description: Total GPU memory in giga bytes of GPU pool. - type: integer - example: 16 - topology: - type: object - description: | - GPU pool topology defines an application's GPU-based - architecture. - When deploying for high availability or redundancy, it - allows for clustering with a configurable number of nodes - and minimum CPU/memory/GPU memory resource per Kubernetes - node requirements. - required: - - minNumberOfNodes - - minNodeCpu - - minNodeMemory - - minNodeGpuMemory - properties: - minNumberOfNodes: - description: | - Minimum number of worker nodes with GPU required by - the application. - type: integer - example: 2 - minNodeCpu: - description: | - Minimum number of vcpus in whole (i.e 1) per cluster - node in GPU pool. - type: integer - example: 2 - minNodeMemory: - description: | - Minimum memory in mega bytes per cluster node in - GPU pool. - type: integer - example: 1024 - minNodeGpuMemory: - description: Minimum memory in giga bytes per cluster - node in GPU pool. - type: integer - example: 8 - isStandalone: - description: | - Define if the Kubernetes clusters can be reused by other - applications. - type: boolean - example: false - version: - type: string - description: Minimum Kubernetes Version. - example: "1.29" - additionalStorage: - type: string - description: | - Amount of persistent storage allocated to the Kubernetes PVC. - example: 80GB - pattern: ^\d+(GB|MB)$ - networking: - $ref: "#/components/schemas/K8sNetworking" - addons: - $ref: "#/components/schemas/K8sAddons" - - VmResources: - description: Definition of Virtual Machine Infrastructure - type: object - required: - - infraKind - - numCPU - - memory - properties: - infraKind: - description: Type of infrastructure for the application. - type: string - example: virtualMachine - enum: - - virtualMachine - numCPU: - type: integer - description: | - Number of vcpus in whole (i.e 1) - example: 1 - memory: - type: integer - example: 1024 - description: Memory in mega bytes - additionalStorages: - $ref: "#/components/schemas/AdditionalStorage" - gpu: - $ref: "#/components/schemas/GpuInfo" - - DockerComposeResources: - description: Definition of Docker Compose Infrastructure - type: object - required: - - infraKind - - numCPU - - memory - properties: - infraKind: - description: Type of infrastructure for the application. - type: string - example: dockerCompose - enum: - - dockerCompose - numCPU: - type: integer - description: | - Number of vcpus in whole (i.e 1) - example: 1 - memory: - type: integer - example: 1024 - description: Memory in mega bytes - storage: - $ref: "#/components/schemas/AdditionalStorage" - gpu: - $ref: "#/components/schemas/GpuInfo" - - ContainerResources: - description: Container Infrastructure Definition - type: object - required: - - infraKind - - numCPU - - memory - properties: - infraKind: - description: Type of infrastructure for the application. - type: string - example: container - enum: - - container - numCPU: - $ref: "#/components/schemas/Vcpu" - memory: - type: integer - example: 1024 - description: Memory in mega bytes - storage: - $ref: "#/components/schemas/AdditionalStorage" - gpu: - $ref: "#/components/schemas/GpuInfo" - - Ipv4Addr: - type: string - format: ipv4 - example: "192.168.0.1" - description: | - IP of the device. A single IPv4 address may be specified in - dotted-quad form 1.2.3.4. Only this exact IP number will match the flow - control rule. - - Ipv6Addr: - type: string - format: ipv6 - example: "2001:db8:85a3:8d3:1319:8a2e:370:7344" - description: | - IP of the device. A single IPv6 address, following IETF 5952 - format, may be specified like 2001:db8:85a3:8d3:1319:8a2e:370:7344 - - OperatingSystem: - description: | - Information about the Operating System of the application image - type: object - required: - - architecture - - family - - version - - license - properties: - architecture: - description: Type of the OS Architecture - type: string - enum: - - x86_64 - - x86 - example: x86_64 - family: - description: Family to which OS belongs - type: string - enum: - - RHEL - - UBUNTU - - COREOS - - WINDOWS - - OTHER - version: - description: Version of the OS - type: string - enum: - - OS_VERSION_UBUNTU_2204_LTS - - OS_VERSION_RHEL_8 - - OS_MS_WINDOWS_2022 - - OTHER - license: - description: License needed to activate the OS - type: string - enum: - - OS_LICENSE_TYPE_FREE - - OS_LICENSE_TYPE_ON_DEMAND - - OTHER - - Port: - type: integer - description: Port to stablish the connection - minimum: 0 - - RequiredResources: - description: | - Fundamental hardware requirements to be provisioned by the - Application Provider. - oneOf: - - $ref: "#/components/schemas/KubernetesResources" - - $ref: "#/components/schemas/VmResources" - - $ref: "#/components/schemas/ContainerResources" - - $ref: "#/components/schemas/DockerComposeResources" - discriminator: - propertyName: infraKind - mapping: - kubernetes: "#/components/schemas/KubernetesResources" - virtualMachine: "#/components/schemas/VmResources" - container: "#/components/schemas/ContainerResources" - dockerCompose: "#/components/schemas/DockerComposeResources" - - SubmittedApp: - description: Information about the submitted app - type: object - properties: - appId: - $ref: "#/components/schemas/AppId" - - serviceAPINames: - minItems: 1 - type: array - description: "List of Service API capability names an OP supports and offers\ - \ to other OPs \"quality_on_demand\", \"device_location\" etc." - items: - type: string - enum: - - QualityOnDemand - - DeviceLocation - - DeviceStatus - - SimSwap - - NumberVerification - - DeviceIdentifier - Status: - type: string - enum: - - FAILED - - TEMPORARY_FAILURE - - AVAILABLE - - LOCKED - - NOT_AVAILABLE - - Uri: - type: string - example: https://charts.bitnami.com/bitnami/helm/example-chart:0.1.0 - description: | - A Uniform Resource Identifier (URI) as per RFC 3986, - identifies the endpoint within an Edge Cloud Zone where the user - equipment may connect to the selected application instance - - responses: - "400": - description: Bad request - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 400 - code: INVALID_ARGUMENT - message: "Schema validation failed at ..." - "401": - description: Unauthorized - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 401 - code: UNAUTHENTICATED - message: "Authorization failed: ..." - "403": - description: Unauthorized - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 403 - code: PERMISSION_DENIED - message: "Operation not allowed: ..." - "404": - description: Not Found - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 404 - code: NOT_FOUND - message: "Resource does not exist" - "500": - description: Internal Server Error - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 500 - code: INTERNAL - message: "Internal server error: ..." - "501": - description: Not Implemented - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 501 - code: NOT_IMPLEMENTED - message: "Service not implemented" - "503": - description: Service Unavailable - headers: - x-correlator: - $ref: "#/components/headers/x-correlator" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorInfo" - example: - status: 503 - code: UNAVAILABLE - message: "Service unavailable" diff --git a/edge_cloud_management_api/configs/__init__.py b/open_exposure_gateway/app/__init__.py similarity index 100% rename from edge_cloud_management_api/configs/__init__.py rename to open_exposure_gateway/app/__init__.py diff --git a/open_exposure_gateway/app/adapters/database/core.py b/open_exposure_gateway/app/adapters/database/core.py new file mode 100644 index 0000000000000000000000000000000000000000..713a77847f0a71de41002051e28e12cd8162b602 --- /dev/null +++ b/open_exposure_gateway/app/adapters/database/core.py @@ -0,0 +1,28 @@ +from typing import Literal + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from open_exposure_gateway.app.adapters.database.sql import get_metadata + + +async def build_engine_and_session_maker( + url: str, echo: bool | Literal["debug"] +) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: + + engine: AsyncEngine = create_async_engine(url, echo=echo) + + session_maker: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=engine, expire_on_commit=False + ) + + return engine, session_maker + + +async def schema_initialization(engine: AsyncEngine) -> None: + async with engine.begin() as conn: + await conn.run_sync(get_metadata().create_all) diff --git a/open_exposure_gateway/app/adapters/database/mappers.py b/open_exposure_gateway/app/adapters/database/mappers.py new file mode 100644 index 0000000000000000000000000000000000000000..f0227e3243761c40a1cd610d98f6a004f9496914 --- /dev/null +++ b/open_exposure_gateway/app/adapters/database/mappers.py @@ -0,0 +1,163 @@ +from open_exposure_gateway.app.adapters.database.sql import ( + AppInstanceRow, + AppRegistrationRow, + CallbackDeliveryRow, + CallbackRegistrationRow, + OperationRow, +) +from open_exposure_gateway.app.domain.models import ( + AppInstance, + AppRegistration, + CallbackDelivery, + CallbackRegistration, + Operation, +) + + +class AppRegistrationMapper: + @staticmethod + def to_domain(row: AppRegistrationRow) -> AppRegistration: + return AppRegistration( + app_registration_id=row.app_registration_id, + app_id=row.app_id, + tenant_id=row.tenant_id, + name=row.name, + version=row.version, + package_type=row.package_type, + status=row.status, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: AppRegistration) -> AppRegistrationRow: + return AppRegistrationRow( + app_registration_id=domain.app_registration_id, + app_id=domain.app_id, + tenant_id=domain.tenant_id, + name=domain.name, + version=domain.version, + package_type=domain.package_type, + status=domain.status, + ) + + +class OperationMapper: + @staticmethod + def to_domain(row: OperationRow) -> Operation: + return Operation( + operation_id=row.operation_id, + correlation_id=row.correlation_id, + tenant_id=row.tenant_id, + app_provider_id=row.app_provider_id, + operation_type=row.operation_type, + status=row.status, + subject=row.subject, + idempotency_key=row.idempotency_key, + app_registration_id=row.app_registration_id, + result=row.result, + error=row.error, + metadata=row.operation_metadata, + completed_at=row.completed_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: Operation) -> OperationRow: + return OperationRow( + operation_id=domain.operation_id, + correlation_id=domain.correlation_id, + tenant_id=domain.tenant_id, + app_provider_id=domain.app_provider_id, + operation_type=domain.operation_type, + status=domain.status, + subject=domain.subject, + idempotency_key=domain.idempotency_key, + app_registration_id=domain.app_registration_id, + result=domain.result, + error=domain.error, + operation_metadata=domain.metadata, + completed_at=domain.completed_at, + ) + + +class AppInstanceMapper: + @staticmethod + def to_domain(row: AppInstanceRow) -> AppInstance: + return AppInstance( + app_instance_id=row.app_instance_id, + operation_id=row.operation_id, + app_registration_id=row.app_registration_id, + edge_cloud_zone_id=row.edge_cloud_zone_id, + state=row.state, + app_deployment_id=row.app_deployment_id, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: AppInstance) -> AppInstanceRow: + return AppInstanceRow( + app_instance_id=domain.app_instance_id, + operation_id=domain.operation_id, + app_registration_id=domain.app_registration_id, + edge_cloud_zone_id=domain.edge_cloud_zone_id, + state=domain.state, + app_deployment_id=domain.app_deployment_id, + ) + + +class CallbackRegistrationMapper: + @staticmethod + def to_domain(row: CallbackRegistrationRow) -> CallbackRegistration: + return CallbackRegistration( + id=row.id, + operation_id=row.operation_id, + tenant_id=row.tenant_id, + api_family=row.api_family, + sink=row.sink, + event_types=row.event_types, + sink_credential_ref=row.sink_credential_ref, + expires_at=row.expires_at, + is_active=row.is_active, + created_at=row.created_at, + ) + + @staticmethod + def to_row(domain: CallbackRegistration) -> CallbackRegistrationRow: + return CallbackRegistrationRow( + id=domain.id, + operation_id=domain.operation_id, + tenant_id=domain.tenant_id, + api_family=domain.api_family, + sink=domain.sink, + event_types=domain.event_types, + sink_credential_ref=domain.sink_credential_ref, + expires_at=domain.expires_at, + is_active=domain.is_active, + ) + + +class CallbackDeliveryMapper: + @staticmethod + def to_domain(row: CallbackDeliveryRow) -> CallbackDelivery: + return CallbackDelivery( + id=row.id, + callback_registration_id=row.callback_registration_id, + operation_id=row.operation_id, + attempt=row.attempt, + state=row.state, + last_error=row.last_error, + ) + + @staticmethod + def to_row(domain: CallbackDelivery) -> CallbackDeliveryRow: + return CallbackDeliveryRow( + id=domain.id, + callback_registration_id=domain.callback_registration_id, + operation_id=domain.operation_id, + attempt=domain.attempt, + state=domain.state, + last_error=domain.last_error, + ) diff --git a/edge_cloud_management_api/controllers/__init__.py b/open_exposure_gateway/app/adapters/database/repos/__init__.py similarity index 100% rename from edge_cloud_management_api/controllers/__init__.py rename to open_exposure_gateway/app/adapters/database/repos/__init__.py diff --git a/open_exposure_gateway/app/adapters/database/repos/app_instances.py b/open_exposure_gateway/app/adapters/database/repos/app_instances.py new file mode 100644 index 0000000000000000000000000000000000000000..107e9e4fa7371969ed3caf2adaf0b3071841c95b --- /dev/null +++ b/open_exposure_gateway/app/adapters/database/repos/app_instances.py @@ -0,0 +1,27 @@ +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.mappers import AppInstanceMapper +from open_exposure_gateway.app.adapters.database.sql import AppInstanceRow +from open_exposure_gateway.app.domain.models import AppInstance +from open_exposure_gateway.app.ports.database.instances import AppInstanceRepository + + +class SqlAppInstanceRepository(AppInstanceRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, app_instance_id: UUID) -> AppInstance | None: + stmt = select(AppInstanceRow).where(AppInstanceRow.app_instance_id == app_instance_id) + row = await self._session.scalar(stmt) + return AppInstanceMapper.to_domain(row) if row is not None else None + + async def save(self, app_instance: AppInstance) -> AppInstance: + merged = await self._session.merge(AppInstanceMapper.to_row(app_instance)) + await self._session.flush() + saved = await self.get_by_id(merged.app_instance_id) + if saved is None: + raise RuntimeError("Saved app instance could not be reloaded") + return saved diff --git a/open_exposure_gateway/app/adapters/database/repos/app_registrations.py b/open_exposure_gateway/app/adapters/database/repos/app_registrations.py new file mode 100644 index 0000000000000000000000000000000000000000..8c1cd3083f1803bb2f972463df9893c041da1683 --- /dev/null +++ b/open_exposure_gateway/app/adapters/database/repos/app_registrations.py @@ -0,0 +1,43 @@ +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.mappers import AppRegistrationMapper +from open_exposure_gateway.app.adapters.database.sql import AppRegistrationRow +from open_exposure_gateway.app.adapters.errors import DuplicateAppRegistrationError +from open_exposure_gateway.app.domain.models import AppRegistration +from open_exposure_gateway.app.ports.database.registration import AppRegistrationRepository + +_UNIQUE_VIOLATION = "23505" + + +class SqlAppRegistrationRepository(AppRegistrationRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, app_registration_id: UUID) -> AppRegistration | None: + stmt = select(AppRegistrationRow).where( + AppRegistrationRow.app_registration_id == app_registration_id + ) + row = await self._session.scalar(stmt) + return AppRegistrationMapper.to_domain(row) if row is not None else None + + async def get_by_app_id(self, app_id: UUID) -> AppRegistration | None: + stmt = select(AppRegistrationRow).where(AppRegistrationRow.app_id == app_id) + row = await self._session.scalar(stmt) + return AppRegistrationMapper.to_domain(row) if row is not None else None + + async def save(self, app_registration: AppRegistration) -> AppRegistration: + merged = await self._session.merge(AppRegistrationMapper.to_row(app_registration)) + try: + await self._session.flush() + except IntegrityError as exc: + if getattr(exc.orig, "sqlstate", None) == _UNIQUE_VIOLATION: + raise DuplicateAppRegistrationError() from exc + raise + saved = await self.get_by_id(merged.app_registration_id) + if saved is None: + raise RuntimeError("Saved app registration could not be reloaded") + return saved diff --git a/open_exposure_gateway/app/adapters/database/repos/callback_deliveries.py b/open_exposure_gateway/app/adapters/database/repos/callback_deliveries.py new file mode 100644 index 0000000000000000000000000000000000000000..4d9f1014ae895bd322fe5c8cd7cd7f67984a8e38 --- /dev/null +++ b/open_exposure_gateway/app/adapters/database/repos/callback_deliveries.py @@ -0,0 +1,32 @@ +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.mappers import CallbackDeliveryMapper +from open_exposure_gateway.app.adapters.database.sql import CallbackDeliveryRow +from open_exposure_gateway.app.domain.models import CallbackDelivery +from open_exposure_gateway.app.ports.database.callbacks import CallbackDeliveryRepository + + +class SqlCallbackDeliveryRepository(CallbackDeliveryRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_by_callback_registration_id( + self, callback_registration_id: UUID + ) -> list[CallbackDelivery]: + stmt = select(CallbackDeliveryRow).where( + CallbackDeliveryRow.callback_registration_id == callback_registration_id + ) + rows = await self._session.scalars(stmt) + return [CallbackDeliveryMapper.to_domain(row) for row in rows] + + async def save(self, callback_delivery: CallbackDelivery) -> CallbackDelivery: + merged = await self._session.merge(CallbackDeliveryMapper.to_row(callback_delivery)) + await self._session.flush() + stmt = select(CallbackDeliveryRow).where(CallbackDeliveryRow.id == merged.id) + row = await self._session.scalar(stmt) + if row is None: + raise RuntimeError("Saved callback delivery could not be reloaded") + return CallbackDeliveryMapper.to_domain(row) diff --git a/open_exposure_gateway/app/adapters/database/repos/callback_registrations.py b/open_exposure_gateway/app/adapters/database/repos/callback_registrations.py new file mode 100644 index 0000000000000000000000000000000000000000..6f8bf909b485129b17d6a7a4d571bd3c0f3b17c1 --- /dev/null +++ b/open_exposure_gateway/app/adapters/database/repos/callback_registrations.py @@ -0,0 +1,30 @@ +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.mappers import CallbackRegistrationMapper +from open_exposure_gateway.app.adapters.database.sql import CallbackRegistrationRow +from open_exposure_gateway.app.domain.models import CallbackRegistration +from open_exposure_gateway.app.ports.database.callbacks import CallbackRegistrationRepository + + +class SqlCallbackRegistrationRepository(CallbackRegistrationRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_operation_id(self, operation_id: UUID) -> CallbackRegistration | None: + stmt = select(CallbackRegistrationRow).where( + CallbackRegistrationRow.operation_id == operation_id + ) + row = await self._session.scalar(stmt) + return CallbackRegistrationMapper.to_domain(row) if row is not None else None + + async def save(self, callback_registration: CallbackRegistration) -> CallbackRegistration: + merged = await self._session.merge(CallbackRegistrationMapper.to_row(callback_registration)) + await self._session.flush() + stmt = select(CallbackRegistrationRow).where(CallbackRegistrationRow.id == merged.id) + row = await self._session.scalar(stmt) + if row is None: + raise RuntimeError("Saved callback registration could not be reloaded") + return CallbackRegistrationMapper.to_domain(row) diff --git a/open_exposure_gateway/app/adapters/database/repos/operations.py b/open_exposure_gateway/app/adapters/database/repos/operations.py new file mode 100644 index 0000000000000000000000000000000000000000..646ab3e006cc7e7b816a2331057e34d21b60ae02 --- /dev/null +++ b/open_exposure_gateway/app/adapters/database/repos/operations.py @@ -0,0 +1,46 @@ +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.mappers import OperationMapper +from open_exposure_gateway.app.adapters.database.sql import OperationRow +from open_exposure_gateway.app.adapters.errors import DuplicateOperationError +from open_exposure_gateway.app.domain.models import Operation +from open_exposure_gateway.app.ports.database.operations import OperationRepository + +_UNIQUE_VIOLATION = "23505" + + +class SqlOperationRepository(OperationRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, operation_id: UUID) -> Operation | None: + stmt = select(OperationRow).where(OperationRow.operation_id == operation_id) + row = await self._session.scalar(stmt) + return OperationMapper.to_domain(row) if row is not None else None + + async def get_by_idempotency_key( + self, tenant_id: str, idempotency_key: str + ) -> Operation | None: + stmt = select(OperationRow).where( + OperationRow.tenant_id == tenant_id, + OperationRow.idempotency_key == idempotency_key, + ) + row = await self._session.scalar(stmt) + return OperationMapper.to_domain(row) if row is not None else None + + async def save(self, operation: Operation) -> Operation: + merged = await self._session.merge(OperationMapper.to_row(operation)) + try: + await self._session.flush() + except IntegrityError as exc: + if getattr(exc.orig, "sqlstate", None) == _UNIQUE_VIOLATION: + raise DuplicateOperationError() from exc + raise + saved = await self.get_by_id(merged.operation_id) + if saved is None: + raise RuntimeError("Saved operation could not be reloaded") + return saved diff --git a/open_exposure_gateway/app/adapters/database/sql.py b/open_exposure_gateway/app/adapters/database/sql.py new file mode 100644 index 0000000000000000000000000000000000000000..3eccf07812fe07b0339944d897868d03d9fe57a2 --- /dev/null +++ b/open_exposure_gateway/app/adapters/database/sql.py @@ -0,0 +1,177 @@ +from datetime import datetime +from enum import Enum as PyEnum +from uuid import UUID + +from sqlalchemy import ( + Boolean, + DateTime, + Enum, + ForeignKey, + Index, + Integer, + MetaData, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from open_exposure_gateway.app.domain.models.instances.enums import AppInstanceState +from open_exposure_gateway.app.domain.models.operations.enums import ( + OperationStatus, + OperationType, +) +from open_exposure_gateway.app.domain.models.registration.enums import ( + AppRegistrationStatus, + PackageType, +) + + +def get_metadata() -> MetaData: + return Base.metadata + + +def _enum_type(enum_cls: type[PyEnum]) -> Enum: + return Enum( + enum_cls, + values_callable=lambda obj: [e.value for e in obj], + native_enum=False, + length=32, + ) + + +class Base(AsyncAttrs, DeclarativeBase): + __abstract__ = True + + +class AuditedMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + +class AppRegistrationRow(AuditedMixin, Base): + __tablename__ = "app_registrations" + __table_args__ = (Index("idx_app_registrations_tenant", "tenant_id"),) + + app_registration_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True) + app_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), unique=True, nullable=False) + tenant_id: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(64), nullable=False) + version: Mapped[str] = mapped_column(String(64), nullable=False) + package_type: Mapped[PackageType] = mapped_column(_enum_type(PackageType), nullable=False) + status: Mapped[AppRegistrationStatus] = mapped_column( + _enum_type(AppRegistrationStatus), nullable=False + ) + + created_at: Mapped[datetime] = mapped_column( + "registered_at", + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + + +class OperationRow(AuditedMixin, Base): + __tablename__ = "operations" + __table_args__ = ( + UniqueConstraint("tenant_id", "idempotency_key", name="uq_operations_tenant_idempotency"), + Index("idx_operations_tenant", "tenant_id"), + Index("idx_operations_app_registration", "app_registration_id"), + Index("idx_operations_status_created_at", "status", "created_at"), + Index("idx_operations_correlation", "correlation_id"), + ) + + operation_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True) + correlation_id: Mapped[str] = mapped_column(String(128), nullable=False) + idempotency_key: Mapped[str | None] = mapped_column(String(128)) + tenant_id: Mapped[str] = mapped_column(String(255), nullable=False) + app_provider_id: Mapped[str] = mapped_column(String(255), nullable=False) + operation_type: Mapped[OperationType] = mapped_column(_enum_type(OperationType), nullable=False) + status: Mapped[OperationStatus] = mapped_column(_enum_type(OperationStatus), nullable=False) + app_registration_id: Mapped[UUID | None] = mapped_column( + ForeignKey("app_registrations.app_registration_id") + ) + subject: Mapped[str] = mapped_column(String(128), nullable=False) + result: Mapped[dict[str, object] | None] = mapped_column(JSONB) + error: Mapped[dict[str, object] | None] = mapped_column(JSONB) + # Attribute renamed from `metadata` (reserved by DeclarativeBase) to + # `operation_metadata`, same convention SRM uses for its *_metadata columns. + operation_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class CallbackRegistrationRow(Base): + __tablename__ = "callback_registrations" + __table_args__ = ( + Index("idx_callback_registrations_operation", "operation_id"), + Index("idx_callback_registrations_tenant", "tenant_id"), + ) + + 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 + ) + tenant_id: Mapped[str] = mapped_column(String(255), nullable=False) + api_family: Mapped[str] = mapped_column(String(64), nullable=False) + sink: Mapped[str] = mapped_column(Text, nullable=False) + sink_credential_ref: Mapped[str | None] = mapped_column(String(255)) + event_types: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + + +class CallbackDeliveryRow(Base): + __tablename__ = "callback_deliveries" + + id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True) + callback_registration_id: Mapped[UUID] = mapped_column( + ForeignKey("callback_registrations.id"), nullable=False + ) + operation_id: Mapped[UUID] = mapped_column( + ForeignKey("operations.operation_id"), nullable=False + ) + attempt: Mapped[int] = mapped_column(Integer, nullable=False) + last_error: Mapped[str | None] = mapped_column(Text) + state: Mapped[str] = mapped_column(String(32), nullable=False) + + +class AppInstanceRow(AuditedMixin, Base): + __tablename__ = "app_instances" + __table_args__ = ( + Index("idx_app_instances_operation", "operation_id"), + Index("idx_app_instances_deployment", "app_deployment_id"), + Index("idx_app_instances_registration", "app_registration_id"), + ) + + app_instance_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 + ) + # No FK yet: app_deployments (multi-zone POST /deployments) is out of + # scope for this MR. + app_deployment_id: Mapped[UUID | None] = mapped_column(PG_UUID(as_uuid=True)) + app_registration_id: Mapped[UUID] = mapped_column( + ForeignKey("app_registrations.app_registration_id"), nullable=False + ) + 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) diff --git a/open_exposure_gateway/app/adapters/databus/nats_adapter.py b/open_exposure_gateway/app/adapters/databus/nats_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..74840cfa2a800e0e1566b028fd7a13bcf7e0d250 --- /dev/null +++ b/open_exposure_gateway/app/adapters/databus/nats_adapter.py @@ -0,0 +1,95 @@ +import json +from typing import Any, Protocol + +import nats +import structlog +from nats.aio.client import Client +from nats.aio.subscription import Subscription + +from open_exposure_gateway.app.core.config import NatsSettings +from open_exposure_gateway.app.ports.databus_port import DataBusPort + +logger: structlog.BoundLogger = structlog.get_logger(__name__) + + +class NatsMessagePublisher(DataBusPort): + def __init__(self, settings: NatsSettings) -> None: + self._settings = settings + self._client: Client | None = None + + @property + def is_connected(self) -> bool: + return self._client is not None and self._client.is_connected + + @property + def client(self) -> Client: + if self._client is None: + raise RuntimeError("NATS client is not connected") + return self._client + + async def connect(self) -> None: + async def _on_error(e: Exception) -> None: + logger.error("nats_error", error=str(e)) + + async def _on_disconnect() -> None: + logger.warning("nats_disconnected", url=self._settings.url) + + async def _on_reconnect() -> None: + logger.info("nats_reconnected", url=self._settings.url) + + self._client = await nats.connect( + servers=[self._settings.url], + connect_timeout=self._settings.connect_timeout, + max_reconnect_attempts=self._settings.max_reconnect_attempts, + error_cb=_on_error, + disconnected_cb=_on_disconnect, + reconnected_cb=_on_reconnect, + ) + + async def close(self) -> None: + if self._client is not None: + await self._client.drain() + await self._client.close() + self._client = None + + async def publish( + self, + subject: str, + payload: dict[str, Any], + headers: dict[str, str] | None = None, + ) -> None: + if self._client is None: + raise RuntimeError("NATS client is not connected") + + body = json.dumps(payload).encode("utf-8") + await self._client.publish(subject, body, headers=headers) + + +class _Msg(Protocol): + data: bytes + subject: str + + +class NatsOperationConsumer: + def __init__(self, client: Client, subject: str) -> None: + self._client = client + self._subject = subject + self._subscription: Subscription | None = None + + async def start(self) -> None: + # TODO: switch to a named durable JetStream consumer (client.jetstream().subscribe( + # ..., durable=...)) for at-least-once delivery of event.srm.* — core-NATS subscribe() + # is at-most-once, so an OEG restart or slow consumer drops events. See + # test_eam_contract.py::TestEventConsumptionDelivery (currently skipped). + 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 + + # TODO: parse operation.completed payload, update oeg_db operation record to + # COMPLETED or FAILED, and trigger webhook callback if registered + logger.info("operation_completed_received", subject=msg.subject, payload=raw) diff --git a/open_exposure_gateway/app/adapters/errors.py b/open_exposure_gateway/app/adapters/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..08baf4028782a9e96016a41eb70ea41657af31a0 --- /dev/null +++ b/open_exposure_gateway/app/adapters/errors.py @@ -0,0 +1,16 @@ +from typing import ClassVar + + +class DuplicateEntryError(Exception): + entity_name: ClassVar[str] = "Entity" + + def __init__(self) -> None: + super().__init__(f"{self.entity_name} already exists.") + + +class DuplicateAppRegistrationError(DuplicateEntryError): + entity_name: ClassVar[str] = "App Registration" + + +class DuplicateOperationError(DuplicateEntryError): + entity_name: ClassVar[str] = "Operation" diff --git a/open_exposure_gateway/app/adapters/http/srm_client.py b/open_exposure_gateway/app/adapters/http/srm_client.py new file mode 100644 index 0000000000000000000000000000000000000000..89bef367aba9cc1ca44ccbbf88e4c55adb851087 --- /dev/null +++ b/open_exposure_gateway/app/adapters/http/srm_client.py @@ -0,0 +1,200 @@ +from typing import Any +from uuid import UUID + +import httpx +import structlog + +from open_exposure_gateway.app.api.camara.quality_on_demand.v0_10_1.schemas import ( + QoDSessionResponse, +) +from open_exposure_gateway.app.core.config import get_settings +from open_exposure_gateway.app.core.exceptions import ( + DownstreamServiceException, + NotFoundException, +) +from open_exposure_gateway.app.domain.edge_application_management import ( + ResourceZone, + SRMCatalogPayload, + SRMCatalogServiceSpecificationCreated, + SRMServiceInstance, +) + +logger = structlog.get_logger(__name__) + + +class SRMClient: + def __init__(self) -> None: + settings = get_settings() + self.base_url = str(settings.srm_settings.base_url).rstrip("/") + self.timeout = settings.srm_settings.timeout + + async def _request( + self, + method: str, + path: str, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + url = f"{self.base_url}{path}" + log = logger.bind( + method=method, + url=url, + x_correlator=(headers or {}).get("x-correlator"), + ) + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.request( + method=method, + url=url, + json=json, + params=params, + headers=headers, + ) + + if response.status_code == 404: + log.warning("SRM resource not found", url=url) + raise NotFoundException(message="Resource not found") + + if response.status_code >= 400: + log.error("SRM returned error", status=response.status_code, body=response.text) + raise DownstreamServiceException( + message="SRM request failed", + details={ + "status_code": response.status_code, + "response": response.text, + }, + ) + + if response.status_code == 204: + return None + + if not response.content: + log.error( + "SRM returned empty body for non-204 response", status=response.status_code + ) + raise DownstreamServiceException( + message="SRM returned empty response", + details={"status_code": response.status_code}, + ) + + return response.json() + + except httpx.TimeoutException as exc: + log.exception("SRM request timed out", error=str(exc)) + raise DownstreamServiceException( + message="SRM request timed out", + details=str(exc), + ) + except httpx.ConnectError as exc: + log.exception("SRM connection failed", error=str(exc)) + raise DownstreamServiceException( + message="Could not connect to SRM", + details=str(exc), + ) + except httpx.RequestError as exc: + log.exception("SRM request error", error=str(exc)) + raise DownstreamServiceException( + message="SRM request failed", + details=str(exc), + ) + + async def get_resource_zones( + self, + region: str | None = None, + status: str | None = None, + x_correlator: str | None = None, + ) -> list[ResourceZone]: + params = {} + if region is not None: + params["region"] = region + if status is not None: + params["status"] = status + + headers = {} + if x_correlator: + headers["x-correlator"] = x_correlator + + data = await self._request( + "GET", + "/internal/zones", + params=params or None, + headers=headers or None, + ) + return [ResourceZone.model_validate(z) for z in data] + + async def create_qod_session( + self, + payload: dict[str, Any], + x_correlator: str | None = None, + ) -> QoDSessionResponse: + 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) + + async def get_qod_session( + self, + session_id: str, + x_correlator: str | None = None, + ) -> QoDSessionResponse: + 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) + + 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( + "GET", "/internal/catalog/service-specifications", headers=headers + ) + return [SRMCatalogPayload.model_validate(a) for a in data] + + async def get_app(self, app_id: UUID, x_correlator: str | None = None) -> SRMCatalogPayload: + headers = {"x-correlator": x_correlator} if x_correlator else None + data = await self._request( + "GET", f"/internal/catalog/service-specifications/{app_id}", headers=headers + ) + return SRMCatalogPayload.model_validate(data) + + async def create_catalog_service_specification( + self, payload: dict[str, Any], x_correlator: str | None = None + ) -> SRMCatalogServiceSpecificationCreated: + headers = {"x-correlator": x_correlator} if x_correlator else None + data = await self._request( + "POST", "/internal/catalog/service-specifications", json=payload, headers=headers + ) + return SRMCatalogServiceSpecificationCreated.model_validate(data) + + async def delete_app(self, app_id: UUID, x_correlator: str | None = None) -> None: + headers = {"x-correlator": x_correlator} if x_correlator else None + await self._request( + "DELETE", f"/internal/catalog/service-specifications/{app_id}", headers=headers + ) + + async def get_app_instances( + self, + app_id: UUID | None = None, + app_instance_id: UUID | None = None, + region: str | None = None, + x_correlator: str | None = None, + ) -> list[SRMServiceInstance]: + params: dict[str, Any] = {} + if app_id is not None: + params["appId"] = str(app_id) + if app_instance_id is not None: + params["appInstanceId"] = str(app_instance_id) + if region is not None: + params["region"] = region + headers = {"x-correlator": x_correlator} if x_correlator else None + data = await self._request( + "GET", "/internal/service-instances", params=params or None, headers=headers + ) + return [SRMServiceInstance.model_validate(i) for i in data] diff --git a/open_exposure_gateway/app/api/camara/common.py b/open_exposure_gateway/app/api/camara/common.py new file mode 100644 index 0000000000000000000000000000000000000000..4ea32b25b4663db00389f0d83314c7cc2f93aad4 --- /dev/null +++ b/open_exposure_gateway/app/api/camara/common.py @@ -0,0 +1,12 @@ +import re +from typing import Annotated, Optional + +from fastapi import Header + +X_CORRELATOR_PATTERN_STR = r"^[a-zA-Z0-9-_:;.\/<>{}]{0,256}$" +X_CORRELATOR_PATTERN = re.compile(X_CORRELATOR_PATTERN_STR) + +XCorrelatorHeader = Annotated[ + Optional[str], + Header(alias="x-correlator", max_length=256, pattern=X_CORRELATOR_PATTERN_STR), +] diff --git a/open_exposure_gateway/app/api/camara/edge_application_management/vwip/API_definitions/edge-application-management.yaml b/open_exposure_gateway/app/api/camara/edge_application_management/vwip/API_definitions/edge-application-management.yaml new file mode 100644 index 0000000000000000000000000000000000000000..154184398baacd590cd2a40b7c2076990e94842e --- /dev/null +++ b/open_exposure_gateway/app/api/camara/edge_application_management/vwip/API_definitions/edge-application-management.yaml @@ -0,0 +1,2335 @@ +--- +openapi: 3.0.3 +info: + title: Edge Application Management API + version: wip + description: | + Edge Application Management API allows API consumers to manage the + Life Cycle of an Application and to Discover Edge Cloud Zones. + # Overview + The reference scenario foresees a distributed Telco Edge Cloud where any + Application Delevoper, known as an Application Provider, can host and + deploy their application according to their specifications and operational + criteria (e.g. within an specific geographical zone for data protection + purposes, ensure a minimum QoS for the application clients, etc). + Through Telco Edge Cloud services Developers around the globe can be + benefit from the traditional Cloud strengths but expertise and advantages + of the Mobile Network Operators offering to their users an evolved + experience for XR, V2X, Holographic and other new services. + + # Introduction + The Edge Application Management API provides capabilities for lifecycle + management of application, instances and edge cloud zone discovery. + Lifecycle Management allows Application Provider to onboard + their application to the Edge Cloud Platform which do bookkeeping, + resource validation and other pre-deployment operations. + Application details can contain components network specification, + package type (QCOW2, OVA, CONTAINER, HELM, CSAR), operating system details and + respository to download the image of the desired application. + Once the application is available on the Edge Cloud + Platform, the Application Provider can instantiate the application. + Edge Cloud Provider helps Application Provider to decide where to + instantiate the applications allowing them to retrieve a list of + Edge Cloud Zones that meets the provided criteria. + + This discovery can be filtered by an specific geographical region + (e.g when data residency is need) and by status (active, inactive, unknown) + Application Provider can ask the Edge Cloud Platform to instantiate the + application to one or several Edge Cloud Zones that meet the criteria. + Typically when more than one Edge Cloud Zone is required in the same + geographic boundary, Application Provider can define instead + the entire Edge Cloud Region. + + Application Provider can retrieve the information of the instances + for a given application, the information could be the Edge Cloud Zone + where the instance is, status (ready, instantiating, failed, + terminating, unknown) and endpoint (ip, port, fqdn). + Application Provider can terminate an instance of an application + (appInstanceId) or all the instances for a given appId. + + # Quick Start + The usage of this API is based on several resources including GSMA + Edge Platform, Public Cloud and SDOs, to define a first approach on the + lifecycle management of application instances and edge cloud zones discovery + + Before starting to use the API, the developer needs to know about + the below specified details. + + __Application Management__ + * __submitApp__ - Submits application details to an Edge Cloud Provider. + Based on the details provided, Edge Cloud Provider shall do bookkeeping, + resource validation and other pre-deployment operations. + * __deleteApp__ - Removes an application from an Edge Cloud Provider, + if there is a running instance of the given application, + the request cannot be done. + * __getApp__ - Retrieves the information of a given application. + + __Application Instance Management__ + * __createAppInstance__ Request the Edge Cloud Provider to instatiate + an instance of an application in a given Edge Cloud Zone, + if this parameter is not set, the Edge Cloud Provider will instantiate + the applications in all the Edge Cloud Zones. + * __getAppInstance__ Retrieves the list with information of the instances + related to a given application. + * __deleteAppInstance__ - Removes a given application instance from an Edge + Cloud Zone. + + __Application Deployment Management__ + * __createAppDeployment__ - Requests the Edge Cloud Provider to create and + maintain application instances to multiple Edge Cloud Zones. + * __getAppDeployments__ - Retrieves a list of deployments for a given + application. + * __deleteAppDeployment__ - Terminates a specific application deployment, + removing all associated instances. + + __Edge Cloud information__ + * __getEdgeCloudZones__ List of the operators Edge Cloud Zones and their + status, ordering the results by location and filtering by status + (active/inactive/unknown) + + + + # Authorization and authentication + + The "Camara Security and Interoperability Profile" provides details of how an API consumer requests an access token. Please refer to Identity and Consent Management (https://github.com/camaraproject/IdentityAndConsentManagement/) for the released version of the profile. + + The specific authorization flows to be used will be agreed upon during the onboarding process, happening between the API consumer and the API provider, taking into account the declared purpose for accessing the API, whilst also being subject to the prevailing legal framework dictated by local legislation. + + In cases where personal data is processed by the API and users can exercise their rights through mechanisms such as opt-in and/or opt-out, the use of three-legged access tokens is mandatory. This ensures that the API remains in compliance with privacy regulations, upholding the principles of transparency and user-centric privacy-by-design. + + + It is important to remark that in cases where personal user data is + processed by the API, and users can exercise their rights through mechanisms + such as opt-in and/or opt-out, the use of 3-legged access tokens becomes + mandatory. This measure ensures that the API remains in strict compliance + with user privacy preferences and regulatory obligations, upholding the + principles of transparency and user-centric data control. + + # API documentation + Two operations have been defined in Edge Application Management API. + + *__Application__* - The Application Provider submit application metadata to + the Edge Cloud Platform. The Edge Cloud Platform generates an appId for that + metadata that will be used to instantiate the application within + the Edge Cloud Zone. + + *__Edge Cloud__* - Retrieves all the Edge Cloud Zones available according to + some defined parameters where an application can be instantiated. + + Definitions of terminologies commonly referred + to throughout the API descriptions. + * __Application Provider__ - The provider of the application that accesses + an Edge Cloud Provider to deploy its application on the Edge Cloud. + An Application Provider may be part of a larger organisation, + like an enterprise, enterprise customer of an Edge Cloud Provider, + or be an independent entity. + * __Application__ - Contains the information about the application to be + instantiated. Descriptor, binary image, charts or any other package + assosiated with the application. The Application Provider request contains + mandatory criteria (e.g. required CPU, memory, storage, bandwidth) defined + in an Application. The Edge Cloud Platform generates a unique ID + for an Application that is ready to be instantiated. + * __Application Instance__ - Is an instance (VM or Container based) running + in an Edge Cloud Zone. The Edge Cloud Platform generates a unique ID + for each instance. + * __Application Deployment__ - A specification that defines the requirements + for the Edge Cloud Provider to create and maintain application instances + across multiple Edge Cloud Zones. The deployment ensures that the + application is instantiated and managed consistently across the specified + zones, meeting the defined requirements. + * __Edge Cloud__ - Cloud-like capabilities located at the network edge + including, from the Application Provider's perspective, access to + elastically allocated compute, data storage and network resources, + this access is provided through the Edge Cloud Platform. + * __Edge Cloud Provider__ - Company name of the provider offering the + Edge Services through the Edge Cloud Platform. + Could be an Operator or a Cloud Provider. + * __Edge Cloud Region__ - An Edge Cloud Region is equivalent + to a Region on a Public Cloud. + The higher construct in the hierarchy exposed to an Application + Provider who wishes to deploy an Application on the Edge Cloud and broadly + represents a geography. An Edge CloudRegion typically contains one or + multiple Edge Cloud Zones. + An Edge Cloud Region exists within an Edge Cloud. + * __Edge Cloud Zone__ - An Edge Cloud Zone is the lowest level of + abstraction exposed to an Application Provider who wants to deploy + an Application on Edge Cloud. + Edge Cloud Zones exists within a Edge Cloud Region. + + + + # Additional CAMARA error responses + + The list of error codes in this API specification is not exhaustive. Therefore the API specification MAY not document some non-mandatory error statuses as indicated in `CAMARA API Design Guide`. + + Please refer to the `CAMARA_common.yaml` of the Commonalities Release associated to this API version for a complete list of error responses. The applicable Commonalities Release can be identified in the `API Readiness Checklist` document associated to this API version. + + As a specific rule, error `501 - NOT_IMPLEMENTED` can be only a possible error response if it is explicitly documented in the API. + + + + + # Request body strictness + + This API rejects requests with JSON request bodies that contain properties not declared in this specification, at any nesting level. Unknown properties result in a `400 INVALID_ARGUMENT` response. + + + --- + x-camara-commonalities: 0.8.0 + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +externalDocs: + description: Product documentation at Camara + url: https://github.com/camaraproject/EdgeCloud + +servers: + - url: "{apiRoot}/edge-application-management/vwip" + 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: Application + description: Application and Application Instance Lice Cycle Management + - name: Edge Cloud + description: Edge Cloud Zones Availability + - name: Cluster + description: Kubernetes Cluster information + - name: App Instance CALLBACK Operation + description: Operations for handling application instance callback notifications + - name: App Deployment CALLBACK Operation + description: Operations for handling application deployment callback notifications +paths: + /apps: + post: + security: + - openId: + - edge-application-management:apps:write + tags: + - Application + summary: Submit application metadata to the Edge Cloud Provider. + description: | + Contains the information about the application to be + instantiated in the Edge Cloud + operationId: submitApp + parameters: + - $ref: "#/components/parameters/x-correlator" + requestBody: + description: | + The Application Provider request contains mandatory + criteria (e.g. required CPU, memory, storage, bandwidth) and + optional parameters. + content: + application/json: + schema: + $ref: "#/components/schemas/AppManifest" + required: true + responses: + "201": + description: Application created successfully + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + $ref: "#/components/schemas/SubmittedApp" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 409 + code: ALREADY_EXISTS + message: "App already exists" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "501": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic501" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + get: + security: + - openId: + - edge-application-management:apps:read + tags: + - Application + summary: Retrieve a list of existing Applications + description: | + Get the list of all existing Application definitions from the + Edge Cloud Provider that the user has permission to view. + operationId: getApps + parameters: + - $ref: "#/components/parameters/x-correlator" + responses: + "200": + description: List of existing applications + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/AppManifest" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + + /apps/{appId}: + get: + security: + - openId: + - edge-application-management:apps:read + tags: + - Application + summary: Retrieve the information of an Application + description: | + Ask the Edge Cloud Provider the information for a given application + operationId: getApp + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: appId + description: | + A globally unique identifier associated with the + application. + Edge Cloud Provider generates this identifier when the application + is submitted. + in: path + required: true + schema: + $ref: "#/components/schemas/AppId" + responses: + "200": + description: Information of Application + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + type: object + properties: + appManifest: + $ref: "#/components/schemas/AppManifest" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "404": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic404" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + delete: + security: + - openId: + - edge-application-management:apps:delete + tags: + - Application + summary: | + Delete an Application from an Edge Cloud Provider + description: Delete all the information and content related to an Application + operationId: deleteApp + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: appId + in: path + description: | + Identificator of the application to be + deleted provided by the Edge Cloud Provider + once the submission was successful + required: true + schema: + $ref: "#/components/schemas/AppId" + responses: + "202": + description: Request accepted + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + "204": + description: App deleted + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "404": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic404" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 409 + code: ABORTED + message: "App with a running application instance cannot be deleted" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + + /appinstances: + post: + security: + - openId: + - edge-application-management:instances:write + tags: + - Application + summary: Instantiation of an Application + description: | + Ask the Edge Cloud Platform to instantiate an application to an + Edge Cloud Zone. + operationId: createAppInstance + parameters: + - $ref: "#/components/parameters/x-correlator" + requestBody: + description: | + Information about the application and where to deploy it. + content: + application/json: + schema: + type: object + required: + - name + - appId + - edgeCloudZoneId + properties: + name: + $ref: "#/components/schemas/AppInstanceName" + appId: + $ref: "#/components/schemas/AppId" + edgeCloudZoneId: + $ref: "#/components/schemas/EdgeCloudZoneId" + kubernetesClusterRef: + $ref: "#/components/schemas/KubernetesClusterRef" + subscriptionRequest: + $ref: "#/components/schemas/SubscriptionRequest" + required: true + responses: + "202": + description: Application instantiation accepted + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + Location: + description: Contains the URI of the newly created application. + required: true + schema: + type: string + format: uri + maxLength: 2048 + content: + application/json: + schema: + $ref: "#/components/schemas/AppInstanceInfo" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 409 + code: ALREADY_EXISTS + message: "Application already instantiated in the given Edge Cloud Zone" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "501": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic501" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + callbacks: + onAppInstanceStatusChange: + $ref: "#/components/callbacks/onAppInstanceStatusChange" + get: + security: + - openId: + - edge-application-management:instances:read + tags: + - Application + summary: Retrieve the information of Application Instances for a given App + description: | + Ask the Edge Cloud Provider the information of the instances for a + given application + operationId: getAppInstance + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: appId + description: | + A globally unique identifier associated with + the application. + Edge Cloud Provider generates this identifier when the + application is submitted. + in: query + required: false + schema: + $ref: "#/components/schemas/AppId" + - name: appInstanceId + description: | + A globally unique identifier associated with a running + instance of an application within an specific Edge Cloud Zone. + Edge Cloud Provider generates this identifier. + in: query + required: false + schema: + $ref: "#/components/schemas/AppInstanceId" + - name: region + description: | + Human readable name of the geographical Edge Cloud Region of + the Edge Cloud. Defined by the Edge Cloud Provider. + in: query + required: false + schema: + $ref: "#/components/schemas/EdgeCloudRegion" + responses: + "200": + description: | + List of application instances. Returns an empty list if no + instances were found or none match the specified query + parameters. + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/AppInstanceInfo" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + /appinstances/{appInstanceId}: + delete: + security: + - openId: + - edge-application-management:instances:delete + tags: + - Application + summary: Terminate an Application Instance + description: | + Terminate a running instance of an application within + an Edge Cloud Zone + operationId: deleteAppInstance + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: appInstanceId + in: path + description: | + Identificator of the specific application instance + that will be terminated + required: true + schema: + $ref: "#/components/schemas/AppInstanceId" + responses: + "202": + description: | + Request accepted to be processed. It applies for async + deletion process + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + "204": + description: Application Instance Deleted + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "404": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic404" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + + /deployments: + post: + security: + - openId: + - edge-application-management:deployments:write + tags: + - Application + summary: Deploy an Application + description: | + Ask the Edge Cloud Platform to instantiate an application to several + Edge Cloud Zones. + operationId: createAppDeployment + parameters: + - $ref: "#/components/parameters/x-correlator" + requestBody: + description: | + Information about the application and where to deploy it. + content: + application/json: + schema: + type: object + required: + - appDeploymentName + - appId + - edgeCloudZones + properties: + appDeploymentName: + $ref: "#/components/schemas/AppDeploymentName" + appId: + $ref: "#/components/schemas/AppId" + edgeCloudZones: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/EdgeCloudZoneId" + kubernetesClusterRefs: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/KubernetesClusterRef" + subscriptionRequest: + $ref: "#/components/schemas/SubscriptionRequest" + required: true + responses: + "202": + description: Application deployment accepted + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + type: object + properties: + appDeploymentId: + $ref: "#/components/schemas/AppDeploymentId" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 409 + code: ALREADY_EXISTS + message: "Deployment already exists" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "501": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic501" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + callbacks: + onAppDeploymentStatusChange: + $ref: "#/components/callbacks/onAppDeploymentStatusChange" + get: + security: + - openId: + - edge-application-management:deployments:read + tags: + - Application + summary: Retrieve a list of Application Deployment for a given App + description: | + Ask the Edge Cloud Provider the information of the Deployments for a + given application + operationId: getAppDeployments + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: appId + description: | + A globally unique identifier associated with + the application. + Edge Cloud Provider generates this identifier when the + application is submitted. + in: query + required: false + schema: + $ref: "#/components/schemas/AppId" + - name: appDeploymentId + description: | + A globally unique identifier associated with a existing deployment + of an application. Edge Cloud Platform generates this identifier when + the deployment request in the Edge Cloud Zone is successful. + in: query + required: false + schema: + $ref: "#/components/schemas/AppDeploymentId" + responses: + "200": + description: | + List of application deployments. Returns an empty list if no + deployments were found or none match the specified query + parameters. + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/AppDeploymentInfo" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "410": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic410" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + /deployments/{appDeploymentId}: + delete: + security: + - openId: + - edge-application-management:deployments:delete + tags: + - Application + summary: Terminate an Application Deployment + description: | + Delete a deployment terminating all related instances of an application + operationId: deleteAppDeployment + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: appDeploymentId + in: path + description: | + Identificator of the specific application deployment + that will be terminated + required: true + schema: + $ref: "#/components/schemas/AppDeploymentId" + responses: + "202": + description: | + Request accepted to be processed. It applies for async + deletion process + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "404": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic404" + "410": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic410" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + patch: + security: + - openId: + - edge-application-management:deployments:update + tags: + - Application + summary: Update an Application Deployment + description: | + Update the configuration or properties of an existing application deployment + using JSON Merge Patch semantics (RFC 7396). Only the fields provided in the + request body will be updated. Fields not included in the request will remain unchanged. + + IMPORTANT: When updating array fields (like edgeCloudZones or kubernetesClusterRefs), + JSON Merge Patch will REPLACE the entire array, not merge or append to it. + + This operation may include changing the deployment name, target Edge Cloud Zones, or other updatable fields. + operationId: updateAppDeployment + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: appDeploymentId + in: path + description: | + Identifier of the specific application deployment to be updated. + required: true + schema: + $ref: "#/components/schemas/AppDeploymentId" + requestBody: + description: | + The fields to update for the application deployment using JSON Merge Patch (RFC 7396). + Only the fields included in the request will be updated; omitted fields remain unchanged. + + NOTE: When updating array fields (edgeCloudZones, kubernetesClusterRefs), the entire array + will be REPLACED, not merged. To modify an array, you must include the complete array + with all desired elements in your request. + required: true + content: + application/merge-patch+json: + schema: + type: object + properties: + appDeploymentName: + $ref: "#/components/schemas/AppDeploymentName" + edgeCloudZones: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/EdgeCloudZoneId" + kubernetesClusterRefs: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/KubernetesClusterRef" + examples: + updateDeploymentName: + summary: Update only the deployment name + description: | + This example shows how to update only the deployment name. + Other fields will remain unchanged. + value: + appDeploymentName: "my_updated_deployment" + updateMultipleFields: + summary: Update multiple fields simultaneously + description: | + This example shows how to update both the deployment name + and Edge Cloud Zones in a single request. Remember that both + array fields will be completely replaced with the new values. + value: + appDeploymentName: "production_deployment" + edgeCloudZones: + - "123e4567-e89b-12d3-a456-426614174000" + - "123e4567-e89b-12d3-a456-426614174001" + kubernetesClusterRefs: + - "642f6105-7015-4af1-a4d1-e1ecb8437abc" + - "642f6105-7015-4af1-a4d1-e1ecb8437def" + arrayReplacementExample: + summary: Example of array replacement behavior + description: | + This example demonstrates how arrays are completely replaced in JSON Merge Patch. + + If the current deployment has: + - edgeCloudZones: ["123e4567-e89b-12d3-a456-426614174000", "123e4567-e89b-12d3-a456-426614174001", "123e4567-e89b-12d3-a456-426614174002"] + - kubernetesClusterRefs: ["642f6105-7015-4af1-a4d1-e1ecb8437abc", "642f6105-7015-4af1-a4d1-e1ecb8437def"] + + And the user sends this patch: + - edgeCloudZones: ["123e4567-e89b-12d3-a456-426614174000", "123e4567-e89b-12d3-a456-426614174003"] + + The result will be: + - edgeCloudZones: ["123e4567-e89b-12d3-a456-426614174000", "123e4567-e89b-12d3-a456-426614174003"] (completely replaced) + - kubernetesClusterRefs: ["642f6105-7015-4af1-a4d1-e1ecb8437abc", "642f6105-7015-4af1-a4d1-e1ecb8437def"] (unchanged, as it wasn't in the patch) + + To add a single zone while keeping existing ones, ALL zones must be included in the request. + value: + edgeCloudZones: + - "123e4567-e89b-12d3-a456-426614174000" + - "123e4567-e89b-12d3-a456-426614174003" + responses: + "200": + description: Application deployment updated successfully + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + $ref: "#/components/schemas/AppDeploymentInfo" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "404": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic404" + "410": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic410" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorInfo" + example: + status: 409 + code: ABORTED + message: "Update conflict" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + + /clusters: + get: + security: + - openId: + - edge-application-management:clusters:read + tags: + - Cluster + summary: | + Retrieve a list of the available clusters filtered by the optional + query parameters. + description: | + List available cluster information + operationId: getClusters + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: region + description: | + Human readable name of the geographical Edge Cloud Region of + the Cluster. Defined by the Edge Cloud Provider. + in: query + required: false + schema: + $ref: "#/components/schemas/EdgeCloudRegion" + - name: clusterRef + description: | + A globally unique identifier for the Cluster. + in: query + required: false + schema: + $ref: "#/components/schemas/KubernetesClusterRef" + - name: edgeCloudZoneId + description: | + Edge Cloud Zone identifier. + in: query + required: false + schema: + $ref: "#/components/schemas/EdgeCloudZoneId" + responses: + "200": + description: | + Successful response, returning the cluster's information. + Returns an empty list if no clusters were found or none match + the specified query parameters. + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/ClusterInfo" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" + /edge-cloud-zones: + get: + security: + - openId: + - edge-application-management:edge-cloud-zones:read + tags: + - Edge Cloud + summary: Retrieve a list of the provider's Edge Cloud Zones and their status + description: | + List of the provider's Edge Cloud Zones and their + status, ordering the results by location and filtering by + status (active/inactive/unknown) + operationId: getEdgeCloudZones + parameters: + - $ref: "#/components/parameters/x-correlator" + - name: region + description: | + Human readable name of the geographical Edge Cloud Region of + the Edge Cloud. Defined by the Edge Cloud Provider. + in: query + required: false + schema: + $ref: "#/components/schemas/EdgeCloudRegion" + - name: status + description: Human readable status of the Edge Cloud Zone + in: query + required: false + schema: + $ref: "#/components/schemas/EdgeCloudZoneStatus" + responses: + "200": + description: | + Successful response, returning the + Available Edge Cloud Zones. + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + $ref: "#/components/schemas/EdgeCloudZones" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "500": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic500" + "503": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic503" +components: + securitySchemes: + openId: + description: OpenID Provider Configuration Information. + type: openIdConnect + openIdConnectUrl: https://example.com/.well-known/openid-configuration + notificationsBearerAuth: + type: http + scheme: bearer + bearerFormat: "{$request.body#/sinkCredential.credentialType}" + description: | + Bearer token for notification delivery. Token format is determined + by `sinkCredential.credentialType` in the subscription request. + parameters: + x-correlator: + name: x-correlator + in: header + description: | + Correlation id for the different services + schema: + $ref: "#/components/schemas/XCorrelator" + headers: + x-correlator: + description: | + Correlation id for the different services + required: false + schema: + $ref: "#/components/schemas/XCorrelator" + + callbacks: + onAppInstanceStatusChange: + "{$request.body#/subscriptionRequest/sink}": + post: + tags: + - App Instance CALLBACK Operation + summary: + Provide a notification for a change in status of the instantiated + application + description: | + Instantiating an application is an asynchronous task. After the application status changes, + the server will return a callback response at the specified URL. + + **WARNING**: This callback endpoint must be exposed on the listener side as `POST {$request.body#/subscriptionRequest/sink}` + operationId: EdgeApplicationManagementCallback + parameters: + - $ref: "#/components/parameters/x-correlator" + security: + - {} + - notificationsBearerAuth: [] + requestBody: + description: Event notification with the status of the Application Instance + required: true + content: + application/cloudevents+json: + schema: + $ref: "#/components/schemas/CloudEvent" + responses: + "204": + description: Successful notification - No Content + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "410": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic410" + "429": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic429" + + onAppDeploymentStatusChange: + "{$request.body#/subscriptionRequest/sink}": + post: + tags: + - App Deployment CALLBACK Operation + summary: Provide a notification for a change in status of the deployed + application + description: | + Deploying an application is an asynchronous task. After the application deployment status changes, + the server will return a callback response at the specified URL. + + **WARNING**: This callback endpoint must be exposed on the listener side as `POST {$request.body#/subscriptionRequest/sink}` + operationId: EdgeApplicationManagementDeploymentCallback + parameters: + - $ref: "#/components/parameters/x-correlator" + security: + - {} + - notificationsBearerAuth: [] + requestBody: + description: Event notification with the status of the Application Deployment + required: true + content: + application/cloudevents+json: + schema: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/CloudEvent" + responses: + "204": + description: Successful notification - No Content + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + "400": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic400" + "401": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic401" + "403": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic403" + "410": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic410" + "429": + $ref: "../common/CAMARA_common.yaml#/components/responses/Generic429" + + schemas: + AccessTokenCredential: + type: object + description: + An access token credential. This type of credential is meant to be + used by API Consumers that have limited capabilities to handle + authorization requests. + allOf: + - $ref: "#/components/schemas/SinkCredential" + - type: object + properties: + accessToken: + description: + Access Token granting access to the POST operation to create + notification + type: string + maxLength: 8192 + accessTokenExpiresUtc: + type: string + format: date-time + maxLength: 64 + description: | + An absolute UTC instant at which the access token shall be considered expired. + Token expiration SHOULD occur after the expiration of the application instance + or deployment, allowing the client to be notified of any changes during its + existence. If the token expires while the resource is still active, the client + will stop receiving notifications. It must + follow [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6) and must have time zone. + example: "2023-07-03T12:27:08.312Z" + accessTokenType: + description: Type of access token - MUST be set to bearer for now + type: string + enum: + - bearer + required: + - accessToken + - accessTokenExpiresUtc + - accessTokenType + + CloudEvent: + $ref: "../common/CAMARA_event_common.yaml#/components/schemas/CloudEvent" + + SubscriptionEventType: + type: string + description: | + Event-type that could be subscribed through this subscription. Several event-type could be defined. + enum: + - org.camaraproject.edge-application-management.v0.app-instance-status-change + - org.camaraproject.edge-application-management.v0.app-deployment-status-change + - org.camaraproject.edge-application-management.v0.subscription-ended + + SubscriptionRequest: + description: + The request for creating an event-type event subscription (implicit + subscription, HTTP only) + type: object + required: + - sink + properties: + sink: + type: string + format: uri + pattern: ^https:\/\/.+$ + maxLength: 2048 + description: + The address to which events shall be delivered using the selected + protocol. + example: "https://endpoint.example.com/sink" + sinkCredential: + $ref: "#/components/schemas/SinkCredential" + types: + description: | + Camara Event types eligible to be delivered by this subscription. + Note: the maximum number of event types per subscription will be decided at API project level + type: array + minItems: 1 + maxItems: 1 + items: + $ref: "#/components/schemas/SubscriptionEventType" + config: + $ref: "#/components/schemas/SubscriptionConfig" + + SubscriptionConfig: + description: | + Implementation-specific configuration parameters needed by the subscription manager for acquiring events. + In CAMARA we have predefined attributes like `subscriptionExpireTime`, `subscriptionMaxEvents`, `initialEvent` + Specific event type attributes must be defined in `subscriptionDetail` + Note: if a request is performed for several event type, all subscribed event will use same `config` parameters. + type: object + required: + - subscriptionDetail + properties: + subscriptionDetail: + description: The detail of the requested event subscription. + type: object + subscriptionExpireTime: + type: string + maxLength: 64 + format: date-time + example: 2023-01-17T13:18:23.682Z + description: + The subscription expiration time (in date-time format) requested by + the API consumer. It must follow [RFC + 3339](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6) and + must have time zone. + subscriptionMaxEvents: + type: integer + format: int32 + description: + Identifies the maximum number of event reports to be generated + (>=1) requested by the API consumer - Once this number is reached, + the subscription ends. + minimum: 1 + maximum: 2147483647 + example: 5 + initialEvent: + type: boolean + description: | + Set to `true` by API consumer if consumer wants to get an event as soon as the subscription is created and current situation reflects event request. + Example: If initialEvent is set to true and application is in a specific status, an event is triggered. + + SinkCredential: + description: A sink credential provides authentication or authorization + information necessary to enable delivery of events to a target. + type: object + required: + - credentialType + properties: + credentialType: + type: string + enum: + - ACCESSTOKEN + description: Type of the credential - MUST be set to ACCESSTOKEN for now + discriminator: + propertyName: credentialType + mapping: + ACCESSTOKEN: "#/components/schemas/AccessTokenCredential" + + AccessEndpoint: + type: object + description: | + Application Endpoint for an especific instance that is + running in an specific Edge Cloud Zone. + required: + - port + anyOf: + - required: + - fqdn + - required: + - ipv4Addresses + - required: + - ipv6Addresses + properties: + port: + $ref: "#/components/schemas/Port" + fqdn: + $ref: "#/components/schemas/Fqdn" + ipv4Addresses: + description: IP version 4 of an application instance + type: array + items: + $ref: "#/components/schemas/Ipv4Addr" + minItems: 1 + maxItems: 16 + ipv6Addresses: + description: IP version 6 of an application instance. + type: array + items: + $ref: "#/components/schemas/Ipv6Addr" + minItems: 1 + maxItems: 16 + + AppId: + type: string + format: uuid + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + minLength: 36 + maxLength: 36 + description: | + A globally unique identifier associated with the application. + Edge Cloud Platform generates this identifier when the + Application is submitted. + + AppInstanceId: + type: string + format: uuid + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + minLength: 36 + maxLength: 36 + description: | + A globally unique identifier associated with a running + instance of an application. + Edge Cloud Platform generates this identifier when the + instantiation in the Edge Cloud Zone is successful. + + AppDeploymentId: + type: string + format: uuid + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + minLength: 36 + maxLength: 36 + description: | + A globally unique identifier associated with a existing deployment + of an application. Edge Cloud Platform generates this identifier when + the deployment request in the Edge Cloud Zone is successful. + + AppDeploymentInfo: + description: Information about the application deployment. + type: object + required: + - appDeploymentName + - appDeploymentId + - appId + - edgeCloudZones + - appInstances + properties: + appDeploymentName: + $ref: "#/components/schemas/AppDeploymentName" + appDeploymentId: + $ref: "#/components/schemas/AppDeploymentId" + appId: + $ref: "#/components/schemas/AppId" + edgeCloudZones: + description: | + List of Edge Cloud Zones where the application is deployed. + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/EdgeCloudZoneId" + appInstances: + description: | + List of application instances created as part of this deployment. + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/AppInstanceId" + + AppInstanceInfo: + description: Information about the application instance. + type: object + required: + - name + - appId + - appInstanceId + - appProvider + - edgeCloudZoneId + properties: + name: + $ref: "#/components/schemas/AppInstanceName" + appId: + $ref: "#/components/schemas/AppId" + appInstanceId: + $ref: "#/components/schemas/AppInstanceId" + appProvider: + $ref: "#/components/schemas/AppProvider" + status: + description: Status of the application instance (default is 'unknown') + type: string + enum: + - ready + - instantiating + - failed + - terminating + - unknown + default: unknown + componentEndpointInfo: + description: | + Information about the IP and Port exposed by the + Edge Cloud Platform. + Application Client shall use these access points to reach this + application instance + type: array + maxItems: 100 + items: + description: | + Information about the IP and Port exposed by the + Edge Cloud Platform. + Application Client shall use these access points to reach this + application instance + type: object + required: + - interfaceId + - accessPoints + properties: + interfaceId: + $ref: "#/components/schemas/InterfaceId" + accessPoints: + $ref: "#/components/schemas/AccessEndpoint" + minItems: 1 + kubernetesClusterRef: + $ref: "#/components/schemas/KubernetesClusterRef" + edgeCloudZoneId: + $ref: "#/components/schemas/EdgeCloudZoneId" + + AppInstanceName: + type: string + maxLength: 64 + pattern: ^[A-Za-z][A-Za-z0-9_]{1,63}$ + description: Name of the App instance, scoped to the AppProvider + + AppDeploymentName: + type: string + maxLength: 64 + pattern: ^[A-Za-z][A-Za-z0-9_]{1,63}$ + description: Name of the App Deployment, scoped to the AppProvider + + AppManifest: + type: object + description: | + Application information and requirements provided by the + Application Provider + properties: + appId: + $ref: "#/components/schemas/AppId" + name: + type: string + maxLength: 64 + pattern: ^[A-Za-z][A-Za-z0-9_]{1,63}$ + description: Name of the application. + appProvider: + $ref: "#/components/schemas/AppProvider" + version: + type: string + maxLength: 64 + description: Application version information + packageType: + description: Format of the application image package + type: string + enum: + - QCOW2 + - OVA + - CONTAINER + - HELM + - CSAR + operatingSystem: + $ref: "#/components/schemas/OperatingSystem" + appRepo: + description: | + Repository where Application Provider stores the application image + type: object + required: + - type + - imagePath + properties: + type: + type: string + enum: + - PRIVATEREPO + - PUBLICREPO + description: | + Application repository and image URI information. + PUBLICREPO is used of public urls like github, helm repo etc. + PRIVATEREPO is used for private repo managed by the application + developer. Private repo can be accessed by using the app + developer provided userName and password. Password is + recommended to be the personal access token created by developer + e.g. in Github repo. + imagePath: + $ref: "#/components/schemas/Uri" + userName: + type: string + maxLength: 64 + description: | + Username to acces the Helm chart, docker-compose + file or VM image repository + credentials: + type: string + maxLength: 2048 + description: | + Password or personal access token created by + developer to acces the app repository. API users can generate + a personal access token e.g. docker clients to use them as + password. + authType: + type: string + enum: + - DOCKER + - HTTP_BASIC + - HTTP_BEARER + - NONE + description: | + The credentials can also be formatted as a Basic + auth or Bearer auth in HTTP "Authorization" header. + checksum: + type: string + maxLength: 128 + pattern: "^(?:[A-Fa-f0-9]{32}|[A-Fa-f0-9]{64})$" + description: | + MD5 checksum for VM and file-based images, sha256 + digest for containers + requiredResources: + $ref: "#/components/schemas/RequiredResources" + componentSpec: + description: | + Information defined in "appRepo" point to the application + descriptor e.g. Helm chart, docker-compose yaml file etc. + The descriptor may contain one or more containers and their + associated meta-data. A component refers to additional details + about these containers to expose the instances of the containers + to external client applications. App provider can define one or + more components (via the associated network port) in componentSpec + corresponding to the containers in helm charts or docker-compose + yaml file as part of app descriptors. + type: array + maxItems: 100 + items: + description: | + List of application components defined in the application descriptor. + type: object + required: + - componentName + - networkInterfaces + properties: + componentName: + type: string + maxLength: 64 + description: Component name must be unique with an application + networkInterfaces: + description: | + Each application component exposes some ports + either for external users or for inter component + communication. + Application provider is required to specify which ports are + to be exposed and the type of traffic that will flow through + these ports.The underlying platform may assign a dynamic port + against the "extPort" that the application clients will use + to connect with edge application instance. + type: array + maxItems: 100 + items: + description: | + Network interface information defined in the application + descriptor. Each network interface is associated with a port + and protocol that will be exposed to external clients or other + components within edge cloud. + type: object + required: + - interfaceId + - protocol + - port + - visibilityType + properties: + interfaceId: + $ref: "#/components/schemas/InterfaceId" + protocol: + type: string + enum: + - TCP + - UDP + - ANY + description: | + Defines the IP transport communication + protocol i.e., TCP, UDP or ANY + port: + $ref: "#/components/schemas/Port" + visibilityType: + description: | + Defines whether the interface is exposed + to outer world or not i.e., external, or internal. + If this is set to "external", then it is exposed + to external applications otherwise it is exposed + internally to edge application components within + edge cloud. When exposed to external world, + an external dynamic port is assigned for UC traffic + and mapped to the extPort + type: string + enum: + - VISIBILITY_EXTERNAL + - VISIBILITY_INTERNAL + minItems: 1 + required: + - name + - version + - appProvider + - packageType + - appRepo + - requiredResources + - componentSpec + + AppProvider: + type: string + maxLength: 64 + pattern: ^[A-Za-z][A-Za-z0-9_]{7,63}$ + description: Human readable name of the Application Provider. + + ClusterInfo: + type: object + description: Kubernetes cluster information + required: + - name + - provider + - clusterRef + - edgeCloudZoneId + properties: + name: + type: string + maxLength: 64 + description: | + Name of the Cluster, scoped to the Provider + provider: + $ref: "#/components/schemas/AppProvider" + clusterRef: + $ref: "#/components/schemas/KubernetesClusterRef" + edgeCloudZoneId: + $ref: "#/components/schemas/EdgeCloudZoneId" + edgeCloudRegion: + $ref: "#/components/schemas/EdgeCloudRegion" + version: + type: string + maxLength: 64 + pattern: ^v?\\d+\\.\\d+(?:\\.\\d+)?(?:[-+][0-9A-Za-z.-]+)?$ + description: Kubernetes version of the cluster. + nodePools: + description: Node Pools in the cluster. + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/KubernetesNodePool" + minItems: 1 + + EdgeCloudProvider: + type: string + maxLength: 64 + description: Human readable name of the Edge Cloud Provider. + + EdgeCloudRegion: + type: string + maxLength: 64 + description: | + Human readable name of the geographical Edge Cloud Region of + the Edge Cloud. Defined by the Edge Cloud Provider. + + EdgeCloudZones: + type: array + items: + $ref: "#/components/schemas/EdgeCloudZone" + minItems: 1 + maxItems: 100 + description: | + A collection of Edge Cloud Zones where the Application Provider can + instantiate an Application Instance. + + EdgeCloudZoneId: + type: string + format: uuid + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + minLength: 36 + maxLength: 36 + description: | + Unique identifier created by the Edge Cloud Platform to identify an + Edge Cloud Zone within an Edge Cloud. + + EdgeCloudZone: + type: object + description: | + An Edge Cloud Zone, uniquely identified by a + combination of the value of the Edge Cloud Zone Id object + and the value of the Edge Cloud Provider + object. This value is used to identify an Edge Cloud zone + between Edge Clouds from different Edge Cloud Providers. + required: + - edgeCloudZoneId + - edgeCloudZoneName + - edgeCloudProvider + properties: + edgeCloudZoneId: + $ref: "#/components/schemas/EdgeCloudZoneId" + edgeCloudZoneName: + $ref: "#/components/schemas/EdgeCloudZoneName" + edgeCloudZoneStatus: + $ref: "#/components/schemas/EdgeCloudZoneStatus" + edgeCloudProvider: + $ref: "#/components/schemas/EdgeCloudProvider" + edgeCloudRegion: + $ref: "#/components/schemas/EdgeCloudRegion" + + EdgeCloudZoneName: + type: string + maxLength: 64 + description: | + Human readable name of the geographical zone of + the Edge Cloud. Defined by the Edge Cloud Provider. + + EdgeCloudZoneStatus: + description: Status of the Edge Cloud Zone (default is 'unknown') + type: string + enum: + - active + - inactive + - unknown + default: unknown + + ErrorInfo: + $ref: "../common/CAMARA_common.yaml#/components/schemas/ErrorInfo" + + Fqdn: + type: string + format: hostname + maxLength: 253 + description: | + Full qualified domain name of an application instance + + GpuInfo: + type: object + description: Information about the supported GPUs + required: + - gpuMemory + - numGPU + properties: + gpuMemory: + type: integer + format: int32 + minimum: 0 + maximum: 16384 + description: GPU memory in mega bytes + numGPU: + type: integer + format: int32 + minimum: 0 + maximum: 16 + description: Number of GPUs + + K8sAddons: + description: | + Addons for the Kubernetes cluster. + Additional addons should be defined in application the helm chart + (Service Mesh, Serverless, AI). + type: array + maxItems: 2 + uniqueItems: true + items: + description: | + Addon for the Kubernetes cluster. + Examples of addons include monitoring and ingress. + type: string + enum: + - monitoring + - ingress + + K8sNetworking: + description: | + Kubernetes networking definition + type: object + required: + - primaryNetwork + properties: + primaryNetwork: + description: Definition of Kubernetes primary Network + type: object + properties: + provider: + description: CNI provider name + type: string + maxLength: 64 + example: cilium + version: + description: CNI provider version + type: string + maxLength: 64 + example: "1.13" + additionalNetworks: + description: Additional Networks for the Kubernetes cluster. + type: array + maxItems: 100 + items: + type: object + description: Additional network interface definition + properties: + name: + description: Additional Network Name + type: string + maxLength: 64 + example: net1 + interfaceType: + description: | + Type of additional Interface: + netdevice: (SR-IOV) A regular kernel network device in the + Network Namespace (netns) of the container + vfio-pci: (SR-IOV) A PCI network interface directly mounted + in the container + interface: Additional interface to be used by cni plugins + such as macvlan, ipvlan + Note: The use of SR-IOV interfaces automatically + configure the required kernel parameters for the nodes. + type: string + example: vfio-pci + enum: + - netdevice + - vfio-pci + - interface + + AdditionalStorage: + description: Additional storage for the application. + type: array + maxItems: 50 + items: + description: | + Additional storage resource for the application. + type: object + required: + - storageSize + - mountPoint + properties: + name: + type: string + maxLength: 64 + description: Name of additional storage resource. + example: logs + storageSize: + type: string + maxLength: 32 + description: Additional persistent volume for the application. + example: 80GB + pattern: ^\d+(GB|MB)$ + mountPoint: + type: string + maxLength: 64 + description: Location of additional storage resource. + example: /logs + + Vcpu: + type: string + maxLength: 16 + pattern: ^\d+((\.\d{1,3})|(m))?$ + description: | + Number of vcpus in whole (i.e 1), decimal (i.e 0.500) up to + millivcpu, or millivcpu (i.e 500m) format. + example: "500m" + + KubernetesClusterRef: + description: | + A global unique identifier associated with a Kubernetes cluster + infrastructure. + type: string + format: uuid + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + minLength: 36 + maxLength: 36 + example: "642f6105-7015-4af1-a4d1-e1ecb8437abc" + + KubernetesNodePool: + type: object + description: | + A Kubernetes node pool is a set of Kubernetes nodes that have the + same configuration (vCPU, memory, networking, OS, etc) on each node. + required: + - name + - numNodes + - nodeResources + - scalable + properties: + name: + description: Human readable name of the Kubernetes Node Pool. + type: string + maxLength: 64 + numNodes: + description: Number of nodes in the Node Pool. + type: integer + format: int32 + minimum: 1 + maximum: 100 + scalable: + description: | + Indicates if the node pool can be dynamically scaled up by the + system to accomodate more applications, and dynamically scaled + down by the system when there are unused resources. + type: boolean + example: false + nodeResources: + description: Resource configuration of a node. + type: object + required: + - numCPU + - memory + properties: + numCPU: + description: | + Number of whole vcpus for the node. + type: integer + format: int32 + minimum: 1 + maximum: 256 + example: 2 + memory: + description: | + Amount of system memory in mega bytes for the node. + type: integer + format: int32 + minimum: 1 + maximum: 16384 + example: 4096 + + KubernetesResources: + type: object + description: Definition of Kubernetes Cluster Infrastructure. + required: + - infraKind + - applicationResources + - isStandalone + properties: + infraKind: + description: Type of infrastructure for the application. + type: string + example: kubernetes + enum: + - kubernetes + applicationResources: + description: | + Application resources define the resources pool required + by the application to be executed in a Kubernetes clusters. + type: object + properties: + cpuPool: + required: + - numCPU + - memory + - topology + type: object + description: | + CPU Pool refers to the amount of application' resources + that is executed in nodes with CPU only. That means the part + of application that doesn't require GPU or other kind of + acceleration. + CPU pool is not mandatory when the application is executed + exclusively in a GPU pool. + A CPU pool is composed by CPU and memory. + properties: + numCPU: + description: | + Total number of vcpus in whole (i.e 1) of CPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 256 + example: 1 + memory: + description: Total memory in mega bytes of CPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 16384 + example: 1024 + topology: + type: object + description: | + CPU pool topology defines an application's CPU-based + architecture. + When deploying for high availability or redundancy, it + allows for clustering with a configurable number of nodes + and minimum CPU/memory resource per Kubernetes node + requirements. + required: + - minNumberOfNodes + - minNodeCpu + - minNodeMemory + properties: + minNumberOfNodes: + description: | + Minimum number of worker nodes required by the + application. + type: integer + format: int32 + minimum: 1 + maximum: 1000 + example: 5 + minNodeCpu: + description: | + Minimum number of vcpus in whole (i.e 1) per cluster + node in CPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 256 + example: 2 + minNodeMemory: + description: | + Minimum memory in mega bytes per cluster node in + CPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 16384 + example: 1024 + gpuPool: + required: + - numCPU + - memory + - gpuMemory + - topology + type: object + description: | + GPU Pool refers to the amount of resources of the application + that is executed in nodes with GPU. + GPU Pool is not mandatory when the application is executed + exclusively in a CPU pool. + A GPU pool is composed by memory, CPU and GPU memory + properties: + numCPU: + description: | + Total Number of vcpus in whole (i.e 1) of GPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 1024 + example: 1 + memory: + description: Total memory in mega bytes of GPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 16384 + example: 1024 + gpuMemory: + description: Total GPU memory in giga bytes of GPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 16 + example: 16 + topology: + type: object + description: | + GPU pool topology defines an application's GPU-based + architecture. + When deploying for high availability or redundancy, it + allows for clustering with a configurable number of nodes + and minimum CPU/memory/GPU memory resource per Kubernetes + node requirements. + required: + - minNumberOfNodes + - minNodeCpu + - minNodeMemory + - minNodeGpuMemory + properties: + minNumberOfNodes: + description: | + Minimum number of worker nodes with GPU required by + the application. + type: integer + format: int32 + minimum: 1 + maximum: 1000 + example: 2 + minNodeCpu: + description: | + Minimum number of vcpus in whole (i.e 1) per cluster + node in GPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 256 + example: 2 + minNodeMemory: + description: | + Minimum memory in mega bytes per cluster node in + GPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 16384 + example: 1024 + minNodeGpuMemory: + description: Minimum memory in giga bytes per cluster node in GPU pool. + type: integer + format: int32 + minimum: 1 + maximum: 16 + example: 8 + isStandalone: + description: | + Define if the Kubernetes clusters can be reused by other + applications. + type: boolean + example: false + version: + type: string + maxLength: 64 + description: Minimum Kubernetes Version. + pattern: ^v?\\d+\\.\\d+(?:\\.\\d+)?(?:[-+][0-9A-Za-z.-]+)?$ + additionalStorage: + type: string + maxLength: 32 + description: | + Amount of persistent storage allocated to the Kubernetes PVC. + example: 80GB + pattern: ^\d+(GB|MB)$ + networking: + $ref: "#/components/schemas/K8sNetworking" + addons: + $ref: "#/components/schemas/K8sAddons" + + VmResources: + description: Definition of Virtual Machine Infrastructure + type: object + required: + - infraKind + - numCPU + - memory + properties: + infraKind: + description: Type of infrastructure for the application. + type: string + example: virtualMachine + enum: + - virtualMachine + numCPU: + type: integer + format: int32 + minimum: 1 + maximum: 256 + description: | + Number of vcpus in whole (i.e 1) + example: 1 + memory: + type: integer + format: int32 + minimum: 1 + maximum: 32768 + example: 1024 + description: Memory in mega bytes + additionalStorages: + $ref: "#/components/schemas/AdditionalStorage" + gpu: + $ref: "#/components/schemas/GpuInfo" + + DockerComposeResources: + description: Definition of Docker Compose Infrastructure + type: object + required: + - infraKind + - numCPU + - memory + properties: + infraKind: + description: Type of infrastructure for the application. + type: string + example: dockerCompose + enum: + - dockerCompose + numCPU: + type: integer + format: int32 + minimum: 1 + maximum: 256 + description: | + Number of vcpus in whole (i.e 1) + example: 1 + memory: + type: integer + format: int32 + minimum: 1 + maximum: 16384 + example: 1024 + description: Memory in mega bytes + storage: + $ref: "#/components/schemas/AdditionalStorage" + gpu: + $ref: "#/components/schemas/GpuInfo" + + ContainerResources: + description: Container Infrastructure Definition + type: object + required: + - infraKind + - numCPU + - memory + properties: + infraKind: + description: Type of infrastructure for the application. + type: string + example: container + enum: + - container + numCPU: + $ref: "#/components/schemas/Vcpu" + memory: + type: integer + format: int32 + minimum: 1 + maximum: 16384 + example: 1024 + description: Memory in mega bytes + storage: + $ref: "#/components/schemas/AdditionalStorage" + gpu: + $ref: "#/components/schemas/GpuInfo" + + InterfaceId: + type: string + minLength: 4 + maxLength: 32 + pattern: ^[A-Za-z0-9][A-Za-z0-9_]{2,30}[A-Za-z0-9]$ + description: | + This is the interface Identifier that Application Provider + defines when application is being submitted. + + Ipv4Addr: + type: string + maxLength: 15 + format: ipv4 + example: "192.168.0.1" + description: | + IP of the device. A single IPv4 address may be specified in + dotted-quad form 1.2.3.4. Only this exact IP number will match the flow + control rule. + + Ipv6Addr: + type: string + maxLength: 45 + format: ipv6 + example: "2001:db8:85a3:8d3:1319:8a2e:370:7344" + description: | + IP of the device. A single IPv6 address, following IETF 5952 + format, may be specified like 2001:db8:85a3:8d3:1319:8a2e:370:7344 + + OperatingSystem: + description: | + Information about the Operating System of the application image + type: object + required: + - architecture + - family + - version + - license + properties: + architecture: + description: Type of the OS Architecture + type: string + enum: + - x86_64 + - x86 + example: x86_64 + family: + description: Family to which OS belongs + type: string + enum: + - RHEL + - UBUNTU + - COREOS + - WINDOWS + - OTHER + version: + description: Version of the OS + type: string + enum: + - OS_VERSION_UBUNTU_2204_LTS + - OS_VERSION_RHEL_8 + - OS_MS_WINDOWS_2022 + - OTHER + license: + description: License needed to activate the OS + type: string + enum: + - OS_LICENSE_TYPE_FREE + - OS_LICENSE_TYPE_ON_DEMAND + - OTHER + + Port: + type: integer + description: Port to stablish the connection + format: int32 + minimum: 1 + maximum: 65535 + + RequiredResources: + description: | + Fundamental hardware requirements to be provisioned by the + Application Provider. + oneOf: + - $ref: "#/components/schemas/KubernetesResources" + - $ref: "#/components/schemas/VmResources" + - $ref: "#/components/schemas/ContainerResources" + - $ref: "#/components/schemas/DockerComposeResources" + discriminator: + propertyName: infraKind + mapping: + kubernetes: "#/components/schemas/KubernetesResources" + virtualMachine: "#/components/schemas/VmResources" + container: "#/components/schemas/ContainerResources" + dockerCompose: "#/components/schemas/DockerComposeResources" + + SubmittedApp: + description: Information about the submitted app + type: object + properties: + appId: + $ref: "#/components/schemas/AppId" + + Uri: + type: string + format: uri + maxLength: 2048 + example: https://charts.bitnami.com/bitnami/helm/example-chart:0.1.0 + description: | + A Uniform Resource Identifier (URI) as per RFC 3986, + identifies the endpoint within an Edge Cloud Zone where the user + equipment may connect to the selected application instance + + XCorrelator: + $ref: "../common/CAMARA_common.yaml#/components/schemas/XCorrelator" diff --git a/open_exposure_gateway/app/api/camara/edge_application_management/vwip/common/CAMARA_common.yaml b/open_exposure_gateway/app/api/camara/edge_application_management/vwip/common/CAMARA_common.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bfdd764ee54816869f2b18a4a7c2fa4ac7f745cc --- /dev/null +++ b/open_exposure_gateway/app/api/camara/edge_application_management/vwip/common/CAMARA_common.yaml @@ -0,0 +1,931 @@ +info: + title: CAMARA common data types + description: | + Common data types for CAMARA APIs. + This file contains Commonalities-owned schemas that are identical across all + CAMARA APIs, including error responses, common parameters, headers, and + reusable data types. + + API repositories place this file in `code/common/` and reference schemas + via `$ref: "../common/CAMARA_common.yaml#/components/schemas/"`. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: wip + x-camara-commonalities: 0.8.0 + +components: + securitySchemes: + openId: + type: openIdConnect + openIdConnectUrl: https://example.com/.well-known/openid-configuration + description: OpenID Connect authentication via discovery metadata. + + headers: + x-correlator: + description: Correlation id for the different services + schema: + $ref: "#/components/schemas/XCorrelator" + + x-total-count: + description: Total number of items. Mirrors `pagination.totalCount` in the response body. + required: false + schema: + $ref: "#/components/schemas/TotalCount" + + x-total-pages: + description: Total number of pages. Mirrors `pagination.totalPages` in the response body. + required: false + schema: + $ref: "#/components/schemas/TotalPages" + + link: + description: | + Navigation links for paginated results following + [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288). + Includes only the rels applicable to the current position + (`first`, `prev`, `next`, `last`). All original query parameters + are preserved in Link URLs. + Example: + Link: ; rel="first", + ; rel="prev", + ; rel="next", + ; rel="last" + required: false + schema: + type: string + maxLength: 8192 + + parameters: + x-correlator: + name: x-correlator + in: header + description: Correlation id for the different services + schema: + $ref: "#/components/schemas/XCorrelator" + + page: + name: page + in: query + description: > + Requested page number. Pages are 1-indexed. + Values below 1 are rejected with `400 INVALID_ARGUMENT`. + required: false + schema: + $ref: "#/components/schemas/Page" + + perPage: + name: perPage + in: query + description: > + Number of subscriptions to return per page. + Values outside the allowed range are rejected with `400 INVALID_ARGUMENT`. + required: false + schema: + $ref: "#/components/schemas/PerPage" + + schemas: + XCorrelator: + type: string + description: Correlator string, UUID format recommended but any string matching the pattern can be used + pattern: ^[a-zA-Z0-9-_:;.\/<>{}]{0,256}$ + maxLength: 256 + example: "b4333c46-49c0-4f62-80d7-f0ef930f1c46" + DateTime: + type: string + format: date-time + maxLength: 64 + description: Timestamp. It must follow [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6) and must have time zone. + example: "2018-04-05T17:31:00Z" + TimePeriod: + type: object + description: A period of time defined by a start date and an optional end date. If `endDate` is not included, then the period has no ending date. + properties: + startDate: + $ref: "#/components/schemas/DateTime" + endDate: + $ref: "#/components/schemas/DateTime" + required: + - startDate + ErrorInfo: + type: object + description: A structured error response providing details about a failed request, including the HTTP status code, an error code, and a human-readable message + required: + - status + - code + - message + properties: + status: + type: integer + format: int32 + minimum: 100 + maximum: 599 + description: HTTP response status code + code: + type: string + maxLength: 96 + description: A human-readable code to describe the error + message: + type: string + maxLength: 512 + description: A human-readable description of what the event represents + 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` + NOTE1: the network operator might support only a subset of these options. The API Consumer can provide multiple identifiers to ensure compatibility across different network operators. In this case, the API Provider will use one of the identifiers for the API logic without performing any matching checks among the provided identifiers. + NOTE2: as for this Commonalities release, we are enforcing that the networkAccessIdentifier is only part of the schema for future-proofing, and CAMARA does not currently allow its use. After the CAMARA meta-release work is concluded and the relevant issues are resolved, its use will need to be explicitly documented in the guidelines. + type: object + properties: + phoneNumber: + $ref: "#/components/schemas/PhoneNumber" + networkAccessIdentifier: + $ref: "#/components/schemas/NetworkAccessIdentifier" + ipv4Address: + $ref: "#/components/schemas/DeviceIpv4Address" + ipv6Address: + $ref: "#/components/schemas/DeviceIpv6Address" + minProperties: 1 + + DeviceResponse: + description: | + An identifier for the end-user equipment able to connect to the network that the response refers to. This parameter is only returned when the API consumer includes the `device` parameter in their request (i.e. they are using a two-legged access token), and is relevant when more than one device identifier is specified, as only one of those device identifiers is allowed in the response. + + If the API consumer provides more than one device identifier in their request, and this schema is included in the response definition, the API provider MUST use it to return a single identifier which is the one they are using to fulfil the request, even if the identifiers do not match the same device. API provider does not perform any logic to validate/correlate that the indicated device identifiers match the same device. No error should be returned if the identifiers are otherwise valid to prevent API consumers correlating different identifiers with a given end user. + + allOf: + - $ref: "#/components/schemas/Device" + - maxProperties: 1 + + 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, prefixed with '+'. + type: string + pattern: '^\+[1-9][0-9]{4,14}$' + maxLength: 16 + example: "+123456789" + + 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 + maxLength: 2048 + example: "123456789@example.com" + + DeviceIpv4Address: + 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/SingleIpv4Address" + privateAddress: + $ref: "#/components/schemas/SingleIpv4Address" + publicPort: + $ref: "#/components/schemas/Port" + anyOf: + - required: [publicAddress, privateAddress] + - required: [publicAddress, publicPort] + example: + publicAddress: "84.125.93.10" + publicPort: 59765 + + SingleIpv4Address: + description: A single IPv4 address with no subnet mask + type: string + format: ipv4 + maxLength: 15 + example: "84.125.93.10" + + Port: + description: TCP or UDP port number + type: integer + format: int32 + minimum: 1 + maximum: 65535 + + 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). + type: string + format: ipv6 + maxLength: 45 + example: 2001:db8:85a3:8d3:1319:8a2e:370:7344 + + Area: + description: Base schema for all areas + type: object + properties: + areaType: + $ref: "#/components/schemas/AreaType" + required: + - areaType + discriminator: + propertyName: areaType + mapping: + CIRCLE: "#/components/schemas/Circle" + POLYGON: "#/components/schemas/Polygon" + + AreaType: + type: string + description: | + Type of this area. + CIRCLE - The area is defined as a circle. + POLYGON - The area is defined as a polygon. + enum: + - CIRCLE + - POLYGON + + Circle: + description: Circular area + allOf: + - $ref: "#/components/schemas/Area" + - type: object + required: + - center + - radius + properties: + center: + $ref: "#/components/schemas/Point" + radius: + type: number + description: Distance from the center in meters + minimum: 1 + + Polygon: + description: Polygonal area. The Polygon should be a simple polygon, i.e. should not intersect itself. + allOf: + - $ref: "#/components/schemas/Area" + - type: object + required: + - boundary + properties: + boundary: + $ref: "#/components/schemas/PointList" + + PointList: + description: List of points defining a polygon + type: array + items: + $ref: "#/components/schemas/Point" + minItems: 3 + maxItems: 15 + + Point: + type: object + description: Coordinates (latitude, longitude) defining a location in a map + required: + - latitude + - longitude + properties: + latitude: + $ref: "#/components/schemas/Latitude" + longitude: + $ref: "#/components/schemas/Longitude" + example: + latitude: 50.735851 + longitude: 7.10066 + + Latitude: + description: Latitude component of a location + type: number + format: double + minimum: -90 + maximum: 90 + + Longitude: + description: Longitude component of location + type: number + format: double + minimum: -180 + maximum: 180 + + # ───────────────────────────────────────────────────────────────────────── + # Pagination + # ───────────────────────────────────────────────────────────────────────── + + Pagination: + description: Pagination details helping to navigate through paged results efficiently. + type: object + properties: + page: + $ref: "#/components/schemas/Page" + perPage: + $ref: "#/components/schemas/PerPage" + totalCount: + $ref: "#/components/schemas/TotalCount" + totalPages: + $ref: "#/components/schemas/TotalPages" + + Page: + type: integer + format: int32 + minimum: 1 + maximum: 2147483647 + default: 1 + description: Current page number (1-indexed). + example: 1 + PerPage: + type: integer + format: int32 + minimum: 1 + maximum: 100 + default: 20 + description: Number of items per page. + example: 20 + TotalCount: + type: integer + format: int32 + minimum: 0 + maximum: 2147483647 + description: Total number of items matching the query, after filters applied. MAY be omitted where a full count query is prohibitively expensive. + example: 87 + TotalPages: + type: integer + format: int32 + minimum: 0 + maximum: 2147483647 + description: Total number of pages. Equals ceil(totalCount / perPage). MAY be omitted where totalCount is omitted. + example: 5 + responses: + ####################################################### + ####################################################### + # ERROR RESPONSE SCHEMA TEMPLATE + # - Objective: Make normative error `status` and `code` values + # - Schema Template rationale: + # - The `allOf` in content.application/json.schema allows a combination of both the generic ErrorInfo schema and the specific schema for this error response, + # which validates that `status` and `code` have only the specified values. + # This `allOf` is used without discriminator because it does not imply any hierarchy between the models, just 2 schemas that must be independently validated. + ####################################################### + # ErrorResponseSchema: + # ... + # content: + # application/json: + # schema: + # allOf: + # - $ref: '#/components/schemas/ErrorInfo' + # - type: object + # properties: + # status: + # enum: + # - + # code: + # enum: + # - + # - + # examples: + # ExampleKey1: + # value: + # status: + # code: + # message: + # ExampleKey2: + # value: + # status: + # code: + # message: + ####################################################### + ####################################################### + Generic400: + description: Bad Request + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 400 + code: + enum: + - INVALID_ARGUMENT + - OUT_OF_RANGE + # - "{{SPECIFIC_CODE}}" - API-specific codes added if needed + examples: + GENERIC_400_INVALID_ARGUMENT: + description: Invalid Argument. Generic Syntax Exception + value: + status: 400 + code: INVALID_ARGUMENT + message: Client specified an invalid argument, request body or query param. + GENERIC_400_OUT_OF_RANGE: + description: Out of Range. Specific Syntax Exception used when a given field has a pre-defined range or a invalid filter criteria combination is requested + value: + status: 400 + code: OUT_OF_RANGE + message: Client specified an invalid range. + # GENERIC_400_{{SPECIFIC_CODE}}: + # description: Specific Syntax Exception regarding a field that is relevant in the context of the API + # value: + # status: 400 + # code: "{{SPECIFIC_CODE}}" + # message: Message for specific code + Generic401: + description: Unauthorized + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 401 + code: + enum: + - UNAUTHENTICATED + examples: + GENERIC_401_UNAUTHENTICATED: + description: Request cannot be authenticated and a new authentication is required + value: + status: 401 + code: UNAUTHENTICATED + message: Request not authenticated due to missing, invalid, or expired credentials. A new authentication is required. + Generic403: + description: Forbidden + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 403 + code: + enum: + - PERMISSION_DENIED + - INVALID_TOKEN_CONTEXT + # - "{{SPECIFIC_CODE}}" - API-specific codes added if needed + examples: + GENERIC_403_PERMISSION_DENIED: + description: Permission denied. OAuth2 token access does not have the required scope or when the user fails operational security + value: + status: 403 + code: PERMISSION_DENIED + message: Client does not have sufficient permissions to perform this action. + GENERIC_403_INVALID_TOKEN_CONTEXT: + description: Reflect some inconsistency between information in some field of the API and the related OAuth2 Token + value: + status: 403 + code: INVALID_TOKEN_CONTEXT + # message: "{{field}} is not consistent with access token." + message: "Request body is not consistent with access token." + # GENERIC_403_{{SPECIFIC_CODE}}: + # description: Indicate a Business Logic condition that forbids a process not attached to a specific field in the context of the API + # value: + # status: 403 + # code: "{{SPECIFIC_CODE}}" + # message: Message for specific code + Generic404: + description: Not found + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 404 + code: + enum: + - NOT_FOUND + - IDENTIFIER_NOT_FOUND + # - "{{SPECIFIC_CODE}}" - API-specific codes added if needed + examples: + GENERIC_404_NOT_FOUND: + description: Resource is not found + value: + status: 404 + code: NOT_FOUND + message: The specified resource is not found. + GENERIC_404_IDENTIFIER_NOT_FOUND: + description: Some identifier cannot be matched to a device + value: + status: 404 + code: IDENTIFIER_NOT_FOUND + message: Device identifier not found. + # GENERIC_404_{{SPECIFIC_CODE}}: + # description: Specific situation to highlight the resource/concept not found + # value: + # status: 404 + # code: "{{SPECIFIC_CODE}}" + # message: Message for specific code + Generic405: + description: Method Not Allowed + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 405 + code: + enum: + - METHOD_NOT_ALLOWED + examples: + GENERIC_405_METHOD_NOT_ALLOWED: + description: Invalid HTTP verb used with a given endpoint + value: + status: 405 + code: METHOD_NOT_ALLOWED + message: The requested method is not allowed/supported on the target resource. + Generic406: + description: Not Acceptable + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 406 + code: + enum: + - NOT_ACCEPTABLE + examples: + GENERIC_406_NOT_ACCEPTABLE: + description: API Server does not accept the media type (`Accept-*` header) indicated by API client + value: + status: 406 + code: NOT_ACCEPTABLE + message: The server cannot produce a response matching the content requested by the client through `Accept-*` headers. + Generic409: + description: Conflict + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 409 + code: + enum: + - ABORTED + - ALREADY_EXISTS + - CONFLICT + - INCOMPATIBLE_STATE + # - "{{SPECIFIC_CODE}}" - API-specific codes added if needed + examples: + GENERIC_409_ABORTED: + description: The resource is undergoing modification by another process + value: + status: 409 + code: ABORTED + message: Resource is being modified by another operation. Please wait, and retry if appropriate. + GENERIC_409_ALREADY_EXISTS: + description: Trying to create an existing resource + value: + status: 409 + code: ALREADY_EXISTS + message: The resource that a client tried to create already exists. + GENERIC_409_CONFLICT: + ################################### + # This Error Code is DEPRECATED + ################################### + description: Duplication of an existing resource + value: + status: 409 + code: CONFLICT + message: A specified resource duplicate entry found. + GENERIC_409_INCOMPATIBLE_STATE: + description: | + The status of the referenced resource is not compatible. + value: + status: 409 + code: INCOMPATIBLE_STATE + message: Resource must be in AVAILABLE state to extend. Current state is UNAVAILABLE. + # GENERIC_409_{{SPECIFIC_CODE}}: + # description: Specific conflict situation that is relevant in the context of the API + # value: + # status: 409 + # code: "{{SPECIFIC_CODE}}" + # message: Message for specific code + Generic410: + description: Gone + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 410 + code: + enum: + - GONE + examples: + GENERIC_410_GONE: + description: Use in notifications flow to allow API Consumer to indicate that its callback is no longer available + value: + status: 410 + code: GONE + message: Access to the target resource is no longer available. + Generic412: + description: Failed precondition + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 412 + code: + enum: + - FAILED_PRECONDITION + examples: + GENERIC_412_FAILED_PRECONDITION: + description: Indication by the API Server that the request cannot be processed in current system state + value: + status: 412 + code: FAILED_PRECONDITION + message: Request cannot be executed in the current system state. + Generic415: + description: Unsupported Media Type + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 415 + code: + enum: + - UNSUPPORTED_MEDIA_TYPE + examples: + GENERIC_415_UNSUPPORTED_MEDIA_TYPE: + description: Payload format of the request is in an unsupported format by the Server. Should not happen + value: + status: 415 + code: UNSUPPORTED_MEDIA_TYPE + message: The server refuses to accept the request because the payload format is in an unsupported format. + Generic422: + description: Unprocessable Content + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 422 + code: + enum: + - SERVICE_NOT_APPLICABLE + - MISSING_IDENTIFIER + - UNSUPPORTED_IDENTIFIER + - UNNECESSARY_IDENTIFIER + # - "{{SPECIFIC_CODE}}" - API-specific codes added if needed + examples: + GENERIC_422_SERVICE_NOT_APPLICABLE: + description: Service not applicable for the provided identifier + value: + status: 422 + code: SERVICE_NOT_APPLICABLE + message: The service is not available for the provided identifier. + GENERIC_422_MISSING_IDENTIFIER: + description: An identifier is not included in the request and the device or phone number identification cannot be derived from the 3-legged access token + value: + status: 422 + code: MISSING_IDENTIFIER + message: The device cannot be identified. + GENERIC_422_UNSUPPORTED_IDENTIFIER: + description: None of the provided identifiers is supported by the implementation + value: + status: 422 + code: UNSUPPORTED_IDENTIFIER + message: The identifier provided is not supported. + GENERIC_422_UNNECESSARY_IDENTIFIER: + description: An explicit identifier is provided when a device or phone number has already been identified from the access token + value: + status: 422 + code: UNNECESSARY_IDENTIFIER + message: The device is already identified by the access token. + # GENERIC_422_{{SPECIFIC_CODE}}: + # description: Any semantic condition associated to business logic, specifically related to a field or data structure + # value: + # status: 422 + # code: "{{SPECIFIC_CODE}}" + # message: Message for specific code + Generic429: + description: Too Many Requests + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 429 + code: + enum: + - QUOTA_EXCEEDED + - TOO_MANY_REQUESTS + examples: + GENERIC_429_QUOTA_EXCEEDED: + description: Request is rejected due to exceeding a business quota limit + value: + status: 429 + code: QUOTA_EXCEEDED + message: Out of resource quota. + GENERIC_429_TOO_MANY_REQUESTS: + description: Access to the API has been temporarily blocked due to rate or spike arrest limits being reached + value: + status: 429 + code: TOO_MANY_REQUESTS + message: Rate limit reached. + Generic500: + description: Internal Server Error + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 500 + code: + enum: + - INTERNAL + examples: + GENERIC_500_INTERNAL: + description: Problem in Server side. Regular Server Exception + value: + status: 500 + code: INTERNAL + message: Unknown server error. Typically a server bug. + Generic501: + description: Not Implemented + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 501 + code: + enum: + - NOT_IMPLEMENTED + examples: + GENERIC_501_NOT_IMPLEMENTED: + description: Service not implemented. The use of this code should be avoided as far as possible to get the objective to reach aligned implementations + value: + status: 501 + code: NOT_IMPLEMENTED + message: This functionality is not implemented yet. + Generic502: + description: Bad Gateway + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 502 + code: + enum: + - BAD_GATEWAY + examples: + GENERIC_502_BAD_GATEWAY: + description: Internal routing problem in the Server side that blocks to manage the service properly + value: + status: 502 + code: BAD_GATEWAY + message: An upstream internal service cannot be reached. + Generic503: + description: Service Unavailable + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 503 + code: + enum: + - UNAVAILABLE + examples: + GENERIC_503_UNAVAILABLE: + description: Service is not available. Temporary situation usually related to maintenance process in the server side + value: + status: 503 + code: UNAVAILABLE + message: Service Unavailable. + Generic504: + description: Gateway Timeout + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 504 + code: + enum: + - TIMEOUT + examples: + GENERIC_504_TIMEOUT: + description: API Server Timeout + value: + status: 504 + code: TIMEOUT + message: Request timeout exceeded. diff --git a/open_exposure_gateway/app/api/camara/edge_application_management/vwip/router.py b/open_exposure_gateway/app/api/camara/edge_application_management/vwip/router.py new file mode 100644 index 0000000000000000000000000000000000000000..4721bed9afdc9f1d3299fabca38317f0948279bb --- /dev/null +++ b/open_exposure_gateway/app/api/camara/edge_application_management/vwip/router.py @@ -0,0 +1,227 @@ +from typing import Annotated, Any, Optional +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, Query, Request, Response, status + +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + AppInstanceInfo, + AppManifest, + AppManifestEnvelope, + CreateAppInstanceRequest, + EdgeCloudZone, + EdgeCloudZoneStatus, + SubmittedApp, +) +from open_exposure_gateway.app.application.services.edge_application_management_service import ( + EdgeApplicationManagementService, +) +from open_exposure_gateway.app.core.exceptions import ( + BadRequestException, + ConflictException, + DownstreamServiceException, + ForbiddenException, + NotFoundException, + NotImplementedException, + UnauthorizedException, +) +from open_exposure_gateway.app.dependencies import ( + CallerContext, + get_caller_context, + get_edge_app_service, +) +from open_exposure_gateway.app.schemas.common import ErrorInfo + +# CAMARA base path: the spec serves at {apiRoot}/edge-application-management/vwip; +# the api-name/version segment is owned by this package, {apiRoot} by the deployment. +BASE_PATH = "/edge-application-management/vwip" + +router = APIRouter(prefix=BASE_PATH) + +EdgeAppService = Annotated[EdgeApplicationManagementService, Depends(get_edge_app_service)] +Caller = Annotated[CallerContext, Depends(get_caller_context)] + +# OpenAPI response docs derived from core.exceptions' status_code/message +# defaults, so codes aren't re-listed. 500 has no dedicated exception class +# (it's the unhandled-exception catch-all in error_handlers.py). +_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { + exc_cls().status_code: {"model": ErrorInfo, "description": exc_cls().message} + for exc_cls in ( + BadRequestException, + UnauthorizedException, + ForbiddenException, + NotFoundException, + ConflictException, + NotImplementedException, + DownstreamServiceException, + ) +} +_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.get( + "/edge-cloud-zones", + tags=["Edge Cloud"], + summary="Retrieve a list of the operators Edge Cloud Zones and their status", + response_model=list[EdgeCloudZone], + response_model_exclude_none=True, + responses=_responses(400, 401, 403, 500, 503), +) +async def get_edge_cloud_zones( + service: EdgeAppService, + caller: Caller, + region: Annotated[Optional[str], Query(max_length=64)] = None, + status: Annotated[Optional[EdgeCloudZoneStatus], Query()] = None, +) -> Any: + return await service.get_edge_cloud_zones( + region=region, + status=status, + x_correlator=caller.x_correlator, + ) + + +@router.get( + "/apps", + tags=["Application"], + summary="Retrieve a list of existing Applications", + response_model=list[AppManifest], + responses=_responses(400, 401, 403, 500, 503), +) +async def get_apps( + service: EdgeAppService, + caller: Caller, +) -> Any: + return await service.get_apps(x_correlator=caller.x_correlator) + + +@router.get( + "/apps/{appId}", + tags=["Application"], + summary="Retrieve the information of an Application", + response_model=AppManifestEnvelope, + responses=_responses(400, 401, 403, 404, 500, 503), +) +async def get_app( + appId: UUID, + service: EdgeAppService, + caller: Caller, +) -> Any: + return await service.get_app(app_id=appId, x_correlator=caller.x_correlator) + + +@router.post( + "/apps", + tags=["Application"], + status_code=201, + summary="Submit application metadata to the Edge Cloud Provider.", + response_model=SubmittedApp, + responses=_responses(400, 401, 403, 409, 500, 501, 503), +) +async def submit_app( + request: AppManifest, + service: EdgeAppService, + caller: Caller, +) -> Any: + app_id = request.appId or uuid4() + return await service.submit_app( + manifest=request, + app_id=app_id, + tenant_id=caller.tenant_id, + app_provider_id=caller.app_provider_id, + x_correlator=caller.x_correlator, + ) + + +@router.delete( + "/apps/{appId}", + tags=["Application"], + status_code=204, + summary="Delete an Application from an Edge Cloud Provider", + responses=_responses(400, 401, 403, 404, 409, 500, 503), +) +async def delete_app( + appId: UUID, + service: EdgeAppService, + caller: Caller, +) -> Response: + await service.delete_app( + app_id=appId, + app_provider_id=caller.app_provider_id, + x_correlator=caller.x_correlator, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post( + "/appinstances", + tags=["Application"], + status_code=202, + summary="Instantiation of an Application", + response_model=AppInstanceInfo, + response_model_exclude_none=True, + responses=_responses(400, 401, 403, 409, 500, 501, 503), +) +async def create_app_instance( + request: CreateAppInstanceRequest, + service: EdgeAppService, + caller: Caller, + http_request: Request, + response: Response, +) -> Any: + instance = await service.create_app_instance( + request=request, + tenant_id=caller.tenant_id, + app_provider_id=caller.app_provider_id, + x_correlator=caller.x_correlator, + ) + # Absolute URI per the spec ("Contains the URI of the newly created application."); + # base_url reflects the scheme/host the caller actually used to reach OEG. + base = str(http_request.base_url).rstrip("/") + response.headers["Location"] = f"{base}{BASE_PATH}/appinstances/{instance.appInstanceId}" + return instance + + +@router.get( + "/appinstances", + tags=["Application"], + summary="Retrieve the information of Application Instances for a given App", + response_model=list[AppInstanceInfo], + response_model_exclude_none=True, + responses=_responses(400, 401, 403, 500, 503), +) +async def get_app_instances( + service: EdgeAppService, + caller: Caller, + appId: Annotated[Optional[UUID], Query()] = None, + appInstanceId: Annotated[Optional[UUID], Query()] = None, + region: Annotated[Optional[str], Query(max_length=64)] = None, +) -> Any: + return await service.get_app_instances( + app_id=appId, + app_instance_id=appInstanceId, + region=region, + x_correlator=caller.x_correlator, + ) + + +@router.delete( + "/appinstances/{appInstanceId}", + tags=["Application"], + status_code=202, + summary="Terminate an Application Instance", + responses=_responses(400, 401, 403, 404, 500, 503), +) +async def delete_app_instance( + appInstanceId: UUID, + service: EdgeAppService, + caller: Caller, +) -> Response: + await service.delete_app_instance( + app_instance_id=appInstanceId, + app_provider_id=caller.app_provider_id, + x_correlator=caller.x_correlator, + ) + return Response(status_code=status.HTTP_202_ACCEPTED) diff --git a/open_exposure_gateway/app/api/camara/edge_application_management/vwip/schemas.py b/open_exposure_gateway/app/api/camara/edge_application_management/vwip/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..d09fbddd4a5ff81282ccc1e437ab84d2fa2e903d --- /dev/null +++ b/open_exposure_gateway/app/api/camara/edge_application_management/vwip/schemas.py @@ -0,0 +1,189 @@ +from datetime import datetime +from enum import StrEnum +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class AppInstanceStatus(StrEnum): + READY = "ready" + INSTANTIATING = "instantiating" + FAILED = "failed" + TERMINATING = "terminating" + UNKNOWN = "unknown" + + +class EdgeCloudZoneStatus(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + UNKNOWN = "unknown" + + +class EdgeCloudZone(BaseModel): + edgeCloudZoneId: UUID + edgeCloudZoneName: str = Field(max_length=64) + edgeCloudProvider: str = Field(max_length=64) + edgeCloudZoneStatus: EdgeCloudZoneStatus = EdgeCloudZoneStatus.UNKNOWN + edgeCloudRegion: Optional[str] = Field(default=None, max_length=64) + + +class SubmittedApp(BaseModel): + appId: UUID + + +class AppRepo(BaseModel): + type: Literal["PRIVATEREPO", "PUBLICREPO"] + imagePath: str = Field(max_length=2048) + userName: Optional[str] = Field(default=None, max_length=64) + credentials: Optional[str] = Field(default=None, max_length=2048) + authType: Optional[Literal["DOCKER", "HTTP_BASIC", "HTTP_BEARER", "NONE"]] = None + checksum: Optional[str] = Field(default=None, max_length=128) + + +class OperatingSystem(BaseModel): + architecture: Literal["x86_64", "x86"] + family: Literal["RHEL", "UBUNTU", "COREOS", "WINDOWS", "OTHER"] + version: Literal[ + "OS_VERSION_UBUNTU_2204_LTS", + "OS_VERSION_RHEL_8", + "OS_MS_WINDOWS_2022", + "OTHER", + ] + license: Literal["OS_LICENSE_TYPE_FREE", "OS_LICENSE_TYPE_ON_DEMAND", "OTHER"] + + +class NetworkInterface(BaseModel): + interfaceId: str = Field( + min_length=4, + max_length=32, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_]{2,30}[A-Za-z0-9]$", + ) + protocol: Literal["TCP", "UDP", "ANY"] + port: int = Field(ge=1, le=65535) + visibilityType: Literal["VISIBILITY_EXTERNAL", "VISIBILITY_INTERNAL"] + + +class ComponentSpecItem(BaseModel): + componentName: str = Field(max_length=64) + networkInterfaces: list[NetworkInterface] + + +class VmResources(BaseModel): + infraKind: Literal["virtualMachine"] + numCPU: int = Field(ge=1, le=256) + memory: int = Field(ge=1, le=32768) + + +class ContainerResources(BaseModel): + infraKind: Literal["container"] + numCPU: str = Field(pattern=r"^\d+((\.\d{1,3})|(m))?$") + memory: int = Field(ge=1, le=16384) + + +class DockerComposeResources(BaseModel): + infraKind: Literal["dockerCompose"] + numCPU: int = Field(ge=1, le=256) + memory: int = Field(ge=1, le=16384) + + +class CpuPoolTopology(BaseModel): + minNumberOfNodes: int = Field(ge=1, le=1000) + minNodeCpu: int = Field(ge=1, le=256) + minNodeMemory: int = Field(ge=1, le=16384) + + +class CpuPool(BaseModel): + numCPU: int = Field(ge=1, le=256) + memory: int = Field(ge=1, le=16384) + topology: CpuPoolTopology + + +class GpuPoolTopology(BaseModel): + minNumberOfNodes: int = Field(ge=1, le=1000) + minNodeCpu: int = Field(ge=1, le=256) + minNodeMemory: int = Field(ge=1, le=16384) + minNodeGpuMemory: int = Field(ge=1, le=16) + + +class GpuPool(BaseModel): + numCPU: int = Field(ge=1, le=1024) + memory: int = Field(ge=1, le=16384) + gpuMemory: int = Field(ge=1, le=16) + topology: GpuPoolTopology + + +class ApplicationResources(BaseModel): + cpuPool: Optional[CpuPool] = None + gpuPool: Optional[GpuPool] = None + + +class KubernetesResources(BaseModel): + infraKind: Literal["kubernetes"] + applicationResources: ApplicationResources + isStandalone: bool + additionalStorage: Optional[str] = Field(default=None, max_length=32, pattern=r"^\d+(GB|MB)$") + + +RequiredResources = VmResources | ContainerResources | DockerComposeResources | KubernetesResources + + +class AppManifest(BaseModel): + appId: Optional[UUID] = None + name: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$") + appProvider: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{7,63}$") + version: str = Field(max_length=64) + packageType: Literal["QCOW2", "OVA", "CONTAINER", "HELM", "CSAR"] + operatingSystem: Optional[OperatingSystem] = None + appRepo: AppRepo + requiredResources: RequiredResources + componentSpec: list[ComponentSpecItem] + + +class AppManifestEnvelope(BaseModel): + appManifest: AppManifest + + +class AccessEndpoint(BaseModel): + port: int = Field(ge=1, le=65535) + fqdn: Optional[str] = Field(default=None, max_length=253) + ipv4Addresses: Optional[list[str]] = None + ipv6Addresses: Optional[list[str]] = None + + +class ComponentEndpointInfo(BaseModel): + interfaceId: str + accessPoints: AccessEndpoint + + +class AppInstanceInfo(BaseModel): + appInstanceId: UUID + name: str + appId: UUID + appProvider: str + status: AppInstanceStatus = AppInstanceStatus.UNKNOWN + componentEndpointInfo: Optional[list[ComponentEndpointInfo]] = None + kubernetesClusterRef: Optional[UUID] = None + edgeCloudZoneId: UUID + + +class SubscriptionConfig(BaseModel): + subscriptionDetail: Optional[dict[str, Any]] = None + subscriptionExpireTime: Optional[datetime] = None + subscriptionMaxEvents: Optional[int] = None + initialEvent: Optional[bool] = None + + +class SubscriptionRequest(BaseModel): + sink: str + sinkCredential: Optional[dict[str, Any]] = None + types: list[str] + config: Optional[SubscriptionConfig] = None + + +class CreateAppInstanceRequest(BaseModel): + name: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$") + appId: UUID + edgeCloudZoneId: UUID + kubernetesClusterRef: Optional[UUID] = None + subscriptionRequest: Optional[SubscriptionRequest] = None diff --git a/open_exposure_gateway/app/api/camara/quality_on_demand/v0_10_1/router.py b/open_exposure_gateway/app/api/camara/quality_on_demand/v0_10_1/router.py new file mode 100644 index 0000000000000000000000000000000000000000..db806a089e0710d4fda26792fecca3060ae2881e --- /dev/null +++ b/open_exposure_gateway/app/api/camara/quality_on_demand/v0_10_1/router.py @@ -0,0 +1,71 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Response, status + +from open_exposure_gateway.app.api.camara.common import XCorrelatorHeader +from open_exposure_gateway.app.api.camara.quality_on_demand.v0_10_1.schemas import ( + QoDSessionRequest, + QoDSessionResponse, +) +from open_exposure_gateway.app.application.services.quality_on_demand_service import ( + QualityOnDemandService, +) +from open_exposure_gateway.app.dependencies import get_qod_service + +# CAMARA base path: the spec serves at {apiRoot}/qod/v0 (wire version of v0.10.1). +BASE_PATH = "/qod/v0" + +router = APIRouter(prefix=BASE_PATH) + +QoDService = Annotated[QualityOnDemandService, Depends(get_qod_service)] + + +@router.post( + "/sessions", + tags=["Quality on Demand Functions"], + summary="Creates a new QoD Session", + status_code=status.HTTP_201_CREATED, +) +async def create_qod_session( + request: QoDSessionRequest, + service: QoDService, + x_correlator: XCorrelatorHeader = None, +) -> QoDSessionResponse: + return await service.create_session( + request=request, + x_correlator=x_correlator, + ) + + +@router.get( + "/sessions/{sessionId}", + tags=["Quality on Demand Functions"], + summary="Retrieve details of a QoD Session", +) +async def get_qod_session( + sessionId: str, + service: QoDService, + x_correlator: XCorrelatorHeader = None, +) -> QoDSessionResponse: + return await service.get_session( + session_id=sessionId, + x_correlator=x_correlator, + ) + + +@router.delete( + "/sessions/{sessionId}", + tags=["Quality on Demand Functions"], + summary="Remove QoD Session", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_qod_session( + sessionId: str, + service: QoDService, + x_correlator: XCorrelatorHeader = None, +) -> Response: + await service.delete_session( + session_id=sessionId, + x_correlator=x_correlator, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/open_exposure_gateway/app/api/camara/quality_on_demand/v0_10_1/schemas.py b/open_exposure_gateway/app/api/camara/quality_on_demand/v0_10_1/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..4a75dc91b51af8d79de16a33984844f33fb64209 --- /dev/null +++ b/open_exposure_gateway/app/api/camara/quality_on_demand/v0_10_1/schemas.py @@ -0,0 +1,66 @@ +from enum import StrEnum +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class QosStatus(StrEnum): + REQUESTED = "REQUESTED" + AVAILABLE = "AVAILABLE" + UNAVAILABLE = "UNAVAILABLE" + + +class Ipv4Address(BaseModel): + publicAddress: Optional[str] = None + privateAddress: Optional[str] = None + publicPort: Optional[int] = Field(default=None, ge=0, le=65535) + + +class Device(BaseModel): + phoneNumber: Optional[str] = None + networkAccessIdentifier: Optional[str] = None + ipv4Address: Optional[Ipv4Address] = None + ipv6Address: Optional[str] = None + + +class ApplicationServer(BaseModel): + ipv4Address: Optional[str] = None + ipv6Address: Optional[str] = None + + +class PortRange(BaseModel): + from_: int = Field(alias="from") + to: int + + model_config = { + "populate_by_name": True, + } + + +class PortsSpec(BaseModel): + ranges: Optional[list[PortRange]] = None + ports: Optional[list[int]] = None + + +class QoDSessionRequest(BaseModel): + device: Device + applicationServer: ApplicationServer + qosProfile: str + devicePorts: Optional[PortsSpec] = None + applicationServerPorts: Optional[PortsSpec] = None + sink: Optional[str] = None + duration: int = Field(default=86400, ge=1, le=86400) + + +class QoDSessionResponse(BaseModel): + sessionId: UUID + device: Device + applicationServer: ApplicationServer + qosProfile: str + duration: int + startedAt: int + expiresAt: int + qosStatus: QosStatus + devicePorts: Optional[PortsSpec] = None + applicationServerPorts: Optional[PortsSpec] = None diff --git a/open_exposure_gateway/app/api/error_handlers.py b/open_exposure_gateway/app/api/error_handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..ad2aa17c517862c671f78db713cd943270402961 --- /dev/null +++ b/open_exposure_gateway/app/api/error_handlers.py @@ -0,0 +1,162 @@ +import uuid + +import structlog +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from open_exposure_gateway.app.api.camara.common import X_CORRELATOR_PATTERN +from open_exposure_gateway.app.core.exceptions import ( + BadRequestException, + ConflictException, + DownstreamServiceException, + ErrorCode, + ForbiddenException, + MethodNotAllowedException, + NotFoundException, + NotImplementedException, + OEGException, + OutOfRangeException, + UnauthorizedException, +) +from open_exposure_gateway.app.schemas.common import ErrorInfo + +_HTTP_EXCEPTION_MAP: dict[int, type[OEGException]] = { + 400: BadRequestException, + 401: UnauthorizedException, + 403: ForbiddenException, + 404: NotFoundException, + 405: MethodNotAllowedException, +} + + +def x_correlator_header(request: Request) -> str: + x_correlator = request.headers.get("x-correlator") + if x_correlator and X_CORRELATOR_PATTERN.match(x_correlator): + return x_correlator + return str(uuid.uuid4()) + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(RequestValidationError) + async def handle_request_validation_error( + request: Request, exc: RequestValidationError + ) -> JSONResponse: + return JSONResponse( + status_code=400, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo( + status=400, + code=ErrorCode.INVALID_ARGUMENT, + message=str(exc.errors())[:512], + ).model_dump(), + ) + + @app.exception_handler(BadRequestException) + async def handle_bad_request(request: Request, exc: BadRequestException) -> JSONResponse: + return JSONResponse( + status_code=400, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo(status=400, code=exc.error_code, message=exc.message).model_dump(), + ) + + @app.exception_handler(OutOfRangeException) + async def handle_out_of_range(request: Request, exc: OutOfRangeException) -> JSONResponse: + return JSONResponse( + status_code=400, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo(status=400, code=exc.error_code, message=exc.message).model_dump(), + ) + + @app.exception_handler(UnauthorizedException) + async def handle_unauthorized(request: Request, exc: UnauthorizedException) -> JSONResponse: + return JSONResponse( + status_code=401, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo(status=401, code=exc.error_code, message=exc.message).model_dump(), + ) + + @app.exception_handler(ForbiddenException) + async def handle_forbidden(request: Request, exc: ForbiddenException) -> JSONResponse: + return JSONResponse( + status_code=403, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo(status=403, code=exc.error_code, message=exc.message).model_dump(), + ) + + @app.exception_handler(NotFoundException) + async def handle_not_found(request: Request, exc: NotFoundException) -> JSONResponse: + return JSONResponse( + status_code=404, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo(status=404, code=exc.error_code, message=exc.message).model_dump(), + ) + + @app.exception_handler(ConflictException) + async def handle_conflict(request: Request, exc: ConflictException) -> JSONResponse: + return JSONResponse( + status_code=409, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo(status=409, code=exc.error_code, message=exc.message).model_dump(), + ) + + @app.exception_handler(NotImplementedException) + async def handle_not_implemented( + request: Request, exc: NotImplementedException + ) -> JSONResponse: + return JSONResponse( + status_code=501, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo(status=501, code=exc.error_code, message=exc.message).model_dump(), + ) + + @app.exception_handler(DownstreamServiceException) + async def handle_downstream(request: Request, exc: DownstreamServiceException) -> JSONResponse: + return JSONResponse( + status_code=503, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo(status=503, code=exc.error_code, message=exc.message).model_dump(), + ) + + @app.exception_handler(OEGException) + async def handle_oeg_exception(request: Request, exc: OEGException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + headers={"x-correlator": x_correlator_header(request)}, + content=ErrorInfo( + status=exc.status_code, code=exc.error_code, message=exc.message + ).model_dump(), + ) + + @app.exception_handler(StarletteHTTPException) + async def handle_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse: + exc_cls = _HTTP_EXCEPTION_MAP.get(exc.status_code) + mapped = ( + exc_cls(message=str(exc.detail)) + if exc_cls is not None + else OEGException(message=str(exc.detail), status_code=exc.status_code) + ) + response = await handle_oeg_exception(request, mapped) + if exc.headers: + response.headers.update(exc.headers) + return response + + @app.exception_handler(Exception) + async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse: + x_correlator = x_correlator_header(request) + structlog.get_logger().exception( + "Unhandled exception", + method=request.method, + path=request.url.path, + client=request.client.host if request.client else "unknown", + x_correlator=x_correlator, + error=str(exc), + ) + return JSONResponse( + status_code=500, + headers={"x-correlator": x_correlator}, + content=ErrorInfo( + status=500, code=ErrorCode.INTERNAL, message="Internal server error" + ).model_dump(), + ) diff --git a/open_exposure_gateway/app/api/platform/health.py b/open_exposure_gateway/app/api/platform/health.py new file mode 100644 index 0000000000000000000000000000000000000000..74b7fcf32fe6461ad4e6a60ae39093d3034328a5 --- /dev/null +++ b/open_exposure_gateway/app/api/platform/health.py @@ -0,0 +1,41 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Response, status + +from open_exposure_gateway.app.core.config import get_settings +from open_exposure_gateway.app.dependencies import get_database_health, get_publisher +from open_exposure_gateway.app.ports.databus_port import DataBusPort +from open_exposure_gateway.app.schemas.common import HealthResponse, HealthStatus + +router = APIRouter(tags=["Platform"]) + + +@router.get("/healthz") +def healthz() -> HealthResponse: + settings = get_settings() + return HealthResponse( + status=HealthStatus.OK, + service=settings.app_name, + version=settings.app_version, + ) + + +@router.get("/readyz") +async def readyz( + response: Response, + publisher: Annotated[DataBusPort, Depends(get_publisher)], + database_is_healthy: Annotated[bool, Depends(get_database_health)], +) -> HealthResponse: + settings = get_settings() + if not publisher.is_connected or not database_is_healthy: + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return HealthResponse( + status=HealthStatus.NOT_OK, + service=settings.app_name, + version=settings.app_version, + ) + return HealthResponse( + status=HealthStatus.OK, + service=settings.app_name, + version=settings.app_version, + ) diff --git a/edge_cloud_management_api/managers/__init__.py b/open_exposure_gateway/app/application/mappers/__init__.py similarity index 100% rename from edge_cloud_management_api/managers/__init__.py rename to open_exposure_gateway/app/application/mappers/__init__.py diff --git a/open_exposure_gateway/app/application/mappers/edge_application_mapper.py b/open_exposure_gateway/app/application/mappers/edge_application_mapper.py new file mode 100644 index 0000000000000000000000000000000000000000..c7cef93b669977a6d462454f1caf34c8ccaf42cc --- /dev/null +++ b/open_exposure_gateway/app/application/mappers/edge_application_mapper.py @@ -0,0 +1,615 @@ +import re +from collections import defaultdict +from typing import Any, Optional +from uuid import UUID + +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + AccessEndpoint, + AppInstanceInfo, + AppInstanceStatus, + AppManifest, + ComponentEndpointInfo, + ComponentSpecItem, + ContainerResources, + CreateAppInstanceRequest, + DockerComposeResources, + EdgeCloudZone, + EdgeCloudZoneStatus, + KubernetesResources, + SubmittedApp, + VmResources, +) +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + ApplicationResources as CamaraApplicationResources, +) +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + AppRepo as CamaraAppRepo, +) +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + NetworkInterface as CamaraNetworkInterface, +) +from open_exposure_gateway.app.domain.edge_application_management import ( + AppDeploymentTranslation, + ApplicationResources, + AppRegistrationTranslation, + AppRepo, + ComponentSpec, + CpuPool, + CpuPoolTopology, + GpuPool, + NetworkInterface, + RequiredResources, + ResourceZone, + SRMAccelerator, + SRMCapabilityRequirement, + SRMCatalogPayload, + SRMComputeIntent, + SRMComputeResources, + SRMDeployCommand, + SRMDeploymentUnit, + SRMDeploymentUnitMetadata, + SRMDeployPayload, + SRMDeployTarget, + SRMNetworkInterface, + SRMRepoMetadata, + SRMServiceInstance, + SRMServiceSpecDescriptor, + SRMServiceSpecEntry, + SRMStorageVolume, + SRMTerminateCommand, + SRMTerminatePayload, + SRMTopologyConstraints, +) + +_PACKAGE_TYPE_TO_RUNTIME_KIND: dict[str, str] = { + "HELM": "helm", + "CONTAINER": "container", + "QCOW2": "qcow2", + "OVA": "ova", + "CSAR": "csar", +} + +_VISIBILITY_MAP: dict[str, str] = { + "VISIBILITY_EXTERNAL": "external", + "VISIBILITY_INTERNAL": "internal", +} + +_RUNTIME_KIND_TO_PACKAGE_TYPE: dict[str, str] = { + v: k for k, v in _PACKAGE_TYPE_TO_RUNTIME_KIND.items() +} + +_VISIBILITY_REVERSE_MAP: dict[str, str] = {v: k for k, v in _VISIBILITY_MAP.items()} + +_SRM_STATE_TO_APP_INSTANCE_STATUS: dict[str, AppInstanceStatus] = { + "creating": AppInstanceStatus.INSTANTIATING, + "active": AppInstanceStatus.READY, + "updating": AppInstanceStatus.INSTANTIATING, + "degraded": AppInstanceStatus.FAILED, + "failed": AppInstanceStatus.FAILED, + "terminating": AppInstanceStatus.TERMINATING, + "terminated": AppInstanceStatus.TERMINATING, +} + + +def _parse_container_cpu_cores(value: str) -> float: + if value.endswith("m"): + return int(value[:-1]) / 1000.0 + return float(value) + + +def _parse_storage_mb(value: str) -> int: + match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(GB|MB|TB)", value.strip(), re.IGNORECASE) + if not match: + raise ValueError(f"Cannot parse storage value: {value!r}") + amount, unit = float(match.group(1)), match.group(2).upper() + if unit == "TB": + return int(amount * 1024 * 1024) + if unit == "GB": + return int(amount * 1024) + return int(amount) + + +def build_edge_cloud_zone(srm_zone: ResourceZone) -> EdgeCloudZone: + try: + status = EdgeCloudZoneStatus(srm_zone.status) + except ValueError: + status = EdgeCloudZoneStatus.UNKNOWN + + return EdgeCloudZone( + edgeCloudZoneId=UUID(srm_zone.resource_zone_id), + edgeCloudZoneName=srm_zone.name, + edgeCloudZoneStatus=status, + edgeCloudProvider=srm_zone.provider, + edgeCloudRegion=srm_zone.location.region if srm_zone.location else None, + ) + + +def build_app_manifest(catalog: SRMCatalogPayload) -> AppManifest: + spec = catalog.service_specification + unit = catalog.service_deployment_units[0] + + try: + app_id: Optional[UUID] = UUID(spec.ref) + except (ValueError, AttributeError): + app_id = None + + package_type = _RUNTIME_KIND_TO_PACKAGE_TYPE.get(unit.runtime_kind, "HELM") + + repo_meta = unit.metadata.repo if unit.metadata else None + if repo_meta and repo_meta.type == "PRIVATEREPO": + app_repo = CamaraAppRepo( + type="PRIVATEREPO", + imagePath=unit.artifact_ref, + userName=repo_meta.user_ref, + credentials=repo_meta.credentials, + authType=repo_meta.auth_type, # type: ignore[arg-type] + ) + else: + app_repo = CamaraAppRepo( + type="PUBLICREPO", + imagePath=unit.artifact_ref, + ) + + compute = unit.resource_requirements.compute + interfaces = unit.resource_requirements.interfaces or [] + + required_resources: Any + if unit.runtime_kind in ("helm", "csar"): + app_res: dict[str, Any] = {} + topo = unit.resource_requirements.topology + if compute and compute.accelerator is not None: + gpu_entry: dict[str, Any] = {} + if compute.cpu_millicores is not None: + gpu_entry["numCPU"] = compute.cpu_millicores / 1000 + if compute.memory_mb is not None: + gpu_entry["memory"] = compute.memory_mb + gpu_entry["gpuMemory"] = compute.accelerator.memory_mb / 1024 + if topo: + gpu_entry["topology"] = { + k: v + for k, v in { + "minNumberOfNodes": topo.min_nodes, + "minNodeCpu": (topo.min_node_cpu_millicores // 1000) + if topo.min_node_cpu_millicores + else None, + "minNodeMemory": topo.min_node_memory_mb, + "minNodeGpuMemory": (topo.min_node_gpu_memory_mb / 1024) + if topo.min_node_gpu_memory_mb + else None, + }.items() + if v is not None + } + app_res["gpuPool"] = gpu_entry + elif compute and compute.cpu_millicores is not None: + cpu_entry: dict[str, Any] = { + "numCPU": compute.cpu_millicores / 1000, + "memory": compute.memory_mb, + } + if topo: + cpu_entry["topology"] = { + k: v + for k, v in { + "minNumberOfNodes": topo.min_nodes, + "minNodeCpu": (topo.min_node_cpu_millicores // 1000) + if topo.min_node_cpu_millicores + else None, + "minNodeMemory": topo.min_node_memory_mb, + "minNodeGpuMemory": topo.min_node_gpu_memory_mb, + }.items() + if v is not None + } + app_res["cpuPool"] = cpu_entry + required_resources = KubernetesResources( + infraKind="kubernetes", + applicationResources=CamaraApplicationResources.model_validate(app_res), + isStandalone=compute.standalone if compute else False, + ) + elif unit.runtime_kind == "container": + millicores = compute.cpu_millicores if compute and compute.cpu_millicores is not None else 0 + num_cpu_str = f"{millicores}m" if millicores % 1000 != 0 else str(millicores // 1000) + required_resources = ContainerResources( + infraKind="container", + numCPU=num_cpu_str, + memory=compute.memory_mb if compute and compute.memory_mb else 0, + ) + elif unit.runtime_kind in ("qcow2", "ova"): + required_resources = VmResources( + infraKind="virtualMachine", + numCPU=max(1, round(compute.cpu_millicores / 1000)) + if compute and compute.cpu_millicores + else 1, + memory=compute.memory_mb if compute and compute.memory_mb else 1, + ) + elif unit.runtime_kind == "docker-compose": + required_resources = DockerComposeResources( + infraKind="dockerCompose", + numCPU=max(1, round(compute.cpu_millicores / 1000)) + if compute and compute.cpu_millicores + else 1, + memory=compute.memory_mb if compute and compute.memory_mb else 1, + ) + else: + required_resources = None + + by_component: dict[str, list[SRMNetworkInterface]] = defaultdict(list) + for ni in interfaces: + by_component[ni.component].append(ni) + + component_spec = [ + ComponentSpecItem( + componentName=comp_name, + networkInterfaces=[ + CamaraNetworkInterface( + interfaceId=ni.interface_id, + protocol=ni.protocol, # type: ignore[arg-type] + port=ni.port, + visibilityType=_VISIBILITY_REVERSE_MAP.get( # type: ignore[arg-type] + ni.visibility, "VISIBILITY_EXTERNAL" + ), + ) + for ni in ni_list + ], + ) + for comp_name, ni_list in by_component.items() + ] + + return AppManifest( + appId=app_id, + name=spec.name, + appProvider=spec.app_provider_id, + version=spec.version, + packageType=package_type, # type: ignore[arg-type] + operatingSystem=None, + appRepo=app_repo, + requiredResources=required_resources, + componentSpec=component_spec, + ) + + +def build_app_instance_info(instance: SRMServiceInstance) -> AppInstanceInfo: + status = _SRM_STATE_TO_APP_INSTANCE_STATUS.get(instance.state, AppInstanceStatus.UNKNOWN) + + endpoint_info: list[ComponentEndpointInfo] = [] + for cap in instance.capability_instances: + if cap.result_summary and cap.result_summary.endpoints: + for ep in cap.result_summary.endpoints: + if ep.port is not None: + endpoint_info.append( + ComponentEndpointInfo( + interfaceId=ep.interface_id, + accessPoints=AccessEndpoint( + port=ep.port, + fqdn=ep.fqdn, + ), + ) + ) + + return AppInstanceInfo( + appInstanceId=UUID(instance.service_instance_id), + name=instance.name or instance.service_instance_id, + appId=UUID(instance.service_specification_id), + appProvider=instance.app_provider_id, + status=status, + edgeCloudZoneId=UUID(instance.resource_zone_id) + if instance.resource_zone_id + else UUID(int=0), + componentEndpointInfo=endpoint_info or None, + ) + + +def build_app_registration_translation( + manifest: AppManifest, + app_id: UUID, + tenant_id: str, + app_provider_id: str, +) -> AppRegistrationTranslation: + if manifest.appRepo.type == "PRIVATEREPO": + app_repo = AppRepo( + type=manifest.appRepo.type, + image_path=manifest.appRepo.imagePath, + user_name=manifest.appRepo.userName, + credentials=f"secret://oeg/{app_id}/repo-credentials", + auth_type=manifest.appRepo.authType, + checksum=manifest.appRepo.checksum, + ) + else: + app_repo = AppRepo( + type=manifest.appRepo.type, + image_path=manifest.appRepo.imagePath, + checksum=manifest.appRepo.checksum, + ) + + required_resources: Optional[RequiredResources] = None + if manifest.requiredResources is not None and isinstance( + manifest.requiredResources, KubernetesResources + ): + rr = manifest.requiredResources + + cpu_pool: Optional[CpuPool] = None + if rr.applicationResources.cpuPool is not None: + cp = rr.applicationResources.cpuPool + cpu_pool = CpuPool( + num_cpu=cp.numCPU, + memory=cp.memory, + topology=CpuPoolTopology( + min_number_of_nodes=cp.topology.minNumberOfNodes, + min_node_cpu=cp.topology.minNodeCpu, + min_node_memory=cp.topology.minNodeMemory, + ), + ) + + gpu_pool: Optional[GpuPool] = None + if rr.applicationResources.gpuPool is not None: + gp = rr.applicationResources.gpuPool + # CAMARA has no GPU-count field; requesting a gpuPool means at + # least one accelerator unit on the SRM side. + gpu_pool = GpuPool( + num_cpu=gp.numCPU, + num_gpu=1, + memory=gp.memory, + gpu_memory=gp.gpuMemory, + topology=CpuPoolTopology( + min_number_of_nodes=gp.topology.minNumberOfNodes, + min_node_cpu=gp.topology.minNodeCpu, + min_node_memory=gp.topology.minNodeMemory, + min_node_gpu_memory=gp.topology.minNodeGpuMemory, + ), + ) + + required_resources = RequiredResources( + infra_kind=rr.infraKind, + is_standalone=rr.isStandalone or False, + application_resources=ApplicationResources(cpu_pool=cpu_pool, gpu_pool=gpu_pool), + additional_storage=rr.additionalStorage, + ) + elif isinstance(manifest.requiredResources, (VmResources, DockerComposeResources)): + required_resources = RequiredResources( + infra_kind=manifest.requiredResources.infraKind, + is_standalone=False, + application_resources=ApplicationResources( + cpu_pool=CpuPool( + num_cpu=float(manifest.requiredResources.numCPU), + memory=manifest.requiredResources.memory, + ) + ), + ) + elif isinstance(manifest.requiredResources, ContainerResources): + required_resources = RequiredResources( + infra_kind=manifest.requiredResources.infraKind, + is_standalone=False, + application_resources=ApplicationResources( + cpu_pool=CpuPool( + num_cpu=_parse_container_cpu_cores(manifest.requiredResources.numCPU), + memory=manifest.requiredResources.memory, + ) + ), + ) + + component_spec = [ + ComponentSpec( + component_name=cs.componentName, + network_interfaces=[ + NetworkInterface( + interface_id=ni.interfaceId, + protocol=ni.protocol, + port=ni.port, + visibility_type=ni.visibilityType, + ) + for ni in cs.networkInterfaces + ], + ) + for cs in manifest.componentSpec + ] + + return AppRegistrationTranslation( + app_id=app_id, + tenant_id=tenant_id, + app_provider_id=app_provider_id, + name=manifest.name, + version=manifest.version, + app_provider=manifest.appProvider or "", + package_type=manifest.packageType, + app_repo=app_repo, + required_resources=required_resources, + component_spec=component_spec, + ) + + +def build_catalog_payload(translation: AppRegistrationTranslation) -> SRMCatalogPayload: + runtime_kind = _PACKAGE_TYPE_TO_RUNTIME_KIND[translation.package_type] + rr = translation.required_resources + standalone = rr.is_standalone if rr else False + + accelerator: Optional[SRMAccelerator] = None + storage: Optional[list[SRMStorageVolume]] = None + topology: Optional[SRMTopologyConstraints] = None + cpu_millicores: Optional[int] = None + memory_mb: Optional[int] = None + + if rr and rr.application_resources: + ar = rr.application_resources + if ar.cpu_pool: + cp = ar.cpu_pool + cpu_millicores = int(cp.num_cpu * 1000) + memory_mb = cp.memory + if cp.topology: + t = cp.topology + topology = SRMTopologyConstraints( + min_nodes=t.min_number_of_nodes, + min_node_cpu_millicores=(t.min_node_cpu * 1000) if t.min_node_cpu else None, + min_node_memory_mb=t.min_node_memory, + ) + + if ar.gpu_pool: + gp = ar.gpu_pool + if gp.num_cpu is not None: + cpu_millicores = int(gp.num_cpu * 1000) + if gp.memory is not None: + memory_mb = gp.memory + + accelerator_memory_mb = int(gp.gpu_memory * 1024) if gp.gpu_memory is not None else 0 + accelerator = SRMAccelerator( + type="gpu", + units=gp.num_gpu or 0, + memory_mb=accelerator_memory_mb, + ) + + min_node_gpu_memory_mb = ( + int(gp.topology.min_node_gpu_memory * 1024) + if gp.topology and gp.topology.min_node_gpu_memory is not None + else None + ) + if topology is not None: + topology.min_node_gpu_memory_mb = min_node_gpu_memory_mb + elif gp.topology: + t = gp.topology + topology = SRMTopologyConstraints( + min_nodes=t.min_number_of_nodes, + min_node_cpu_millicores=(t.min_node_cpu * 1000) if t.min_node_cpu else None, + min_node_memory_mb=t.min_node_memory, + min_node_gpu_memory_mb=min_node_gpu_memory_mb, + ) + + if rr and rr.additional_storage: + size_mb = _parse_storage_mb(rr.additional_storage) + storage = [SRMStorageVolume(name="additional", size_mb=size_mb)] + + compute = SRMComputeResources( + cpu_millicores=cpu_millicores, + memory_mb=memory_mb, + standalone=standalone, + accelerator=accelerator, + storage=storage, + ) + + interfaces = [ + SRMNetworkInterface( + component=cs.component_name, + interface_id=ni.interface_id, + protocol=ni.protocol, + port=ni.port, + visibility=_VISIBILITY_MAP.get(ni.visibility_type, "external"), + ) + for cs in translation.component_spec + for ni in cs.network_interfaces + ] + + resource_requirements = SRMComputeIntent( + compute=compute, + topology=topology, + interfaces=interfaces or None, + ) + + repo = translation.app_repo + if repo.type == "PRIVATEREPO": + repo_metadata = SRMRepoMetadata( + type=repo.type, + user_ref=repo.user_name, + credentials=repo.credentials, + auth_type=repo.auth_type, + ) + else: + repo_metadata = SRMRepoMetadata(type=repo.type) + + unit_ref = "main-runtime" + deployment_unit = SRMDeploymentUnit( + ref=unit_ref, + name="Main Runtime", + runtime_kind=runtime_kind, + artifact_ref=repo.image_path, + metadata=SRMDeploymentUnitMetadata(repo=repo_metadata), + resource_requirements=resource_requirements, + ) + + capability_requirement = SRMCapabilityRequirement( + ref="require-workload-deployment", + deployment_unit_ref=unit_ref, + capability_kind="deploy_workload", + domain_kind="compute", + is_required=True, + ) + + return SRMCatalogPayload( + service_specification=SRMServiceSpecEntry( + id=str(translation.app_id), + ref=str(translation.app_id), + name=translation.name, + version=translation.version, + app_provider_id=translation.app_provider_id, + descriptor=SRMServiceSpecDescriptor( + artifact_type=runtime_kind, + ), + ), + service_deployment_units=[deployment_unit], + service_capability_requirements=[capability_requirement], + ) + + +def build_app_deployment_translation( + request: CreateAppInstanceRequest, + operation_id: UUID, + app_instance_id: UUID, + tenant_id: str, + app_provider_id: str, + correlation_id: str, + idempotency_key: Optional[str] = None, +) -> AppDeploymentTranslation: + return AppDeploymentTranslation( + app_id=request.appId, + operation_id=operation_id, + app_instance_id=app_instance_id, + correlation_id=correlation_id, + tenant_id=tenant_id, + app_provider_id=app_provider_id, + resource_zone_id=str(request.edgeCloudZoneId), + name=request.name, + compute_domain_id=str(request.kubernetesClusterRef) + if request.kubernetesClusterRef + else None, + idempotency_key=idempotency_key, + requested_at="", + ) + + +def build_deploy_command( + translation: AppDeploymentTranslation, + requested_at: str, +) -> SRMDeployCommand: + return SRMDeployCommand( + operation_id=str(translation.operation_id), + correlation_id=translation.correlation_id, + requested_at=requested_at, + app_provider_id=translation.app_provider_id, + service_specification_id=str(translation.app_id), + targets=[ + SRMDeployTarget( + app_instance_id=str(translation.app_instance_id), + resource_zone_id=translation.resource_zone_id, + compute_domain_id=translation.compute_domain_id, + ) + ], + deploy=SRMDeployPayload( + instance_name=translation.name, + ), + ) + + +def build_submitted_app(translation: AppRegistrationTranslation) -> SubmittedApp: + return SubmittedApp(appId=translation.app_id) + + +def build_terminate_instance_command( + app_instance_id: UUID, + operation_id: UUID, + app_provider_id: str, + correlation_id: str, + requested_at: str, +) -> SRMTerminateCommand: + return SRMTerminateCommand( + operation_id=str(operation_id), + correlation_id=correlation_id, + requested_at=requested_at, + app_provider_id=app_provider_id, + service_instance_id=str(app_instance_id), + terminate=SRMTerminatePayload(), + ) diff --git a/open_exposure_gateway/app/application/services/edge_application_management_service.py b/open_exposure_gateway/app/application/services/edge_application_management_service.py new file mode 100644 index 0000000000000000000000000000000000000000..9eda219c2dc1aa9fcd6e54519844d35e48c28efa --- /dev/null +++ b/open_exposure_gateway/app/application/services/edge_application_management_service.py @@ -0,0 +1,220 @@ +from datetime import datetime, timezone +from typing import Optional +from uuid import UUID, uuid4 + +import structlog +from pydantic import BaseModel + +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + AppInstanceInfo, + AppInstanceStatus, + AppManifest, + AppManifestEnvelope, + CreateAppInstanceRequest, + EdgeCloudZone, + KubernetesResources, + SubmittedApp, +) +from open_exposure_gateway.app.application.mappers.edge_application_mapper import ( + build_app_deployment_translation, + build_app_instance_info, + build_app_manifest, + build_app_registration_translation, + build_catalog_payload, + build_deploy_command, + build_edge_cloud_zone, + build_submitted_app, + build_terminate_instance_command, +) +from open_exposure_gateway.app.core.exceptions import ( + DownstreamServiceException, + NotImplementedException, +) +from open_exposure_gateway.app.domain.edge_application_management import ( + ResourceZone, + SRMCatalogPayload, + Subject, +) +from open_exposure_gateway.app.ports.databus_port import DataBusPort +from open_exposure_gateway.app.ports.srm_port import SRMClientPort + +logger: structlog.BoundLogger = structlog.get_logger(__name__) + +# OOP Release 2 only fulfils CONTAINER/HELM onto kubernetes; other schema-valid +# AppManifest variants (QCOW2/OVA/CSAR packages, virtualMachine/container/ +# dockerCompose infraKinds) are rejected with 501 rather than accepted into the +# catalog or left to fail at deploy time (ADR-0009). +_SUPPORTED_PACKAGE_TYPES = frozenset({"CONTAINER", "HELM"}) + + +class EdgeApplicationManagementService: + def __init__( + self, + srm_client: SRMClientPort, + publisher: DataBusPort | None = None, + ) -> None: + self.srm_client = srm_client + self._publisher = publisher + + async def get_edge_cloud_zones( + self, + region: Optional[str] = None, + status: Optional[str] = None, + x_correlator: Optional[str] = None, + ) -> list[EdgeCloudZone]: + srm_zones = await self.srm_client.get_resource_zones( + region=region, + status=status, + x_correlator=x_correlator, + ) + zones = [] + for zone in srm_zones: + try: + zones.append(build_edge_cloud_zone(zone)) + except (ValueError, TypeError) as exc: + self._log_skipped_entry("zone", zone, exc) + return zones + + async def get_apps(self, x_correlator: Optional[str] = None) -> list[AppManifest]: + catalogs = await self.srm_client.get_apps(x_correlator=x_correlator) + manifests = [] + for catalog in catalogs: + try: + manifests.append(build_app_manifest(catalog)) + except (ValueError, TypeError, IndexError) as exc: + self._log_skipped_entry("catalog entry", catalog, exc) + return manifests + + def _log_skipped_entry( + self, kind: str, entry: ResourceZone | SRMCatalogPayload, exc: Exception + ) -> None: + logger.warning( + "unmappable_srm_entry_skipped", + kind=kind, + error=str(exc), + entry=entry.model_dump(mode="json"), + ) + + async def get_app( + self, app_id: UUID, x_correlator: Optional[str] = None + ) -> AppManifestEnvelope: + catalog = await self.srm_client.get_app(app_id=app_id, x_correlator=x_correlator) + return AppManifestEnvelope(appManifest=build_app_manifest(catalog)) + + async def submit_app( + self, + manifest: AppManifest, + app_id: UUID, + tenant_id: str, + app_provider_id: str, + x_correlator: Optional[str] = None, + ) -> SubmittedApp: + if manifest.packageType not in _SUPPORTED_PACKAGE_TYPES: + raise NotImplementedException( + f"packageType '{manifest.packageType}' is not supported in this release" + ) + if not isinstance(manifest.requiredResources, KubernetesResources): + raise NotImplementedException( + f"requiredResources.infraKind '{manifest.requiredResources.infraKind}' " + "is not supported in this release" + ) + + translation = build_app_registration_translation( + manifest, app_id, tenant_id, app_provider_id + ) + catalog_payload = build_catalog_payload(translation) + created = await self.srm_client.create_catalog_service_specification( + payload=catalog_payload.model_dump(mode="json"), x_correlator=x_correlator + ) + if created.id != translation.app_id: + raise DownstreamServiceException( + message="SRM confirmed a different service specification id than requested", + details={"requested_id": str(translation.app_id), "confirmed_id": str(created.id)}, + ) + return build_submitted_app(translation) + + 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 delete_app( + self, + app_id: UUID, + app_provider_id: str, + x_correlator: Optional[str] = None, + ) -> None: + await self.srm_client.delete_app(app_id=app_id, x_correlator=x_correlator) + + async def create_app_instance( + self, + request: CreateAppInstanceRequest, + tenant_id: str, + app_provider_id: str, + x_correlator: Optional[str] = None, + ) -> AppInstanceInfo: + operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) + app_instance_id = uuid4() + translation = build_app_deployment_translation( + request, + operation_id, + app_instance_id, + tenant_id=tenant_id, + app_provider_id=app_provider_id, + correlation_id=correlation_id, + ) + _command = build_deploy_command(translation, requested_at) + await self._publish( + Subject.TASK_DEPLOY, + _command, + "Failed to publish app instance deployment task", + ) + return AppInstanceInfo( + appInstanceId=translation.app_instance_id, + name=translation.name, + appId=translation.app_id, + appProvider=translation.app_provider_id, + status=AppInstanceStatus.INSTANTIATING, + edgeCloudZoneId=UUID(translation.resource_zone_id), + ) + + async def get_app_instances( + self, + app_id: Optional[UUID] = None, + app_instance_id: Optional[UUID] = None, + region: Optional[str] = None, + x_correlator: Optional[str] = None, + ) -> list[AppInstanceInfo]: + instances = await self.srm_client.get_app_instances( + app_id=app_id, + app_instance_id=app_instance_id, + region=region, + x_correlator=x_correlator, + ) + return [build_app_instance_info(i) for i in instances] + + async def delete_app_instance( + self, app_instance_id: UUID, app_provider_id: str, x_correlator: Optional[str] = None + ) -> None: + operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) + command = build_terminate_instance_command( + app_instance_id=app_instance_id, + operation_id=operation_id, + app_provider_id=app_provider_id, + correlation_id=correlation_id, + requested_at=requested_at, + ) + await self._publish( + Subject.TASK_TERMINATE, + command, + "Failed to publish app instance termination command", + ) diff --git a/open_exposure_gateway/app/application/services/quality_on_demand_service.py b/open_exposure_gateway/app/application/services/quality_on_demand_service.py new file mode 100644 index 0000000000000000000000000000000000000000..244f3ed2edb61b069d1a85c4d85d9136a7404c06 --- /dev/null +++ b/open_exposure_gateway/app/application/services/quality_on_demand_service.py @@ -0,0 +1,42 @@ +from typing import Optional + +from open_exposure_gateway.app.api.camara.quality_on_demand.v0_10_1.schemas import ( + QoDSessionRequest, + QoDSessionResponse, +) +from open_exposure_gateway.app.ports.srm_port import SRMClientPort + + +class QualityOnDemandService: + def __init__(self, srm_client: SRMClientPort) -> None: + self.srm_client = srm_client + + async def create_session( + self, + request: QoDSessionRequest, + x_correlator: Optional[str] = None, + ) -> QoDSessionResponse: + return await self.srm_client.create_qod_session( + payload=request.model_dump(mode="json"), + x_correlator=x_correlator, + ) + + async def get_session( + self, + session_id: str, + x_correlator: Optional[str] = None, + ) -> QoDSessionResponse: + return await self.srm_client.get_qod_session( + session_id=session_id, + x_correlator=x_correlator, + ) + + async def delete_session( + self, + session_id: str, + x_correlator: Optional[str] = None, + ) -> None: + await self.srm_client.delete_qod_session( + session_id=session_id, + x_correlator=x_correlator, + ) diff --git a/open_exposure_gateway/app/core/config.py b/open_exposure_gateway/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..5bca7d691da5f2cb27499cffb42dbf3834d7f648 --- /dev/null +++ b/open_exposure_gateway/app/core/config.py @@ -0,0 +1,50 @@ +from functools import lru_cache + +from pydantic import BaseModel, HttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class SRMSettings(BaseModel): + base_url: HttpUrl = HttpUrl("http://localhost:8081") + timeout: float = 10.0 + + +class PostgreSQLSettings(BaseModel): + url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/oeg" + echo: bool = False + create_schema_on_startup: bool = False + + +class NatsSettings(BaseModel): + url: str = "nats://localhost:4222" + connect_timeout: int = 10 + max_reconnect_attempts: int = 3 + + +class ObservabilitySettings(BaseModel): + log_level: str = "INFO" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + env_nested_delimiter="__", + ) + + app_name: str = "Open Exposure Gateway" + app_version: str = "1.0.0" + debug: bool = False + host: str = "0.0.0.0" + port: int = 8080 + srm_settings: SRMSettings = SRMSettings() + postgresql_settings: PostgreSQLSettings = PostgreSQLSettings() + nats_settings: NatsSettings = NatsSettings() + observability_settings: ObservabilitySettings = ObservabilitySettings() + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/open_exposure_gateway/app/core/exceptions.py b/open_exposure_gateway/app/core/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..52e5e2ee69f1aeabb25756d37b8e26373bdd44df --- /dev/null +++ b/open_exposure_gateway/app/core/exceptions.py @@ -0,0 +1,87 @@ +from enum import StrEnum +from typing import Any, Optional + + +class ErrorCode(StrEnum): + INTERNAL = "INTERNAL" + INVALID_ARGUMENT = "INVALID_ARGUMENT" + OUT_OF_RANGE = "OUT_OF_RANGE" + UNAUTHENTICATED = "UNAUTHENTICATED" + PERMISSION_DENIED = "PERMISSION_DENIED" + NOT_FOUND = "NOT_FOUND" + METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED" + ALREADY_EXISTS = "ALREADY_EXISTS" + CONFLICT = "CONFLICT" + ABORTED = "ABORTED" + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + UNAVAILABLE = "UNAVAILABLE" + + +class OEGException(Exception): + def __init__( + self, + message: str, + status_code: int = 500, + error_code: ErrorCode = ErrorCode.INTERNAL, + details: Optional[Any] = None, + ) -> None: + self.message = message + self.status_code = status_code + self.error_code = error_code + self.details = details + super().__init__(message) + + +class BadRequestException(OEGException): + def __init__(self, message: str = "Bad request", details: Optional[Any] = None): + super().__init__(message, 400, ErrorCode.INVALID_ARGUMENT, details) + + +class UnauthorizedException(OEGException): + def __init__(self, message: str = "Unauthorized", details: Optional[Any] = None): + super().__init__(message, 401, ErrorCode.UNAUTHENTICATED, details) + + +class ForbiddenException(OEGException): + def __init__(self, message: str = "Forbidden", details: Optional[Any] = None): + super().__init__(message, 403, ErrorCode.PERMISSION_DENIED, details) + + +class NotFoundException(OEGException): + def __init__(self, message: str = "Resource not found", details: Optional[Any] = None): + super().__init__(message, 404, ErrorCode.NOT_FOUND, details) + + +class MethodNotAllowedException(OEGException): + def __init__(self, message: str = "Method not allowed", details: Optional[Any] = None): + super().__init__(message, 405, ErrorCode.METHOD_NOT_ALLOWED, details) + + +class ConflictException(OEGException): + def __init__(self, message: str = "Conflict", details: Optional[Any] = None): + super().__init__(message, 409, ErrorCode.CONFLICT, details) + + +class AlreadyExistsException(OEGException): + def __init__(self, message: str = "Resource already exists", details: Optional[Any] = None): + super().__init__(message, 409, ErrorCode.ALREADY_EXISTS, details) + + +class AbortedException(OEGException): + def __init__(self, message: str = "Operation aborted", details: Optional[Any] = None): + super().__init__(message, 409, ErrorCode.ABORTED, details) + + +class OutOfRangeException(OEGException): + def __init__(self, message: str = "Value out of range", details: Optional[Any] = None): + super().__init__(message, 400, ErrorCode.OUT_OF_RANGE, details) + + +class NotImplementedException(OEGException): + def __init__(self, message: str = "Not implemented", details: Optional[Any] = None): + super().__init__(message, 501, ErrorCode.NOT_IMPLEMENTED, details) + + +class DownstreamServiceException(OEGException): + def __init__(self, message: str = "Service unavailable", details: Optional[Any] = None): + super().__init__(message, 503, ErrorCode.UNAVAILABLE, details) diff --git a/open_exposure_gateway/app/core/logging.py b/open_exposure_gateway/app/core/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..1cec1fbe815b964fd40dc5109b630a553d2b817c --- /dev/null +++ b/open_exposure_gateway/app/core/logging.py @@ -0,0 +1,40 @@ +import logging +import sys + +import structlog + + +def configure_logging(log_level: str = "INFO") -> None: + shared_processors: list[structlog.types.Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + ] + + structlog.configure( + processors=shared_processors + + [ + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + structlog.dev.ConsoleRenderer(), + ], + foreign_pre_chain=shared_processors, + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root = logging.getLogger() + root.handlers.clear() + root.addHandler(handler) + root.setLevel(getattr(logging, log_level.upper(), logging.INFO)) diff --git a/open_exposure_gateway/app/core/state.py b/open_exposure_gateway/app/core/state.py new file mode 100644 index 0000000000000000000000000000000000000000..a629e87ea7052667c8579c1c33b7f4e5544bd4a2 --- /dev/null +++ b/open_exposure_gateway/app/core/state.py @@ -0,0 +1,13 @@ +from typing import Protocol + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from open_exposure_gateway.app.ports.databus_port import DataBusPort +from open_exposure_gateway.app.ports.srm_port import SRMClientPort + + +class AppState(Protocol): + srm_client: SRMClientPort + publisher: DataBusPort + db_engine: AsyncEngine + session_maker: async_sessionmaker[AsyncSession] diff --git a/open_exposure_gateway/app/dependencies.py b/open_exposure_gateway/app/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..56c51b26004adb9513db9562f646be4912399e95 --- /dev/null +++ b/open_exposure_gateway/app/dependencies.py @@ -0,0 +1,92 @@ +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from typing import Annotated, Optional, cast + +from fastapi import Depends, Request +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from open_exposure_gateway.app.api.camara.common import XCorrelatorHeader +from open_exposure_gateway.app.application.services.edge_application_management_service import ( + EdgeApplicationManagementService, +) +from open_exposure_gateway.app.application.services.quality_on_demand_service import ( + QualityOnDemandService, +) +from open_exposure_gateway.app.core.state import AppState +from open_exposure_gateway.app.ports.databus_port import DataBusPort +from open_exposure_gateway.app.ports.srm_port import SRMClientPort + + +@dataclass +class CallerContext: + x_correlator: Optional[str] + tenant_id: str + app_provider_id: str + + +def get_caller_context( + x_correlator: XCorrelatorHeader = None, +) -> CallerContext: + return CallerContext( + x_correlator=x_correlator, + tenant_id="placeholder", # TODO: extract from JWT + app_provider_id="placeholder", # TODO: extract from JWT + ) + + +def get_app_state(request: Request) -> AppState: + return cast(AppState, request.app.state) + + +def get_client(request: Request) -> SRMClientPort: + return get_app_state(request=request).srm_client + + +def get_publisher(request: Request) -> DataBusPort: + return get_app_state(request=request).publisher + + +def get_db_engine(request: Request) -> AsyncEngine: + return get_app_state(request=request).db_engine + + +async def get_database_health( + db_engine: Annotated[AsyncEngine, Depends(get_db_engine)], +) -> bool: + # Broad catch is deliberate: a readiness check only needs to know whether + # the database is reachable, not why it isn't (connection refused, DNS + # failure, and a SQLAlchemy-wrapped DBAPI error all mean the same thing + # here: report not-ready, don't let the check itself crash the request). + try: + async with db_engine.connect() as connection: + await connection.execute(text("SELECT 1")) + return True + except Exception: + return False + + +async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]: + async with get_app_state(request=request).session_maker() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +def get_edge_app_service( + srm: SRMClientPort = Depends(get_client), + publisher: DataBusPort = Depends(get_publisher), +) -> EdgeApplicationManagementService: + return EdgeApplicationManagementService(srm, publisher) + + +def get_qod_service( + srm: SRMClientPort = Depends(get_client), +) -> QualityOnDemandService: + return QualityOnDemandService(srm) diff --git a/open_exposure_gateway/app/domain/edge_application_management.py b/open_exposure_gateway/app/domain/edge_application_management.py new file mode 100644 index 0000000000000000000000000000000000000000..ca5348f175c7fca27cb2c2e8f40ae8e5d7e4f725 --- /dev/null +++ b/open_exposure_gateway/app/domain/edge_application_management.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel, Field, model_validator + + +class Subject(StrEnum): + TASK_DEPLOY = "command.srm.service.deploy" + TASK_TERMINATE = "command.srm.service.terminate" + OPERATION_COMPLETED = "event.srm.operation.completed" + + +class AppRepo(BaseModel): + type: str + image_path: str + user_name: str | None = None + credentials: str | None = None + auth_type: str | None = None + checksum: str | None = None + + +class NetworkInterface(BaseModel): + interface_id: str + protocol: str + port: int + visibility_type: str + + +class ComponentSpec(BaseModel): + component_name: str + network_interfaces: list[NetworkInterface] + + +class CpuPoolTopology(BaseModel): + min_number_of_nodes: int | None = None + min_node_cpu: int | None = None + min_node_memory: int | None = None + min_node_gpu_memory: int | None = None + + +class CpuPool(BaseModel): + num_cpu: float + memory: int + topology: CpuPoolTopology | None = None + + +class GpuPool(BaseModel): + num_cpu: int | None = None + num_gpu: int | None = None + memory: int | None = None + gpu_memory: int | None = None + topology: CpuPoolTopology | None = None + + +class ApplicationResources(BaseModel): + cpu_pool: CpuPool | None = None + gpu_pool: GpuPool | None = None + + +class RequiredResources(BaseModel): + infra_kind: str + application_resources: ApplicationResources | None = None + is_standalone: bool = False + additional_storage: str | None = None + + +class AppRegistrationTranslation(BaseModel): + app_id: UUID + tenant_id: str + app_provider_id: str + name: str + version: str + app_provider: str + package_type: str + app_repo: AppRepo + required_resources: RequiredResources | None = None + component_spec: list[ComponentSpec] = [] + + +class AppDeploymentTranslation(BaseModel): + app_id: UUID + operation_id: UUID + app_instance_id: UUID + correlation_id: str + tenant_id: str + app_provider_id: str + resource_zone_id: str + name: str + compute_domain_id: str | None = None + idempotency_key: str | None = None + requested_at: str + + +class ResourceZoneLocation(BaseModel): + region: str | None = None + country: str | None = None + + +class ResourceZone(BaseModel): + resource_zone_id: str + name: str + status: str + provider: str + location: ResourceZoneLocation | None = None + + +class SRMAccelerator(BaseModel): + type: str + units: int + memory_mb: int + + +class SRMStorageVolume(BaseModel): + name: str + size_mb: int + mount_point: str | None = None + + +class SRMComputeResources(BaseModel): + cpu_millicores: int | None = None + memory_mb: int | None = None + standalone: bool = False + accelerator: SRMAccelerator | None = None + storage: list[SRMStorageVolume] | None = None + + +class SRMTopologyConstraints(BaseModel): + min_nodes: int | None = None + min_node_cpu_millicores: int | None = None + min_node_memory_mb: int | None = None + min_node_gpu_memory_mb: int | None = None + + +class SRMNetworkInterface(BaseModel): + component: str + interface_id: str + protocol: str + port: int + visibility: str + + +class SRMComputeIntent(BaseModel): + schema_version: str = "srm.compute/v1" + compute: SRMComputeResources | None = None + topology: SRMTopologyConstraints | None = None + interfaces: list[SRMNetworkInterface] | None = None + + +class SRMServiceSpecDescriptor(BaseModel): + artifact_type: str + source_api: str = "edge-application-management" + + +class SRMServiceSpecEntry(BaseModel): + # Client-supplied specification id; SRM adopts it as the service_specification + # primary key, which is what makes service_specification_id == app_id (ADR-0011). + id: str + ref: str + name: str + version: str + app_provider_id: str + descriptor: SRMServiceSpecDescriptor + + +class SRMRepoMetadata(BaseModel): + type: str + user_ref: str | None = None + credentials: str | None = None + auth_type: str | None = None + + +class SRMDeploymentUnitMetadata(BaseModel): + repo: SRMRepoMetadata | None = None + + +class SRMDeploymentUnit(BaseModel): + ref: str + name: str + runtime_kind: str + artifact_ref: str + metadata: SRMDeploymentUnitMetadata | None = None + resource_requirements: SRMComputeIntent + + +class SRMCapabilityRequirement(BaseModel): + ref: str + deployment_unit_ref: str + capability_kind: str + domain_kind: str + is_required: bool = True + + +class SRMCatalogPayload(BaseModel): + service_specification: SRMServiceSpecEntry + service_deployment_units: list[SRMDeploymentUnit] + service_capability_requirements: list[SRMCapabilityRequirement] + + +class SRMCatalogServiceSpecificationCreated(BaseModel): + id: UUID + + +class SRMDeployPayload(BaseModel): + instance_name: str | None = None + # object, not nullable, per srm/interface-contract.md §B.2 — non-authoritative + # placement hints; OEG has no source field for these in v1, so it's always {}. + placement_constraints: dict[str, Any] = Field(default_factory=dict) + + +class SRMDeployTarget(BaseModel): + app_instance_id: str + resource_zone_id: str + compute_domain_id: str | None = None + + +class SRMDeployCommand(BaseModel): + schema_version: str = "1.0" + operation_id: str + correlation_id: str + requested_at: str + app_provider_id: str + federation_partner_ref: str | None = None + source: str = "nbi_camara" + service_specification_id: str + # One entry for POST /appinstances (single-zone), N for POST /deployments + # (multi-zone) — both publish the same command.srm.service.deploy shape + # (ADR-0005). srm/interface-contract.md §B.2: "Minimum 1". + targets: list[SRMDeployTarget] = Field(min_length=1) + deploy: SRMDeployPayload + + +class SRMTerminatePayload(BaseModel): + grace_period_seconds: int = 0 + + +class SRMTerminateCommand(BaseModel): + schema_version: str = "1.0" + operation_id: str + correlation_id: str + requested_at: str + app_provider_id: str + federation_partner_ref: str | None = None + source: str = "nbi_camara" + service_specification_id: str | None = None + service_instance_id: str + 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 + # One entry per app instance produced (one per targeted zone); required + # unless status == failed, since "failed" means none were produced + # (srm/interface-contract.md §C.2). + instances: list[SRMCompletedInstance] = [] + metadata: dict[str, Any] | None = None + error: dict[str, Any] | None = None + 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 + + +class SRMCapabilityEndpoint(BaseModel): + interface_id: str + fqdn: str | None = None + port: int | None = None + + +class SRMResultSummary(BaseModel): + schema_version: str = "srm.result/v1" + status: str + endpoints: list[SRMCapabilityEndpoint] | None = None + backend: dict[str, Any] | None = None + + +class SRMCapabilityInstanceSummary(BaseModel): + capability_instance_id: str + kind: str + external_ref: str | None = None + result_summary: SRMResultSummary | None = None + + +class SRMServiceInstance(BaseModel): + service_instance_id: str + service_specification_id: str + state: str + app_provider_id: str + resource_zone_id: str | None = None + name: str | None = None + capability_instances: list[SRMCapabilityInstanceSummary] = [] diff --git a/open_exposure_gateway/app/domain/models/__init__.py b/open_exposure_gateway/app/domain/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e55d51f10a4bcc9956cd480de74cf0cee53178f9 --- /dev/null +++ b/open_exposure_gateway/app/domain/models/__init__.py @@ -0,0 +1,28 @@ +from open_exposure_gateway.app.domain.models.callbacks import ( + CallbackDelivery, + CallbackRegistration, +) +from open_exposure_gateway.app.domain.models.instances import AppInstance, AppInstanceState +from open_exposure_gateway.app.domain.models.operations import ( + Operation, + OperationStatus, + OperationType, +) +from open_exposure_gateway.app.domain.models.registration import ( + AppRegistration, + AppRegistrationStatus, + PackageType, +) + +__all__ = [ + "AppInstance", + "AppInstanceState", + "AppRegistration", + "AppRegistrationStatus", + "CallbackDelivery", + "CallbackRegistration", + "Operation", + "OperationStatus", + "OperationType", + "PackageType", +] diff --git a/open_exposure_gateway/app/domain/models/callbacks/__init__.py b/open_exposure_gateway/app/domain/models/callbacks/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..279d4bf130bb056f6ca687595757fb18e648eb04 --- /dev/null +++ b/open_exposure_gateway/app/domain/models/callbacks/__init__.py @@ -0,0 +1,6 @@ +from open_exposure_gateway.app.domain.models.callbacks.models import ( + CallbackDelivery, + CallbackRegistration, +) + +__all__ = ["CallbackDelivery", "CallbackRegistration"] diff --git a/open_exposure_gateway/app/domain/models/callbacks/models.py b/open_exposure_gateway/app/domain/models/callbacks/models.py new file mode 100644 index 0000000000000000000000000000000000000000..88bf7fe35f38f75b56a126ab8ecff12ca7a55314 --- /dev/null +++ b/open_exposure_gateway/app/domain/models/callbacks/models.py @@ -0,0 +1,26 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class CallbackRegistration(BaseModel): + id: UUID + operation_id: UUID + tenant_id: str + api_family: str + sink: str + event_types: list[str] + sink_credential_ref: str | None = None + expires_at: datetime | None = None + is_active: bool = True + created_at: datetime | None = None + + +class CallbackDelivery(BaseModel): + id: UUID + callback_registration_id: UUID + operation_id: UUID + attempt: int + state: str + last_error: str | None = None diff --git a/open_exposure_gateway/app/domain/models/instances/__init__.py b/open_exposure_gateway/app/domain/models/instances/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..daa1c4e6049f9046ff2935b81c283c9af506863a --- /dev/null +++ b/open_exposure_gateway/app/domain/models/instances/__init__.py @@ -0,0 +1,4 @@ +from open_exposure_gateway.app.domain.models.instances.enums import AppInstanceState +from open_exposure_gateway.app.domain.models.instances.models import AppInstance + +__all__ = ["AppInstance", "AppInstanceState"] diff --git a/open_exposure_gateway/app/domain/models/instances/enums.py b/open_exposure_gateway/app/domain/models/instances/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..2eec145977bc51d4c10115afc43b0829b1602d32 --- /dev/null +++ b/open_exposure_gateway/app/domain/models/instances/enums.py @@ -0,0 +1,8 @@ +from enum import StrEnum + + +class AppInstanceState(StrEnum): + INSTANTIATING = "instantiating" + READY = "ready" + FAILED = "failed" + TERMINATING = "terminating" diff --git a/open_exposure_gateway/app/domain/models/instances/models.py b/open_exposure_gateway/app/domain/models/instances/models.py new file mode 100644 index 0000000000000000000000000000000000000000..64f9c5e739e0af793eebad6ab178cb9086120788 --- /dev/null +++ b/open_exposure_gateway/app/domain/models/instances/models.py @@ -0,0 +1,17 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + +from open_exposure_gateway.app.domain.models.instances.enums import AppInstanceState + + +class AppInstance(BaseModel): + app_instance_id: UUID + operation_id: UUID + app_registration_id: UUID + edge_cloud_zone_id: UUID + state: AppInstanceState + app_deployment_id: UUID | None = None + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/open_exposure_gateway/app/domain/models/operations/__init__.py b/open_exposure_gateway/app/domain/models/operations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fb7d6fc4986fb88d43dcb28a4cc7deef91f42237 --- /dev/null +++ b/open_exposure_gateway/app/domain/models/operations/__init__.py @@ -0,0 +1,7 @@ +from open_exposure_gateway.app.domain.models.operations.enums import ( + OperationStatus, + OperationType, +) +from open_exposure_gateway.app.domain.models.operations.models import Operation + +__all__ = ["Operation", "OperationStatus", "OperationType"] diff --git a/open_exposure_gateway/app/domain/models/operations/enums.py b/open_exposure_gateway/app/domain/models/operations/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..95399d4fdf953cb03cf23592020ebfab2b23ed4e --- /dev/null +++ b/open_exposure_gateway/app/domain/models/operations/enums.py @@ -0,0 +1,16 @@ +from enum import StrEnum + + +class OperationType(StrEnum): + DEPLOY = "deploy" + SCALE = "scale" + TERMINATE = "terminate" + NETWORK_CAPABILITY = "network_capability" + + +class OperationStatus(StrEnum): + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + PARTIALLY_COMPLETED = "PARTIALLY_COMPLETED" + FAILED = "FAILED" diff --git a/open_exposure_gateway/app/domain/models/operations/models.py b/open_exposure_gateway/app/domain/models/operations/models.py new file mode 100644 index 0000000000000000000000000000000000000000..3fded496a2855dcb752b782e3fdb0dae6374e8ec --- /dev/null +++ b/open_exposure_gateway/app/domain/models/operations/models.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field + +from open_exposure_gateway.app.domain.models.operations.enums import ( + OperationStatus, + OperationType, +) + + +class Operation(BaseModel): + operation_id: UUID + correlation_id: str + tenant_id: str + app_provider_id: str + operation_type: OperationType + status: OperationStatus + subject: str + idempotency_key: str | None = None + app_registration_id: UUID | None = None + result: dict[str, Any] | None = None + error: dict[str, Any] | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + completed_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/open_exposure_gateway/app/domain/models/registration/__init__.py b/open_exposure_gateway/app/domain/models/registration/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..737ed9b525842a1a6e83a7c4724766e8328d9666 --- /dev/null +++ b/open_exposure_gateway/app/domain/models/registration/__init__.py @@ -0,0 +1,7 @@ +from open_exposure_gateway.app.domain.models.registration.enums import ( + AppRegistrationStatus, + PackageType, +) +from open_exposure_gateway.app.domain.models.registration.models import AppRegistration + +__all__ = ["AppRegistration", "AppRegistrationStatus", "PackageType"] diff --git a/open_exposure_gateway/app/domain/models/registration/enums.py b/open_exposure_gateway/app/domain/models/registration/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..d91e990854737547fb5d2f8ffc7ae6745606a3b5 --- /dev/null +++ b/open_exposure_gateway/app/domain/models/registration/enums.py @@ -0,0 +1,11 @@ +from enum import StrEnum + + +class PackageType(StrEnum): + CONTAINER = "CONTAINER" + HELM = "HELM" + + +class AppRegistrationStatus(StrEnum): + REGISTERED = "REGISTERED" + DELETED = "DELETED" diff --git a/open_exposure_gateway/app/domain/models/registration/models.py b/open_exposure_gateway/app/domain/models/registration/models.py new file mode 100644 index 0000000000000000000000000000000000000000..5eb79991f407fc272b10d230e2a7e99b2c340f46 --- /dev/null +++ b/open_exposure_gateway/app/domain/models/registration/models.py @@ -0,0 +1,21 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + +from open_exposure_gateway.app.domain.models.registration.enums import ( + AppRegistrationStatus, + PackageType, +) + + +class AppRegistration(BaseModel): + app_registration_id: UUID + app_id: UUID + tenant_id: str + name: str + version: str + package_type: PackageType + status: AppRegistrationStatus + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/open_exposure_gateway/app/domain/quality_on_demand.py b/open_exposure_gateway/app/domain/quality_on_demand.py new file mode 100644 index 0000000000000000000000000000000000000000..f3a59ebb361e3d57d6a22733aabf26080a8a8f97 --- /dev/null +++ b/open_exposure_gateway/app/domain/quality_on_demand.py @@ -0,0 +1,3 @@ +# 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. diff --git a/open_exposure_gateway/app/main.py b/open_exposure_gateway/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..af11927fe8f75212431ed51659faec78989246c5 --- /dev/null +++ b/open_exposure_gateway/app/main.py @@ -0,0 +1,150 @@ +from collections.abc import AsyncGenerator, Awaitable, Callable +from contextlib import asynccontextmanager +from typing import Optional + +import structlog +from fastapi import FastAPI, Request, Response +from starlette.types import Lifespan + +from open_exposure_gateway.app.adapters.database.core import ( + build_engine_and_session_maker, + schema_initialization, +) +from open_exposure_gateway.app.adapters.databus.nats_adapter import ( + NatsMessagePublisher, + NatsOperationConsumer, +) +from open_exposure_gateway.app.adapters.http.srm_client import SRMClient +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.router import ( + router as edge_application_management_router, +) +from open_exposure_gateway.app.api.camara.quality_on_demand.v0_10_1.router import ( + router as quality_on_demand_router, +) +from open_exposure_gateway.app.api.error_handlers import ( + register_exception_handlers, + x_correlator_header, +) +from open_exposure_gateway.app.api.platform.health import router as health_router +from open_exposure_gateway.app.core.config import get_settings +from open_exposure_gateway.app.core.logging import configure_logging +from open_exposure_gateway.app.domain.edge_application_management import Subject + +openapi_tags = [ + { + "name": "Application", + "description": "Application and Application Instance Life Cycle Management", + }, + { + "name": "Edge Cloud", + "description": "Edge Cloud Zones Availability", + }, + { + "name": "Quality on Demand Functions", + "description": "Quality on Demand session management", + }, + { + "name": "Platform", + "description": "Platform-specific endpoints (health, readiness probes)", + }, +] + + +@asynccontextmanager +async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + try: + settings = get_settings() + except Exception as e: + structlog.get_logger().error("Failed to load settings", error=str(e)) + raise + + configure_logging(settings.observability_settings.log_level) + logger = structlog.get_logger() + logger.info("Starting", app=settings.app_name, version=settings.app_version) + + try: + db_engine, session_maker = await build_engine_and_session_maker( + url=settings.postgresql_settings.url, + echo=settings.postgresql_settings.echo, + ) + + if settings.postgresql_settings.create_schema_on_startup: + await schema_initialization(db_engine) + + logger.info("Database engine initialized") + except Exception as e: + logger.error("Database engine init failed!", error=str(e)) + raise + + try: + srm_client = SRMClient() + logger.info("SRM client initialized") + except Exception as e: + logger.error("Failed to initialize SRM client", error=str(e)) + raise + + try: + publisher = NatsMessagePublisher(settings.nats_settings) + await publisher.connect() + logger.info("NATS publisher connected") + except Exception as e: + logger.error("Failed to connect NATS publisher", error=str(e)) + raise + + consumer = NatsOperationConsumer( + client=publisher.client, + subject=Subject.OPERATION_COMPLETED, + ) + await consumer.start() + logger.info("NATS consumer started", subject=Subject.OPERATION_COMPLETED) + + app.state.srm_client = srm_client + app.state.publisher = publisher + app.state.db_engine = db_engine + app.state.session_maker = session_maker + + yield + + logger.info("Shutting down application") + await publisher.close() + logger.info("NATS publisher closed") + await db_engine.dispose() + logger.info("Database engine disposed") + + +def create_app(lifespan: Optional[Lifespan[FastAPI]] = None) -> FastAPI: + """Build the application. + + `lifespan` overrides the composition root: the default connects to real + NATS/SRM, tests inject a lifespan that wires fakes (or nothing) instead. + """ + settings = get_settings() + + app = FastAPI( + title="Open Exposure Gateway", + version=settings.app_version, + debug=settings.debug, + lifespan=lifespan or default_lifespan, + openapi_tags=openapi_tags, + ) + + register_exception_handlers(app) + + @app.middleware("http") + async def echo_x_correlator( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + x_correlator = x_correlator_header(request) + response = await call_next(request) + if "x-correlator" not in response.headers: + response.headers["x-correlator"] = x_correlator + return response + + app.include_router(health_router, prefix="/platform") + app.include_router(edge_application_management_router) + app.include_router(quality_on_demand_router) + + return app + + +app = create_app() diff --git a/open_exposure_gateway/app/ports/database/__init__.py b/open_exposure_gateway/app/ports/database/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9a9c5b86735011edeff6f96d9456ccaeaf61a0f6 --- /dev/null +++ b/open_exposure_gateway/app/ports/database/__init__.py @@ -0,0 +1,15 @@ +from open_exposure_gateway.app.ports.database.callbacks import ( + CallbackDeliveryRepository, + CallbackRegistrationRepository, +) +from open_exposure_gateway.app.ports.database.instances import AppInstanceRepository +from open_exposure_gateway.app.ports.database.operations import OperationRepository +from open_exposure_gateway.app.ports.database.registration import AppRegistrationRepository + +__all__ = [ + "AppInstanceRepository", + "AppRegistrationRepository", + "CallbackDeliveryRepository", + "CallbackRegistrationRepository", + "OperationRepository", +] diff --git a/open_exposure_gateway/app/ports/database/callbacks.py b/open_exposure_gateway/app/ports/database/callbacks.py new file mode 100644 index 0000000000000000000000000000000000000000..1c883db8b017ebd87b08ba8ac93e3f875a95ebde --- /dev/null +++ b/open_exposure_gateway/app/ports/database/callbacks.py @@ -0,0 +1,28 @@ +"""Callback repository ports.""" + +from abc import ABC, abstractmethod +from uuid import UUID + +from open_exposure_gateway.app.domain.models import CallbackDelivery, CallbackRegistration + + +class CallbackRegistrationRepository(ABC): + @abstractmethod + async def get_by_operation_id(self, operation_id: UUID) -> CallbackRegistration | None: + pass + + @abstractmethod + async def save(self, callback_registration: CallbackRegistration) -> CallbackRegistration: + pass + + +class CallbackDeliveryRepository(ABC): + @abstractmethod + async def list_by_callback_registration_id( + self, callback_registration_id: UUID + ) -> list[CallbackDelivery]: + pass + + @abstractmethod + async def save(self, callback_delivery: CallbackDelivery) -> CallbackDelivery: + pass diff --git a/open_exposure_gateway/app/ports/database/instances.py b/open_exposure_gateway/app/ports/database/instances.py new file mode 100644 index 0000000000000000000000000000000000000000..27b483481f8c6ce43cb899cb7eba57b96c176750 --- /dev/null +++ b/open_exposure_gateway/app/ports/database/instances.py @@ -0,0 +1,16 @@ +"""App instance repository ports.""" + +from abc import ABC, abstractmethod +from uuid import UUID + +from open_exposure_gateway.app.domain.models import AppInstance + + +class AppInstanceRepository(ABC): + @abstractmethod + async def get_by_id(self, app_instance_id: UUID) -> AppInstance | None: + pass + + @abstractmethod + async def save(self, app_instance: AppInstance) -> AppInstance: + pass diff --git a/open_exposure_gateway/app/ports/database/operations.py b/open_exposure_gateway/app/ports/database/operations.py new file mode 100644 index 0000000000000000000000000000000000000000..cbf4754499458769de70e42bee25944d38c99a83 --- /dev/null +++ b/open_exposure_gateway/app/ports/database/operations.py @@ -0,0 +1,22 @@ +"""Operation repository ports.""" + +from abc import ABC, abstractmethod +from uuid import UUID + +from open_exposure_gateway.app.domain.models import Operation + + +class OperationRepository(ABC): + @abstractmethod + async def get_by_id(self, operation_id: UUID) -> Operation | None: + pass + + @abstractmethod + async def get_by_idempotency_key( + self, tenant_id: str, idempotency_key: str + ) -> Operation | None: + pass + + @abstractmethod + async def save(self, operation: Operation) -> Operation: + pass diff --git a/open_exposure_gateway/app/ports/database/registration.py b/open_exposure_gateway/app/ports/database/registration.py new file mode 100644 index 0000000000000000000000000000000000000000..596106ad0a09e7919ba80d3c6b914f7b42cf7783 --- /dev/null +++ b/open_exposure_gateway/app/ports/database/registration.py @@ -0,0 +1,20 @@ +"""Registration repository ports.""" + +from abc import ABC, abstractmethod +from uuid import UUID + +from open_exposure_gateway.app.domain.models import AppRegistration + + +class AppRegistrationRepository(ABC): + @abstractmethod + async def get_by_id(self, app_registration_id: UUID) -> AppRegistration | None: + pass + + @abstractmethod + async def get_by_app_id(self, app_id: UUID) -> AppRegistration | None: + pass + + @abstractmethod + async def save(self, app_registration: AppRegistration) -> AppRegistration: + pass diff --git a/open_exposure_gateway/app/ports/databus_port.py b/open_exposure_gateway/app/ports/databus_port.py new file mode 100644 index 0000000000000000000000000000000000000000..eee7611d8fdd5938343a3d220bad846cd5eb4d3d --- /dev/null +++ b/open_exposure_gateway/app/ports/databus_port.py @@ -0,0 +1,13 @@ +from typing import Any, Protocol + + +class DataBusPort(Protocol): + @property + def is_connected(self) -> bool: ... + + async def publish( + self, + subject: str, + payload: dict[str, Any], + headers: dict[str, str] | None = None, + ) -> None: ... diff --git a/open_exposure_gateway/app/ports/srm_port.py b/open_exposure_gateway/app/ports/srm_port.py new file mode 100644 index 0000000000000000000000000000000000000000..4bf822b93af9e83917e16c354ac99d6d3f7323f4 --- /dev/null +++ b/open_exposure_gateway/app/ports/srm_port.py @@ -0,0 +1,49 @@ +from typing import Any, Protocol +from uuid import UUID + +from open_exposure_gateway.app.api.camara.quality_on_demand.v0_10_1.schemas import ( + QoDSessionResponse, +) +from open_exposure_gateway.app.domain.edge_application_management import ( + ResourceZone, + SRMCatalogPayload, + SRMCatalogServiceSpecificationCreated, + SRMServiceInstance, +) + + +class SRMClientPort(Protocol): + async def get_resource_zones( + self, + region: str | None, + status: str | None, + x_correlator: str | None, + ) -> list[ResourceZone]: ... + + async def get_apps(self, x_correlator: str | None) -> list[SRMCatalogPayload]: ... + + async def get_app(self, app_id: UUID, x_correlator: str | None) -> SRMCatalogPayload: ... + + async def create_catalog_service_specification( + self, payload: dict[str, Any], x_correlator: str | None + ) -> SRMCatalogServiceSpecificationCreated: ... + + async def delete_app(self, app_id: UUID, x_correlator: str | None) -> None: ... + + async def get_app_instances( + self, + app_id: UUID | None, + app_instance_id: UUID | None, + region: str | None, + x_correlator: str | None, + ) -> list[SRMServiceInstance]: ... + + async def create_qod_session( + self, payload: dict[str, Any], x_correlator: str | None + ) -> QoDSessionResponse: ... + + async def get_qod_session( + self, session_id: str, x_correlator: str | None + ) -> QoDSessionResponse: ... + + async def delete_qod_session(self, session_id: str, x_correlator: str | None) -> None: ... diff --git a/open_exposure_gateway/app/ports/storage_port.py b/open_exposure_gateway/app/ports/storage_port.py new file mode 100644 index 0000000000000000000000000000000000000000..d40a31ee703fb6968063740e2f629826b5f55f1b --- /dev/null +++ b/open_exposure_gateway/app/ports/storage_port.py @@ -0,0 +1,28 @@ +from typing import Optional, Protocol +from uuid import UUID + +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + AppInstanceInfo, + AppManifest, +) + + +class OEGStoragePort(Protocol): + async def store_app(self, app: AppManifest) -> None: ... + + async def get_app(self, app_id: UUID) -> Optional[AppManifest]: ... + + async def get_apps(self) -> list[AppManifest]: ... + + async def delete_app(self, app_id: UUID) -> None: ... + + async def store_app_instance(self, instance: AppInstanceInfo) -> None: ... + + async def get_app_instances( + self, + app_id: Optional[UUID] = None, + app_instance_id: Optional[UUID] = None, + region: Optional[str] = None, + ) -> list[AppInstanceInfo]: ... + + async def delete_app_instance(self, app_instance_id: UUID) -> None: ... diff --git a/open_exposure_gateway/app/schemas/common.py b/open_exposure_gateway/app/schemas/common.py new file mode 100644 index 0000000000000000000000000000000000000000..187c98bb53001787b962e30881b30d1724df1ab3 --- /dev/null +++ b/open_exposure_gateway/app/schemas/common.py @@ -0,0 +1,21 @@ +from enum import StrEnum + +from pydantic import BaseModel + + +class HealthStatus(StrEnum): + OK = "OK" + NOT_OK = "NOT_OK" + UNKNOWN = "UNKNOWN" + + +class HealthResponse(BaseModel): + status: HealthStatus + service: str + version: str + + +class ErrorInfo(BaseModel): + status: int + code: str + message: str diff --git a/edge_cloud_management_api/models/__init__.py b/open_exposure_gateway/test/conformance/__init__.py similarity index 100% rename from edge_cloud_management_api/models/__init__.py rename to open_exposure_gateway/test/conformance/__init__.py diff --git a/open_exposure_gateway/test/conformance/conftest.py b/open_exposure_gateway/test/conformance/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..b61b0a5b13ee56b39febdcee2ed8ed5fe69d86ca --- /dev/null +++ b/open_exposure_gateway/test/conformance/conftest.py @@ -0,0 +1,58 @@ +"""Harness for CAMARA conformance tests. + +Schemathesis drives the ASGI app in-process. The app comes from harness.py +with a no-op lifespan (the real one connects to NATS/SRM); services are +injected here through dependency_overrides with the same fakes the unit +tests use. +""" + +from collections.abc import Generator +from uuid import uuid4 + +import pytest + +from open_exposure_gateway.app.application.services.edge_application_management_service import ( + EdgeApplicationManagementService, +) +from open_exposure_gateway.app.application.services.quality_on_demand_service import ( + QualityOnDemandService, +) +from open_exposure_gateway.app.dependencies import ( + get_edge_app_service, + get_publisher, + get_qod_service, +) +from open_exposure_gateway.app.domain.edge_application_management import ResourceZone +from open_exposure_gateway.test.conformance.harness import app +from open_exposure_gateway.test.unit.fakes import ( + FakeDataBus, + FakeSRMClient, + wire_operation_consumer, + wire_srm_worker, +) + + +@pytest.fixture(autouse=True) +def service_overrides() -> Generator[None, None, None]: + bus = FakeDataBus() + srm = FakeSRMClient() + # A registered provider always has at least one Edge Cloud Zone (CAMARA's + # EdgeCloudZones schema requires minItems: 1); the fake starts empty + # otherwise, which no real provider would ever be. + srm.zones.append( + ResourceZone( + resource_zone_id=str(uuid4()), + name="conformance-zone", + status="active", + provider="conformance-provider", + ) + ) + wire_operation_consumer(bus) + wire_srm_worker(bus, srm) + app.dependency_overrides[get_edge_app_service] = lambda: EdgeApplicationManagementService( + srm_client=srm, publisher=bus + ) + app.dependency_overrides[get_qod_service] = lambda: QualityOnDemandService(srm_client=srm) + app.dependency_overrides[get_publisher] = lambda: bus + yield + app.dependency_overrides.clear() diff --git a/open_exposure_gateway/test/conformance/harness.py b/open_exposure_gateway/test/conformance/harness.py new file mode 100644 index 0000000000000000000000000000000000000000..22293c2d56420c4558623f68c98b547593b1bd86 --- /dev/null +++ b/open_exposure_gateway/test/conformance/harness.py @@ -0,0 +1,21 @@ +"""Dedicated app instance for conformance runs. + +Built through the regular factory, but with a lifespan that skips the real +composition root, so startup never dials NATS/SRM. Services are injected per +test via dependency_overrides (see conftest.py). +""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from open_exposure_gateway.app.main import create_app + + +@asynccontextmanager +async def _no_infrastructure_lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: + yield + + +app = create_app(lifespan=_no_infrastructure_lifespan) diff --git a/open_exposure_gateway/test/conformance/test_eam_conformance.py b/open_exposure_gateway/test/conformance/test_eam_conformance.py new file mode 100644 index 0000000000000000000000000000000000000000..426597a52f0b4c5063c750b24d0fc86fb43d582f --- /dev/null +++ b/open_exposure_gateway/test/conformance/test_eam_conformance.py @@ -0,0 +1,47 @@ +"""CAMARA Edge Application Management 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 open_exposure_gateway.test.conformance.harness import app + +if TYPE_CHECKING: + from schemathesis.specs.openapi.schemas import OpenApiCase + +pytestmark = pytest.mark.conformance + +SPEC = ( + Path(__file__).parents[2] + / "app" + / "api" + / "camara" + / "edge_application_management" + / "vwip" + / "API_definitions" + / "edge-application-management.yaml" +) + +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") + + +@schema.parametrize() +def test_eam_conformance(case: "OpenApiCase") -> None: + case.call_and_validate() diff --git a/edge_cloud_management_api/services/__init__.py b/open_exposure_gateway/test/integration/__init__.py similarity index 100% rename from edge_cloud_management_api/services/__init__.py rename to open_exposure_gateway/test/integration/__init__.py diff --git a/open_exposure_gateway/test/integration/conftest.py b/open_exposure_gateway/test/integration/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..4ee190f0074d36491871be4c889fa9a89fcab182 --- /dev/null +++ b/open_exposure_gateway/test/integration/conftest.py @@ -0,0 +1,187 @@ +from collections.abc import AsyncGenerator, AsyncIterator, Generator +from contextlib import asynccontextmanager + +import nats +import pytest +import pytest_asyncio +from asgi_lifespan import LifespanManager +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from nats.aio.client import Client +from pytest import Item +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from testcontainers.core.container import DockerContainer +from testcontainers.core.wait_strategies import ExecWaitStrategy, LogMessageWaitStrategy + +from open_exposure_gateway.app.adapters.database.core import ( + build_engine_and_session_maker, + schema_initialization, +) +from open_exposure_gateway.app.adapters.database.sql import get_metadata +from open_exposure_gateway.app.adapters.databus.nats_adapter import NatsMessagePublisher +from open_exposure_gateway.app.core.config import NatsSettings, get_settings +from open_exposure_gateway.app.main import create_app +from open_exposure_gateway.test.unit.fakes import FakeDataBus, FakeSRMClient + + +def pytest_collection_modifyitems(items: list[Item]) -> None: + for item in items: + if "test/integration" in str(item.fspath): + item.add_marker(pytest.mark.integration) + + +@pytest.fixture(scope="session") +def nats_url() -> Generator[str, None, None]: + container = ( + DockerContainer("nats:2.10-alpine") + .with_exposed_ports(4222) + .waiting_for(LogMessageWaitStrategy("Server is ready")) + ) + with container: + host = container.get_container_host_ip() + port = container.get_exposed_port(4222) + yield f"nats://{host}:{port}" + + +@pytest.fixture +async def nats_client(nats_url: str) -> AsyncGenerator[Client, None]: + client = await nats.connect(nats_url) + yield client + await client.drain() + await client.close() + + +@pytest.fixture +async def publisher(nats_url: str) -> AsyncGenerator[NatsMessagePublisher, None]: + settings = NatsSettings(url=nats_url, connect_timeout=5, max_reconnect_attempts=3) + p = NatsMessagePublisher(settings) + await p.connect() + yield p + await p.close() + + +@pytest.fixture(scope="session") +def postgres_url() -> Generator[str, None, None]: + container = ( + DockerContainer("postgres:16-alpine") + .with_env("POSTGRES_USER", "postgres") + .with_env("POSTGRES_PASSWORD", "postgres") + .with_env("POSTGRES_DB", "oeg_test") + .with_exposed_ports(5432) + .waiting_for(ExecWaitStrategy(["pg_isready", "-U", "postgres"])) + ) + with container: + host = container.get_container_host_ip() + port = container.get_exposed_port(5432) + yield f"postgresql+asyncpg://postgres:postgres@{host}:{port}/oeg_test" + + +@pytest_asyncio.fixture +async def db_engine(postgres_url: str) -> AsyncIterator[AsyncEngine]: + engine = create_async_engine(postgres_url, echo=False) + async with engine.begin() as conn: + await conn.run_sync(get_metadata().create_all) + try: + yield engine + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: + async with db_engine.connect() as connection: + transaction = await connection.begin() + session_factory = async_sessionmaker(bind=connection, expire_on_commit=False) + session = session_factory() + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + if transaction.is_active: + await transaction.rollback() + + +@pytest.fixture(autouse=True) +def clear_settings_cache() -> Generator[None, None, None]: + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@asynccontextmanager +async def _lifespan_with_real_db(app: FastAPI) -> AsyncIterator[None]: + """Test-only lifespan: real DB engine, fakes for everything else. + + Mirrors main.py's default_lifespan for the DB portion, but swaps the real + SRM client/NATS publisher (which would need their own reachable services) + for FakeSRMClient/FakeDataBus, so this fixture only depends on Postgres. + """ + settings = get_settings() + db_engine, session_maker = await build_engine_and_session_maker( + url=settings.postgresql_settings.url, + echo=settings.postgresql_settings.echo, + ) + if settings.postgresql_settings.create_schema_on_startup: + await schema_initialization(db_engine) + + app.state.srm_client = FakeSRMClient() + app.state.publisher = FakeDataBus() + app.state.db_engine = db_engine + app.state.session_maker = session_maker + + yield + + await db_engine.dispose() + + +@pytest_asyncio.fixture +async def app_with_db(monkeypatch: pytest.MonkeyPatch, postgres_url: str) -> FastAPI: + monkeypatch.setenv("POSTGRESQL_SETTINGS__URL", postgres_url) + monkeypatch.setenv("POSTGRESQL_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + return create_app(lifespan=_lifespan_with_real_db) + + +@pytest_asyncio.fixture +async def client_with_db(app_with_db: FastAPI) -> AsyncIterator[AsyncClient]: + async with LifespanManager(app_with_db): + async with AsyncClient( + transport=ASGITransport(app=app_with_db), base_url="http://test" + ) as c: + yield c + + +@pytest_asyncio.fixture +async def app_with_unreachable_db(monkeypatch: pytest.MonkeyPatch) -> FastAPI: + # Port 1 is a reserved low port nothing will ever be listening on locally, + # so the connection attempt fails fast instead of timing out. + monkeypatch.setenv( + "POSTGRESQL_SETTINGS__URL", + "postgresql+asyncpg://postgres:postgres@localhost:1/oeg", + ) + return create_app(lifespan=_lifespan_with_real_db) + + +@pytest_asyncio.fixture +async def client_with_unreachable_db( + app_with_unreachable_db: FastAPI, +) -> AsyncIterator[AsyncClient]: + async with LifespanManager(app_with_unreachable_db): + async with AsyncClient( + transport=ASGITransport(app=app_with_unreachable_db), base_url="http://test" + ) as c: + yield c + + +@pytest_asyncio.fixture +async def client() -> AsyncIterator[AsyncClient]: + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c diff --git a/open_exposure_gateway/test/integration/test_health.py b/open_exposure_gateway/test/integration/test_health.py new file mode 100644 index 0000000000000000000000000000000000000000..9fa8a3e35996a3c78987305e8e4322e9f851dfba --- /dev/null +++ b/open_exposure_gateway/test/integration/test_health.py @@ -0,0 +1,21 @@ +from httpx import AsyncClient + + +async def test_healthz_returns_200(client: AsyncClient) -> None: + response = await client.get("/platform/healthz") + assert response.status_code == 200 + assert response.json()["status"] == "OK" + + +async def test_readyz_returns_200_when_db_and_bus_ok(client_with_db: AsyncClient) -> None: + response = await client_with_db.get("/platform/readyz") + assert response.status_code == 200 + assert response.json()["status"] == "OK" + + +async def test_readyz_returns_503_when_db_unreachable( + client_with_unreachable_db: AsyncClient, +) -> None: + response = await client_with_unreachable_db.get("/platform/readyz") + assert response.status_code == 503 + assert response.json()["status"] == "NOT_OK" diff --git a/open_exposure_gateway/test/integration/test_nats_consumer.py b/open_exposure_gateway/test/integration/test_nats_consumer.py new file mode 100644 index 0000000000000000000000000000000000000000..6e31987d8340c4baaad3a49aa1046bc61f53e0c8 --- /dev/null +++ b/open_exposure_gateway/test/integration/test_nats_consumer.py @@ -0,0 +1,86 @@ +import asyncio +import json +from typing import Any + +import nats +from nats.aio.client import Client + +from open_exposure_gateway.app.adapters.databus.nats_adapter import NatsOperationConsumer + + +async def test_consumer_subscribes_to_subject(nats_client: Client) -> None: + consumer = NatsOperationConsumer(client=nats_client, subject="operation.completed") + await consumer.start() + assert consumer._subscription is not None + + +async def test_consumer_invokes_handler_when_message_arrives(nats_client: Client) -> None: + invoked = asyncio.Event() + consumer = NatsOperationConsumer(client=nats_client, subject="operation.completed") + + original = consumer._handle_message + + async def spy(msg: Any) -> None: + invoked.set() + await original(msg) + + consumer._handle_message = spy # type: ignore[method-assign] + await consumer.start() + + await nats_client.publish("operation.completed", json.dumps({"status": "done"}).encode()) + await asyncio.wait_for(invoked.wait(), timeout=2.0) + + +async def test_consumer_decodes_json_payload(nats_client: Client) -> None: + decoded: list[Any] = [] + ready = asyncio.Event() + consumer = NatsOperationConsumer(client=nats_client, subject="operation.completed") + + original = consumer._handle_message + + async def spy(msg: Any) -> None: + decoded.append(json.loads(msg.data.decode())) + ready.set() + await original(msg) + + consumer._handle_message = spy # type: ignore[method-assign] + await consumer.start() + + payload = {"status": "completed", "app_instance_id": "abc-123"} + await nats_client.publish("operation.completed", json.dumps(payload).encode()) + await asyncio.wait_for(ready.wait(), timeout=2.0) + + assert decoded[0] == payload + + +async def test_consumer_handles_invalid_json_gracefully(nats_client: Client) -> None: + handled = asyncio.Event() + consumer = NatsOperationConsumer(client=nats_client, subject="operation.completed") + + original = consumer._handle_message + + async def spy(msg: Any) -> None: + handled.set() + await original(msg) + + consumer._handle_message = spy # type: ignore[method-assign] + await consumer.start() + + await nats_client.publish("operation.completed", b"not-valid-json") + await asyncio.wait_for(handled.wait(), timeout=2.0) + + +async def test_consumer_unsubscribes_cleanly(nats_url: str) -> None: + client = await nats.connect(nats_url) + try: + consumer = NatsOperationConsumer(client=client, subject="operation.completed") + await consumer.start() + assert consumer._subscription is not None + + await consumer._subscription.unsubscribe() + consumer._subscription = None + + assert consumer._subscription is None + finally: + await client.drain() + await client.close() diff --git a/open_exposure_gateway/test/integration/test_nats_publisher.py b/open_exposure_gateway/test/integration/test_nats_publisher.py new file mode 100644 index 0000000000000000000000000000000000000000..eda301cd620a652b2f265b239f6ac6b30e388460 --- /dev/null +++ b/open_exposure_gateway/test/integration/test_nats_publisher.py @@ -0,0 +1,25 @@ +import asyncio +import json +from typing import Any + +from open_exposure_gateway.app.adapters.databus.nats_adapter import NatsMessagePublisher + + +async def test_publisher_connects_to_nats(publisher: NatsMessagePublisher) -> None: + assert publisher.is_connected is True + + +async def test_publisher_publishes_json_message_to_subject(publisher: NatsMessagePublisher) -> None: + received: list[dict[str, Any]] = [] + ready = asyncio.Event() + + async def handler(msg) -> None: # type: ignore[no-untyped-def] + received.append(json.loads(msg.data)) + ready.set() + + sub = await publisher.client.subscribe("task.deploy", cb=handler) + await publisher.publish("task.deploy", {"app_id": "abc-123"}) + await asyncio.wait_for(ready.wait(), timeout=2.0) + await sub.unsubscribe() + + assert received == [{"app_id": "abc-123"}] diff --git a/open_exposure_gateway/test/integration/test_postgres.py b/open_exposure_gateway/test/integration/test_postgres.py new file mode 100644 index 0000000000000000000000000000000000000000..89fc52adcc6378c759cfa8f72bdcbd27a4b6df80 --- /dev/null +++ b/open_exposure_gateway/test/integration/test_postgres.py @@ -0,0 +1,232 @@ +import asyncio +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from open_exposure_gateway.app.adapters.database.repos.app_instances import ( + SqlAppInstanceRepository, +) +from open_exposure_gateway.app.adapters.database.repos.app_registrations import ( + SqlAppRegistrationRepository, +) +from open_exposure_gateway.app.adapters.database.repos.callback_deliveries import ( + SqlCallbackDeliveryRepository, +) +from open_exposure_gateway.app.adapters.database.repos.callback_registrations import ( + SqlCallbackRegistrationRepository, +) +from open_exposure_gateway.app.adapters.database.repos.operations import SqlOperationRepository +from open_exposure_gateway.app.adapters.errors import ( + DuplicateAppRegistrationError, + DuplicateOperationError, +) +from open_exposure_gateway.app.domain.models import ( + AppInstance, + AppInstanceState, + AppRegistration, + AppRegistrationStatus, + CallbackDelivery, + CallbackRegistration, + Operation, + OperationStatus, + OperationType, + PackageType, +) + + +def _app_registration() -> AppRegistration: + return AppRegistration( + app_registration_id=uuid4(), + app_id=uuid4(), + tenant_id="tenant-a", + name="video-analytics", + version="1.2.3", + package_type=PackageType.CONTAINER, + status=AppRegistrationStatus.REGISTERED, + ) + + +def _operation( + app_registration_id: UUID | None = None, + idempotency_key: str | None = None, +) -> Operation: + return Operation( + operation_id=uuid4(), + correlation_id=str(uuid4()), + tenant_id="tenant-a", + app_provider_id="tenant-a", + operation_type=OperationType.DEPLOY, + status=OperationStatus.PENDING, + subject="command.srm.service.deploy", + app_registration_id=app_registration_id, + idempotency_key=idempotency_key, + ) + + +def _app_instance(operation_id: UUID, app_registration_id: UUID) -> AppInstance: + return AppInstance( + app_instance_id=uuid4(), + operation_id=operation_id, + app_registration_id=app_registration_id, + edge_cloud_zone_id=uuid4(), + state=AppInstanceState.INSTANTIATING, + ) + + +def _callback_registration(operation_id: UUID) -> CallbackRegistration: + return CallbackRegistration( + id=uuid4(), + operation_id=operation_id, + tenant_id="tenant-a", + api_family="edge-application-management", + sink="https://example.com/callback", + event_types=["org.camara.eam.appinstance.status-changed"], + ) + + +def _callback_delivery(callback_registration_id: UUID, operation_id: UUID) -> CallbackDelivery: + return CallbackDelivery( + id=uuid4(), + callback_registration_id=callback_registration_id, + operation_id=operation_id, + attempt=1, + state="delivered", + ) + + +async def test_app_registration_repo_persists_and_loads(db_session: AsyncSession) -> None: + repo = SqlAppRegistrationRepository(db_session) + registration = _app_registration() + + saved = await repo.save(registration) + + assert saved.app_registration_id == registration.app_registration_id + assert saved.created_at is not None + assert saved.updated_at is not None + + by_id = await repo.get_by_id(saved.app_registration_id) + by_app_id = await repo.get_by_app_id(saved.app_id) + + assert by_id is not None + assert by_app_id is not None + assert by_id == by_app_id + assert by_id.tenant_id == registration.tenant_id + assert by_id.package_type == PackageType.CONTAINER + assert by_id.status == AppRegistrationStatus.REGISTERED + + +async def test_app_registration_duplicate_entry(db_session: AsyncSession) -> None: + repo = SqlAppRegistrationRepository(db_session) + registration = _app_registration() + duplicate = _app_registration() + duplicate.app_id = registration.app_id + + await repo.save(registration) + with pytest.raises(DuplicateAppRegistrationError): + await repo.save(duplicate) + + +async def test_operation_repo_persists_and_loads_by_idempotency_key( + db_session: AsyncSession, +) -> None: + repo = SqlOperationRepository(db_session) + operation = _operation(idempotency_key="idem-key-1") + + saved = await repo.save(operation) + + by_id = await repo.get_by_id(saved.operation_id) + by_idempotency = await repo.get_by_idempotency_key(saved.tenant_id, "idem-key-1") + + assert by_id is not None + assert by_idempotency is not None + assert by_id == by_idempotency + assert by_id.subject == operation.subject + assert by_id.status == OperationStatus.PENDING + + +async def test_operation_repo_rejects_duplicate_idempotency_key(db_session: AsyncSession) -> None: + repo = SqlOperationRepository(db_session) + + await repo.save(_operation(idempotency_key="idem-dup")) + with pytest.raises(DuplicateOperationError): + await repo.save(_operation(idempotency_key="idem-dup")) + + +async def test_operation_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: + repo = SqlOperationRepository(session) + saved = await repo.save(_operation()) + await session.commit() + + await asyncio.sleep(0.01) + + async with session_factory() as session: + repo = SqlOperationRepository(session) + saved.status = OperationStatus.COMPLETED + 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.status == OperationStatus.COMPLETED + + +async def test_app_instance_repo_persists_and_loads(db_session: AsyncSession) -> None: + registration = await SqlAppRegistrationRepository(db_session).save(_app_registration()) + operation = await SqlOperationRepository(db_session).save( + _operation(app_registration_id=registration.app_registration_id) + ) + repo = SqlAppInstanceRepository(db_session) + instance = _app_instance( + operation_id=operation.operation_id, + app_registration_id=registration.app_registration_id, + ) + + saved = await repo.save(instance) + reloaded = await repo.get_by_id(saved.app_instance_id) + + assert reloaded is not None + assert reloaded.operation_id == operation.operation_id + assert reloaded.app_registration_id == registration.app_registration_id + assert reloaded.state == AppInstanceState.INSTANTIATING + assert reloaded.edge_cloud_zone_id == instance.edge_cloud_zone_id + + +async def test_callback_registration_repo_persists_and_loads(db_session: AsyncSession) -> None: + operation = await SqlOperationRepository(db_session).save(_operation()) + repo = SqlCallbackRegistrationRepository(db_session) + registration = _callback_registration(operation_id=operation.operation_id) + + saved = await repo.save(registration) + reloaded = await repo.get_by_operation_id(operation.operation_id) + + assert reloaded is not None + assert reloaded.id == saved.id + assert reloaded.sink == registration.sink + assert reloaded.event_types == registration.event_types + assert reloaded.is_active is True + + +async def test_callback_delivery_repo_persists_and_lists(db_session: AsyncSession) -> None: + operation = await SqlOperationRepository(db_session).save(_operation()) + callback_registration = await SqlCallbackRegistrationRepository(db_session).save( + _callback_registration(operation_id=operation.operation_id) + ) + repo = SqlCallbackDeliveryRepository(db_session) + delivery = _callback_delivery( + callback_registration_id=callback_registration.id, + operation_id=operation.operation_id, + ) + + saved = await repo.save(delivery) + deliveries = await repo.list_by_callback_registration_id(callback_registration.id) + + assert saved.attempt == 1 + assert len(deliveries) == 1 + assert deliveries[0].id == saved.id + assert deliveries[0].state == "delivered" diff --git a/tests/__init__.py b/open_exposure_gateway/test/unit/__init__.py similarity index 100% rename from tests/__init__.py rename to open_exposure_gateway/test/unit/__init__.py diff --git a/open_exposure_gateway/test/unit/conftest.py b/open_exposure_gateway/test/unit/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..32d6f6281e9c80de4b64c51d5a25bd3ed01fd0bd --- /dev/null +++ b/open_exposure_gateway/test/unit/conftest.py @@ -0,0 +1,91 @@ +import json as _json +from collections.abc import Generator +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from open_exposure_gateway.app.application.services.edge_application_management_service import ( + EdgeApplicationManagementService, +) +from open_exposure_gateway.app.application.services.quality_on_demand_service import ( + QualityOnDemandService, +) +from open_exposure_gateway.app.dependencies import ( + get_database_health, + get_edge_app_service, + get_publisher, + get_qod_service, +) +from open_exposure_gateway.app.main import app +from open_exposure_gateway.test.unit.fakes import ( + FakeDataBus, + FakeSRMClient, + wire_operation_consumer, + wire_srm_worker, +) + + +class LoggingTestClient(TestClient): + """TestClient that prints each request/response; visible with pytest -s.""" + + def request(self, method: str, url: Any, **kwargs: Any) -> Any: + print(f"\n→ {method.upper()} {url}") + if kwargs.get("json") is not None: + print(f" Request body:\n{_json.dumps(kwargs['json'], indent=4)}") + if kwargs.get("headers"): + print(f" Request headers: {kwargs['headers']}") + + response = super().request(method, url, **kwargs) + + print(f"← {response.status_code}") + try: + print(f" Response body:\n{_json.dumps(response.json(), indent=4)}") + except Exception: + if response.text: + print(f" Response body: {response.text}") + return response + + +@pytest.fixture() +def fake_bus() -> FakeDataBus: + return FakeDataBus() + + +@pytest.fixture() +def fake_srm() -> FakeSRMClient: + return FakeSRMClient() + + +@pytest.fixture() +def live_srm(fake_bus: FakeDataBus, fake_srm: FakeSRMClient) -> FakeSRMClient: + """Fake SRM with its async side running: consumes commands, publishes completions.""" + wire_operation_consumer(fake_bus) + wire_srm_worker(fake_bus, fake_srm) + return fake_srm + + +@pytest.fixture() +def eam_service(fake_srm: FakeSRMClient, fake_bus: FakeDataBus) -> EdgeApplicationManagementService: + return EdgeApplicationManagementService(srm_client=fake_srm, publisher=fake_bus) + + +@pytest.fixture() +def qod_service(fake_srm: FakeSRMClient) -> QualityOnDemandService: + return QualityOnDemandService(srm_client=fake_srm) + + +@pytest.fixture() +def api_client( + eam_service: EdgeApplicationManagementService, + qod_service: QualityOnDemandService, + fake_bus: FakeDataBus, +) -> Generator[TestClient, None, None]: + app.dependency_overrides[get_edge_app_service] = lambda: eam_service + app.dependency_overrides[get_qod_service] = lambda: qod_service + app.dependency_overrides[get_publisher] = lambda: fake_bus + app.dependency_overrides[get_database_health] = lambda: True + # raise_server_exceptions=False: unhandled errors surface as the 500 envelope + # a real client would see, so flow tests assert status codes, not tracebacks. + yield LoggingTestClient(app, raise_server_exceptions=False) + app.dependency_overrides.clear() diff --git a/open_exposure_gateway/test/unit/fakes.py b/open_exposure_gateway/test/unit/fakes.py new file mode 100644 index 0000000000000000000000000000000000000000..c42549dc49d2a1cfc7285799dcb9dcf359ee4a4c --- /dev/null +++ b/open_exposure_gateway/test/unit/fakes.py @@ -0,0 +1,356 @@ +"""In-memory fakes for OEG's ports, used by the flow tests. + +A fake is a small but real implementation of a port that keeps its state in +plain dicts instead of talking to a specific component, so tests assert on +outcomes rather than on calls. Flow tests (see conftest.py) drive the real +routers, services, and mappers against these fakes: FakeSRMClient covers the +HTTP path to SRM, FakeDataBus covers the async command path, wire_srm_worker +plays SRM's side of the bus, and wire_operation_consumer attaches OEG's real +completion handler. + +Seed and inspect state through the public dicts (`zones`, `catalog`, +`instances`, etc.). Keep fakes true to the real adapters in app/adapters/ +and free of test logic; for fault injection, override the one method in +the one test that needs it with an AsyncMock. +""" + +import json +from collections import defaultdict +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +from open_exposure_gateway.app.adapters.databus.nats_adapter import NatsOperationConsumer +from open_exposure_gateway.app.adapters.errors import ( + DuplicateAppRegistrationError, + DuplicateOperationError, +) +from open_exposure_gateway.app.api.camara.quality_on_demand.v0_10_1.schemas import ( + QoDSessionResponse, + QosStatus, +) +from open_exposure_gateway.app.core.exceptions import NotFoundException +from open_exposure_gateway.app.domain.edge_application_management import ( + ResourceZone, + SRMCatalogPayload, + SRMCatalogServiceSpecificationCreated, + SRMServiceInstance, + Subject, +) +from open_exposure_gateway.app.domain.models import ( + AppInstance, + AppRegistration, + CallbackDelivery, + CallbackRegistration, + Operation, +) +from open_exposure_gateway.app.ports.database.callbacks import ( + CallbackDeliveryRepository, + CallbackRegistrationRepository, +) +from open_exposure_gateway.app.ports.database.instances import AppInstanceRepository +from open_exposure_gateway.app.ports.database.operations import OperationRepository +from open_exposure_gateway.app.ports.database.registration import AppRegistrationRepository + +Handler = Callable[[dict[str, Any]], Awaitable[None]] + + +class FakeDataBus: + def __init__(self) -> None: + self._handlers: dict[str, list[Handler]] = defaultdict(list) + self.published: list[tuple[str, dict[str, Any]]] = [] + self.connected = True + + @property + def is_connected(self) -> bool: + return self.connected + + async def publish( + self, + subject: str, + payload: dict[str, Any], + headers: dict[str, str] | None = None, + ) -> None: + self.published.append((subject, payload)) + for handler in list(self._handlers[subject]): + await handler(payload) + + def subscribe(self, subject: str, handler: Handler) -> None: + self._handlers[subject].append(handler) + + +@dataclass +class FakeMsg: + data: bytes + subject: str + + +class FakeSRMClient: + def __init__(self) -> None: + self.zones: list[ResourceZone] = [] + self.catalog: dict[str, dict[str, Any]] = {} + self.instances: dict[str, SRMServiceInstance] = {} + self.qod_sessions: dict[str, QoDSessionResponse] = {} + + async def get_resource_zones( + self, + region: str | None = None, + status: str | None = None, + x_correlator: str | None = None, + ) -> list[ResourceZone]: + return list(self.zones) + + async def get_apps(self, x_correlator: str | None = None) -> list[SRMCatalogPayload]: + return [SRMCatalogPayload.model_validate(p) for p in self.catalog.values()] + + async def get_app(self, app_id: UUID, x_correlator: str | None = None) -> SRMCatalogPayload: + entry = self.catalog.get(str(app_id)) + if entry is None: + raise NotFoundException(message=f"App {app_id} not found") + return SRMCatalogPayload.model_validate(entry) + + async def create_catalog_service_specification( + self, payload: dict[str, Any], x_correlator: str | None = None + ) -> SRMCatalogServiceSpecificationCreated: + ref = payload["service_specification"]["ref"] + self.catalog[ref] = payload + return SRMCatalogServiceSpecificationCreated(id=UUID(ref)) + + async def delete_app(self, app_id: UUID, x_correlator: str | None = None) -> None: + self.catalog.pop(str(app_id), None) + + async def get_app_instances( + self, + app_id: UUID | None = None, + app_instance_id: UUID | None = None, + region: str | None = None, + x_correlator: str | None = None, + ) -> list[SRMServiceInstance]: + result = list(self.instances.values()) + if app_id is not None: + result = [i for i in result if i.service_specification_id == str(app_id)] + if app_instance_id is not None: + 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 + ) -> QoDSessionResponse: + duration = payload.get("duration", 86400) + session = QoDSessionResponse( + 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"), + ) + 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] + + async def delete_qod_session(self, session_id: str, x_correlator: str | None = None) -> None: + self.qod_sessions.pop(session_id, None) + + +def completion_payload(operation_id: str, **overrides: Any) -> dict[str, Any]: + payload: dict[str, Any] = { + "schema_version": "1.0", + "operation_id": operation_id, + "status": "completed", + "correlation_id": str(uuid4()), + "instances": [ + { + "service_instance_id": str(uuid4()), + "zone_id": str(uuid4()), + "status": "completed", + } + ], + "completed_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 + # (ADR-0005); multi-zone /deployments (N entries) is out of scope here. + target = command["targets"][0] + # SRM adopts the OEG-minted app_instance_id as its own service_instance_id (ADR-0005). + srm_id = target["app_instance_id"] + srm.instances[srm_id] = SRMServiceInstance( + service_instance_id=srm_id, + service_specification_id=command["service_specification_id"], + state="active", + app_provider_id=command["app_provider_id"], + resource_zone_id=target["resource_zone_id"], + name=command["deploy"]["instance_name"], + ) + await bus.publish( + Subject.OPERATION_COMPLETED, + completion_payload( + command["operation_id"], + correlation_id=command["correlation_id"], + instances=[ + { + "service_instance_id": srm_id, + "zone_id": target["resource_zone_id"], + "status": "completed", + } + ], + ), + ) + + async def on_terminate(command: dict[str, Any]) -> None: + instance_id = command.get("service_instance_id") + zone_id = None + if instance_id is not None: + existing = srm.instances.pop(instance_id, None) + zone_id = existing.resource_zone_id if existing else None + await bus.publish( + Subject.OPERATION_COMPLETED, + completion_payload( + command["operation_id"], + correlation_id=command["correlation_id"], + instances=[ + { + "service_instance_id": instance_id, + "zone_id": zone_id or str(uuid4()), + "status": "completed", + } + ], + ), + ) + + bus.subscribe(Subject.TASK_DEPLOY, on_deploy) + bus.subscribe(Subject.TASK_TERMINATE, on_terminate) + + +def wire_operation_consumer(bus: FakeDataBus) -> NatsOperationConsumer: + consumer = NatsOperationConsumer(client=AsyncMock(), subject=Subject.OPERATION_COMPLETED) + + async def deliver(payload: dict[str, Any]) -> None: + await consumer._handle_message( + FakeMsg(data=json.dumps(payload).encode(), subject=str(Subject.OPERATION_COMPLETED)) + ) + + bus.subscribe(Subject.OPERATION_COMPLETED, deliver) + return consumer + + +class FakeAppRegistrationRepository(AppRegistrationRepository): + def __init__(self) -> None: + self.rows: dict[UUID, AppRegistration] = {} + + async def get_by_id(self, app_registration_id: UUID) -> AppRegistration | None: + found = self.rows.get(app_registration_id) + return found.model_copy(deep=True) if found is not None else None + + async def get_by_app_id(self, app_id: UUID) -> AppRegistration | None: + for row in self.rows.values(): + if row.app_id == app_id: + return row.model_copy(deep=True) + return None + + async def save(self, app_registration: AppRegistration) -> AppRegistration: + for existing in self.rows.values(): + if ( + existing.app_registration_id != app_registration.app_registration_id + and existing.app_id == app_registration.app_id + ): + raise DuplicateAppRegistrationError() + stored = app_registration.model_copy(deep=True) + self.rows[stored.app_registration_id] = stored + return stored.model_copy(deep=True) + + +class FakeOperationRepository(OperationRepository): + def __init__(self) -> None: + self.rows: dict[UUID, Operation] = {} + + async def get_by_id(self, operation_id: UUID) -> Operation | None: + found = self.rows.get(operation_id) + return found.model_copy(deep=True) if found is not None else None + + async def get_by_idempotency_key( + self, tenant_id: str, idempotency_key: str + ) -> Operation | None: + for row in self.rows.values(): + if row.tenant_id == tenant_id and row.idempotency_key == idempotency_key: + return row.model_copy(deep=True) + return None + + async def save(self, operation: Operation) -> Operation: + # Mirrors UNIQUE(tenant_id, idempotency_key). NULL keys never collide, + # matching Postgres NULL semantics (operations without a key coexist). + if operation.idempotency_key is not None: + for existing in self.rows.values(): + if ( + existing.operation_id != operation.operation_id + and existing.tenant_id == operation.tenant_id + and existing.idempotency_key == operation.idempotency_key + ): + raise DuplicateOperationError() + stored = operation.model_copy(deep=True) + self.rows[stored.operation_id] = stored + return stored.model_copy(deep=True) + + +class FakeAppInstanceRepository(AppInstanceRepository): + def __init__(self) -> None: + self.rows: dict[UUID, AppInstance] = {} + + async def get_by_id(self, app_instance_id: UUID) -> AppInstance | None: + found = self.rows.get(app_instance_id) + return found.model_copy(deep=True) if found is not None else None + + async def save(self, app_instance: AppInstance) -> AppInstance: + stored = app_instance.model_copy(deep=True) + self.rows[stored.app_instance_id] = stored + return stored.model_copy(deep=True) + + +class FakeCallbackRegistrationRepository(CallbackRegistrationRepository): + def __init__(self) -> None: + self.rows: dict[UUID, CallbackRegistration] = {} + + async def get_by_operation_id(self, operation_id: UUID) -> CallbackRegistration | 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, callback_registration: CallbackRegistration) -> CallbackRegistration: + stored = callback_registration.model_copy(deep=True) + self.rows[stored.id] = stored + return stored.model_copy(deep=True) + + +class FakeCallbackDeliveryRepository(CallbackDeliveryRepository): + def __init__(self) -> None: + self.rows: dict[UUID, CallbackDelivery] = {} + + async def list_by_callback_registration_id( + self, callback_registration_id: UUID + ) -> list[CallbackDelivery]: + return [ + row.model_copy(deep=True) + for row in self.rows.values() + if row.callback_registration_id == callback_registration_id + ] + + async def save(self, callback_delivery: CallbackDelivery) -> CallbackDelivery: + stored = callback_delivery.model_copy(deep=True) + self.rows[stored.id] = stored + return stored.model_copy(deep=True) diff --git a/open_exposure_gateway/test/unit/test_app_instance_repo.py b/open_exposure_gateway/test/unit/test_app_instance_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..921158b965c235f020ac1f46b255b25108e44b43 --- /dev/null +++ b/open_exposure_gateway/test/unit/test_app_instance_repo.py @@ -0,0 +1,81 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.repos.app_instances import ( + SqlAppInstanceRepository, +) +from open_exposure_gateway.app.adapters.database.sql import AppInstanceRow +from open_exposure_gateway.app.domain.models import AppInstance, AppInstanceState + + +def _row() -> AppInstanceRow: + return AppInstanceRow( + app_instance_id=uuid4(), + operation_id=uuid4(), + app_registration_id=uuid4(), + edge_cloud_zone_id=uuid4(), + state=AppInstanceState.INSTANTIATING, + ) + + +def _domain() -> AppInstance: + return AppInstance( + app_instance_id=uuid4(), + operation_id=uuid4(), + app_registration_id=uuid4(), + edge_cloud_zone_id=uuid4(), + state=AppInstanceState.INSTANTIATING, + ) + + +async def test_get_by_id_returns_mapped_app_instance() -> None: + row = _row() + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = row + repo = SqlAppInstanceRepository(session) + + result = await repo.get_by_id(row.app_instance_id) + + assert result is not None + assert result.app_instance_id == row.app_instance_id + session.scalar.assert_awaited_once() + + +async def test_get_by_id_returns_none_when_missing() -> None: + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = None + repo = SqlAppInstanceRepository(session) + + assert await repo.get_by_id(uuid4()) is None + + +async def test_save_flushes_and_reloads_app_instance() -> None: + domain = _domain() + expected = _domain() + expected.app_instance_id = domain.app_instance_id + session = AsyncMock(spec=AsyncSession) + merged = _row() + merged.app_instance_id = domain.app_instance_id + session.merge.return_value = merged + repo = SqlAppInstanceRepository(session) + repo.get_by_id = AsyncMock(return_value=expected) # type: ignore[method-assign] + + result = await repo.save(domain) + + assert result == expected + session.merge.assert_awaited_once() + session.flush.assert_awaited_once() + repo.get_by_id.assert_awaited_once_with(domain.app_instance_id) + + +async def test_save_raises_when_reloaded_instance_missing() -> None: + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _row() + repo = SqlAppInstanceRepository(session) + repo.get_by_id = AsyncMock(return_value=None) # type: ignore[method-assign] + + with pytest.raises(RuntimeError): + await repo.save(_domain()) diff --git a/open_exposure_gateway/test/unit/test_app_registration_repo.py b/open_exposure_gateway/test/unit/test_app_registration_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..7a39e0ab97e9c9da87283965592b186dd492893d --- /dev/null +++ b/open_exposure_gateway/test/unit/test_app_registration_repo.py @@ -0,0 +1,129 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.repos.app_registrations import ( + SqlAppRegistrationRepository, +) +from open_exposure_gateway.app.adapters.database.sql import AppRegistrationRow +from open_exposure_gateway.app.adapters.errors import DuplicateAppRegistrationError +from open_exposure_gateway.app.domain.models import ( + AppRegistration, + AppRegistrationStatus, + PackageType, +) + + +def _row() -> AppRegistrationRow: + return AppRegistrationRow( + app_registration_id=uuid4(), + app_id=uuid4(), + tenant_id="tenant-a", + name="video-analytics", + version="1.2.3", + package_type=PackageType.CONTAINER, + status=AppRegistrationStatus.REGISTERED, + ) + + +def _domain() -> AppRegistration: + return AppRegistration( + app_registration_id=uuid4(), + app_id=uuid4(), + tenant_id="tenant-a", + name="video-analytics", + version="1.2.3", + package_type=PackageType.CONTAINER, + status=AppRegistrationStatus.REGISTERED, + ) + + +async def test_get_by_id_returns_mapped_app_registration() -> None: + row = _row() + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = row + repo = SqlAppRegistrationRepository(session) + + result = await repo.get_by_id(row.app_registration_id) + + assert result is not None + assert result.app_registration_id == row.app_registration_id + session.scalar.assert_awaited_once() + + +async def test_get_by_app_id_returns_mapped_app_registration() -> None: + row = _row() + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = row + repo = SqlAppRegistrationRepository(session) + + result = await repo.get_by_app_id(row.app_id) + + assert result is not None + assert result.app_id == row.app_id + session.scalar.assert_awaited_once() + + +async def test_get_by_id_returns_none_when_missing() -> None: + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = None + repo = SqlAppRegistrationRepository(session) + + assert await repo.get_by_id(uuid4()) is None + + +async def test_get_by_app_id_returns_none_when_missing() -> None: + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = None + repo = SqlAppRegistrationRepository(session) + + assert await repo.get_by_app_id(uuid4()) is None + + +async def test_save_flushes_and_reloads_app_registration() -> None: + domain = _domain() + expected = _domain() + expected.app_registration_id = domain.app_registration_id + session = AsyncMock(spec=AsyncSession) + merged = _row() + merged.app_registration_id = domain.app_registration_id + session.merge.return_value = merged + repo = SqlAppRegistrationRepository(session) + repo.get_by_id = AsyncMock(return_value=expected) # type: ignore[method-assign] + + result = await repo.save(domain) + + assert result == expected + session.merge.assert_awaited_once() + session.flush.assert_awaited_once() + session.commit.assert_not_awaited() + repo.get_by_id.assert_awaited_once_with(domain.app_registration_id) + + +async def test_save_raises_duplicate_app_registration_error_on_unique_violation() -> None: + domain = _domain() + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _row() + orig = Exception("duplicate key value violates unique constraint") + orig.sqlstate = "23505" # type: ignore[attr-defined] + session.flush.side_effect = IntegrityError("duplicate", params=None, orig=orig) + repo = SqlAppRegistrationRepository(session) + + with pytest.raises(DuplicateAppRegistrationError): + await repo.save(domain) + + +async def test_save_propagates_non_unique_integrity_errors() -> None: + domain = _domain() + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _row() + orig = Exception("null value in column violates not-null constraint") + orig.sqlstate = "23502" # type: ignore[attr-defined] # not_null_violation + session.flush.side_effect = IntegrityError("insert", params=None, orig=orig) + repo = SqlAppRegistrationRepository(session) + + with pytest.raises(IntegrityError): + await repo.save(domain) diff --git a/open_exposure_gateway/test/unit/test_callback_delivery_repo.py b/open_exposure_gateway/test/unit/test_callback_delivery_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..5d7d9c30a58492558d5cbf93f20517c788f417e0 --- /dev/null +++ b/open_exposure_gateway/test/unit/test_callback_delivery_repo.py @@ -0,0 +1,79 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.repos.callback_deliveries import ( + SqlCallbackDeliveryRepository, +) +from open_exposure_gateway.app.adapters.database.sql import CallbackDeliveryRow +from open_exposure_gateway.app.domain.models import CallbackDelivery + + +def _row() -> CallbackDeliveryRow: + return CallbackDeliveryRow( + id=uuid4(), + callback_registration_id=uuid4(), + operation_id=uuid4(), + attempt=1, + state="delivered", + ) + + +def _domain() -> CallbackDelivery: + return CallbackDelivery( + id=uuid4(), + callback_registration_id=uuid4(), + operation_id=uuid4(), + attempt=1, + state="delivered", + ) + + +async def test_list_by_callback_registration_id_returns_mapped_deliveries() -> None: + row = _row() + session = AsyncMock(spec=AsyncSession) + session.scalars.return_value = [row] + repo = SqlCallbackDeliveryRepository(session) + + result = await repo.list_by_callback_registration_id(row.callback_registration_id) + + assert len(result) == 1 + assert result[0].id == row.id + session.scalars.assert_awaited_once() + + +async def test_list_returns_empty_when_none_found() -> None: + session = AsyncMock(spec=AsyncSession) + session.scalars.return_value = [] + repo = SqlCallbackDeliveryRepository(session) + + assert await repo.list_by_callback_registration_id(uuid4()) == [] + + +async def test_save_flushes_and_reloads_callback_delivery() -> None: + session = AsyncMock(spec=AsyncSession) + merged = _row() + session.merge.return_value = merged + reloaded = _row() + reloaded.id = merged.id + session.scalar.return_value = reloaded + repo = SqlCallbackDeliveryRepository(session) + + result = await repo.save(_domain()) + + assert result.id == merged.id + session.merge.assert_awaited_once() + session.flush.assert_awaited_once() + session.scalar.assert_awaited_once() + + +async def test_save_raises_when_reloaded_callback_delivery_missing() -> None: + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _row() + session.scalar.return_value = None + repo = SqlCallbackDeliveryRepository(session) + + with pytest.raises(RuntimeError): + await repo.save(_domain()) diff --git a/open_exposure_gateway/test/unit/test_callback_registration_repo.py b/open_exposure_gateway/test/unit/test_callback_registration_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..37179dc164a636443229995f45199c26b1f6f149 --- /dev/null +++ b/open_exposure_gateway/test/unit/test_callback_registration_repo.py @@ -0,0 +1,82 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.repos.callback_registrations import ( + SqlCallbackRegistrationRepository, +) +from open_exposure_gateway.app.adapters.database.sql import CallbackRegistrationRow +from open_exposure_gateway.app.domain.models import CallbackRegistration + + +def _row() -> CallbackRegistrationRow: + return CallbackRegistrationRow( + id=uuid4(), + operation_id=uuid4(), + tenant_id="tenant-a", + api_family="edge-application-management", + sink="https://example.com/callback", + event_types=["org.camara.eam.appinstance.status-changed"], + is_active=True, + ) + + +def _domain() -> CallbackRegistration: + return CallbackRegistration( + id=uuid4(), + operation_id=uuid4(), + tenant_id="tenant-a", + api_family="edge-application-management", + sink="https://example.com/callback", + event_types=["org.camara.eam.appinstance.status-changed"], + ) + + +async def test_get_by_operation_id_returns_mapped_callback_registration() -> None: + row = _row() + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = row + repo = SqlCallbackRegistrationRepository(session) + + result = await repo.get_by_operation_id(row.operation_id) + + assert result is not None + assert result.operation_id == row.operation_id + session.scalar.assert_awaited_once() + + +async def test_get_by_operation_id_returns_none_when_missing() -> None: + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = None + repo = SqlCallbackRegistrationRepository(session) + + assert await repo.get_by_operation_id(uuid4()) is None + + +async def test_save_flushes_and_reloads_callback_registration() -> None: + session = AsyncMock(spec=AsyncSession) + merged = _row() + session.merge.return_value = merged + reloaded = _row() + reloaded.id = merged.id + session.scalar.return_value = reloaded + repo = SqlCallbackRegistrationRepository(session) + + result = await repo.save(_domain()) + + assert result.id == merged.id + session.merge.assert_awaited_once() + session.flush.assert_awaited_once() + session.scalar.assert_awaited_once() + + +async def test_save_raises_when_reloaded_callback_registration_missing() -> None: + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _row() + session.scalar.return_value = None + repo = SqlCallbackRegistrationRepository(session) + + with pytest.raises(RuntimeError): + await repo.save(_domain()) diff --git a/open_exposure_gateway/test/unit/test_config.py b/open_exposure_gateway/test/unit/test_config.py new file mode 100644 index 0000000000000000000000000000000000000000..3242ef5957fd05ec32376c204b60b446f1fb45eb --- /dev/null +++ b/open_exposure_gateway/test/unit/test_config.py @@ -0,0 +1,73 @@ +import os + +import pytest + +from open_exposure_gateway.app.core.config import Settings, get_settings + + +class TestNestedEnvironmentVariables: + def test_nested_delimiter_form_configures_the_srm_endpoint( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("SRM_SETTINGS__BASE_URL", "https://srm.prod.example.com") + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert str(settings.srm_settings.base_url).rstrip("/") == "https://srm.prod.example.com" + + def test_srm_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SRM_SETTINGS__TIMEOUT", "2.5") + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.srm_settings.timeout == 2.5 + + def test_postgresql_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv( + "POSTGRESQL_SETTINGS__URL", "postgresql+asyncpg://oeg:secret@db.prod:5432/oeg" + ) + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert ( + settings.postgresql_settings.url == "postgresql+asyncpg://oeg:secret@db.prod:5432/oeg" + ) + + def test_postgresql_echo(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("POSTGRESQL_SETTINGS__ECHO", "true") + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.postgresql_settings.echo is True + + def test_postgresql_create_schema_on_startup(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("POSTGRESQL_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.postgresql_settings.create_schema_on_startup is True + + def test_nats_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NATS_SETTINGS__URL", "nats://bus.prod:4222") + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.nats_settings.url == "nats://bus.prod:4222" + + def test_log_level(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OBSERVABILITY_SETTINGS__LOG_LEVEL", "DEBUG") + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.observability_settings.log_level == "DEBUG" + + def test_flat_top_level_fields(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PORT", "9000") + monkeypatch.setenv("DEBUG", "true") + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.port == 9000 + assert settings.debug is True + + +class TestDefaults: + def test_sensible_local_defaults_without_any_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + for var in list(os.environ): + if "__" in var or var in ("PORT", "DEBUG", "HOST"): + monkeypatch.delenv(var, raising=False) + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert str(settings.srm_settings.base_url).startswith("http://localhost") + assert settings.nats_settings.url == "nats://localhost:4222" + assert settings.port == 8080 + assert settings.postgresql_settings.echo is False + assert settings.postgresql_settings.create_schema_on_startup is False + + +class TestGetSettingsCaching: + def test_get_settings_returns_same_instance(self) -> None: + assert get_settings() is get_settings() diff --git a/open_exposure_gateway/test/unit/test_domain_models.py b/open_exposure_gateway/test/unit/test_domain_models.py new file mode 100644 index 0000000000000000000000000000000000000000..e46a5929153702afe04d5f33b56d4538851d49c5 --- /dev/null +++ b/open_exposure_gateway/test/unit/test_domain_models.py @@ -0,0 +1,477 @@ +import uuid +from typing import Any, Literal + +import pytest +from pydantic import ValidationError + +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + AppInstanceStatus, + ApplicationResources, + AppManifest, + AppRepo, + ContainerResources, + DockerComposeResources, + EdgeCloudZone, + EdgeCloudZoneStatus, + KubernetesResources, + NetworkInterface, + OperatingSystem, + VmResources, +) + +_ZONE_ID = uuid.uuid4() + +_VALID_APP_REPO = AppRepo( + type="PRIVATEREPO", + imagePath="https://charts.example.com/nginx:1.0", +) + +_VALID_PROVIDER = "acme_provider" + +_VALID_CPU_POOL: dict[str, Any] = { + "numCPU": 2, + "memory": 4096, + "topology": {"minNumberOfNodes": 1, "minNodeCpu": 1, "minNodeMemory": 1024}, +} + +_VALID_REQUIRED_RESOURCES = KubernetesResources( + infraKind="kubernetes", + applicationResources=ApplicationResources.model_validate({"cpuPool": _VALID_CPU_POOL}), + isStandalone=False, +) + + +class TestEdgeCloudZone: + def test_valid_zone(self) -> None: + zone = EdgeCloudZone( + edgeCloudZoneId=_ZONE_ID, + edgeCloudZoneName="zone-1", + edgeCloudProvider="acme", + ) + assert zone.edgeCloudZoneId == _ZONE_ID + + def test_status_defaults_to_unknown(self) -> None: + zone = EdgeCloudZone( + edgeCloudZoneId=_ZONE_ID, + edgeCloudZoneName="z", + edgeCloudProvider="p", + ) + assert zone.edgeCloudZoneStatus == EdgeCloudZoneStatus.UNKNOWN + + def test_explicit_status(self) -> None: + zone = EdgeCloudZone( + edgeCloudZoneId=_ZONE_ID, + edgeCloudZoneName="z", + edgeCloudProvider="p", + edgeCloudZoneStatus=EdgeCloudZoneStatus.ACTIVE, + ) + assert zone.edgeCloudZoneStatus == EdgeCloudZoneStatus.ACTIVE + + def test_region_is_optional(self) -> None: + zone = EdgeCloudZone( + edgeCloudZoneId=_ZONE_ID, + edgeCloudZoneName="z", + edgeCloudProvider="p", + ) + assert zone.edgeCloudRegion is None + + def test_name_exceeds_max_length_raises(self) -> None: + with pytest.raises(ValidationError): + EdgeCloudZone( + edgeCloudZoneId=_ZONE_ID, + edgeCloudZoneName="x" * 65, + edgeCloudProvider="p", + ) + + def test_provider_exceeds_max_length_raises(self) -> None: + with pytest.raises(ValidationError): + EdgeCloudZone( + edgeCloudZoneId=_ZONE_ID, + edgeCloudZoneName="z", + edgeCloudProvider="p" * 65, + ) + + def test_invalid_uuid_raises(self) -> None: + invalid: Any = "not-a-uuid" + with pytest.raises(ValidationError): + EdgeCloudZone( + edgeCloudZoneId=invalid, + edgeCloudZoneName="z", + edgeCloudProvider="p", + ) + + +class TestNetworkInterface: + def test_valid_interface(self) -> None: + iface = NetworkInterface( + interfaceId="eth0", + protocol="TCP", + port=80, + visibilityType="VISIBILITY_EXTERNAL", + ) + assert iface.interfaceId == "eth0" + assert iface.port == 80 + + @pytest.mark.parametrize("port", [1, 80, 443, 8080, 65535]) + def test_valid_port_range(self, port: int) -> None: + iface = NetworkInterface( + interfaceId="eth0", + protocol="TCP", + port=port, + visibilityType="VISIBILITY_INTERNAL", + ) + assert iface.port == port + + @pytest.mark.parametrize("port", [0, -1, 65536, 99999]) + def test_port_out_of_range_raises(self, port: int) -> None: + with pytest.raises(ValidationError): + NetworkInterface( + interfaceId="eth0", + protocol="TCP", + port=port, + visibilityType="VISIBILITY_EXTERNAL", + ) + + @pytest.mark.parametrize("interface_id", ["eth0", "abcde", "A1b2C3d4"]) + def test_valid_interface_id(self, interface_id: str) -> None: + iface = NetworkInterface( + interfaceId=interface_id, + protocol="UDP", + port=1234, + visibilityType="VISIBILITY_EXTERNAL", + ) + assert iface.interfaceId == interface_id + + @pytest.mark.parametrize( + "interface_id", + [ + "ab", + "a" * 33, + "-eth0", + "eth-", + ], + ) + def test_invalid_interface_id_raises(self, interface_id: str) -> None: + with pytest.raises(ValidationError): + NetworkInterface( + interfaceId=interface_id, + protocol="TCP", + port=80, + visibilityType="VISIBILITY_EXTERNAL", + ) + + @pytest.mark.parametrize("protocol", ["TCP", "UDP", "ANY"]) + def test_valid_protocols(self, protocol: Literal["TCP", "UDP", "ANY"]) -> None: + iface = NetworkInterface( + interfaceId="eth0", + protocol=protocol, + port=80, + visibilityType="VISIBILITY_EXTERNAL", + ) + assert iface.protocol == protocol + + def test_invalid_protocol_raises(self) -> None: + invalid: Any = "ICMP" + with pytest.raises(ValidationError): + NetworkInterface( + interfaceId="eth0", + protocol=invalid, + port=80, + visibilityType="VISIBILITY_EXTERNAL", + ) + + @pytest.mark.parametrize("visibility", ["VISIBILITY_EXTERNAL", "VISIBILITY_INTERNAL"]) + def test_valid_visibility_types( + self, visibility: Literal["VISIBILITY_EXTERNAL", "VISIBILITY_INTERNAL"] + ) -> None: + iface = NetworkInterface( + interfaceId="eth0", + protocol="TCP", + port=80, + visibilityType=visibility, + ) + assert iface.visibilityType == visibility + + def test_invalid_visibility_type_raises(self) -> None: + invalid: Any = "VISIBILITY_PUBLIC" + with pytest.raises(ValidationError): + NetworkInterface( + interfaceId="eth0", + protocol="TCP", + port=80, + visibilityType=invalid, + ) + + +class TestAppRepo: + def test_private_repo(self) -> None: + repo = AppRepo( + type="PRIVATEREPO", + imagePath="https://example.com/image:1.0", + userName="user", + credentials="secret", + ) + assert repo.type == "PRIVATEREPO" + + def test_public_repo(self) -> None: + repo = AppRepo(type="PUBLICREPO", imagePath="https://hub.docker.com/nginx") + assert repo.type == "PUBLICREPO" + + def test_invalid_type_raises(self) -> None: + invalid: Any = "S3REPO" + with pytest.raises(ValidationError): + AppRepo(type=invalid, imagePath="https://example.com") + + def test_image_path_exceeds_max_length_raises(self) -> None: + with pytest.raises(ValidationError): + AppRepo(type="PUBLICREPO", imagePath="x" * 2049) + + @pytest.mark.parametrize("auth_type", ["DOCKER", "HTTP_BASIC", "HTTP_BEARER", "NONE"]) + def test_valid_auth_types( + self, auth_type: Literal["DOCKER", "HTTP_BASIC", "HTTP_BEARER", "NONE"] + ) -> None: + repo = AppRepo( + type="PRIVATEREPO", + imagePath="https://example.com", + authType=auth_type, + ) + assert repo.authType == auth_type + + def test_invalid_auth_type_raises(self) -> None: + invalid: Any = "SSH_KEY" + with pytest.raises(ValidationError): + AppRepo( + type="PRIVATEREPO", + imagePath="https://example.com", + authType=invalid, + ) + + def test_optional_fields_default_to_none(self) -> None: + repo = AppRepo(type="PUBLICREPO", imagePath="https://example.com") + assert repo.userName is None + assert repo.credentials is None + assert repo.authType is None + assert repo.checksum is None + + +class TestAppManifestName: + @pytest.mark.parametrize("name", ["ab", "nginx_app", "MyApp123"]) + def test_valid_name(self, name: str) -> None: + manifest = AppManifest( + name=name, + appProvider=_VALID_PROVIDER, + version="1.0", + packageType="HELM", + appRepo=_VALID_APP_REPO, + requiredResources=_VALID_REQUIRED_RESOURCES, + componentSpec=[], + ) + assert manifest.name == name + + @pytest.mark.parametrize( + "name", + [ + "a", + "1nginx", + "_nginx", + "a" * 65, + "my-app", + ], + ) + def test_invalid_name_raises(self, name: str) -> None: + with pytest.raises(ValidationError): + AppManifest( + name=name, + appProvider=_VALID_PROVIDER, + version="1.0", + packageType="HELM", + appRepo=_VALID_APP_REPO, + requiredResources=_VALID_REQUIRED_RESOURCES, + componentSpec=[], + ) + + +class TestAppManifestProvider: + @pytest.mark.parametrize("provider", ["nginx_inc_x", "Acme_Corp_Ltd"]) + def test_valid_provider(self, provider: str) -> None: + manifest = AppManifest( + name="myapp", + appProvider=provider, + version="1.0", + packageType="HELM", + appRepo=_VALID_APP_REPO, + requiredResources=_VALID_REQUIRED_RESOURCES, + componentSpec=[], + ) + assert manifest.appProvider == provider + + @pytest.mark.parametrize( + "provider", + [ + "short", + "1provider", + "a" * 65, + ], + ) + def test_invalid_provider_raises(self, provider: str) -> None: + with pytest.raises(ValidationError): + AppManifest( + name="myapp", + appProvider=provider, + version="1.0", + packageType="HELM", + appRepo=_VALID_APP_REPO, + requiredResources=_VALID_REQUIRED_RESOURCES, + componentSpec=[], + ) + + def test_provider_is_required(self) -> None: + """appProvider is mandatory in the CAMARA EAM spec.""" + with pytest.raises(ValidationError): + AppManifest( # type: ignore[call-arg] + name="myapp", + version="1.0", + packageType="HELM", + appRepo=_VALID_APP_REPO, + requiredResources=_VALID_REQUIRED_RESOURCES, + componentSpec=[], + ) + + +class TestAppManifestPackageType: + @pytest.mark.parametrize("package_type", ["QCOW2", "OVA", "CONTAINER", "HELM", "CSAR"]) + def test_valid_package_types( + self, package_type: Literal["QCOW2", "OVA", "CONTAINER", "HELM", "CSAR"] + ) -> None: + manifest = AppManifest( + name="myapp", + appProvider=_VALID_PROVIDER, + version="1.0", + packageType=package_type, + appRepo=_VALID_APP_REPO, + requiredResources=_VALID_REQUIRED_RESOURCES, + componentSpec=[], + ) + assert manifest.packageType == package_type + + def test_invalid_package_type_raises(self) -> None: + invalid: Any = "DOCKER" + with pytest.raises(ValidationError): + AppManifest( + name="myapp", + appProvider=_VALID_PROVIDER, + version="1.0", + packageType=invalid, + appRepo=_VALID_APP_REPO, + requiredResources=_VALID_REQUIRED_RESOURCES, + componentSpec=[], + ) + + def test_app_id_is_optional(self) -> None: + manifest = AppManifest( + name="myapp", + appProvider=_VALID_PROVIDER, + version="1.0", + packageType="HELM", + appRepo=_VALID_APP_REPO, + requiredResources=_VALID_REQUIRED_RESOURCES, + componentSpec=[], + ) + assert manifest.appId is None + + +class TestOperatingSystem: + def test_valid_os(self) -> None: + os = OperatingSystem( + architecture="x86_64", + family="UBUNTU", + version="OS_VERSION_UBUNTU_2204_LTS", + license="OS_LICENSE_TYPE_FREE", + ) + assert os.architecture == "x86_64" + + def test_invalid_architecture_raises(self) -> None: + invalid: Any = "arm64" + with pytest.raises(ValidationError): + OperatingSystem( + architecture=invalid, + family="UBUNTU", + version="OS_VERSION_UBUNTU_2204_LTS", + license="OS_LICENSE_TYPE_FREE", + ) + + def test_invalid_family_raises(self) -> None: + invalid: Any = "DEBIAN" + with pytest.raises(ValidationError): + OperatingSystem( + architecture="x86_64", + family=invalid, + version="OS_VERSION_UBUNTU_2204_LTS", + license="OS_LICENSE_TYPE_FREE", + ) + + +class TestVmResources: + def test_valid(self) -> None: + r = VmResources(infraKind="virtualMachine", numCPU=4, memory=8192) + assert r.infraKind == "virtualMachine" + + def test_cpu_below_min_raises(self) -> None: + with pytest.raises(ValidationError): + VmResources(infraKind="virtualMachine", numCPU=0, memory=1024) + + def test_cpu_above_max_raises(self) -> None: + with pytest.raises(ValidationError): + VmResources(infraKind="virtualMachine", numCPU=257, memory=1024) + + def test_memory_below_min_raises(self) -> None: + with pytest.raises(ValidationError): + VmResources(infraKind="virtualMachine", numCPU=1, memory=0) + + def test_memory_above_max_raises(self) -> None: + with pytest.raises(ValidationError): + VmResources(infraKind="virtualMachine", numCPU=1, memory=32769) + + +class TestContainerResources: + @pytest.mark.parametrize("num_cpu", ["1", "2.5", "500m", "0.125"]) + def test_valid_cpu_formats(self, num_cpu: str) -> None: + r = ContainerResources(infraKind="container", numCPU=num_cpu, memory=512) + assert r.numCPU == num_cpu + + @pytest.mark.parametrize("num_cpu", ["invalid", "1.5.5", "cpu2", "500k"]) + def test_invalid_cpu_format_raises(self, num_cpu: str) -> None: + with pytest.raises(ValidationError): + ContainerResources(infraKind="container", numCPU=num_cpu, memory=512) + + +class TestDockerComposeResources: + def test_valid(self) -> None: + r = DockerComposeResources(infraKind="dockerCompose", numCPU=2, memory=2048) + assert r.infraKind == "dockerCompose" + + +class TestKubernetesResources: + def test_valid(self) -> None: + r = KubernetesResources( + infraKind="kubernetes", + applicationResources=ApplicationResources.model_validate({"cpuPool": _VALID_CPU_POOL}), + isStandalone=True, + ) + assert r.isStandalone is True + + +class TestEdgeCloudZoneStatus: + def test_values(self) -> None: + assert EdgeCloudZoneStatus.ACTIVE.value == "active" + assert EdgeCloudZoneStatus.INACTIVE.value == "inactive" + assert EdgeCloudZoneStatus.UNKNOWN.value == "unknown" + + +class TestAppInstanceStatus: + def test_values(self) -> None: + assert AppInstanceStatus.READY.value == "ready" + assert AppInstanceStatus.INSTANTIATING.value == "instantiating" + assert AppInstanceStatus.FAILED.value == "failed" + assert AppInstanceStatus.TERMINATING.value == "terminating" + assert AppInstanceStatus.UNKNOWN.value == "unknown" diff --git a/open_exposure_gateway/test/unit/test_eam_contract.py b/open_exposure_gateway/test/unit/test_eam_contract.py new file mode 100644 index 0000000000000000000000000000000000000000..43dcbc23cc20939bc8a167bd6679cc2613a67aa9 --- /dev/null +++ b/open_exposure_gateway/test/unit/test_eam_contract.py @@ -0,0 +1,272 @@ +"""SRM contract conformance tests for EAM + +Every test pins a specific clause of the SRM contract. A failing test here means +OEG's wire behavior does not match what SRM accepts or publishes — fix the app +code (or the fakes, where noted) until the test passes; do not weaken the test. + +""" + +from datetime import datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from open_exposure_gateway.app.adapters.databus.nats_adapter import NatsOperationConsumer +from open_exposure_gateway.app.adapters.http.srm_client import SRMClient +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.router import ( + BASE_PATH as EAM_BASE, +) +from open_exposure_gateway.app.domain.edge_application_management import ( + SRMCompletedInstance, + SRMOperationCompleted, + SRMTerminateCommand, + Subject, +) +from open_exposure_gateway.test.unit.fakes import ( + FakeDataBus, + FakeSRMClient, + completion_payload, +) + +APP_ID = uuid4() +ZONE_ID = uuid4() + +CREATE_INSTANCE_BODY: dict[str, Any] = { + "name": "myvideoapp_inst", + "appId": str(APP_ID), + "edgeCloudZoneId": str(ZONE_ID), +} + +ENVELOPE_SOURCES = {"nbi_camara", "nbi_tmf", "operator_portal", "federation"} + + +def _published(bus: FakeDataBus, subject: Subject) -> list[dict[str, Any]]: + return [p for s, p in bus.published if s == subject] + + +def _assert_envelope(payload: dict[str, Any]) -> None: + """Every command.srm.* message carries the full shared command envelope.""" + assert payload["schema_version"] == "1.0" + UUID(payload["operation_id"]) + assert payload["correlation_id"] + requested_at = datetime.fromisoformat(payload["requested_at"]) + assert requested_at.tzinfo is not None, "requested_at must be ISO-8601 UTC" + assert payload["app_provider_id"] + assert payload["source"] in ENVELOPE_SOURCES + + +class TestCommandEnvelope: + def test_deploy_and_terminate_carry_the_full_envelope( + self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient + ) -> None: + api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + srm_id = next(iter(live_srm.instances)) + api_client.delete(f"{EAM_BASE}/appinstances/{srm_id}") + + (deploy,) = _published(fake_bus, Subject.TASK_DEPLOY) + (terminate,) = _published(fake_bus, Subject.TASK_TERMINATE) + _assert_envelope(deploy) + _assert_envelope(terminate) + + +class TestTerminateCommandConformance: + def test_terminate_carries_required_terminate_object( + self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient + ) -> None: + """The `terminate` payload object is required on terminate commands. A + real SRM schema-validates the message and dead-letters it when the + object is missing — every DELETE /appinstances would silently fail.""" + api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + srm_id = next(iter(live_srm.instances)) + api_client.delete(f"{EAM_BASE}/appinstances/{srm_id}") + + (payload,) = _published(fake_bus, Subject.TASK_TERMINATE) + assert "terminate" in payload, "the contract requires a `terminate` payload object" + assert payload["terminate"]["grace_period_seconds"] == 0 + + def test_terminate_model_rejects_missing_service_instance_id(self) -> None: + """`service_instance_id` is required on terminate. The model must make + it impossible to build a terminate command without it.""" + with pytest.raises(ValidationError): + SRMTerminateCommand( # type: ignore[call-arg] + operation_id=str(uuid4()), + correlation_id="corr-1", + requested_at="2026-07-03T12:00:00+00:00", + app_provider_id="acme", + ) + + +class TestOperationCompletedConformance: + def test_fake_srm_completion_event_has_all_required_fields(self) -> None: + """The fake's reference event stands in for what SRM publishes — it + must carry every field the contract requires, or flow tests validate + OEG against an invalid event. (Fix in test/unit/fakes.py: + completion_payload.)""" + payload = completion_payload(str(uuid4())) + for field in ( + "schema_version", + "operation_id", + "status", + "instances", + "correlation_id", + "completed_at", + ): + assert field in payload, f"the contract requires `{field}`" + + def test_consumer_model_requires_correlation_id(self) -> None: + """`correlation_id` is required. OEG's model must reject an event + without it instead of silently accepting a non-conformant SRM.""" + with pytest.raises(ValidationError): + SRMOperationCompleted( # type: ignore[call-arg] + schema_version="1.0", + operation_id=str(uuid4()), + status="completed", + instances=[ + SRMCompletedInstance( + service_instance_id=str(uuid4()), + zone_id=str(uuid4()), + status="completed", + ) + ], + completed_at="2026-07-03T12:00:00+00:00", + ) + + def test_failed_completion_must_carry_error(self) -> None: + """`error` (an RFC 7807 object) is required when status=failed.""" + with pytest.raises(ValidationError): + SRMOperationCompleted( + schema_version="1.0", + operation_id=str(uuid4()), + status="failed", + correlation_id="corr-1", + completed_at="2026-07-03T12:00:00+00:00", + ) + + def test_completion_status_restricted_to_contract_enum(self) -> None: + """`status` is an enum — `completed` | `partially_completed` | `failed`, + nothing else.""" + with pytest.raises(ValidationError): + SRMOperationCompleted( + schema_version="1.0", + operation_id=str(uuid4()), + status="in_progress", # type: ignore[arg-type] + correlation_id="corr-1", + completed_at="2026-07-03T12:00:00+00:00", + ) + + def test_instances_required_unless_failed(self) -> None: + """`instances` is required when status != failed — a `completed` + event with no instances would mean nothing for OEG to update.""" + with pytest.raises(ValidationError): + SRMOperationCompleted( + schema_version="1.0", + operation_id=str(uuid4()), + status="completed", + correlation_id="corr-1", + completed_at="2026-07-03T12:00:00+00:00", + ) + + def test_partially_completed_is_a_valid_status(self) -> None: + """status supports partially_completed as an operation-level roll-up + over per-instance outcomes in instances[].""" + event = SRMOperationCompleted( + schema_version="1.0", + operation_id=str(uuid4()), + status="partially_completed", + correlation_id="corr-1", + completed_at="2026-07-03T12:00:00+00:00", + instances=[ + SRMCompletedInstance( + service_instance_id=str(uuid4()), + zone_id=str(uuid4()), + status="completed", + ), + SRMCompletedInstance( + service_instance_id=str(uuid4()), + zone_id=str(uuid4()), + status="failed", + error={ + "type": "about:blank", + "title": "Zone Capacity Exceeded", + "status": 503, + "detail": "no capacity", + }, + ), + ], + ) + assert event.status == "partially_completed" + assert event.instances[1].status == "failed" + + def test_failed_instance_must_carry_error(self) -> None: + """Per-instance `error` is required when that instance's own + status=failed, independent of the operation-level status/error.""" + with pytest.raises(ValidationError): + SRMOperationCompleted( + schema_version="1.0", + operation_id=str(uuid4()), + status="partially_completed", + correlation_id="corr-1", + completed_at="2026-07-03T12:00:00+00:00", + instances=[ + SRMCompletedInstance( + service_instance_id=str(uuid4()), + zone_id=str(uuid4()), + status="failed", + ) + ], + ) + + +class TestEventConsumptionDelivery: + @pytest.mark.skip( + reason="TODO: JetStream durable consumer not implemented yet — " + "NatsOperationConsumer still uses core-NATS subscribe() (at-most-once)." + ) + async def test_consumer_subscribes_durably_via_jetstream(self) -> None: + """OEG must consume event.srm.* durable, at-least-once (the OOP_EVENTS + stream). A core-NATS subscribe() is non-durable and at-most-once: every + operation.completed emitted while OEG is down or slow is lost, and the + upstream operation hangs forever.""" + client = MagicMock() + client.subscribe = AsyncMock() + jetstream = MagicMock() + jetstream.subscribe = AsyncMock() + client.jetstream.return_value = jetstream + + consumer = NatsOperationConsumer(client=client, subject=str(Subject.OPERATION_COMPLETED)) + await consumer.start() + + client.subscribe.assert_not_awaited() + jetstream.subscribe.assert_awaited_once() + assert jetstream.subscribe.await_args.kwargs.get("durable"), ( + "the contract requires a named durable consumer" + ) + + +class TestInternalHttpPaths: + async def test_zone_listing_uses_internal_zones_path(self) -> None: + """The zones read is `GET /internal/zones`. Any other path 404s + against a conformant SRM and every /edge-cloud-zones call returns 503.""" + client = SRMClient.__new__(SRMClient) + client.base_url = "http://srm:8081" + client.timeout = 1.0 + calls: list[tuple[str, str]] = [] + + async def record( + method: str, + path: str, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + calls.append((method, path)) + return [] + + client._request = record # type: ignore[method-assign] + await client.get_resource_zones() + + assert calls == [("GET", "/internal/zones")] diff --git a/open_exposure_gateway/test/unit/test_eam_endpoints.py b/open_exposure_gateway/test/unit/test_eam_endpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..20eee509d4b97aff440d4369c5b0e134a98ba87b --- /dev/null +++ b/open_exposure_gateway/test/unit/test_eam_endpoints.py @@ -0,0 +1,168 @@ +from collections.abc import Generator +from typing import Any +from unittest.mock import ANY, AsyncMock +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.router import ( + BASE_PATH as EAM_BASE, +) +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + AppInstanceInfo, + AppInstanceStatus, + EdgeCloudZone, + EdgeCloudZoneStatus, + SubmittedApp, +) +from open_exposure_gateway.app.application.services.edge_application_management_service import ( + EdgeApplicationManagementService, +) +from open_exposure_gateway.app.dependencies import get_edge_app_service +from open_exposure_gateway.app.main import app + +_ZONE_ID = uuid4() +_APP_ID = uuid4() +_INSTANCE_ID = uuid4() + +_VALID_MANIFEST: dict[str, Any] = { + "name": "myvideoapp", + "appProvider": "acme_provider", + "version": "1.0.0", + "packageType": "HELM", + "appRepo": {"type": "PUBLICREPO", "imagePath": "oci://registry.example.com/charts/app:1.0"}, + "requiredResources": { + "infraKind": "kubernetes", + "applicationResources": { + "cpuPool": { + "numCPU": 2, + "memory": 4096, + "topology": {"minNumberOfNodes": 1, "minNodeCpu": 1, "minNodeMemory": 1024}, + } + }, + "isStandalone": False, + }, + "componentSpec": [], +} + +_VALID_CREATE_INSTANCE: dict[str, Any] = { + "name": "myvideoapp_inst", + "appId": str(_APP_ID), + "edgeCloudZoneId": str(_ZONE_ID), +} + + +@pytest.fixture() +def mock_eam_service() -> AsyncMock: + service = AsyncMock(spec=EdgeApplicationManagementService) + service.get_edge_cloud_zones.return_value = [ + EdgeCloudZone( + edgeCloudZoneId=_ZONE_ID, + edgeCloudZoneName="berlin-edge-1", + edgeCloudProvider="acme", + edgeCloudZoneStatus=EdgeCloudZoneStatus.ACTIVE, + ) + ] + service.submit_app.return_value = SubmittedApp(appId=_APP_ID) + service.create_app_instance.return_value = AppInstanceInfo( + appInstanceId=_INSTANCE_ID, + name="myvideoapp_inst", + appId=_APP_ID, + appProvider="acme", + status=AppInstanceStatus.INSTANTIATING, + edgeCloudZoneId=_ZONE_ID, + ) + return service + + +@pytest.fixture() +def client(mock_eam_service: AsyncMock) -> Generator[TestClient, None, None]: + app.dependency_overrides[get_edge_app_service] = lambda: mock_eam_service + yield TestClient(app) + app.dependency_overrides.clear() + + +class TestGetEdgeCloudZones: + def test_returns_200_with_zones(self, client: TestClient) -> None: + response = client.get(f"{EAM_BASE}/edge-cloud-zones") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["edgeCloudZoneId"] == str(_ZONE_ID) + + def test_passes_region_and_status_filters( + self, client: TestClient, mock_eam_service: AsyncMock + ) -> None: + client.get(f"{EAM_BASE}/edge-cloud-zones?region=eu-west&status=active") + mock_eam_service.get_edge_cloud_zones.assert_called_once_with( + region="eu-west", status="active", x_correlator=None + ) + + +class TestSubmitApp: + def test_returns_201_with_app_id(self, client: TestClient) -> None: + response = client.post(f"{EAM_BASE}/apps", json=_VALID_MANIFEST) + assert response.status_code == 201 + assert response.json()["appId"] == str(_APP_ID) + + def test_service_called_with_manifest( + self, client: TestClient, mock_eam_service: AsyncMock + ) -> None: + client.post(f"{EAM_BASE}/apps", json=_VALID_MANIFEST) + mock_eam_service.submit_app.assert_called_once_with( + manifest=ANY, + app_id=ANY, + tenant_id=ANY, + app_provider_id=ANY, + x_correlator=ANY, + ) + + def test_x_correlator_forwarded(self, client: TestClient, mock_eam_service: AsyncMock) -> None: + client.post(f"{EAM_BASE}/apps", json=_VALID_MANIFEST, headers={"x-correlator": "corr-123"}) + mock_eam_service.submit_app.assert_called_once_with( + manifest=ANY, + app_id=ANY, + tenant_id=ANY, + app_provider_id=ANY, + x_correlator="corr-123", + ) + + def test_missing_required_field_returns_400(self, client: TestClient) -> None: + response = client.post(f"{EAM_BASE}/apps", json={"name": "myvideoapp"}) + assert response.status_code == 400 + + +class TestCreateAppInstance: + def test_returns_202(self, client: TestClient) -> None: + response = client.post(f"{EAM_BASE}/appinstances", json=_VALID_CREATE_INSTANCE) + assert response.status_code == 202 + + def test_returns_app_instance_info_body(self, client: TestClient) -> None: + response = client.post(f"{EAM_BASE}/appinstances", json=_VALID_CREATE_INSTANCE) + assert response.json()["appInstanceId"] == str(_INSTANCE_ID) + + def test_service_called_with_request( + self, client: TestClient, mock_eam_service: AsyncMock + ) -> None: + client.post(f"{EAM_BASE}/appinstances", json=_VALID_CREATE_INSTANCE) + mock_eam_service.create_app_instance.assert_called_once_with( + request=ANY, + tenant_id=ANY, + app_provider_id=ANY, + x_correlator=ANY, + ) + + def test_location_header_set(self, client: TestClient) -> None: + response = client.post(f"{EAM_BASE}/appinstances", json=_VALID_CREATE_INSTANCE) + assert ( + response.headers["location"] + == f"http://testserver{EAM_BASE}/appinstances/{_INSTANCE_ID}" + ) + + def test_missing_app_id_returns_400(self, client: TestClient) -> None: + response = client.post( + f"{EAM_BASE}/appinstances", + json={"name": "myvideoapp_inst", "edgeCloudZoneId": str(_ZONE_ID)}, + ) + assert response.status_code == 400 diff --git a/open_exposure_gateway/test/unit/test_eam_flows.py b/open_exposure_gateway/test/unit/test_eam_flows.py new file mode 100644 index 0000000000000000000000000000000000000000..d8e9d194d59b21931c538728cd3b2250cf76b6a9 --- /dev/null +++ b/open_exposure_gateway/test/unit/test_eam_flows.py @@ -0,0 +1,408 @@ +"""End-to-end flow tests for Edge Application Management. + +Each test drives the real HTTP API (router -> service -> mapper) against the +in-memory fakes from fakes.py: the FakeSRMClient covers the HTTP path to SRM, +the FakeDataBus + wire_srm_worker cover the async command path, and +wire_operation_consumer attaches OEG's real completion handler. + +Tests in this module assert *intended* behavior. A failing test here means a +real hole in the flow, not a broken test. +""" + +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.router import ( + BASE_PATH as EAM_BASE, +) +from open_exposure_gateway.app.core.exceptions import DownstreamServiceException +from open_exposure_gateway.app.domain.edge_application_management import ( + ResourceZone, + SRMDeployCommand, + SRMTerminateCommand, + Subject, +) +from open_exposure_gateway.test.unit.fakes import ( + FakeDataBus, + FakeMsg, + FakeSRMClient, + wire_operation_consumer, +) + +APP_ID = uuid4() +ZONE_ID = uuid4() + +CREATE_INSTANCE_BODY: dict[str, Any] = { + "name": "myvideoapp_inst", + "appId": str(APP_ID), + "edgeCloudZoneId": str(ZONE_ID), +} + + +def _manifest_body(num_cpu: float | int) -> dict[str, Any]: + return { + "name": "myvideoapp", + "appProvider": "acme_provider", + "version": "1.0.0", + "packageType": "HELM", + "appRepo": {"type": "PUBLICREPO", "imagePath": "oci://registry.example.com/app:1.0"}, + "requiredResources": { + "infraKind": "kubernetes", + "applicationResources": { + "cpuPool": { + "numCPU": num_cpu, + "memory": 4096, + "topology": {"minNumberOfNodes": 1, "minNodeCpu": 1, "minNodeMemory": 1024}, + } + }, + "isStandalone": False, + }, + "componentSpec": [], + } + + +class TestCreateAppInstanceFlow: + def test_returns_202_with_location_header( + self, api_client: TestClient, live_srm: FakeSRMClient + ) -> None: + response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + assert response.status_code == 202 + assert response.headers["Location"].endswith( + f"{EAM_BASE}/appinstances/{response.json()['appInstanceId']}" + ) + + def test_returns_app_instance_info_body( + self, api_client: TestClient, live_srm: FakeSRMClient + ) -> None: + """The 202 must carry the AppInstanceInfo body the spec requires, not an + empty response — the Location header alone is not the full contract.""" + response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + body = response.json() + assert body["name"] == "myvideoapp_inst" + assert body["appId"] == str(APP_ID) + assert body["edgeCloudZoneId"] == str(ZONE_ID) + assert body["status"] == "instantiating" + assert "appInstanceId" in body + + def test_srm_receives_a_valid_deploy_command( + self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient + ) -> None: + api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + + deploys = [p for s, p in fake_bus.published if s == Subject.TASK_DEPLOY] + assert len(deploys) == 1 + command = SRMDeployCommand.model_validate(deploys[0]) + assert command.service_specification_id == str(APP_ID) + assert len(command.targets) == 1 + assert command.targets[0].resource_zone_id == str(ZONE_ID) + assert command.deploy.instance_name == "myvideoapp_inst" + assert command.source == "nbi_camara" + assert command.deploy.placement_constraints == {} + + def test_x_correlator_header_propagates_into_command( + self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient + ) -> None: + api_client.post( + f"{EAM_BASE}/appinstances", + json=CREATE_INSTANCE_BODY, + headers={"x-correlator": "corr-123"}, + ) + _, command = fake_bus.published[0] + assert command["correlation_id"] == "corr-123" + + def test_each_request_gets_a_distinct_operation_id( + self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient + ) -> None: + api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + deploys = [p for s, p in fake_bus.published if s == Subject.TASK_DEPLOY] + assert len({d["operation_id"] for d in deploys}) == 2 + + def test_publish_failure_maps_to_503_envelope( + self, api_client: TestClient, fake_bus: FakeDataBus + ) -> None: + fake_bus.publish = AsyncMock( # type: ignore[method-assign] + side_effect=RuntimeError("nats down") + ) + response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + assert response.status_code == 503 + assert response.json()["code"] == "UNAVAILABLE" + + def test_instance_is_retrievable_via_location_id_after_srm_completes( + self, api_client: TestClient, live_srm: FakeSRMClient + ) -> None: + """The 202+Location contract: the id in the Location header must resolve + once SRM has deployed (SRM has already published operation.completed by + the time the POST returns, since the fake bus is synchronous).""" + response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + location_id = response.headers["Location"].rsplit("/", 1)[1] + assert len(live_srm.instances) == 1 + + poll = api_client.get(f"{EAM_BASE}/appinstances?appInstanceId={location_id}") + assert poll.status_code == 200 + instances = poll.json() + assert len(instances) == 1 + assert instances[0]["status"] == "ready" + + +class TestDeleteAppInstanceFlow: + def test_returns_202_and_srm_terminates_the_instance( + self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient + ) -> None: + api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + srm_id = next(iter(live_srm.instances)) + + response = api_client.delete(f"{EAM_BASE}/appinstances/{srm_id}") + + assert response.status_code == 202 + terminates = [p for s, p in fake_bus.published if s == Subject.TASK_TERMINATE] + assert len(terminates) == 1 + command = SRMTerminateCommand.model_validate(terminates[0]) + assert command.service_instance_id == srm_id + assert command.service_specification_id is None + assert srm_id not in live_srm.instances + + +class TestSubmitAppFlow: + def test_returns_201_and_registers_catalog_entry_in_srm( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + response = api_client.post(f"{EAM_BASE}/apps", json=_manifest_body(num_cpu=2)) + assert response.status_code == 201 + app_id = response.json()["appId"] + + assert app_id in fake_srm.catalog + compute = fake_srm.catalog[app_id]["service_deployment_units"][0]["resource_requirements"][ + "compute" + ] + assert compute["cpu_millicores"] == 2000 + assert compute["memory_mb"] == 4096 + + def test_manifest_survives_submit_then_get_roundtrip( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + api_client.post(f"{EAM_BASE}/apps", json=_manifest_body(num_cpu=2)) + + listed = api_client.get(f"{EAM_BASE}/apps").json() + assert len(listed) == 1 + manifest = listed[0] + assert manifest["name"] == "myvideoapp" + assert manifest["version"] == "1.0.0" + assert manifest["packageType"] == "HELM" + assert manifest["appRepo"]["imagePath"] == "oci://registry.example.com/app:1.0" + assert manifest["requiredResources"]["applicationResources"]["cpuPool"]["numCPU"] == 2 + + def test_fractional_cpu_is_rejected(self, api_client: TestClient) -> None: + """cpuPool.numCPU is "Total number of vcpus in whole", integer 1..256, + in the CAMARA EAM spec — fractional cores are only expressible via the + container flavor's string form (e.g. "2500m"). A kubernetes manifest + with numCPU=2.5 must be rejected, not silently truncated.""" + response = api_client.post(f"{EAM_BASE}/apps", json=_manifest_body(num_cpu=2.5)) + assert response.status_code == 400 + + def test_manifest_without_app_provider_is_rejected(self, api_client: TestClient) -> None: + """appProvider is a required AppManifest field in the CAMARA EAM spec; + accepting its absence registers apps with an empty provider id downstream.""" + body = _manifest_body(num_cpu=2) + del body["appProvider"] + response = api_client.post(f"{EAM_BASE}/apps", json=body) + assert response.status_code == 400 + + def test_manifest_without_required_resources_is_rejected(self, api_client: TestClient) -> None: + """requiredResources is a required AppManifest field in the CAMARA EAM spec; + accepting its absence registers a catalog entry with no compute intent.""" + body = _manifest_body(num_cpu=2) + del body["requiredResources"] + response = api_client.post(f"{EAM_BASE}/apps", json=body) + assert response.status_code == 400 + + def test_qcow2_package_type_is_rejected_as_not_implemented( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + """QCOW2 is a schema-valid packageType, but OOP Release 2 only fulfils + CONTAINER/HELM onto kubernetes (ADR-0009): 501, not a silent accept.""" + body = _manifest_body(num_cpu=2) + body["packageType"] = "QCOW2" + response = api_client.post(f"{EAM_BASE}/apps", json=body) + assert response.status_code == 501 + assert fake_srm.catalog == {} + + def test_virtual_machine_infra_kind_is_rejected_as_not_implemented( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + """virtualMachine is a schema-valid infraKind, but only kubernetes is + fulfilled in OOP Release 2 (ADR-0009).""" + body = _manifest_body(num_cpu=2) + body["requiredResources"] = { + "infraKind": "virtualMachine", + "numCPU": 2, + "memory": 4096, + } + response = api_client.post(f"{EAM_BASE}/apps", json=body) + assert response.status_code == 501 + assert fake_srm.catalog == {} + + +class TestGetAppsFlow: + def test_sub_core_vm_catalog_entry_is_listed_not_500( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + """A 400-millicore VM entry is valid SRM data; listing the catalog must + not blow up on it.""" + app_id = str(uuid4()) + fake_srm.catalog[app_id] = { + "service_specification": { + "id": app_id, + "ref": app_id, + "name": "tinyvm", + "version": "1.0.0", + "app_provider_id": "acme_provider", + "descriptor": {"artifact_type": "qcow2"}, + }, + "service_deployment_units": [ + { + "ref": "main-runtime", + "name": "Main Runtime", + "runtime_kind": "qcow2", + "artifact_ref": "https://images.example.com/tiny.qcow2", + "resource_requirements": {"compute": {"cpu_millicores": 400, "memory_mb": 512}}, + } + ], + "service_capability_requirements": [], + } + + response = api_client.get(f"{EAM_BASE}/apps") + assert response.status_code == 200 + assert len(response.json()) == 1 + + def test_entry_without_deployment_units_does_not_break_listing( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + """One malformed catalog entry must not take down the whole listing: + the healthy entries must still be returned.""" + api_client.post(f"{EAM_BASE}/apps", json=_manifest_body(num_cpu=2)) + broken_id = str(uuid4()) + fake_srm.catalog[broken_id] = { + "service_specification": { + "id": broken_id, + "ref": broken_id, + "name": "brokenapp", + "version": "1.0.0", + "app_provider_id": "acme", + "descriptor": {"artifact_type": "helm"}, + }, + "service_deployment_units": [], + "service_capability_requirements": [], + } + + response = api_client.get(f"{EAM_BASE}/apps") + assert response.status_code == 200 + assert "myvideoapp" in [m["name"] for m in response.json()] + + +class TestGetAppFlow: + def test_get_app_returns_manifest_envelope( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + app_id = api_client.post(f"{EAM_BASE}/apps", json=_manifest_body(num_cpu=2)).json()["appId"] + response = api_client.get(f"{EAM_BASE}/apps/{app_id}") + assert response.status_code == 200 + assert response.json()["appManifest"]["name"] == "myvideoapp" + + +class TestDeleteAppFlow: + def test_delete_app_removes_catalog_entry_in_srm( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + app_id = api_client.post(f"{EAM_BASE}/apps", json=_manifest_body(num_cpu=2)).json()["appId"] + assert app_id in fake_srm.catalog + + response = api_client.delete(f"{EAM_BASE}/apps/{app_id}") + + assert response.status_code == 204 + assert app_id not in fake_srm.catalog + + +class TestEdgeCloudZonesFlow: + def test_zones_are_mapped_to_camara_shape( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + fake_srm.zones.append( + ResourceZone( + resource_zone_id=str(ZONE_ID), + name="berlin-edge-1", + status="active", + provider="acme", + ) + ) + response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") + assert response.status_code == 200 + zone = response.json()[0] + assert zone["edgeCloudZoneId"] == str(ZONE_ID) + assert zone["edgeCloudZoneName"] == "berlin-edge-1" + assert zone["edgeCloudZoneStatus"] == "active" + + def test_one_malformed_zone_id_does_not_break_listing( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + """SRM's ResourceZone model allows free-form string ids; one non-UUID id + must not turn the whole zone listing into a 500 — healthy zones must + still be returned.""" + fake_srm.zones.append( + ResourceZone( + resource_zone_id=str(ZONE_ID), name="good-zone", status="active", provider="acme" + ) + ) + fake_srm.zones.append( + ResourceZone( + resource_zone_id="zone-west-1", name="bad-zone", status="active", provider="acme" + ) + ) + response = api_client.get(f"{EAM_BASE}/edge-cloud-zones") + assert response.status_code == 200 + assert str(ZONE_ID) in [z["edgeCloudZoneId"] for z in response.json()] + + def test_x_correlator_is_echoed_on_success_responses( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + """CAMARA API design guidelines: the x-correlator header must be echoed + on every response, not only on errors.""" + response = api_client.get( + f"{EAM_BASE}/edge-cloud-zones", headers={"x-correlator": "corr-42"} + ) + assert response.status_code == 200 + assert response.headers.get("x-correlator") == "corr-42" + + def test_downstream_failure_maps_to_503_envelope_with_correlator( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + fake_srm.get_resource_zones = AsyncMock( # type: ignore[method-assign] + side_effect=DownstreamServiceException("SRM request failed") + ) + response = api_client.get( + f"{EAM_BASE}/edge-cloud-zones", headers={"x-correlator": "corr-9"} + ) + assert response.status_code == 503 + assert response.headers["x-correlator"] == "corr-9" + body = response.json() + assert body["status"] == 503 + assert body["code"] == "UNAVAILABLE" + + +class TestOperationCompletedHandling: + async def test_consumer_survives_malformed_event(self, fake_bus: FakeDataBus) -> None: + consumer = wire_operation_consumer(fake_bus) + await consumer._handle_message( + FakeMsg(data=b"not-json", subject=str(Subject.OPERATION_COMPLETED)) + ) + + async def test_consumer_survives_event_missing_required_fields( + self, fake_bus: FakeDataBus + ) -> None: + consumer = wire_operation_consumer(fake_bus) + await consumer._handle_message( + FakeMsg(data=b'{"status": "completed"}', subject=str(Subject.OPERATION_COMPLETED)) + ) diff --git a/open_exposure_gateway/test/unit/test_eam_mapper.py b/open_exposure_gateway/test/unit/test_eam_mapper.py new file mode 100644 index 0000000000000000000000000000000000000000..7270de44df6551041088b223f717a4c1e79118f2 --- /dev/null +++ b/open_exposure_gateway/test/unit/test_eam_mapper.py @@ -0,0 +1,638 @@ +from uuid import UUID + +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + AppInstanceStatus, + ApplicationResources, + AppManifest, + AppRepo, + ComponentSpecItem, + ContainerResources, + CreateAppInstanceRequest, + DockerComposeResources, + EdgeCloudZoneStatus, + KubernetesResources, + NetworkInterface, + VmResources, +) +from open_exposure_gateway.app.application.mappers.edge_application_mapper import ( + build_app_deployment_translation, + build_app_instance_info, + build_app_manifest, + build_app_registration_translation, + build_catalog_payload, + build_deploy_command, + build_edge_cloud_zone, +) +from open_exposure_gateway.app.domain.edge_application_management import ( + ResourceZone, + ResourceZoneLocation, + SRMAccelerator, + SRMCapabilityEndpoint, + SRMCapabilityInstanceSummary, + SRMCapabilityRequirement, + SRMCatalogPayload, + SRMComputeIntent, + SRMComputeResources, + SRMDeploymentUnit, + SRMDeploymentUnitMetadata, + SRMNetworkInterface, + SRMRepoMetadata, + SRMResultSummary, + SRMServiceInstance, + SRMServiceSpecDescriptor, + SRMServiceSpecEntry, + SRMTopologyConstraints, +) + +APP_ID = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") +ZONE_ID = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") +INSTANCE_ID = UUID("cccccccc-cccc-cccc-cccc-cccccccccccc") +OP_ID = UUID("dddddddd-dddd-dddd-dddd-dddddddddddd") + + +def _make_srm_catalog( + runtime_kind: str = "helm", + image_path: str = "oci://registry.example.com/charts/app:1.0", + repo_type: str = "PUBLICREPO", + cpu_millicores: int = 2000, + memory_mb: int = 4096, + interfaces: list[SRMNetworkInterface] | None = None, +) -> SRMCatalogPayload: + return SRMCatalogPayload( + service_specification=SRMServiceSpecEntry( + id=str(APP_ID), + ref=str(APP_ID), + name="myvideoapp", + version="1.0.0", + app_provider_id="VideoAppsCo", + descriptor=SRMServiceSpecDescriptor(artifact_type=runtime_kind), + ), + service_deployment_units=[ + SRMDeploymentUnit( + ref="main-runtime", + name="Main Runtime", + runtime_kind=runtime_kind, + artifact_ref=image_path, + metadata=SRMDeploymentUnitMetadata(repo=SRMRepoMetadata(type=repo_type)), + resource_requirements=SRMComputeIntent( + compute=SRMComputeResources( + cpu_millicores=cpu_millicores, + memory_mb=memory_mb, + ), + # OEG-registered apps always carry topology (required by + # the CAMARA cpuPool schema at submit time). + topology=SRMTopologyConstraints( + min_nodes=1, + min_node_cpu_millicores=1000, + min_node_memory_mb=1024, + ), + interfaces=interfaces, + ), + ) + ], + service_capability_requirements=[ + SRMCapabilityRequirement( + ref="require-workload-deployment", + deployment_unit_ref="main-runtime", + capability_kind="deploy_workload", + domain_kind="compute", + ) + ], + ) + + +def _make_helm_manifest( + cpu: int = 2, + memory: int = 4096, + standalone: bool = False, +) -> AppManifest: + return AppManifest( + appId=APP_ID, + name="myvideoapp", + version="1.0.0", + appProvider="VideoAppsCo", + packageType="HELM", + appRepo=AppRepo( + type="PUBLICREPO", + imagePath="oci://registry.example.com/charts/app:1.0", + ), + requiredResources=KubernetesResources( + infraKind="kubernetes", + applicationResources=ApplicationResources.model_validate( + { + "cpuPool": { + "numCPU": cpu, + "memory": memory, + "topology": { + "minNumberOfNodes": 1, + "minNodeCpu": 1, + "minNodeMemory": 1024, + }, + } + } + ), + isStandalone=standalone, + ), + componentSpec=[ + ComponentSpecItem( + componentName="nginx_server", + networkInterfaces=[ + NetworkInterface( + interfaceId="eth0", + protocol="TCP", + port=80, + visibilityType="VISIBILITY_EXTERNAL", + ) + ], + ) + ], + ) + + +class TestBuildEdgeCloudZone: + def test_maps_all_fields(self) -> None: + zone = ResourceZone( + resource_zone_id=str(ZONE_ID), + name="berlin-edge-1", + status="active", + provider="acme", + location=ResourceZoneLocation(region="eu-central-1"), + ) + result = build_edge_cloud_zone(zone) + assert result.edgeCloudZoneId == ZONE_ID + assert result.edgeCloudZoneName == "berlin-edge-1" + assert result.edgeCloudProvider == "acme" + assert result.edgeCloudRegion == "eu-central-1" + + def test_unknown_status_falls_back(self) -> None: + zone = ResourceZone( + resource_zone_id=str(ZONE_ID), + name="z", + status="maintenance", + provider="p", + ) + result = build_edge_cloud_zone(zone) + assert result.edgeCloudZoneStatus == EdgeCloudZoneStatus.UNKNOWN + + def test_no_location_gives_none_region(self) -> None: + zone = ResourceZone(resource_zone_id=str(ZONE_ID), name="z", status="active", provider="p") + result = build_edge_cloud_zone(zone) + assert result.edgeCloudRegion is None + + +class TestBuildAppManifest: + def test_helm_maps_app_id_and_metadata(self) -> None: + catalog = _make_srm_catalog() + result = build_app_manifest(catalog) + assert result.appId == APP_ID + assert result.name == "myvideoapp" + assert result.version == "1.0.0" + assert result.appProvider == "VideoAppsCo" + assert result.packageType == "HELM" + + def test_helm_produces_kubernetes_resources(self) -> None: + catalog = _make_srm_catalog(cpu_millicores=2000, memory_mb=4096) + result = build_app_manifest(catalog) + assert isinstance(result.requiredResources, KubernetesResources) + cpu_pool = result.requiredResources.applicationResources.cpuPool + assert cpu_pool is not None + assert cpu_pool.numCPU == 2 + assert cpu_pool.memory == 4096 + + def test_container_produces_container_resources(self) -> None: + catalog = _make_srm_catalog(runtime_kind="container", cpu_millicores=500) + result = build_app_manifest(catalog) + assert isinstance(result.requiredResources, ContainerResources) + assert result.requiredResources.numCPU == "500m" + + def test_container_whole_core_omits_m_suffix(self) -> None: + catalog = _make_srm_catalog(runtime_kind="container", cpu_millicores=1000) + result = build_app_manifest(catalog) + assert isinstance(result.requiredResources, ContainerResources) + assert result.requiredResources.numCPU == "1" + + def test_qcow2_produces_vm_resources(self) -> None: + catalog = _make_srm_catalog(runtime_kind="qcow2", cpu_millicores=4000, memory_mb=8192) + result = build_app_manifest(catalog) + assert isinstance(result.requiredResources, VmResources) + assert result.requiredResources.numCPU == 4 + assert result.requiredResources.memory == 8192 + + def test_docker_compose_produces_docker_compose_resources(self) -> None: + catalog = _make_srm_catalog( + runtime_kind="docker-compose", cpu_millicores=2000, memory_mb=2048 + ) + result = build_app_manifest(catalog) + assert isinstance(result.requiredResources, DockerComposeResources) + + def test_public_repo_mapped(self) -> None: + catalog = _make_srm_catalog(repo_type="PUBLICREPO") + result = build_app_manifest(catalog) + assert result.appRepo.type == "PUBLICREPO" + assert result.appRepo.imagePath == "oci://registry.example.com/charts/app:1.0" + + def test_invalid_spec_ref_gives_none_app_id(self) -> None: + catalog = _make_srm_catalog() + catalog.service_specification.ref = "not-a-uuid" + result = build_app_manifest(catalog) + assert result.appId is None + + def test_network_interfaces_grouped_by_component(self) -> None: + interfaces = [ + SRMNetworkInterface( + component="frontend", + interface_id="eth0", + protocol="TCP", + port=80, + visibility="external", + ), + SRMNetworkInterface( + component="frontend", + interface_id="eth1", + protocol="TCP", + port=443, + visibility="external", + ), + ] + catalog = _make_srm_catalog(interfaces=interfaces) + result = build_app_manifest(catalog) + assert len(result.componentSpec) == 1 + assert result.componentSpec[0].componentName == "frontend" + assert len(result.componentSpec[0].networkInterfaces) == 2 + + def test_helm_topology_reconstructed(self) -> None: + catalog = _make_srm_catalog() + catalog.service_deployment_units[0].resource_requirements.topology = SRMTopologyConstraints( + min_nodes=3, + min_node_cpu_millicores=2000, + min_node_memory_mb=4096, + ) + result = build_app_manifest(catalog) + assert isinstance(result.requiredResources, KubernetesResources) + cpu_pool = result.requiredResources.applicationResources.cpuPool + assert cpu_pool is not None + topo = cpu_pool.topology + assert topo.minNumberOfNodes == 3 + assert topo.minNodeCpu == 2 + assert topo.minNodeMemory == 4096 + + def test_helm_gpu_pool_reconstructed_from_accelerator(self) -> None: + catalog = _make_srm_catalog(cpu_millicores=2000, memory_mb=2048) + compute = catalog.service_deployment_units[0].resource_requirements.compute + assert compute is not None + compute.accelerator = SRMAccelerator(type="gpu", units=1, memory_mb=16 * 1024) + catalog.service_deployment_units[0].resource_requirements.topology = SRMTopologyConstraints( + min_nodes=2, + min_node_cpu_millicores=1000, + min_node_memory_mb=1024, + min_node_gpu_memory_mb=16 * 1024, + ) + result = build_app_manifest(catalog) + assert isinstance(result.requiredResources, KubernetesResources) + assert result.requiredResources.applicationResources.cpuPool is None + gpu_pool = result.requiredResources.applicationResources.gpuPool + assert gpu_pool is not None + assert gpu_pool.numCPU == 2 + assert gpu_pool.memory == 2048 + assert gpu_pool.gpuMemory == 16 + topo = gpu_pool.topology + assert topo.minNumberOfNodes == 2 + assert topo.minNodeCpu == 1 + assert topo.minNodeMemory == 1024 + assert topo.minNodeGpuMemory == 16 + + +class TestBuildAppInstanceInfo: + def _make_instance(self, state: str = "active") -> SRMServiceInstance: + return SRMServiceInstance( + service_instance_id=str(INSTANCE_ID), + service_specification_id=str(APP_ID), + state=state, + app_provider_id="VideoAppsCo", + resource_zone_id=str(ZONE_ID), + name="myvideoapp_inst", + capability_instances=[], + ) + + def test_maps_ids_and_provider(self) -> None: + result = build_app_instance_info(self._make_instance()) + assert result.appInstanceId == INSTANCE_ID + assert result.appId == APP_ID + assert result.appProvider == "VideoAppsCo" + assert result.edgeCloudZoneId == ZONE_ID + + def test_state_mapping_active_to_ready(self) -> None: + result = build_app_instance_info(self._make_instance(state="active")) + assert result.status == AppInstanceStatus.READY + + def test_state_mapping_creating_to_instantiating(self) -> None: + result = build_app_instance_info(self._make_instance(state="creating")) + assert result.status == AppInstanceStatus.INSTANTIATING + + def test_state_mapping_failed_to_failed(self) -> None: + result = build_app_instance_info(self._make_instance(state="failed")) + assert result.status == AppInstanceStatus.FAILED + + def test_state_mapping_terminating(self) -> None: + result = build_app_instance_info(self._make_instance(state="terminating")) + assert result.status == AppInstanceStatus.TERMINATING + + def test_unknown_state_defaults_to_unknown(self) -> None: + result = build_app_instance_info(self._make_instance(state="exotic")) + assert result.status == AppInstanceStatus.UNKNOWN + + def test_endpoints_extracted_from_capability_instances(self) -> None: + instance = self._make_instance() + instance.capability_instances = [ + SRMCapabilityInstanceSummary( + capability_instance_id="cap-1", + kind="activation", + result_summary=SRMResultSummary( + status="active", + endpoints=[ + SRMCapabilityEndpoint(interface_id="eth0", fqdn="app.example.com", port=80) + ], + ), + ) + ] + result = build_app_instance_info(instance) + assert result.componentEndpointInfo is not None + assert len(result.componentEndpointInfo) == 1 + ep = result.componentEndpointInfo[0] + assert ep.interfaceId == "eth0" + assert ep.accessPoints.fqdn == "app.example.com" + assert ep.accessPoints.port == 80 + + def test_no_endpoints_gives_none(self) -> None: + result = build_app_instance_info(self._make_instance()) + assert result.componentEndpointInfo is None + + def test_name_falls_back_to_instance_id_when_missing(self) -> None: + instance = self._make_instance() + instance.name = None + result = build_app_instance_info(instance) + assert result.name == str(INSTANCE_ID) + + +class TestBuildAppRegistrationTranslation: + def test_helm_translation_fields(self) -> None: + manifest = _make_helm_manifest() + result = build_app_registration_translation(manifest, APP_ID, "tenant-1", "provider-1") + assert result.app_id == APP_ID + assert result.name == "myvideoapp" + assert result.package_type == "HELM" + assert result.tenant_id == "tenant-1" + assert result.app_provider_id == "provider-1" + + def test_cpu_pool_extracted(self) -> None: + manifest = _make_helm_manifest(cpu=4, memory=8192) + result = build_app_registration_translation(manifest, APP_ID, "t", "p") + assert result.required_resources is not None + assert result.required_resources.application_resources is not None + assert result.required_resources.application_resources.cpu_pool is not None + assert result.required_resources.application_resources.cpu_pool.num_cpu == 4 + assert result.required_resources.application_resources.cpu_pool.memory == 8192 + + def test_network_interfaces_mapped(self) -> None: + manifest = _make_helm_manifest() + result = build_app_registration_translation(manifest, APP_ID, "t", "p") + assert len(result.component_spec) == 1 + assert result.component_spec[0].component_name == "nginx_server" + ni = result.component_spec[0].network_interfaces[0] + assert ni.interface_id == "eth0" + assert ni.port == 80 + assert ni.visibility_type == "VISIBILITY_EXTERNAL" + + def test_private_repo_uses_secret_ref(self) -> None: + manifest = AppManifest( + name="myapp", + appProvider="acme_provider", + version="1.0", + packageType="HELM", + appRepo=AppRepo( + type="PRIVATEREPO", + imagePath="oci://private.registry.com/app:1.0", + userName="user", + credentials="plain-token", + authType="DOCKER", + ), + requiredResources=KubernetesResources( + infraKind="kubernetes", + applicationResources=ApplicationResources.model_validate( + { + "cpuPool": { + "numCPU": 2, + "memory": 4096, + "topology": { + "minNumberOfNodes": 1, + "minNodeCpu": 1, + "minNodeMemory": 1024, + }, + } + } + ), + isStandalone=False, + ), + componentSpec=[], + ) + result = build_app_registration_translation(manifest, APP_ID, "t", "p") + assert result.app_repo.credentials == f"secret://oeg/{APP_ID}/repo-credentials" + assert result.app_repo.user_name == "user" + + def test_public_repo_no_credentials(self) -> None: + manifest = AppManifest( + name="myapp", + appProvider="acme_provider", + version="1.0", + packageType="HELM", + appRepo=AppRepo(type="PUBLICREPO", imagePath="oci://pub.registry.com/app:1.0"), + requiredResources=KubernetesResources( + infraKind="kubernetes", + applicationResources=ApplicationResources.model_validate( + { + "cpuPool": { + "numCPU": 2, + "memory": 4096, + "topology": { + "minNumberOfNodes": 1, + "minNodeCpu": 1, + "minNodeMemory": 1024, + }, + } + } + ), + isStandalone=False, + ), + componentSpec=[], + ) + result = build_app_registration_translation(manifest, APP_ID, "t", "p") + assert result.app_repo.credentials is None + + def test_gpu_pool_extracted(self) -> None: + manifest = AppManifest( + appId=APP_ID, + name="myvideoapp", + version="1.0.0", + appProvider="VideoAppsCo", + packageType="HELM", + appRepo=AppRepo(type="PUBLICREPO", imagePath="oci://pub.registry.com/app:1.0"), + requiredResources=KubernetesResources( + infraKind="kubernetes", + applicationResources=ApplicationResources.model_validate( + { + "gpuPool": { + "numCPU": 2, + "memory": 2048, + "gpuMemory": 16, + "topology": { + "minNumberOfNodes": 2, + "minNodeCpu": 1, + "minNodeMemory": 1024, + "minNodeGpuMemory": 16, + }, + } + } + ), + isStandalone=False, + ), + componentSpec=[], + ) + result = build_app_registration_translation(manifest, APP_ID, "t", "p") + assert result.required_resources is not None + assert result.required_resources.application_resources is not None + gpu_pool = result.required_resources.application_resources.gpu_pool + assert gpu_pool is not None + assert gpu_pool.num_cpu == 2 + assert gpu_pool.memory == 2048 + assert gpu_pool.gpu_memory == 16 + assert gpu_pool.num_gpu == 1 + assert gpu_pool.topology is not None + assert gpu_pool.topology.min_number_of_nodes == 2 + assert gpu_pool.topology.min_node_cpu == 1 + assert gpu_pool.topology.min_node_memory == 1024 + assert gpu_pool.topology.min_node_gpu_memory == 16 + + +class TestBuildCatalogPayload: + def test_runtime_kind_from_package_type(self) -> None: + manifest = _make_helm_manifest() + translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + catalog = build_catalog_payload(translation) + unit = catalog.service_deployment_units[0] + assert unit.runtime_kind == "helm" + + def test_cpu_converted_to_millicores(self) -> None: + manifest = _make_helm_manifest(cpu=2, memory=4096) + translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + catalog = build_catalog_payload(translation) + compute = catalog.service_deployment_units[0].resource_requirements.compute + assert compute is not None + assert compute.cpu_millicores == 2000 + assert compute.memory_mb == 4096 + + def test_spec_ref_is_app_id(self) -> None: + manifest = _make_helm_manifest() + translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + catalog = build_catalog_payload(translation) + assert catalog.service_specification.ref == str(APP_ID) + + def test_spec_id_is_app_id(self) -> None: + """service_specification.id must carry app_id so SRM adopts it as the + specification PK, making service_specification_id == app_id (ADR-0011).""" + manifest = _make_helm_manifest() + translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + catalog = build_catalog_payload(translation) + assert catalog.service_specification.id == str(APP_ID) + + def test_interfaces_visibility_mapped(self) -> None: + manifest = _make_helm_manifest() + translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + catalog = build_catalog_payload(translation) + ifaces = catalog.service_deployment_units[0].resource_requirements.interfaces + assert ifaces is not None + assert ifaces[0].visibility == "external" + + def test_capability_requirement_present(self) -> None: + manifest = _make_helm_manifest() + translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + catalog = build_catalog_payload(translation) + assert len(catalog.service_capability_requirements) == 1 + req = catalog.service_capability_requirements[0] + assert req.capability_kind == "deploy_workload" + + def test_gpu_pool_maps_to_compute_and_accelerator(self) -> None: + manifest = AppManifest( + appId=APP_ID, + name="myvideoapp", + version="1.0.0", + appProvider="VideoAppsCo", + packageType="HELM", + appRepo=AppRepo(type="PUBLICREPO", imagePath="oci://pub.registry.com/app:1.0"), + requiredResources=KubernetesResources( + infraKind="kubernetes", + applicationResources=ApplicationResources.model_validate( + { + "gpuPool": { + "numCPU": 2, + "memory": 2048, + "gpuMemory": 16, + "topology": { + "minNumberOfNodes": 2, + "minNodeCpu": 1, + "minNodeMemory": 1024, + "minNodeGpuMemory": 16, + }, + } + } + ), + isStandalone=False, + ), + componentSpec=[], + ) + translation = build_app_registration_translation(manifest, APP_ID, "t", "p") + catalog = build_catalog_payload(translation) + resource_requirements = catalog.service_deployment_units[0].resource_requirements + compute = resource_requirements.compute + assert compute is not None + assert compute.cpu_millicores == 2000 + assert compute.memory_mb == 2048 + assert compute.accelerator is not None + assert compute.accelerator.type == "gpu" + assert compute.accelerator.units == 1 + assert compute.accelerator.memory_mb == 16 * 1024 + + topology = resource_requirements.topology + assert topology is not None + assert topology.min_nodes == 2 + assert topology.min_node_cpu_millicores == 1000 + assert topology.min_node_memory_mb == 1024 + assert topology.min_node_gpu_memory_mb == 16 * 1024 + + +class TestBuildDeployCommand: + def test_fields_set_correctly(self) -> None: + request = CreateAppInstanceRequest( + name="myapp_inst", + appId=APP_ID, + edgeCloudZoneId=ZONE_ID, + ) + translation = build_app_deployment_translation( + request, + OP_ID, + INSTANCE_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + correlation_id="corr-456", + ) + cmd = build_deploy_command(translation, "2026-07-02T12:00:00Z") + assert cmd.service_specification_id == str(APP_ID) + # POST /appinstances always carries exactly one targets[] entry (ADR-0005). + assert len(cmd.targets) == 1 + assert cmd.targets[0].app_instance_id == str(INSTANCE_ID) + assert cmd.targets[0].resource_zone_id == str(ZONE_ID) + assert cmd.operation_id == str(OP_ID) + assert cmd.correlation_id == "corr-456" + assert cmd.deploy.instance_name == "myapp_inst" + assert cmd.source == "nbi_camara" + # object, not null, per srm/interface-contract.md §B.2 — fixed in v1. + assert cmd.deploy.placement_constraints == {} diff --git a/open_exposure_gateway/test/unit/test_eam_service.py b/open_exposure_gateway/test/unit/test_eam_service.py new file mode 100644 index 0000000000000000000000000000000000000000..ef8ab0203368a0fcc3d28dad51504dd37bbe559f --- /dev/null +++ b/open_exposure_gateway/test/unit/test_eam_service.py @@ -0,0 +1,405 @@ +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest + +from open_exposure_gateway.app.api.camara.edge_application_management.vwip.schemas import ( + AppInstanceStatus, + ApplicationResources, + AppManifest, + AppRepo, + CreateAppInstanceRequest, + KubernetesResources, + VmResources, +) +from open_exposure_gateway.app.application.services.edge_application_management_service import ( + EdgeApplicationManagementService, +) +from open_exposure_gateway.app.core.exceptions import ( + DownstreamServiceException, + NotImplementedException, +) +from open_exposure_gateway.app.domain.edge_application_management import ( + ResourceZone, + SRMCapabilityRequirement, + SRMCatalogPayload, + SRMCatalogServiceSpecificationCreated, + SRMComputeIntent, + SRMComputeResources, + SRMDeploymentUnit, + SRMDeploymentUnitMetadata, + SRMRepoMetadata, + SRMServiceInstance, + SRMServiceSpecDescriptor, + SRMServiceSpecEntry, + SRMTopologyConstraints, + Subject, +) + +APP_ID = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") +ZONE_ID = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") +INSTANCE_ID = UUID("cccccccc-cccc-cccc-cccc-cccccccccccc") + + +def _make_srm_catalog() -> SRMCatalogPayload: + return SRMCatalogPayload( + service_specification=SRMServiceSpecEntry( + id=str(APP_ID), + ref=str(APP_ID), + name="myvideoapp", + version="1.0.0", + app_provider_id="VideoAppsCo", + descriptor=SRMServiceSpecDescriptor(artifact_type="helm"), + ), + service_deployment_units=[ + SRMDeploymentUnit( + ref="main-runtime", + name="Main Runtime", + runtime_kind="helm", + artifact_ref="oci://registry.example.com/charts/app:1.0", + metadata=SRMDeploymentUnitMetadata(repo=SRMRepoMetadata(type="PUBLICREPO")), + resource_requirements=SRMComputeIntent( + compute=SRMComputeResources(cpu_millicores=2000, memory_mb=4096), + topology=SRMTopologyConstraints( + min_nodes=1, + min_node_cpu_millicores=1000, + min_node_memory_mb=1024, + ), + ), + ) + ], + service_capability_requirements=[ + SRMCapabilityRequirement( + ref="require-workload-deployment", + deployment_unit_ref="main-runtime", + capability_kind="deploy_workload", + domain_kind="compute", + ) + ], + ) + + +def _make_srm_instance(state: str = "active") -> SRMServiceInstance: + return SRMServiceInstance( + service_instance_id=str(INSTANCE_ID), + service_specification_id=str(APP_ID), + state=state, + app_provider_id="VideoAppsCo", + resource_zone_id=str(ZONE_ID), + name="myvideoapp_inst", + capability_instances=[], + ) + + +def _make_manifest() -> AppManifest: + return AppManifest( + appId=APP_ID, + name="myvideoapp", + version="1.0.0", + appProvider="VideoAppsCo", + packageType="HELM", + appRepo=AppRepo(type="PUBLICREPO", imagePath="oci://registry.example.com/charts/app:1.0"), + requiredResources=KubernetesResources( + infraKind="kubernetes", + applicationResources=ApplicationResources.model_validate( + { + "cpuPool": { + "numCPU": 2, + "memory": 4096, + "topology": { + "minNumberOfNodes": 1, + "minNodeCpu": 1, + "minNodeMemory": 1024, + }, + } + } + ), + isStandalone=False, + ), + componentSpec=[], + ) + + +@pytest.fixture() +def srm_client() -> AsyncMock: + client = AsyncMock() + client.create_catalog_service_specification.return_value = ( + SRMCatalogServiceSpecificationCreated(id=APP_ID) + ) + return client + + +@pytest.fixture() +def publisher() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture() +def service(srm_client: AsyncMock, publisher: AsyncMock) -> EdgeApplicationManagementService: + return EdgeApplicationManagementService(srm_client=srm_client, publisher=publisher) + + +class TestGetEdgeCloudZones: + async def test_returns_mapped_camara_zones( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + srm_client.get_resource_zones.return_value = [ + ResourceZone( + resource_zone_id=str(ZONE_ID), + name="berlin-edge-1", + status="active", + provider="acme", + ) + ] + result = await service.get_edge_cloud_zones() + assert len(result) == 1 + assert result[0].edgeCloudZoneId == ZONE_ID + assert result[0].edgeCloudZoneName == "berlin-edge-1" + + async def test_passes_filters_to_srm( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + srm_client.get_resource_zones.return_value = [] + await service.get_edge_cloud_zones(region="eu-west", status="active", x_correlator="c-1") + srm_client.get_resource_zones.assert_called_once_with( + region="eu-west", status="active", x_correlator="c-1" + ) + + +class TestGetApps: + async def test_returns_camara_app_manifests( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + srm_client.get_apps.return_value = [_make_srm_catalog()] + result = await service.get_apps() + assert len(result) == 1 + assert result[0].appId == APP_ID + assert result[0].name == "myvideoapp" + assert result[0].packageType == "HELM" + + async def test_empty_list_returns_empty( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + srm_client.get_apps.return_value = [] + result = await service.get_apps() + assert result == [] + + +class TestGetApp: + async def test_returns_app_manifest_envelope( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + srm_client.get_app.return_value = _make_srm_catalog() + result = await service.get_app(app_id=APP_ID) + assert result.appManifest.appId == APP_ID + assert result.appManifest.name == "myvideoapp" + + async def test_passes_app_id_and_correlator( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + srm_client.get_app.return_value = _make_srm_catalog() + await service.get_app(app_id=APP_ID, x_correlator="corr-1") + srm_client.get_app.assert_called_once_with(app_id=APP_ID, x_correlator="corr-1") + + +class TestSubmitApp: + async def test_returns_submitted_app_with_id( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + result = await service.submit_app( + manifest=_make_manifest(), + app_id=APP_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + assert result.appId == APP_ID + + async def test_calls_srm_catalog_endpoint( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + await service.submit_app( + manifest=_make_manifest(), + app_id=APP_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + srm_client.create_catalog_service_specification.assert_called_once() + + async def test_catalog_payload_contains_app_id( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + await service.submit_app( + manifest=_make_manifest(), + app_id=APP_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + call_kwargs = srm_client.create_catalog_service_specification.call_args.kwargs + payload = call_kwargs["payload"] + assert payload["service_specification"]["ref"] == str(APP_ID) + # id must also carry app_id: SRM adopts it as the specification PK, + # making service_specification_id == app_id (ADR-0011). + assert payload["service_specification"]["id"] == str(APP_ID) + + async def test_raises_when_srm_confirms_a_different_id( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + srm_client.create_catalog_service_specification.return_value = ( + SRMCatalogServiceSpecificationCreated(id=UUID(int=APP_ID.int + 1)) + ) + with pytest.raises(DownstreamServiceException): + await service.submit_app( + manifest=_make_manifest(), + app_id=APP_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + + +class TestSubmitAppRejectsUnsupportedVariants: + """ADR-0009: schema-valid but unfulfilled AppManifest variants must be + rejected with 501, not accepted into the catalog or left to fail at deploy + time.""" + + @pytest.mark.parametrize("package_type", ["QCOW2", "OVA", "CSAR"]) + async def test_unsupported_package_type_raises_not_implemented( + self, + service: EdgeApplicationManagementService, + srm_client: AsyncMock, + package_type: str, + ) -> None: + manifest = _make_manifest().model_copy(update={"packageType": package_type}) + with pytest.raises(NotImplementedException): + await service.submit_app( + manifest=manifest, + app_id=APP_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + srm_client.create_catalog_service_specification.assert_not_called() + + async def test_unsupported_infra_kind_raises_not_implemented( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + manifest = _make_manifest().model_copy( + update={ + "requiredResources": VmResources(infraKind="virtualMachine", numCPU=2, memory=4096) + } + ) + with pytest.raises(NotImplementedException): + await service.submit_app( + manifest=manifest, + app_id=APP_ID, + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + srm_client.create_catalog_service_specification.assert_not_called() + + +class TestDeleteApp: + async def test_delegates_to_srm_client( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + await service.delete_app( + app_id=APP_ID, app_provider_id="VideoAppsCo", x_correlator="corr-1" + ) + srm_client.delete_app.assert_called_once_with(app_id=APP_ID, x_correlator="corr-1") + + +class TestCreateAppInstance: + def _make_request(self) -> CreateAppInstanceRequest: + return CreateAppInstanceRequest( + name="myapp_inst", + appId=APP_ID, + edgeCloudZoneId=ZONE_ID, + ) + + async def test_publishes_deploy_command( + self, service: EdgeApplicationManagementService, publisher: AsyncMock + ) -> None: + await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + publisher.publish.assert_called_once() + subject, payload = publisher.publish.call_args.args + assert subject == Subject.TASK_DEPLOY + + async def test_returns_instantiating_status( + self, service: EdgeApplicationManagementService, publisher: AsyncMock + ) -> None: + result = await service.create_app_instance( + request=self._make_request(), + tenant_id="tenant-1", + app_provider_id="provider-1", + ) + assert result.status == AppInstanceStatus.INSTANTIATING + assert result.appId == APP_ID + assert result.edgeCloudZoneId == ZONE_ID + + async def test_raises_when_publisher_unavailable(self, srm_client: AsyncMock) -> None: + service = EdgeApplicationManagementService(srm_client=srm_client, publisher=None) + with pytest.raises(RuntimeError, match="DataBus publisher is not available"): + await service.create_app_instance( + request=self._make_request(), tenant_id="t", app_provider_id="p" + ) + + async def test_wraps_publish_error( + self, service: EdgeApplicationManagementService, publisher: AsyncMock + ) -> None: + publisher.publish.side_effect = Exception("NATS down") + with pytest.raises(DownstreamServiceException, match="deployment"): + await service.create_app_instance( + request=self._make_request(), tenant_id="t", app_provider_id="p" + ) + + +class TestGetAppInstances: + async def test_returns_mapped_instances( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + srm_client.get_app_instances.return_value = [_make_srm_instance(state="active")] + result = await service.get_app_instances() + assert len(result) == 1 + assert result[0].appInstanceId == INSTANCE_ID + assert result[0].status == AppInstanceStatus.READY + + async def test_passes_filters_to_srm( + self, service: EdgeApplicationManagementService, srm_client: AsyncMock + ) -> None: + srm_client.get_app_instances.return_value = [] + await service.get_app_instances(app_id=APP_ID, app_instance_id=INSTANCE_ID) + srm_client.get_app_instances.assert_called_once_with( + app_id=APP_ID, + app_instance_id=INSTANCE_ID, + region=None, + x_correlator=None, + ) + + +class TestDeleteAppInstance: + async def test_publishes_terminate_command( + self, service: EdgeApplicationManagementService, publisher: AsyncMock + ) -> None: + await service.delete_app_instance( + app_instance_id=INSTANCE_ID, app_provider_id="VideoAppsCo", x_correlator="corr-1" + ) + publisher.publish.assert_called_once() + subject, payload = publisher.publish.call_args.args + assert subject == Subject.TASK_TERMINATE + assert payload["service_instance_id"] == str(INSTANCE_ID) + assert payload["app_provider_id"] == "VideoAppsCo" + assert payload["correlation_id"] == "corr-1" + + async def test_raises_when_publisher_unavailable(self, srm_client: AsyncMock) -> None: + service = EdgeApplicationManagementService(srm_client=srm_client, publisher=None) + with pytest.raises(RuntimeError, match="DataBus publisher is not available"): + await service.delete_app_instance(app_instance_id=INSTANCE_ID, app_provider_id="p") + + async def test_wraps_publish_error( + self, service: EdgeApplicationManagementService, publisher: AsyncMock + ) -> None: + publisher.publish.side_effect = Exception("NATS down") + with pytest.raises(DownstreamServiceException, match="termination"): + await service.delete_app_instance(app_instance_id=INSTANCE_ID, app_provider_id="p") diff --git a/open_exposure_gateway/test/unit/test_in_memory_repositories.py b/open_exposure_gateway/test/unit/test_in_memory_repositories.py new file mode 100644 index 0000000000000000000000000000000000000000..dbbe024ae282d1a25d9d33da6f6d970fd8efee5e --- /dev/null +++ b/open_exposure_gateway/test/unit/test_in_memory_repositories.py @@ -0,0 +1,245 @@ +"""Contract tests for the in-memory repository fakes (test.unit.fakes). + +These pin the behaviour application-layer tests will rely on, and double as a +reference for the persistence contract the SQL adapter is expected to meet. +""" + +from uuid import UUID, uuid4 + +import pytest + +from open_exposure_gateway.app.adapters.errors import ( + DuplicateAppRegistrationError, + DuplicateOperationError, +) +from open_exposure_gateway.app.domain.models import ( + AppInstance, + AppInstanceState, + AppRegistration, + AppRegistrationStatus, + CallbackDelivery, + CallbackRegistration, + Operation, + OperationStatus, + OperationType, + PackageType, +) +from open_exposure_gateway.test.unit.fakes import ( + FakeAppInstanceRepository, + FakeAppRegistrationRepository, + FakeCallbackDeliveryRepository, + FakeCallbackRegistrationRepository, + FakeOperationRepository, +) + + +def _app_registration(app_id: UUID | None = None) -> AppRegistration: + return AppRegistration( + app_registration_id=uuid4(), + app_id=app_id if app_id is not None else uuid4(), + tenant_id="tenant-a", + name="video-analytics", + version="1.2.3", + package_type=PackageType.CONTAINER, + status=AppRegistrationStatus.REGISTERED, + ) + + +def _operation(idempotency_key: str | None = None) -> Operation: + return Operation( + operation_id=uuid4(), + correlation_id=str(uuid4()), + tenant_id="tenant-a", + app_provider_id="tenant-a", + operation_type=OperationType.DEPLOY, + status=OperationStatus.PENDING, + subject="command.srm.service.deploy", + idempotency_key=idempotency_key, + ) + + +def _app_instance() -> AppInstance: + return AppInstance( + app_instance_id=uuid4(), + operation_id=uuid4(), + app_registration_id=uuid4(), + edge_cloud_zone_id=uuid4(), + state=AppInstanceState.INSTANTIATING, + ) + + +def _callback_registration(operation_id: UUID | None = None) -> CallbackRegistration: + return CallbackRegistration( + id=uuid4(), + operation_id=operation_id if operation_id is not None else uuid4(), + tenant_id="tenant-a", + api_family="edge-application-management", + sink="https://example.com/callback", + event_types=["org.camara.eam.appinstance.status-changed"], + ) + + +def _callback_delivery(callback_registration_id: UUID | None = None) -> CallbackDelivery: + return CallbackDelivery( + id=uuid4(), + callback_registration_id=( + callback_registration_id if callback_registration_id is not None else uuid4() + ), + operation_id=uuid4(), + attempt=1, + state="delivered", + ) + + +class TestFakeAppRegistrationRepository: + async def test_save_round_trips_by_id(self) -> None: + repo = FakeAppRegistrationRepository() + + saved = await repo.save(_app_registration()) + + assert await repo.get_by_id(saved.app_registration_id) == saved + + async def test_get_by_app_id(self) -> None: + repo = FakeAppRegistrationRepository() + saved = await repo.save(_app_registration()) + + assert await repo.get_by_app_id(saved.app_id) == saved + + async def test_get_by_id_returns_none_for_unknown(self) -> None: + assert await FakeAppRegistrationRepository().get_by_id(uuid4()) is None + + async def test_duplicate_app_id_is_rejected(self) -> None: + repo = FakeAppRegistrationRepository() + first = _app_registration() + duplicate = _app_registration(app_id=first.app_id) + + await repo.save(first) + with pytest.raises(DuplicateAppRegistrationError): + await repo.save(duplicate) + + async def test_returned_objects_are_isolated_copies(self) -> None: + repo = FakeAppRegistrationRepository() + saved = await repo.save(_app_registration()) + + saved.name = "mutated after save" + + reloaded = await repo.get_by_id(saved.app_registration_id) + assert reloaded is not None + assert reloaded.name == "video-analytics" + + +class TestFakeOperationRepository: + async def test_save_round_trips_by_id(self) -> None: + repo = FakeOperationRepository() + + saved = await repo.save(_operation()) + + assert await repo.get_by_id(saved.operation_id) == saved + + async def test_get_by_idempotency_key(self) -> None: + repo = FakeOperationRepository() + saved = await repo.save(_operation(idempotency_key="idem-1")) + + assert await repo.get_by_idempotency_key(saved.tenant_id, "idem-1") == saved + + async def test_get_by_idempotency_key_returns_none_for_unknown(self) -> None: + repo = FakeOperationRepository() + await repo.save(_operation(idempotency_key="idem-1")) + + assert await repo.get_by_idempotency_key("tenant-a", "idem-2") is None + + async def test_duplicate_idempotency_key_is_rejected(self) -> None: + repo = FakeOperationRepository() + + await repo.save(_operation(idempotency_key="idem-1")) + with pytest.raises(DuplicateOperationError): + await repo.save(_operation(idempotency_key="idem-1")) + + async def test_null_idempotency_keys_do_not_collide(self) -> None: + repo = FakeOperationRepository() + + await repo.save(_operation(idempotency_key=None)) + await repo.save(_operation(idempotency_key=None)) + + async def test_returned_objects_are_isolated_copies(self) -> None: + repo = FakeOperationRepository() + saved = await repo.save(_operation()) + + saved.status = OperationStatus.COMPLETED + + reloaded = await repo.get_by_id(saved.operation_id) + assert reloaded is not None + assert reloaded.status == OperationStatus.PENDING + + +class TestFakeAppInstanceRepository: + async def test_save_round_trips_by_id(self) -> None: + repo = FakeAppInstanceRepository() + + saved = await repo.save(_app_instance()) + + assert await repo.get_by_id(saved.app_instance_id) == saved + + async def test_get_by_id_returns_none_for_unknown(self) -> None: + assert await FakeAppInstanceRepository().get_by_id(uuid4()) is None + + async def test_returned_objects_are_isolated_copies(self) -> None: + repo = FakeAppInstanceRepository() + saved = await repo.save(_app_instance()) + + saved.state = AppInstanceState.READY + + reloaded = await repo.get_by_id(saved.app_instance_id) + assert reloaded is not None + assert reloaded.state == AppInstanceState.INSTANTIATING + + +class TestFakeCallbackRegistrationRepository: + async def test_save_round_trips_by_operation_id(self) -> None: + repo = FakeCallbackRegistrationRepository() + + saved = await repo.save(_callback_registration()) + + assert await repo.get_by_operation_id(saved.operation_id) == saved + + async def test_get_by_operation_id_returns_none_for_unknown(self) -> None: + assert await FakeCallbackRegistrationRepository().get_by_operation_id(uuid4()) is None + + async def test_returned_objects_are_isolated_copies(self) -> None: + repo = FakeCallbackRegistrationRepository() + saved = await repo.save(_callback_registration()) + + saved.is_active = False + + reloaded = await repo.get_by_operation_id(saved.operation_id) + assert reloaded is not None + assert reloaded.is_active is True + + +class TestFakeCallbackDeliveryRepository: + async def test_save_and_lists_by_callback_registration_id(self) -> None: + repo = FakeCallbackDeliveryRepository() + callback_registration_id = uuid4() + + first = await repo.save(_callback_delivery(callback_registration_id)) + second = await repo.save(_callback_delivery(callback_registration_id)) + await repo.save(_callback_delivery()) # different registration, must not appear + + deliveries = await repo.list_by_callback_registration_id(callback_registration_id) + + assert {d.id for d in deliveries} == {first.id, second.id} + + async def test_list_returns_empty_for_unknown_registration(self) -> None: + assert ( + await FakeCallbackDeliveryRepository().list_by_callback_registration_id(uuid4()) == [] + ) + + async def test_returned_objects_are_isolated_copies(self) -> None: + repo = FakeCallbackDeliveryRepository() + callback_registration_id = uuid4() + saved = await repo.save(_callback_delivery(callback_registration_id)) + + saved.state = "failed" + + [reloaded] = await repo.list_by_callback_registration_id(callback_registration_id) + assert reloaded.state == "delivered" diff --git a/open_exposure_gateway/test/unit/test_nats_adapter.py b/open_exposure_gateway/test/unit/test_nats_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..a478ea9572f27715400b6697c41a17628d71608a --- /dev/null +++ b/open_exposure_gateway/test/unit/test_nats_adapter.py @@ -0,0 +1,156 @@ +import json +from dataclasses import dataclass, field +from unittest.mock import AsyncMock, patch + +import pytest + +from open_exposure_gateway.app.adapters.databus.nats_adapter import ( + NatsMessagePublisher, + NatsOperationConsumer, +) +from open_exposure_gateway.app.core.config import NatsSettings + + +@pytest.fixture +def settings() -> NatsSettings: + return NatsSettings( + url="nats://broker:4222", + connect_timeout=3, + max_reconnect_attempts=2, + ) + + +async def test_connect_uses_settings(settings: NatsSettings) -> None: + mock_client = AsyncMock() + with patch( + "open_exposure_gateway.app.adapters.databus.nats_adapter.nats.connect", + new_callable=AsyncMock, + return_value=mock_client, + ) as connect: + publisher = NatsMessagePublisher(settings) + await publisher.connect() + + call_kwargs = connect.call_args.kwargs + assert call_kwargs["servers"] == ["nats://broker:4222"] + assert call_kwargs["connect_timeout"] == 3 + assert call_kwargs["max_reconnect_attempts"] == 2 + assert callable(call_kwargs["error_cb"]) + assert callable(call_kwargs["disconnected_cb"]) + assert callable(call_kwargs["reconnected_cb"]) + + +async def test_is_connected_false_when_no_client(settings: NatsSettings) -> None: + publisher = NatsMessagePublisher(settings) + assert publisher.is_connected is False + + +async def test_is_connected_delegates_to_nats_client(settings: NatsSettings) -> None: + mock_client = AsyncMock() + publisher = NatsMessagePublisher(settings) + publisher._client = mock_client + + mock_client.is_connected = True + assert publisher.is_connected is True + + mock_client.is_connected = False + assert publisher.is_connected is False + + +async def test_publish_serializes_json_payload(settings: NatsSettings) -> None: + mock_client = AsyncMock() + publisher = NatsMessagePublisher(settings) + publisher._client = mock_client + + await publisher.publish("events.test", {"key": "value"}) + + mock_client.publish.assert_awaited_once_with( + "events.test", + json.dumps({"key": "value"}).encode("utf-8"), + headers=None, + ) + + +async def test_publish_passes_headers(settings: NatsSettings) -> None: + mock_client = AsyncMock() + publisher = NatsMessagePublisher(settings) + publisher._client = mock_client + + await publisher.publish( + "events.test", + {"key": "value"}, + headers={"x-request-id": "req-1"}, + ) + + mock_client.publish.assert_awaited_once_with( + "events.test", + json.dumps({"key": "value"}).encode("utf-8"), + headers={"x-request-id": "req-1"}, + ) + + +async def test_publish_rejects_non_json_serializable_payload(settings: NatsSettings) -> None: + mock_client = AsyncMock() + publisher = NatsMessagePublisher(settings) + publisher._client = mock_client + + with pytest.raises(TypeError): + await publisher.publish("events.test", {"created_at": object()}) + + mock_client.publish.assert_not_awaited() + + +async def test_publish_requires_connected_client(settings: NatsSettings) -> None: + publisher = NatsMessagePublisher(settings) + + with pytest.raises(RuntimeError, match="not connected"): + await publisher.publish("events.test", {"key": "value"}) + + +async def test_close_drains_and_closes_client(settings: NatsSettings) -> None: + mock_client = AsyncMock() + publisher = NatsMessagePublisher(settings) + publisher._client = mock_client + + await publisher.close() + + mock_client.drain.assert_awaited_once() + mock_client.close.assert_awaited_once() + assert publisher._client is None + + +async def test_close_when_not_connected_is_noop(settings: NatsSettings) -> None: + publisher = NatsMessagePublisher(settings) + + await publisher.close() + + +@dataclass +class Msg: + data: bytes + subject: str = field(default="operation.completed") + + +def make_consumer() -> NatsOperationConsumer: + return NatsOperationConsumer( + client=AsyncMock(), + subject="operation.completed", + ) + + +async def test_invalid_json_is_ignored() -> None: + consumer = make_consumer() + + await consumer._handle_message(Msg(data=b"not json")) + + +async def test_unicode_decode_error_is_ignored() -> None: + consumer = make_consumer() + + await consumer._handle_message(Msg(data=b"\xff\xfe")) + + +async def test_valid_message_is_handled() -> None: + consumer = make_consumer() + payload = {"status": "completed", "app_instance_id": "abc-123"} + + await consumer._handle_message(Msg(data=json.dumps(payload).encode())) diff --git a/open_exposure_gateway/test/unit/test_operation_repo.py b/open_exposure_gateway/test/unit/test_operation_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..9b01c3cce77ba373ad23eb32240cf4921b613175 --- /dev/null +++ b/open_exposure_gateway/test/unit/test_operation_repo.py @@ -0,0 +1,94 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from open_exposure_gateway.app.adapters.database.repos.operations import SqlOperationRepository +from open_exposure_gateway.app.adapters.database.sql import OperationRow +from open_exposure_gateway.app.adapters.errors import DuplicateOperationError +from open_exposure_gateway.app.domain.models import Operation, OperationStatus, OperationType + + +def _row() -> OperationRow: + return OperationRow( + operation_id=uuid4(), + correlation_id=str(uuid4()), + tenant_id="tenant-a", + app_provider_id="tenant-a", + operation_type=OperationType.DEPLOY, + status=OperationStatus.PENDING, + subject="command.srm.service.deploy", + operation_metadata={}, + ) + + +def _domain() -> Operation: + return Operation( + operation_id=uuid4(), + correlation_id=str(uuid4()), + tenant_id="tenant-a", + app_provider_id="tenant-a", + operation_type=OperationType.DEPLOY, + status=OperationStatus.PENDING, + subject="command.srm.service.deploy", + idempotency_key="idem-1", + ) + + +async def test_get_by_idempotency_key_returns_mapped_operation() -> None: + row = _row() + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = row + repo = SqlOperationRepository(session) + + result = await repo.get_by_idempotency_key(row.tenant_id, "idem-1") + + assert result is not None + assert result.operation_id == row.operation_id + session.scalar.assert_awaited_once() + + +async def test_save_flushes_and_reloads_operation() -> None: + domain = _domain() + expected = _domain() + expected.operation_id = domain.operation_id + session = AsyncMock(spec=AsyncSession) + merged = _row() + merged.operation_id = domain.operation_id + session.merge.return_value = merged + repo = SqlOperationRepository(session) + repo.get_by_id = AsyncMock(return_value=expected) # type: ignore[method-assign] + + result = await repo.save(domain) + + assert result == expected + session.merge.assert_awaited_once() + session.flush.assert_awaited_once() + session.commit.assert_not_awaited() + repo.get_by_id.assert_awaited_once_with(domain.operation_id) + + +async def test_save_raises_duplicate_operation_error_on_unique_violation() -> None: + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _row() + orig = Exception("duplicate key value violates unique constraint") + orig.sqlstate = "23505" # type: ignore[attr-defined] + session.flush.side_effect = IntegrityError("duplicate", params=None, orig=orig) + repo = SqlOperationRepository(session) + + with pytest.raises(DuplicateOperationError): + await repo.save(_domain()) + + +async def test_save_propagates_non_unique_integrity_errors() -> None: + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _row() + orig = Exception("null value in column violates not-null constraint") + orig.sqlstate = "23502" # type: ignore[attr-defined] # not_null_violation + session.flush.side_effect = IntegrityError("insert", params=None, orig=orig) + repo = SqlOperationRepository(session) + + with pytest.raises(IntegrityError): + await repo.save(_domain()) diff --git a/open_exposure_gateway/test/unit/test_platform_flows.py b/open_exposure_gateway/test/unit/test_platform_flows.py new file mode 100644 index 0000000000000000000000000000000000000000..1ed493aa80ff492c9d51795d5427f2546a2076ed --- /dev/null +++ b/open_exposure_gateway/test/unit/test_platform_flows.py @@ -0,0 +1,29 @@ +"""Flow tests for the platform endpoints (health/readiness).""" + +from fastapi.testclient import TestClient + +from open_exposure_gateway.test.unit.fakes import FakeDataBus + + +class TestReadyzFlow: + def test_ready_when_databus_connected( + self, api_client: TestClient, fake_bus: FakeDataBus + ) -> None: + response = api_client.get("/platform/readyz") + assert response.status_code == 200 + + def test_unavailable_when_databus_disconnected( + self, api_client: TestClient, fake_bus: FakeDataBus + ) -> None: + fake_bus.connected = False + response = api_client.get("/platform/readyz") + assert response.status_code == 503 + + +class TestHealthzFlow: + def test_liveness_does_not_depend_on_downstreams( + self, api_client: TestClient, fake_bus: FakeDataBus + ) -> None: + fake_bus.connected = False + response = api_client.get("/platform/healthz") + assert response.status_code == 200 diff --git a/open_exposure_gateway/test/unit/test_qod_flows.py b/open_exposure_gateway/test/unit/test_qod_flows.py new file mode 100644 index 0000000000000000000000000000000000000000..6e4355e8bb79cddf4f167301f6410617c9aab802 --- /dev/null +++ b/open_exposure_gateway/test/unit/test_qod_flows.py @@ -0,0 +1,53 @@ +"""End-to-end flow tests for Quality on Demand. + +Same setup as test_eam_flows.py: real HTTP API against the in-memory FakeSRMClient. +A failing test here means a real hole in the flow, not a broken test. +""" + +from typing import Any + +from fastapi.testclient import TestClient + +from open_exposure_gateway.app.api.camara.quality_on_demand.v0_10_1.router import ( + BASE_PATH as QOD_BASE, +) +from open_exposure_gateway.test.unit.fakes import FakeSRMClient + +SESSION_BODY: dict[str, Any] = { + "device": {"ipv4Address": {"publicAddress": "84.125.93.10"}}, + "applicationServer": {"ipv4Address": "192.168.0.1"}, + "qosProfile": "QOS_E", + "duration": 3600, +} + + +class TestQodSessionFlow: + def test_create_session_returns_201_and_reserves_downstream( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> 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 + + def test_get_session_returns_created_session( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> 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 + assert response.json()["sessionId"] == session_id + + def test_delete_session_releases_the_downstream_reservation( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + """DELETE must actually tear the session down at SRM: a 204 with the + reservation still alive downstream silently leaks QoS resources.""" + 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 session_id not in fake_srm.qod_sessions diff --git a/pyproject.toml b/pyproject.toml index 743897ce99e981079f923acbd18aa68999872576..b193be3d43b096c6263e63dfaa7a31856f967b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,68 +1,75 @@ [project] -name = "edge-cloud-management-api" -version = "0.1.0" -description = """The SUNRISE-6G edge cloud management api CAMARA implementation enables Application Providers to manage the Life Cycle of Edge Deployed Applications and Edge Cloud Zones discovery.""" -readme = "README.md" +name = "open-exposure-gateway" +version = "1.0.0" +description="Open Exposure Gateway microservice" +readme="README.md" authors = [ - { name = "Karagkounis Dimitris", email = "dkaragkounis@intracom-telecom.com" }, + {name="Dimitris Gogos", email="dgogos@intracom-telecom.com"}, + {name="Paris Stentoumis", email="stentps@intracom-telecom.com"}, + {name= "George Papathanail", email="gpapathan@intracom-telecom.com"}, ] -requires-python = ">=3.12" +license="LicenseRef-My-License" +requires-python = "==3.12.*" dependencies = [ - "connexion[flask,swagger-ui,uvicorn]>=3.1.0", - "pydantic>=2.10.3", - "pymongo>=4.10.1", - "requests>=2.32.3", + "asyncpg>=0.31.0", + "fastapi[standard]>=0.135.1", + "nats-py>=2.10.0", + "python-dotenv>=1.2.2", + "sqlalchemy>=2.0.48", + "structlog>=25.5.0", ] -[project.scripts] -edge-cloud-management-api = "edge_cloud_management_api:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[dependency-groups] +[project.optional-dependencies] dev = [ - "hatchling>=1.26.3", - "mongomock>=4.3.0", - "mypy>=1.14.1", - "pytest>=8.3.4", - "ruff>=0.8.1", - "tox>=4.23.2", + "asgi-lifespan>=2.1.0", + "import-linter>=2.11", + "mypy>=1.19.1", + "pre-commit>=4.5.1", + "pytest>=9.0.2", + "pytest-asyncio>=0.24", + "ruff>=0.15.6", + "schemathesis>=4.22", + "testcontainers>=4.0.0", ] -[tool.pytest.ini_options] -pythonpath = ["."] -testpaths = ["tests"] -markers = [ - "unit: Marks a test as a unit test. Run 'pytest -m \"unit\"' to run only unit tests.", - "component: Marks a test as a component test requiring external dependencies (mongodb).", -] +[tool.setuptools.packages.find] +where = ["."] +include = ["open_exposure_gateway*"] [tool.ruff] -exclude = [".eggs", ".git", ".ruff_cache", ".tox", ".venv"] -line-length = 200 -indent-width = 4 - -[tool.ruff.format] -quote-style = "double" -indent-style = "space" -skip-magic-trailing-comma = false -line-ending = "auto" -docstring-code-format = false -docstring-code-line-length = "dynamic" - - -[tool.hatch.build.targets.wheel] -packages = ["edge_cloud_management_api"] +line-length = 100 +target-version = "py312" +cache-dir = ".cache/ruff" +exclude = ["generate_architecture_report.py", "generate_federation_report.py"] +[tool.ruff.lint] +select = ["E", "W", "F", "FAST", "I"] +ignore = ["E3"] +[tool.ruff.format] +docstring-code-format = true [tool.mypy] +python_version = "3.12" +cache_dir = ".cache/mypy" +strict = true ignore_missing_imports = true explicit_package_bases = true +mypy_path = "." -[project.optional-dependencies] -dev = [ - "types-requests" +[[tool.mypy.overrides]] +module = "open_exposure_gateway.test.conformance.*" +disallow_untyped_decorators = false + +[tool.importlinter] +root_package = "open_exposure_gateway" + +[tool.pytest.ini_options] +cache_dir = ".cache/pytest" +testpaths = ["open_exposure_gateway/test"] +pythonpath = ["."] +asyncio_mode = "auto" +markers = [ + "integration: tests that require a running Docker daemon (testcontainers)", + "conformance: schemathesis runs validating the northbound API against vendored CAMARA specs", ] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f11bd9ac98ee368bb00db9058cec20fdfd363dc6..0000000000000000000000000000000000000000 --- a/requirements.txt +++ /dev/null @@ -1,67 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv export -#-e . -a2wsgi==1.10.7 -annotated-types==0.7.0 -anyio==4.7.0 -attrs==24.2.0 -blinker==1.9.0 -cachetools==5.5.0 -certifi==2024.8.30 -chardet==5.2.0 -charset-normalizer==3.4.0 -click==8.1.7 -colorama==0.4.6 -connexion==3.1.0 -distlib==0.3.9 -dnspython==2.7.0 -filelock==3.16.1 -flask==3.1.0 -h11==0.14.0 -hatchling==1.26.3 -httpcore==1.0.7 -httptools==0.6.4 -httpx==0.28.1 -idna==3.10 -inflection==0.5.1 -iniconfig==2.0.0 -itsdangerous==2.2.0 -jinja2==3.1.4 -jsonschema==4.23.0 -jsonschema-specifications==2024.10.1 -markupsafe==3.0.2 -mongomock==4.3.0 -mypy==1.14.1 -mypy-extensions==1.0.0 -packaging==24.2 -pathspec==0.12.1 -platformdirs==4.3.6 -pluggy==1.5.0 -pydantic==2.10.3 -pydantic-core==2.27.1 -pymongo==4.10.1 -pyproject-api==1.8.0 -pytest==8.3.4 -python-dotenv==1.0.1 -python-multipart==0.0.19 -pytz==2024.2 -pyyaml==6.0.2 -referencing==0.35.1 -requests==2.32.3 -rpds-py==0.22.3 -ruff==0.8.2 -sentinels==1.0.0 -sniffio==1.3.1 -starlette==0.41.3 -swagger-ui-bundle==1.1.0 -tox==4.23.2 -trove-classifiers==2024.10.21.16 -typing-extensions==4.12.2 -urllib3==2.2.3 -uvicorn==0.32.1 -uvloop==0.21.0 -virtualenv==20.28.1 -watchfiles==1.0.0 -websockets==14.1 -werkzeug==3.1.3 -python-jose[cryptography]==3.5.0 \ No newline at end of file diff --git a/scripts/start-mongo.sh b/scripts/start-mongo.sh deleted file mode 100644 index a499ce0b2ddb8195c9a6a9890e24cd99d27cfee7..0000000000000000000000000000000000000000 --- a/scripts/start-mongo.sh +++ /dev/null @@ -1,4 +0,0 @@ -set -e - -echo "Starting MongoDB Server..." -podman run -d -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=root -e MONGO_INITDB_ROOT_PASSWORD=example --name=mongo mongo:6.0 diff --git a/tests/component/__init__.py b/tests/component/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/tests/component/controllers/__init__.py b/tests/component/controllers/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/tests/component/controllers/test_app_controllers.py b/tests/component/controllers/test_app_controllers.py deleted file mode 100644 index 1ba17c3f0367ddcfed304c2ada7830d1e138285d..0000000000000000000000000000000000000000 --- a/tests/component/controllers/test_app_controllers.py +++ /dev/null @@ -1,111 +0,0 @@ -import pytest -from unittest.mock import patch, MagicMock -from flask import Flask -from edge_cloud_management_api.controllers import app_controllers - - -@pytest.fixture -def test_app(): - app = Flask(__name__) - app.config["TESTING"] = True - with app.app_context(): - yield app - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_delete_app(mock_factory_class, test_app: Flask): - """Test delete_app returns SRM response as-is""" - app_id = "mock-app-id" - mock_client = MagicMock() - mock_client.delete_app.return_value = {"result": "deleted"} - - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with test_app.test_request_context(): - result = app_controllers.delete_app(app_id) - - assert isinstance(result, dict) - assert result == {"result": "deleted"} - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.app_controllers.resolve_target_zone") -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_create_app_instance(mock_factory_class, mock_resolve_target_zone, test_app: Flask): - """Test create_app_instance returns local deployment response""" - body = { - "appId": "mock-app-id", - "appZones": [{ - "EdgeCloudZone": { - "edgeCloudZoneId": "zone-1", - "edgeCloudProvider": "Local Operator", - } - }], - } - - mock_client = MagicMock() - mock_client.deploy_service_function.return_value = {"deploymentId": "xyz-123"} - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - mock_resolve_target_zone.return_value = { - "edgeCloudZoneId": "zone-1", - "edgeCloudProvider": "Local Operator", - "isLocal": "true", - } - - with test_app.test_request_context(json=body): - response, status_code = app_controllers.create_app_instance() - - assert status_code == 202 - data = response.get_json() - assert data["message"] == "Application deployed locally" - assert data["appId"] == "mock-app-id" - assert data["response"] == {"deploymentId": "xyz-123"} - mock_client.deploy_service_function.assert_called_once_with(data={ - "appId": "mock-app-id", - "appZones": [{ - "EdgeCloudZone": { - "edgeCloudZoneId": "zone-1", - "edgeCloudZoneName": None, - "edgeCloudProvider": "Local Operator", - "edgeCloudZoneStatus": None, - "edgeCloudRegion": None, - } - }], - }) - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_get_app_instance(mock_factory_class, test_app: Flask): - """Test get_app_instance returns normalized app instances""" - mock_client = MagicMock() - mock_client.get_app_instances.return_value = [{"appInstanceId": "abc123"}] - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with test_app.test_request_context(): - response, status_code = app_controllers.get_app_instance() - - assert status_code == 200 - data = response.get_json() - assert isinstance(data, list) - assert data[0]["appInstanceId"] == "abc123" - assert data[0]["status"] == "unknown" - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_delete_app_instance(mock_factory_class, test_app: Flask): - """Test delete_app_instance returns Flask response for local delete""" - app_instance_id = "instance-123" - - mock_client = MagicMock() - mock_client.delete_app_instance.return_value = {"result": "Deleted", "status_code": 200} - - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with test_app.test_request_context(): - response, status_code = app_controllers.delete_app_instance(app_instance_id) - - assert status_code == 200 - assert response.get_json() == {"result": "Deleted", "status_code": 200} diff --git a/tests/fixtures/edge-cloud-zones.json b/tests/fixtures/edge-cloud-zones.json deleted file mode 100644 index 41bf8c0017d1d072025e9414053796da860c4d8d..0000000000000000000000000000000000000000 --- a/tests/fixtures/edge-cloud-zones.json +++ /dev/null @@ -1,23 +0,0 @@ -[ - { - "edgeCloudZoneId": "046b6c7f-0b8a-43b9-b35d-6489e6daee91", - "edgeCloudZoneName": "Zone1", - "edgeCloudZoneStatus": "active", - "edgeCloudProvider": "Provider1", - "edgeCloudRegion": "Region1" - }, - { - "edgeCloudZoneId": "513e4567-e89b-12d3-a456-426614174000", - "edgeCloudZoneName": "Zone3", - "edgeCloudZoneStatus": "inactive", - "edgeCloudProvider": "Provider3", - "edgeCloudRegion": "Region1" - }, - { - "edgeCloudZoneId": "123e4567-e89b-12d3-a456-426614174000", - "edgeCloudZoneName": "Zone2", - "edgeCloudZoneStatus": "inactive", - "edgeCloudProvider": "Provider2", - "edgeCloudRegion": "Region2" - } -] \ No newline at end of file diff --git a/tests/fixtures/mongo-apps-test-collection-dump.json b/tests/fixtures/mongo-apps-test-collection-dump.json deleted file mode 100644 index 4d28eedfb9663334addee112bd3bddbe4ad62b11..0000000000000000000000000000000000000000 --- a/tests/fixtures/mongo-apps-test-collection-dump.json +++ /dev/null @@ -1,233 +0,0 @@ -[ - { - "_id": "b1dd2c62-603a-48ec-8207-e17b11e7b6b5", - "name": "dummy app 1", - "appProvider": "rQxQr3mSdo3dgYZTh1Hq55CWIqsEPHqB80P9_ja3HH8Bp9Hj9Ygc", - "version": "string", - "packageType": "QCOW2", - "operatingSystem": { - "architecture": "x86_64", - "family": "RHEL", - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE" - }, - "appRepo": { - "type": "PRIVATEREPO", - "imagePath": "https://charts.bitnami.com/bitnami/helm/example-chart:0.1.0", - "userName": "string", - "credentials": "string", - "authType": "DOCKER", - "checksum": "string" - }, - "requiredResources": { - "applicationResources": { - "cpuPool": { - "numCPU": 1, - "memory": 1024, - "topology": { - "minNumberOfNodes": 5, - "minNodeCpu": 2, - "minNodeMemory": 1024 - } - }, - "gpuPool": { - "numCPU": 1, - "memory": 1024, - "gpuMemory": 16, - "topology": { - "minNumberOfNodes": 2, - "minNodeCpu": 2, - "minNodeMemory": 1024, - "minNodeGpuMemory": 8 - } - } - }, - "isStandalone": false, - "version": "1.29", - "additionalStorage": "80GB", - "networking": { - "primaryNetwork": { - "provider": "cilium", - "version": "1.13" - }, - "additionalNetworks": [ - { - "name": "net1", - "interfaceType": "vfio-pci" - } - ] - }, - "addons": { - "monitoring": true, - "ingress": true - } - }, - "componentSpec": [ - { - "componentName": "string", - "networkInterfaces": [ - { - "interfaceId": "I4Xp", - "protocol": "TCP", - "port": 65535, - "visibilityType": "VISIBILITY_EXTERNAL" - } - ] - } - ] - }, - { - "_id": "8dfc33ed-a27d-412c-888b-7a86635fc01f", - "name": "dummy app 2", - "appProvider": "rQxQr3mSdo3dgYZTh1Hq55CWIqsEPHqB80P9_ja3HH8Bp9Hj9Ygc", - "version": "string", - "packageType": "QCOW2", - "operatingSystem": { - "architecture": "x86_64", - "family": "RHEL", - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE" - }, - "appRepo": { - "type": "PRIVATEREPO", - "imagePath": "https://charts.bitnami.com/bitnami/helm/example-chart:0.1.0", - "userName": "string", - "credentials": "string", - "authType": "DOCKER", - "checksum": "string" - }, - "requiredResources": { - "applicationResources": { - "cpuPool": { - "numCPU": 1, - "memory": 1024, - "topology": { - "minNumberOfNodes": 5, - "minNodeCpu": 2, - "minNodeMemory": 1024 - } - }, - "gpuPool": { - "numCPU": 1, - "memory": 1024, - "gpuMemory": 16, - "topology": { - "minNumberOfNodes": 2, - "minNodeCpu": 2, - "minNodeMemory": 1024, - "minNodeGpuMemory": 8 - } - } - }, - "isStandalone": false, - "version": "1.29", - "additionalStorage": "80GB", - "networking": { - "primaryNetwork": { - "provider": "cilium", - "version": "1.13" - }, - "additionalNetworks": [ - { - "name": "net1", - "interfaceType": "vfio-pci" - } - ] - }, - "addons": { - "monitoring": true, - "ingress": true - } - }, - "componentSpec": [ - { - "componentName": "string", - "networkInterfaces": [ - { - "interfaceId": "I4Xp", - "protocol": "TCP", - "port": 65535, - "visibilityType": "VISIBILITY_EXTERNAL" - } - ] - } - ] - }, - { - "_id": "17b1b16b-5202-4ab6-9262-de53537ed787", - "name": "dummy app 3", - "appProvider": "rQxQr3mSdo3dgYZTh1Hq55CWIqsEPHqB80P9_ja3HH8Bp9Hj9Ygc", - "version": "string", - "packageType": "QCOW2", - "operatingSystem": { - "architecture": "x86_64", - "family": "RHEL", - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE" - }, - "appRepo": { - "type": "PRIVATEREPO", - "imagePath": "https://charts.bitnami.com/bitnami/helm/example-chart:0.1.0", - "userName": "string", - "credentials": "string", - "authType": "DOCKER", - "checksum": "string" - }, - "requiredResources": { - "applicationResources": { - "cpuPool": { - "numCPU": 1, - "memory": 1024, - "topology": { - "minNumberOfNodes": 5, - "minNodeCpu": 2, - "minNodeMemory": 1024 - } - }, - "gpuPool": { - "numCPU": 1, - "memory": 1024, - "gpuMemory": 16, - "topology": { - "minNumberOfNodes": 2, - "minNodeCpu": 2, - "minNodeMemory": 1024, - "minNodeGpuMemory": 8 - } - } - }, - "isStandalone": false, - "version": "1.29", - "additionalStorage": "80GB", - "networking": { - "primaryNetwork": { - "provider": "cilium", - "version": "1.13" - }, - "additionalNetworks": [ - { - "name": "net1", - "interfaceType": "vfio-pci" - } - ] - }, - "addons": { - "monitoring": true, - "ingress": true - } - }, - "componentSpec": [ - { - "componentName": "string", - "networkInterfaces": [ - { - "interfaceId": "I4Xp", - "protocol": "TCP", - "port": 65535, - "visibilityType": "VISIBILITY_EXTERNAL" - } - ] - } - ] - } -] \ No newline at end of file diff --git a/tests/fixtures/submit-app-sample.json b/tests/fixtures/submit-app-sample.json deleted file mode 100644 index 43b401112c7322b2c0e56c311b8c694b89ac6ebd..0000000000000000000000000000000000000000 --- a/tests/fixtures/submit-app-sample.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "H38L1jMMndtyRR3WFCSI_he_m4NwQ0rggZYrOX9r09shL597SF4bGHoqUI5t", - "appProvider": "rQxQr3mSdo3dgYZTh1Hq55CWIqsEPHqB80P9_ja3HH8Bp9Hj9Ygc", - "version": "string", - "packageType": "QCOW2", - "operatingSystem": { - "architecture": "x86_64", - "family": "RHEL", - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE" - }, - "appRepo": { - "type": "PRIVATEREPO", - "imagePath": "https://charts.bitnami.com/bitnami/helm/example-chart:0.1.0", - "userName": "string", - "credentials": "string", - "authType": "DOCKER", - "checksum": "string" - }, - "requiredResources": { - "applicationResources": { - "cpuPool": { - "numCPU": 1, - "memory": 1024, - "topology": { - "minNumberOfNodes": 5, - "minNodeCpu": 2, - "minNodeMemory": 1024 - } - }, - "gpuPool": { - "numCPU": 1, - "memory": 1024, - "gpuMemory": 16, - "topology": { - "minNumberOfNodes": 2, - "minNodeCpu": 2, - "minNodeMemory": 1024, - "minNodeGpuMemory": 8 - } - } - }, - "isStandalone": false, - "version": "1.29", - "additionalStorage": "80GB", - "networking": { - "primaryNetwork": { - "provider": "cilium", - "version": "1.13" - }, - "additionalNetworks": [ - { - "name": "net1", - "interfaceType": "vfio-pci" - } - ] - }, - "addons": { - "monitoring": true, - "ingress": true - } - }, - "componentSpec": [ - { - "componentName": "string", - "networkInterfaces": [ - { - "interfaceId": "I4Xp", - "protocol": "TCP", - "port": 65535, - "visibilityType": "VISIBILITY_EXTERNAL" - } - ] - } - ] -} \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/tests/unit/controllers/__init__.py b/tests/unit/controllers/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/tests/unit/controllers/test_app_controllers.py b/tests/unit/controllers/test_app_controllers.py deleted file mode 100644 index f75df589b172cb9e2d36550d596bc8b58340242d..0000000000000000000000000000000000000000 --- a/tests/unit/controllers/test_app_controllers.py +++ /dev/null @@ -1,676 +0,0 @@ -from edge_cloud_management_api.controllers import app_controllers -from unittest.mock import MagicMock, patch - -from flask import Flask - - -def test_split_image_reference_preserves_helm_repo_url(): - repo_url, image_ref = app_controllers._split_image_reference( - "https://charts.bitnami.com/bitnami/helm/example-chart:0.1.0" - ) - - assert repo_url == "https://charts.bitnami.com/bitnami/helm" - assert image_ref == "example-chart:0.1.0" - - -def test_split_image_reference_preserves_container_registry_behavior(): - repo_url, image_ref = app_controllers._split_image_reference( - "ghcr.io/example/image:1.2.3" - ) - - assert repo_url == "ghcr.io" - assert image_ref == "example/image:1.2.3" - - -def test_resolve_federated_app_provider_id_normalizes_local_provider_name(): - provider_id = app_controllers._resolve_federated_app_provider_id( - "app-123", - "Local Operator", - ) - - assert provider_id == "providerapp123" - - -def test_resolve_federated_app_identity_normalizes_app_and_provider_ids(): - app_id, provider_id = app_controllers._resolve_federated_app_identity( - "playground-oeg-nginx", - "Local Operator", - ) - - assert app_id == "3d8467a8-9d80-50d2-afa2-65374b46c378" - assert provider_id == "providerplaygroundoegnginx" - - -@patch("edge_cloud_management_api.controllers.app_partner_orchestration.get_local_zones") -@patch("edge_cloud_management_api.controllers.app_partner_orchestration.get_zone") -def test_resolve_target_zone_maps_default_local_alias_to_live_zone(mock_get_zone, mock_get_local_zones): - mock_get_zone.return_value = None - mock_get_local_zones.return_value = [{ - "edgeCloudZoneId": "7f1c87c2-1de3-44bb-9888-a1067b425775", - "edgeCloudZoneName": "221aba31da3c", - "edgeCloudProvider": "Local Operator", - "edgeCloudZoneStatus": "active", - "edgeCloudRegion": "unknown", - "isLocal": "true", - }] - - zone = app_controllers.resolve_target_zone( - srm_client=MagicMock(), - edge_cloud_zone_id="default", - edge_cloud_provider="Local Operator", - zone_payload={ - "edgeCloudZoneId": "default", - "edgeCloudProvider": "Local Operator", - }, - ) - - assert zone == { - "edgeCloudZoneId": "7f1c87c2-1de3-44bb-9888-a1067b425775", - "edgeCloudZoneName": "221aba31da3c", - "edgeCloudProvider": "Local Operator", - "edgeCloudZoneStatus": "active", - "edgeCloudRegion": "unknown", - "isLocal": "true", - } - - -def test_submit_app_rejects_non_uuid_app_id(): - app = Flask(__name__) - app.config["TESTING"] = True - - body = { - "appId": "playground-oeg-nginx", - "name": "playground_oeg_nginx", - "appProvider": "Local_Operator", - "version": "1", - "packageType": "CONTAINER", - "appRepo": { - "type": "PUBLICREPO", - "imagePath": "https://docker.io/library/nginx:latest", - }, - "componentSpec": [{ - "componentName": "frontend", - "networkInterfaces": [{ - "interfaceId": "eth0", - "protocol": "TCP", - "port": 80, - "visibilityType": "VISIBILITY_EXTERNAL", - }], - }], - "requiredResources": { - "infraKind": "container", - "numCPU": "100m", - "memory": 512, - }, - } - - with app.test_request_context(json=body): - response, status_code = app_controllers.submit_app(body) - - assert status_code == 400 - payload = response.get_json() - assert payload["error"] == "Invalid input" - assert "appId" in str(payload["details"]) - - -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_submit_app_accepts_camara_valid_container_manifest(mock_factory_class): - app = Flask(__name__) - app.config["TESTING"] = True - - body = { - "appId": "4d3b2f0e-6e5e-4c4d-9c7a-2c7b6de8a101", - "name": "playground_oeg_nginx", - "appProvider": "Local_Oper", - "version": "1", - "packageType": "CONTAINER", - "appRepo": { - "type": "PUBLICREPO", - "imagePath": "nginx", - }, - "requiredResources": { - "infraKind": "container", - "numCPU": "100m", - "memory": 512, - }, - "componentSpec": [{ - "componentName": "frontend", - "networkInterfaces": [{ - "interfaceId": "eth0", - "protocol": "TCP", - "port": 80, - "visibilityType": "VISIBILITY_EXTERNAL", - }], - }], - } - - mock_client = MagicMock() - mock_client.submit_app.return_value = {"appId": body["appId"]} - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with app.test_request_context(json=body): - response = app_controllers.submit_app(body) - - assert response == {"appId": body["appId"]} - mock_client.submit_app.assert_called_once_with(body) - - -@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") -@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_get_app_instance_uses_normalized_provider_for_federated_lookup( - mock_factory_class, - mock_federation_client, - mock_get_all_feds, -): - app = Flask(__name__) - app.config["TESTING"] = True - - mock_client = MagicMock() - mock_client.get_app_instances.return_value = {"appInstances": []} - mock_client.get_app.return_value = { - "appManifest": { - "appProvider": "Local Operator", - } - } - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - mock_get_all_feds.return_value = [{"_id": "fed-1", "token": "token-1", "partnerOPFederationId": "Remote Operator"}] - mock_federation_client.get_all_app_instances.return_value = ([{ - "zoneId": "default", - "appInstanceInfo": [{"appInstIdentifier": "inst-1", "appInstanceState": "ready"}], - }], 200) - - with app.test_request_context(): - response, status_code = app_controllers.get_app_instance(app_id="app-123") - - assert status_code == 200 - assert response.get_json() == [{ - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "unknown", - "edgeCloudProvider": "Remote Operator", - "edgeCloudZoneStatus": "unknown", - "edgeCloudRegion": "unknown", - }, - }] - mock_federation_client.get_all_app_instances.assert_called_once_with( - federation_context_id="fed-1", - app_id="a522ed85-40f3-5e11-8b55-8e0e516331e1", - app_provider_id="providerapp123", - token="token-1", - ) - - -@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") -@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_get_app_instance_dedupes_local_and_federated_results( - mock_factory_class, - mock_federation_client, - mock_get_all_feds, -): - app = Flask(__name__) - app.config["TESTING"] = True - - mock_client = MagicMock() - mock_client.get_app_instances.return_value = { - "appInstances": [{ - "appId": "app-123", - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "unknown", - "edgeCloudProvider": "unknown", - "edgeCloudZoneStatus": "unknown", - "edgeCloudRegion": "unknown", - }, - }] - } - mock_client.get_app.return_value = { - "appManifest": { - "appProvider": "Local Operator", - } - } - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - mock_get_all_feds.return_value = [{"_id": "fed-1", "token": "token-1", "partnerOPFederationId": "Remote Operator"}] - mock_federation_client.get_all_app_instances.return_value = ([{ - "zoneId": "default", - "appInstanceInfo": [{ - "appId": "app-123", - "appInstIdentifier": "inst-1", - "appInstanceState": "ready", - }], - }], 200) - - with app.test_request_context(): - response, status_code = app_controllers.get_app_instance(app_id="app-123") - - assert status_code == 200 - assert response.get_json() == [{ - "appId": "app-123", - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "unknown", - "edgeCloudProvider": "Remote Operator", - "edgeCloudZoneStatus": "unknown", - "edgeCloudRegion": "unknown", - }, - }] - - -@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") -@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_get_app_instance_ignores_federated_tombstones_from_partner_lookup( - mock_factory_class, - mock_federation_client, - mock_get_all_feds, -): - app = Flask(__name__) - app.config["TESTING"] = True - - mock_client = MagicMock() - mock_client.get_app_instances.return_value = {"appInstances": []} - mock_client.get_app.return_value = { - "appManifest": { - "appProvider": "Local Operator", - } - } - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - mock_get_all_feds.return_value = [{"_id": "fed-1", "token": "token-1", "partnerOPFederationId": "Remote Operator"}] - mock_federation_client.get_all_app_instances.return_value = ([{ - "zoneId": "default", - "appInstanceInfo": [{ - "appInstIdentifier": "inst-1", - "appInstanceState": "Error 404 - b'{\"detail\": {\"error\": \"App instance not found\"}}'", - }], - }], 200) - - with app.test_request_context(): - response, status_code = app_controllers.get_app_instance(app_id="app-123") - - assert status_code == 200 - assert response.get_json() == [] - - -def test_dedupe_app_instances_prefers_richer_zone_identity(): - deduped = app_controllers._dedupe_app_instances([ - { - "appId": "app-123", - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "unknown", - "edgeCloudProvider": "unknown", - "edgeCloudZoneStatus": "unknown", - "edgeCloudRegion": "unknown", - }, - }, - { - "appId": "app-123", - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "zone-default", - "edgeCloudProvider": "Local Operator", - "edgeCloudZoneStatus": "active", - "edgeCloudRegion": "unknown", - }, - }, - ]) - - assert deduped == [{ - "appId": "app-123", - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "zone-default", - "edgeCloudProvider": "Local Operator", - "edgeCloudZoneStatus": "active", - "edgeCloudRegion": "unknown", - }, - }] - - -@patch("edge_cloud_management_api.controllers.app_instance_helpers.get_zone") -@patch("edge_cloud_management_api.controllers.app_instance_helpers.get_local_zones") -def test_enrich_instance_zone_from_catalog_uses_live_local_zone_first(mock_get_local_zones, mock_get_zone): - mock_get_local_zones.return_value = [{ - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "zone-default", - "edgeCloudProvider": "Local Operator", - "edgeCloudZoneStatus": "active", - "edgeCloudRegion": "unknown", - }] - mock_get_zone.return_value = None - - enriched = app_controllers._enrich_instance_zone_from_catalog({ - "appId": "app-123", - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "unknown", - "edgeCloudProvider": "unknown", - "edgeCloudZoneStatus": "unknown", - "edgeCloudRegion": "unknown", - }, - }) - - assert enriched == { - "appId": "app-123", - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "zone-default", - "edgeCloudProvider": "Local Operator", - "edgeCloudZoneStatus": "active", - "edgeCloudRegion": "unknown", - }, - } - mock_get_zone.assert_not_called() - - -@patch("edge_cloud_management_api.controllers.app_instance_helpers.get_zone") -def test_enrich_instance_zone_from_catalog_uses_stored_provider_identity(mock_get_zone): - mock_get_zone.return_value = { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "zone-default", - "edgeCloudProvider": "Local Operator", - "edgeCloudZoneStatus": "active", - "edgeCloudRegion": "unknown", - } - - enriched = app_controllers._enrich_instance_zone_from_catalog({ - "appId": "app-123", - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "unknown", - "edgeCloudProvider": "unknown", - "edgeCloudZoneStatus": "unknown", - "edgeCloudRegion": "unknown", - }, - }) - - assert enriched == { - "appId": "app-123", - "appInstanceId": "inst-1", - "status": "ready", - "edgeCloudZone": { - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "zone-default", - "edgeCloudProvider": "Local Operator", - "edgeCloudZoneStatus": "active", - "edgeCloudRegion": "unknown", - }, - } - - -@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_get_app_instance_returns_empty_list_when_no_instances(mock_factory_class, _mock_get_all_feds): - app = Flask(__name__) - app.config["TESTING"] = True - - mock_client = MagicMock() - mock_client.get_app_instances.return_value = {"appInstances": []} - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with app.test_request_context(): - response, status_code = app_controllers.get_app_instance() - - assert status_code == 200 - assert response.get_json() == [] - - -@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_get_app_instance_supports_openapi_query_param_names(mock_factory_class, _mock_get_all_feds): - app = Flask(__name__) - app.config["TESTING"] = True - - mock_client = MagicMock() - mock_client.get_app_instances.return_value = { - "appInstances": [ - {"appInstanceId": "inst-1", "appId": "app-1", "status": "ready"}, - {"appInstanceId": "inst-2", "appId": "app-2", "status": "failed"}, - ] - } - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with app.test_request_context(): - response, status_code = app_controllers.get_app_instance(appId="app-1", appInstanceId="inst-1") - - assert status_code == 200 - assert response.get_json() == [{ - "appId": "app-1", - "appInstanceId": "inst-1", - "status": "ready", - }] - - -@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds", return_value=[]) -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_get_app_instance_filters_local_results_by_app_id(mock_factory_class, _mock_get_all_feds): - app = Flask(__name__) - app.config["TESTING"] = True - - mock_client = MagicMock() - mock_client.get_app_instances.return_value = { - "appInstances": [ - {"appInstanceId": "inst-1", "appId": "app-1", "status": "ready"}, - {"appInstanceId": "inst-2", "appId": "app-2", "status": "failed"}, - ] - } - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with app.test_request_context(): - response, status_code = app_controllers.get_app_instance(app_id="app-1") - - assert status_code == 200 - assert response.get_json() == [{ - "appId": "app-1", - "appInstanceId": "inst-1", - "status": "ready", - }] - - -@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") -@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_delete_app_instance_uses_normalized_federated_app_id_for_partner_removal( - mock_factory_class, - mock_federation_client, - mock_get_all_feds, -): - app = Flask(__name__) - app.config["TESTING"] = True - - mock_client = MagicMock() - mock_client.delete_app_instance.return_value = {"status_code": 404, "error": "not found"} - mock_client.get_service_functions_catalogue.return_value = [{ - "appId": "playground-oeg-nginx", - "appProvider": "Local Operator", - }] - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - mock_get_all_feds.return_value = [{ - "_id": "fed-1", - "token": "token-1", - "partnerOPFederationId": "Remote Operator", - }] - mock_federation_client.get_all_app_instances.return_value = ([{ - "zoneId": "default", - "appInstanceInfo": [{ - "appInstIdentifier": "inst-remote-1", - "appInstanceState": "ready", - }], - }], 200) - mock_federation_client.remove_app_instance.return_value = ({"termination": "accepted"}, 200) - - with app.test_request_context(): - response, status_code = app_controllers.delete_app_instance("inst-remote-1") - - assert status_code == 200 - assert response.get_json() == {"termination": "accepted"} - mock_federation_client.remove_app_instance.assert_called_once_with( - federation_context_id="fed-1", - app_id="3d8467a8-9d80-50d2-afa2-65374b46c378", - app_instance_id="inst-remote-1", - zone_id="default", - token="token-1", - ) - - -@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") -@patch("edge_cloud_management_api.controllers.app_controllers.federation_client") -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_delete_app_instance_treats_missing_partner_instance_as_already_deleted( - mock_factory_class, - mock_federation_client, - mock_get_all_feds, -): - app = Flask(__name__) - app.config["TESTING"] = True - - mock_client = MagicMock() - mock_client.delete_app_instance.return_value = {"status_code": 404, "error": "not found"} - mock_client.get_service_functions_catalogue.return_value = [{ - "appId": "playground-oeg-nginx", - "appProvider": "Local Operator", - }] - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - mock_get_all_feds.return_value = [{ - "_id": "fed-1", - "token": "token-1", - "partnerOPFederationId": "Remote Operator", - }] - mock_federation_client.get_all_app_instances.return_value = ([{ - "zoneId": "default", - "appInstanceInfo": [{ - "appInstIdentifier": "inst-remote-1", - "appInstanceState": "instantiating", - }], - }], 200) - mock_federation_client.remove_app_instance.return_value = ({ - "error": { - "detail": "Partner API error: HTTP 500: App instance not found", - } - }, 500) - - with app.test_request_context(): - response, status_code = app_controllers.delete_app_instance("inst-remote-1") - - assert status_code == 204 - assert response == "" - - -@patch("edge_cloud_management_api.controllers.app_controllers.get_all_feds") -@patch("edge_cloud_management_api.controllers.app_controllers.cleanup_federated_app") -@patch("edge_cloud_management_api.controllers.app_controllers.SRMAPIClientFactory") -def test_delete_app_runs_federated_cleanup_before_local_delete( - mock_factory_class, - mock_cleanup_federated_app, - mock_get_all_feds, -): - app = Flask(__name__) - app.config["TESTING"] = True - - mock_client = MagicMock() - mock_client.get_app.return_value = { - "appComponentSpecs": [{ - "artefactId": "12345678-1234-1234-9234-123456789abc", - }], - "appManifest": { - "appProvider": "Local Operator", - } - } - mock_client.delete_app.return_value = {"status_code": 204} - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - mock_get_all_feds.return_value = [{"_id": "fed-1", "token": "token-1"}] - mock_cleanup_federated_app.return_value = ("", 204) - - with app.test_request_context(): - response = app_controllers.delete_app("app-123") - - assert response == {"status_code": 204} - mock_cleanup_federated_app.assert_called_once() - mock_client.delete_app.assert_called_once_with(appId="app-123") - - -@patch("edge_cloud_management_api.controllers.app_partner_orchestration.jsonify") -def test_cleanup_federated_app_handles_dict_delete_response(mock_jsonify): - federation_client = MagicMock() - federation_client.get_all_app_instances.return_value = ([{ - "zoneId": "default", - "appInstanceInfo": [{"appInstIdentifier": "inst-1"}], - }], 200) - federation_client.remove_app_instance.return_value = ({"termination": "accepted"}, 200) - federation_client.delete_onboarded_app.return_value = { - "message": "Deleted successfully", - "status_code": 204, - } - federation_client.delete_artefact.return_value = { - "message": "Deleted successfully", - "status_code": 204, - } - - result = app_controllers.cleanup_federated_app( - federation_client=federation_client, - feds=[{"_id": "fed-1", "token": "token-1"}], - app_id="app-123", - app_provider_id="Local Operator", - normalize_federated_app_id=app_controllers._normalize_federated_app_id, - artefact_id="12345678-1234-1234-9234-123456789abc", - normalize_federated_artefact_id=app_controllers._normalize_federated_artefact_id, - resolve_federated_app_identity=app_controllers._resolve_federated_app_identity, - ) - - assert result == ("", 204) - mock_jsonify.assert_not_called() - federation_client.delete_artefact.assert_called_once_with( - "fed-1", - "f2175bf7-71e5-54aa-99f1-a72de468ac60", - "token-1", - ) - - -@patch("edge_cloud_management_api.controllers.app_partner_orchestration.jsonify") -def test_cleanup_federated_app_ignores_missing_artefact(mock_jsonify): - federation_client = MagicMock() - federation_client.get_all_app_instances.return_value = ([], 200) - federation_client.delete_onboarded_app.return_value = { - "message": "Deleted successfully", - "status_code": 204, - } - federation_client.delete_artefact.return_value = { - "error": "not found", - "status_code": 404, - } - - result = app_controllers.cleanup_federated_app( - federation_client=federation_client, - feds=[{"_id": "fed-1", "token": "token-1"}], - app_id="app-123", - app_provider_id="Local Operator", - normalize_federated_app_id=app_controllers._normalize_federated_app_id, - artefact_id="12345678-1234-1234-9234-123456789abc", - normalize_federated_artefact_id=app_controllers._normalize_federated_artefact_id, - resolve_federated_app_identity=app_controllers._resolve_federated_app_identity, - ) - - assert result == ("", 204) - mock_jsonify.assert_not_called() diff --git a/tests/unit/controllers/test_edge_cloud_controller.py b/tests/unit/controllers/test_edge_cloud_controller.py deleted file mode 100644 index c75ce46623e07cc2b6f160af3ec0f1290c1ea7e8..0000000000000000000000000000000000000000 --- a/tests/unit/controllers/test_edge_cloud_controller.py +++ /dev/null @@ -1,151 +0,0 @@ -import json -import pathlib -import pytest -from unittest.mock import MagicMock, patch -from flask import Flask -from edge_cloud_management_api.controllers.edge_cloud_controller import ( - get_all_cloud_zones, - get_partner_zones, - get_edge_cloud_zones, -) -from edge_cloud_management_api.app import get_app_instance - - -@pytest.fixture -def test_app(): - flask_app = get_app_instance() - return flask_app.app - - -@pytest.fixture -def mock_zones(): - tests_path = pathlib.Path(__file__).resolve().parent.parent.parent - with open(tests_path / "fixtures/edge-cloud-zones.json") as f: - data = json.load(f) - return data - - -@pytest.fixture -def mock_get_all_cloud_zones(mock_zones): - with patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_all_cloud_zones", - return_value=mock_zones, - ) as mock_function: - yield mock_function - - -@pytest.mark.unit -@pytest.mark.parametrize( - "x_correlator, region, status, expected_response_status, expected_count", - [ - (None, None, None, 200, 3), # No filters applied (returns all) - (None, "Region2", None, 200, 1), - (None, None, "inactive", 200, 2), - (None, None, "active", 200, 1), - (None, "Region1", "active", 200, 1), - (None, "Region3", None, 200, 0), - (None, None, "invalid", 400, 0), # This is the only test expecting validation error - ], -) -def test_get_edge_cloud_zones( - x_correlator, - region, - status, - expected_response_status, - expected_count, - mock_get_all_cloud_zones: MagicMock, - test_app: Flask, -): - """ - Test the get_edge_cloud_zones controller. - """ - with test_app.test_request_context(): - response, response_status = get_edge_cloud_zones(x_correlator, region, status) - assert response_status == expected_response_status - - if expected_response_status == 400: - data = response.get_json() - assert data is not None - assert data["code"] == "VALIDATION_ERROR" - elif expected_response_status == 200: - data = response.get_json() - assert isinstance(data, list) - assert len(data) == expected_count - mock_get_all_cloud_zones.assert_called_once() - else: - # Defensive: should not get here - assert False, "Unexpected response status" - - -@pytest.mark.unit -def test_get_partner_zones_returns_only_partner_zones(mock_zones): - partner_zone = dict(mock_zones[0]) - partner_zone["isLocal"] = "false" - local_zone = dict(mock_zones[1]) - local_zone["isLocal"] = "true" - with patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", - return_value=[partner_zone, local_zone], - ): - assert get_partner_zones() == [partner_zone] - - -@pytest.mark.unit -def test_get_all_cloud_zones_merges_live_local_with_partner(mock_zones): - local_zone = { - "edgeCloudZoneId": "local-zone", - "edgeCloudZoneName": "local-zone", - "edgeCloudZoneStatus": "unknown", - "edgeCloudProvider": "local", - "edgeCloudRegion": "local", - } - with patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", - return_value=[{**mock_zones[0], "isLocal": "false"}], - ), patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_local_zones", - return_value=[local_zone], - ): - result = get_all_cloud_zones() - assert local_zone in result - assert mock_zones[0] in result - - -@pytest.mark.unit -def test_get_all_cloud_zones_ignores_persisted_local_zones(): - stale_cached_local_zone = { - "_id": "Local Operator::default", - "edgeCloudZoneId": "default", - "edgeCloudZoneName": "zone-default", - "edgeCloudZoneStatus": "active", - "edgeCloudProvider": "Local Operator", - "edgeCloudRegion": "unknown", - "isLocal": "true", - } - live_local_zone = { - "edgeCloudZoneId": "069a2791-71b5-40e5-a415-3820ecf2aa87", - "edgeCloudZoneName": "bdd776dc8c06", - "edgeCloudZoneStatus": "active", - "edgeCloudProvider": "Local Operator", - "edgeCloudRegion": "unknown", - } - with patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", - return_value=[stale_cached_local_zone], - ), patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_local_zones", - return_value=[live_local_zone], - ): - result = get_all_cloud_zones() - assert result == [live_local_zone] - - -def test_get_cached_zones_fallback_to_srm(mock_zones): - with patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_zones", - side_effect=Exception("db error"), - ), patch( - "edge_cloud_management_api.controllers.edge_cloud_controller.get_local_zones", - return_value=mock_zones, - ): - assert get_all_cloud_zones() == mock_zones diff --git a/tests/unit/controllers/test_federation_manager_controller.py b/tests/unit/controllers/test_federation_manager_controller.py deleted file mode 100644 index 24a8920c63532ac64faeed61c1672e29588ce87f..0000000000000000000000000000000000000000 --- a/tests/unit/controllers/test_federation_manager_controller.py +++ /dev/null @@ -1,233 +0,0 @@ -import pytest -from unittest.mock import patch, MagicMock -from flask import Flask -from edge_cloud_management_api.controllers import federation_manager_controller - - -@pytest.fixture -def test_app(): - app = Flask(__name__) - app.config["TESTING"] = True - with app.app_context(): - yield app - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_federation") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_zones") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_create_federation(mock_federation_client, _mock_get_token, mock_insert_zones, mock_insert_federation, test_app: Flask): - """Test create_federation returns federation data""" - body = { - "origOPFederationId": "orig-123", - "initialDate": "2024-01-01T00:00:00Z", - "partnerStatusLink": "https://callback.example.com/status" - } - - mock_federation_client.post_partner.return_value = ( - {"federationContextId": "abc", "partnerOPFederationId": "partner-xyz"}, - 200, - ) - - with test_app.test_request_context(json=body): - response, status = federation_manager_controller.create_federation() - - assert status == 200 - data = response - assert "federationContextId" in data - assert data["federationContextId"] == "abc" - mock_insert_federation.assert_called_once_with({ - "_id": "abc", - "token": "token", - "partnerOPFederationId": "partner-xyz", - }) - mock_insert_zones.assert_not_called() - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_federation") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_zones") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_create_federation_accepts_gsma_minimum_request( - mock_federation_client, - _mock_get_token, - mock_insert_zones, - mock_insert_federation, - test_app: Flask, -): - body = { - "initialDate": "2024-01-01T00:00:00Z", - "partnerStatusLink": "https://callback.example.com/status" - } - - mock_federation_client.post_partner.return_value = ( - {"federationContextId": "abc"}, - 200, - ) - - with test_app.test_request_context(json=body): - response, status = federation_manager_controller.create_federation() - - assert status == 200 - data = response - assert data == {"federationContextId": "abc"} - mock_insert_federation.assert_called_once_with({ - "_id": "abc", - "token": "token", - "partnerOPFederationId": None, - }) - mock_insert_zones.assert_not_called() - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_create_federation_rejects_missing_required_fields(mock_federation_client, _mock_get_token, test_app: Flask): - body = { - "initialDate": "2024-01-01T00:00:00Z" - } - - with test_app.test_request_context(json=body): - response, status = federation_manager_controller.create_federation() - - assert status == 400 - payload = response.get_json() - assert payload["error"] == "Invalid input" - assert "partnerStatusLink" in str(payload["details"]) - mock_federation_client.post_partner.assert_not_called() - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.get_fed", return_value={"token": "token"}) -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_get_federation(mock_federation_client, _mock_get_fed, test_app: Flask): - federation_context_id = "abc" - - mock_federation_client.get_partner.return_value = ({ - "offeredAvailabilityZones": [{"zoneId": "zone-1", "geographyDetails": "Zone 1", "geolocation": "0.0,0.0"}], - "platformCaps": ["homeRouting"], - }, 200) - - with test_app.test_request_context(): - response, status = federation_manager_controller.get_federation(federation_context_id) - - assert status == 200 - assert response["platformCaps"] == ["homeRouting"] - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.get_fed", return_value={"token": "token"}) -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_delete_federation(mock_federation_client, _mock_get_fed, test_app: Flask): - federation_context_id = "abc" - - mock_federation_client.delete_partner.return_value = ({"result": "Deleted"}, 200) - - with test_app.test_request_context(): - response, status = federation_manager_controller.delete_federation(federation_context_id) - - assert status == 200 - assert response == {"result": "Deleted"} - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.get_all_feds", return_value=[{"token": "token"}]) -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_get_federation_context_ids(mock_federation_client, _mock_get_all_feds, test_app: Flask): - mock_federation_client.get_federation_context_ids.return_value = ({"federationContextId": "ctx-123"}, 200) - - with test_app.test_request_context(): - response, status = federation_manager_controller.get_federation_context_ids() - - assert status == 200 - assert response == {"federationContextId": "ctx-123"} - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.get_fed", return_value={"partnerOPFederationId": "partner-xyz"}) -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_request_zone_synch(mock_federation_client, _mock_get_fed, _mock_get_token, test_app: Flask): - federation_context_id = "ctx-123" - body = { - "acceptedAvailabilityZones": ["zone-1"], - "availZoneNotifLink": "http://callback.local" - } - mock_federation_client.request_zone_sync.return_value = ({"status": "ok"}, 200) - mock_federation_client.get_partner.return_value = ({ - "offeredAvailabilityZones": [ - {"zoneId": "zone-1", "geographyDetails": "Zone 1"}, - {"zoneId": "zone-2", "geographyDetails": "Zone 2"}, - ], - }, 200) - - with patch("edge_cloud_management_api.controllers.federation_manager_controller.insert_zones") as mock_insert_zones: - with test_app.test_request_context(json=body): - response, status = federation_manager_controller.request_zone_synch(federation_context_id) - - assert status == 200 - assert response.get_json() == {"status": "ok"} - mock_federation_client.request_zone_sync.assert_called_once() - mock_federation_client.get_partner.assert_called_once_with(federation_context_id, "token") - mock_insert_zones.assert_called_once_with([ - { - "_id": "zone-1", - "edgeCloudProvider": "partner-xyz", - "edgeCloudZoneId": "zone-1", - "edgeCloudZoneName": "Zone 1", - "edgeCloudZoneStatus": "unknown", - "isLocal": "false", - "fedContextId": federation_context_id, - } - ]) - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_request_zone_synch_rejects_missing_accepted_zones(mock_federation_client, _mock_get_token, test_app: Flask): - federation_context_id = "ctx-123" - body = { - "availZoneNotifLink": "http://callback.local" - } - - with test_app.test_request_context(json=body): - response, status = federation_manager_controller.request_zone_synch(federation_context_id) - - assert status == 400 - payload = response.get_json() - assert payload["error"] == "Invalid input" - assert "acceptedAvailabilityZones" in str(payload["details"]) - mock_federation_client.request_zone_sync.assert_not_called() - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_get_zone_resource_info(mock_federation_client, _mock_get_token, test_app: Flask): - federation_context_id = "ctx-123" - zone_id = "zone-1" - mock_federation_client.get_zone_resource_info.return_value = {"zoneId": zone_id} - - with test_app.test_request_context(): - response = federation_manager_controller.get_zone_resource_info(federation_context_id, zone_id) - - assert response.get_json() == {"zoneId": zone_id} - mock_federation_client.get_zone_resource_info.assert_called_once() - - -@pytest.mark.component -@patch("edge_cloud_management_api.controllers.federation_manager_controller.__get_token", return_value="token") -@patch("edge_cloud_management_api.controllers.federation_manager_controller.federation_client") -def test_remove_zone_sync(mock_federation_client, _mock_get_token, test_app: Flask): - federation_context_id = "ctx-123" - zone_id = "zone-1" - mock_federation_client.remove_zone_sync.return_value = {"status": "deleted"} - - with test_app.test_request_context(): - response = federation_manager_controller.remove_zone_sync(federation_context_id, zone_id) - - assert response.get_json() == {"status": "deleted"} - mock_federation_client.remove_zone_sync.assert_called_once() diff --git a/tests/unit/controllers/test_network_functions_controller.py b/tests/unit/controllers/test_network_functions_controller.py deleted file mode 100644 index ed1fd0c36a4f619cc3ed757c4777448a7cabb65e..0000000000000000000000000000000000000000 --- a/tests/unit/controllers/test_network_functions_controller.py +++ /dev/null @@ -1,147 +0,0 @@ -import pytest -from unittest.mock import patch, MagicMock -from flask import Flask -from edge_cloud_management_api.controllers import network_functions_controller - - -@pytest.fixture -def test_app(): - app = Flask(__name__) - app.config["TESTING"] = True - with app.app_context(): - yield app - - -SAMPLE_LOCATION_REQUEST = { - "device": {"phoneNumber": "+123456789"}, - "maxAge": 60, -} - -SAMPLE_LOCATION_RESPONSE = { - "lastLocationTime": "2024-06-01T12:00:00Z", - "area": { - "areaType": "CIRCLE", - "center": {"latitude": 37.9553, "longitude": 23.8522}, - "radius": 800, - }, -} - - -@pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.SRMAPIClientFactory") -def test_retrieve_location_success(mock_factory_class, test_app: Flask): - """Successful location retrieval returns the SRM response as-is.""" - mock_client = MagicMock() - mock_client.retrieve_location.return_value = SAMPLE_LOCATION_RESPONSE - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with test_app.test_request_context(): - result = network_functions_controller.retrieve_location(SAMPLE_LOCATION_REQUEST) - - assert result == SAMPLE_LOCATION_RESPONSE - mock_client.retrieve_location.assert_called_once_with(SAMPLE_LOCATION_REQUEST) - - -@pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.SRMAPIClientFactory") -def test_retrieve_location_srm_error_is_relayed(mock_factory_class, test_app: Flask): - """When the SRM returns an error tuple (body, status), the controller relays it.""" - error_body = {"error": "Device not found"} - mock_client = MagicMock() - mock_client.retrieve_location.return_value = (error_body, 404) - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with test_app.test_request_context(): - result = network_functions_controller.retrieve_location(SAMPLE_LOCATION_REQUEST) - - assert result == (error_body, 404) - - -@pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.SRMAPIClientFactory") -def test_retrieve_location_connection_error(mock_factory_class, test_app: Flask): - """When the SRM is unreachable, the controller returns a 500 error.""" - mock_client = MagicMock() - mock_client.retrieve_location.side_effect = ConnectionError("Connection refused") - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with test_app.test_request_context(): - response, status = network_functions_controller.retrieve_location(SAMPLE_LOCATION_REQUEST) - - assert status == 500 - data = response.get_json() - assert data["error"] == "An unexpected error occurred" - assert "Connection refused" in data["details"] - - -@pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.SRMAPIClientFactory") -def test_retrieve_location_with_ipv4(mock_factory_class, test_app: Flask): - """Location retrieval works with IPv4 device identifier.""" - ipv4_request = { - "device": { - "ipv4Address": { - "publicAddress": "198.51.100.1", - "publicPort": 59765, - } - }, - "maxAge": 120, - "maxSurface": 10000, - } - mock_client = MagicMock() - mock_client.retrieve_location.return_value = SAMPLE_LOCATION_RESPONSE - mock_factory_class.return_value.create_srm_api_client.return_value = mock_client - - with test_app.test_request_context(): - result = network_functions_controller.retrieve_location(ipv4_request) - - assert result == SAMPLE_LOCATION_RESPONSE - mock_client.retrieve_location.assert_called_once_with(ipv4_request) - - -@pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.PiEdgeAPIClientFactory") -def test_retrieve_location_legacy_success(mock_factory_class, test_app: Flask): - """Legacy route alias returns the same successful response as the canonical route.""" - mock_client = MagicMock() - mock_client.retrieve_location.return_value = SAMPLE_LOCATION_RESPONSE - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client - - with test_app.test_request_context(): - result = network_functions_controller.retrieve_location_legacy(SAMPLE_LOCATION_REQUEST) - - assert result == SAMPLE_LOCATION_RESPONSE - mock_client.retrieve_location.assert_called_once_with(SAMPLE_LOCATION_REQUEST) - - -@pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.PiEdgeAPIClientFactory") -def test_retrieve_location_legacy_srm_error_is_relayed(mock_factory_class, test_app: Flask): - """Legacy route alias relays SRM errors the same way as the canonical route.""" - error_body = {"error": "Device not found"} - mock_client = MagicMock() - mock_client.retrieve_location.return_value = (error_body, 404) - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client - - with test_app.test_request_context(): - result = network_functions_controller.retrieve_location_legacy(SAMPLE_LOCATION_REQUEST) - - assert result == (error_body, 404) - - -@pytest.mark.unit -@patch("edge_cloud_management_api.controllers.network_functions_controller.PiEdgeAPIClientFactory") -def test_retrieve_location_legacy_connection_error(mock_factory_class, test_app: Flask): - """Legacy route alias preserves canonical connection error behavior.""" - mock_client = MagicMock() - mock_client.retrieve_location.side_effect = ConnectionError("Connection refused") - mock_factory_class.return_value.create_pi_edge_api_client.return_value = mock_client - - with test_app.test_request_context(): - response, status = network_functions_controller.retrieve_location_legacy(SAMPLE_LOCATION_REQUEST) - - assert status == 500 - data = response.get_json() - assert data["error"] == "An unexpected error occurred" - assert "Connection refused" in data["details"] - diff --git a/tests/unit/managers/__init__.py b/tests/unit/managers/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/tests/unit/managers/test_db_manager.py.old b/tests/unit/managers/test_db_manager.py.old deleted file mode 100644 index aefc9630594a96ac815d08d4782845fa382e7b0b..0000000000000000000000000000000000000000 --- a/tests/unit/managers/test_db_manager.py.old +++ /dev/null @@ -1,101 +0,0 @@ -import pytest -from unittest.mock import patch -import mongomock - -from edge_cloud_management_api.managers.db_manager import MongoManager - - -class TestConfig: - MONGO_URI = "mongodb://test_admin:test_password@localhost:27017/test_db" - - -@pytest.fixture -def mock_mongo_manager(): - """ - Fixture to provide a MongoManager instance with a mocked MongoDB client. - """ - - with patch("edge_cloud_management_api.managers.db_manager.config", new=TestConfig): - with patch( - "edge_cloud_management_api.managers.db_manager.MongoClient", - new=mongomock.MongoClient, - ): - # mongo_manager = MongoManager() - # yield mongo_manager - # mongo_manager.close_connection() - with MongoManager() as mongo_manager: - yield mongo_manager - - -@pytest.mark.unit -class TestMongoManager: - """ - Test the MongoManager class. - """ - - # def test_insert_document(mock_mongo_manager): - # """ - # Test the insert_document method. - # """ - # inserted_id = mock_mongo_manager.insert_document( - # "test_collection", {"name": "Test User", "email": " - def test_insert_document(self, mock_mongo_manager): - """ - Test the insert_document method. - """ - inserted_id = mock_mongo_manager.insert_document("test_collection", {"name": "Test User", "email": "test@example.com"}) - assert inserted_id is not None - result = mock_mongo_manager.find_document("test_collection", {"_id": inserted_id}) - assert result["name"] == "Test User" - - def test_find_document(self, mock_mongo_manager): - """ - Test the find_document method. - """ - mock_mongo_manager.insert_document("test_collection", {"name": "Test User", "email": "test@example.com"}) - result = mock_mongo_manager.find_document("test_collection", {"name": "Test User"}) - assert result is not None - assert result["email"] == "test@example.com" - - def test_find_documents(self, mock_mongo_manager): - """ - Test the find_documents method. - """ - mock_mongo_manager.insert_document("test_collection", {"name": "User 1", "email": "user1@example.com"}) - mock_mongo_manager.insert_document("test_collection", {"name": "User 2", "email": "user2@example.com"}) - results = mock_mongo_manager.find_documents("test_collection", {}) - assert len(list(results)) == 2 - - def test_update_document(self, mock_mongo_manager): - """ - Test the update_document method. - """ - inserted_id = mock_mongo_manager.insert_document("test_collection", {"name": "User", "email": "user@example.com"}) - update_count = mock_mongo_manager.update_document("test_collection", {"_id": inserted_id}, {"name": "Updated User"}) - assert update_count == 1 - result = mock_mongo_manager.find_document("test_collection", {"_id": inserted_id}) - assert result["name"] == "Updated User" - - def test_delete_document(self, mock_mongo_manager): - """ - Test the delete_document method. - """ - inserted_id = mock_mongo_manager.insert_document("test_collection", {"name": "User", "email": "user@example.com"}) - delete_count = mock_mongo_manager.delete_document("test_collection", {"_id": inserted_id}) - assert delete_count == 1 - result = mock_mongo_manager.find_document("test_collection", {"_id": inserted_id}) - assert result is None - - def test_update_nonexistent_document(self, mock_mongo_manager): - """ - Test updating a nonexistent document. - """ - update_count = mock_mongo_manager.update_document("test_collection", {"name": "Nonexistent"}, {"name": "Updated"}) - assert update_count == 0 - - def test_delete_nonexistent_document(self, mock_mongo_manager): - """ - Test deleting a nonexistent document. - """ - delete_count = mock_mongo_manager.delete_document("test_collection", {"name": "Nonexistent"}) - assert delete_count == 0 diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 37f22c7f6dc58ecc7a37b238ee4f7ca79a3dec6a..0000000000000000000000000000000000000000 --- a/tox.ini +++ /dev/null @@ -1,37 +0,0 @@ -[tox] -minversion = 4.23.2 -env_list = lint, type, 3.1{2} -skip_missing_interpreters = true - -[testenv] -description = run the tests with pytest -passenv = MONGO_URI -package = wheel -wheel_build_env = .pkg -deps = - pytest-cov - pytest>=6 - mongomock>=4.3.0 -commands = - pytest {tty:--color=yes} {posargs} \ - --cov=edge_cloud_management_api \ - --cov-report=xml \ - --cov-report=term-missing \ - tests/ - -[testenv:lint] -description = run linters -skip_install = true -deps = ruff>=0.8.1 -commands = ruff check {posargs} edge_cloud_management_api tests - -[testenv:type] -description = run type checks -skip_install = true -deps = - mypy - types-requests -commands = mypy {posargs} edge_cloud_management_api tests - -[mypy-edge_cloud_management_api.controllers.edge_cloud_controller] -ignore_errors = True diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..1002210d57499675496cb7bcb974ab372b8c3d14 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1387 @@ +version = 1 +revision = 2 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload_time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload_time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload_time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload_time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asgi-lifespan" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627, upload_time = "2023-03-28T17:35:49.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload_time = "2023-03-28T17:35:47.772Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload_time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload_time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload_time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload_time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload_time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload_time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload_time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload_time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload_time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload_time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload_time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload_time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload_time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload_time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload_time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload_time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload_time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload_time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload_time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload_time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload_time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload_time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload_time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload_time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload_time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload_time = "2025-11-24T23:26:02.564Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload_time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload_time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload_time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload_time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload_time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload_time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload_time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload_time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload_time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload_time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload_time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload_time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload_time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload_time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload_time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload_time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload_time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload_time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload_time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload_time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload_time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload_time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload_time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload_time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload_time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload_time = "2026-05-17T00:47:56.842Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload_time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload_time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload_time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload_time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload_time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload_time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload_time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload_time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload_time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload_time = "2026-04-23T16:49:42.437Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload_time = "2026-02-24T10:45:10.476Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload_time = "2026-02-24T10:45:09.552Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/57/cee8e91b83f39e75ae5562a2237261442a8179dcb3b631c7398113157398/fastapi_cloud_cli-0.17.1.tar.gz", hash = "sha256:0baece208fa88063bec46dccb5fb512f3199162092165e57654b44e64adbc44d", size = 47409, upload_time = "2026-04-27T13:38:07.094Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/a0/e252b68cf155409afabea037ab2971f41509481838847f6503fe890884ea/fastapi_cloud_cli-0.17.1-py3-none-any.whl", hash = "sha256:325e0199bdac7cb86f5df4f4a1d2070054095588088ef7b923a60cec458dcd63", size = 34046, upload_time = "2026-04-27T13:38:08.319Z" }, +] + +[[package]] +name = "fastar" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload_time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload_time = "2026-04-13T17:09:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload_time = "2026-04-13T17:09:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload_time = "2026-04-13T17:09:11.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload_time = "2026-04-13T17:07:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload_time = "2026-04-13T17:08:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload_time = "2026-04-13T17:08:25.526Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload_time = "2026-04-13T17:08:56.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload_time = "2026-04-13T17:09:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload_time = "2026-04-13T17:08:40.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload_time = "2026-04-13T17:10:12.997Z" }, + { url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload_time = "2026-04-13T17:10:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload_time = "2026-04-13T17:10:47.072Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload_time = "2026-04-13T17:11:04.293Z" }, + { url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload_time = "2026-04-13T17:11:45.168Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload_time = "2026-04-13T17:11:31.084Z" }, + { url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload_time = "2026-04-13T17:11:20.577Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload_time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload_time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "graphql-core" +version = "3.2.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/90/f2aff026ab4aebd80eb71905106a0885f4cfde85dcf965543f45bed0d9ee/graphql_core-3.2.11.tar.gz", hash = "sha256:e7e156d10beb127cab5c89ff0da71416fc73d27c484a4757d3b2d35633774802", size = 528407, upload_time = "2026-06-05T13:45:22.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl", hash = "sha256:0b3e35ff41e9adba53021ab0cef475eb18f57c7f53f0f2ca55567fbf3c537ea0", size = 214879, upload_time = "2026-06-05T13:45:21.245Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload_time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload_time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload_time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload_time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload_time = "2026-05-20T14:09:09.18Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload_time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload_time = "2026-05-20T14:01:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload_time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload_time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload_time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload_time = "2026-05-20T13:08:45.056Z" }, +] + +[[package]] +name = "grimp" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/46/79764cfb61a3ac80dadae5d94fb10acdb7800e31fecf4113cf3d345e4952/grimp-3.14.tar.gz", hash = "sha256:645fbd835983901042dae4e1b24fde3a89bf7ac152f9272dd17a97e55cb4f871", size = 830882, upload_time = "2025-12-10T17:55:01.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/d6/a35ff62f35aa5fd148053506eddd7a8f2f6afaed31870dc608dd0eb38e4f/grimp-3.14-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ffabc6940301214753bad89ec0bfe275892fa1f64b999e9a101f6cebfc777133", size = 2178573, upload_time = "2025-12-10T17:53:42.836Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/bd2e80273da4d46110969fc62252e5372e0249feb872bc7fe76fdc7f1818/grimp-3.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:075d9a1c78d607792d0ed8d4d3d7754a621ef04c8a95eaebf634930dc9232bb2", size = 2110452, upload_time = "2025-12-10T17:53:19.831Z" }, + { url = "https://files.pythonhosted.org/packages/44/c3/7307249c657d34dca9d250d73ba027d6cfe15a98fb3119b6e5210bc388b7/grimp-3.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06ff52addeb20955a4d6aa097bee910573ffc9ef0d3c8a860844f267ad958156", size = 2283064, upload_time = "2025-12-10T17:52:07.673Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d2/cae4cf32dc8d4188837cc4ab183300d655f898969b0f169e240f3b7c25be/grimp-3.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d10e0663e961fcbe8d0f54608854af31f911f164c96a44112d5173050132701f", size = 2235893, upload_time = "2025-12-10T17:52:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/3f58bc3064fc305dac107d08003ba65713a5bc89a6d327f1c06b30cce752/grimp-3.14-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ab874d7ddddc7a1291259cf7c31a4e7b5c612e9da2e24c67c0eb1a44a624e67", size = 2393376, upload_time = "2025-12-10T17:53:02.397Z" }, + { url = "https://files.pythonhosted.org/packages/06/b8/f476f30edf114f04cb58e8ae162cb4daf52bda0ab01919f3b5b7edb98430/grimp-3.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54fec672ec83355636a852177f5a470c964bede0f6730f9ba3c7b5c8419c9eab", size = 2571342, upload_time = "2025-12-10T17:52:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ae/2e44d3c4f591f95f86322a8f4dbb5aac17001d49e079f3a80e07e7caaf09/grimp-3.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9e221b5e8070a916c780e88c877fee2a61c95a76a76a2a076396e459511b0bb", size = 2359022, upload_time = "2025-12-10T17:52:49.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/ac/42b4d6bc0ea119ce2e91e1788feabf32c5433e9617dbb495c2a3d0dc7f12/grimp-3.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eea6b495f9b4a8d82f5ce544921e76d0d12017f5d1ac3a3bd2f5ac88ab055b1c", size = 2309424, upload_time = "2025-12-10T17:53:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c7/6a731989625c1790f4da7602dcbf9d6525512264e853cda77b3b3602d5e0/grimp-3.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:655e8d3f79cd99bb859e09c9dd633515150e9d850879ca71417d5ac31809b745", size = 2462754, upload_time = "2025-12-10T17:53:50.886Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/3d1571c0a39a59dd68be4835f766da64fe64cbab0d69426210b716a8bdf0/grimp-3.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a14f10b1b71c6c37647a76e6a49c226509648107abc0f48c1e3ecd158ba05531", size = 2501356, upload_time = "2025-12-10T17:54:06.014Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d1/8950b8229095ebda5c54c8784e4d1f0a6e19423f2847289ef9751f878798/grimp-3.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:81685111ee24d3e25f8ed9e77ed00b92b58b2414e1a1c2937236026900972744", size = 2504631, upload_time = "2025-12-10T17:54:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/23bed3da9206138d36d01890b656c7fb7adfb3a37daac8842d84d8777ade/grimp-3.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce8352a8ea0e27b143136ea086582fc6653419aa8a7c15e28ed08c898c42b185", size = 2514751, upload_time = "2025-12-10T17:54:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/6f1f55c97ee982f133ec5ccb22fc99bf5335aee70c208f4fb86cd833b8d5/grimp-3.14-cp312-cp312-win32.whl", hash = "sha256:3fc0f98b3c60d88e9ffa08faff3200f36604930972f8b29155f323b76ea25a06", size = 1875041, upload_time = "2025-12-10T17:55:13.326Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/03ba01288e2a41a948bc8526f32c2eeaddd683ed34be1b895e31658d5a4c/grimp-3.14-cp312-cp312-win_amd64.whl", hash = "sha256:6bca77d1d50c8dc402c96af21f4e28e2f1e9938eeabd7417592a22bd83cde3c3", size = 2013868, upload_time = "2025-12-10T17:55:05.907Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "harfile" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/0e/ffbb98cd1910f1f898ddc62d649dbf0e3d68026ee9313d3cff035206b727/harfile-0.5.0.tar.gz", hash = "sha256:c1524b8f0a39dd9f19365760aefb3adbba951818310d17b2eaa293de1f4c170a", size = 10293, upload_time = "2026-05-29T11:50:03.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/21/949c004d730d610dac63bcf5964f2585e98aa7bd44ff570ef8844472e76b/harfile-0.5.0-py3-none-any.whl", hash = "sha256:ab8a01d0d21d3b7f5ad57e7dc56b8b829f6a8855c1fa143aad464e7e8169f5e3", size = 7172, upload_time = "2026-05-29T11:50:02.206Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload_time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload_time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload_time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload_time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload_time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload_time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload_time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload_time = "2025-10-10T03:54:45.923Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.156.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/3d/1a0e54652f1b3cad354949ba83f6ab211048ff3a6cfb24edcddc748a8be1/hypothesis-6.156.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0409dea0c0b48705cad8413468282af9c5e7d3a017b6c2b94ef80e7ba4b64862", size = 749005, upload_time = "2026-07-03T14:30:26.045Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/c8b6084023faabef7612ea0169fd4df565fdc5fd73f47484e5edf03640fb/hypothesis-6.156.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:72b9305414c20802540ec0cd798478c9e06c8a248ce32d7df04722b64d7150ad", size = 741899, upload_time = "2026-07-03T14:30:34.442Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/51644c53ae055a3a3411906cc8e41e5dd53af487ca76465342256e4f18c1/hypothesis-6.156.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40584eed6a57c402d8ab32ea5c1779bae6f7390ff33d073c876e09dc8af31941", size = 1069788, upload_time = "2026-07-03T14:31:24.552Z" }, + { url = "https://files.pythonhosted.org/packages/cc/43/d2ec198fa56f72f8690563247e5e71a81cf5f1c9c42e977008b76338803f/hypothesis-6.156.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76f2aaa6725185f16a8fa7b58e0ba5d5119cafa33eae5e5dec996d6a28cd9dc6", size = 1119541, upload_time = "2026-07-03T14:30:15.15Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/ba8a1bf72988e7b8b743b4f4f30705fe1c2128780a7d93b203cdeb6bae79/hypothesis-6.156.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bac95ae29f7850a923f43bc7e21bbc7ee8b8094bbf07691be4f153e537b5d3db", size = 1243864, upload_time = "2026-07-03T14:30:47.869Z" }, + { url = "https://files.pythonhosted.org/packages/74/a3/f30ebb96f099f19e71361aefb233542f3763a0fb7c0ba3c84513c353b065/hypothesis-6.156.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8159b5e93a7724920cf55dd4ec743a84fc9c3c457a6736a92283aa4068f54daa", size = 1286401, upload_time = "2026-07-03T14:30:07.762Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a9/19b36a5deb78217f4f6214c7be36a14cf2da8b29392199bcd18a0628b9b8/hypothesis-6.156.1-cp312-cp312-win_amd64.whl", hash = "sha256:d8e7deed3bc76c8c12c30e092e228a6978180f2bcab9483f5fb22240cd8eaa7d", size = 637634, upload_time = "2026-07-03T14:30:40.794Z" }, +] + +[[package]] +name = "hypothesis-graphql" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphql-core" }, + { name = "hypothesis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/8c/3f0ebc9b557073986234c42bd21ea9c7ea146e7e2616b7b0739f5e81cf2b/hypothesis_graphql-0.13.0.tar.gz", hash = "sha256:788d89be1bbb561f27616f3a7077290054e4f664f88315a0ad03edee93e5d681", size = 750971, upload_time = "2026-05-29T20:57:04.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/3e/9e4b29a509fadec889dcd1f115fcc30655fd5296bd00198dcd8a0ea78d64/hypothesis_graphql-0.13.0-py3-none-any.whl", hash = "sha256:0f866e4d338dca76286a9bd5f1412785a2426daf41854bd74ff2c9525e71eae8", size = 22706, upload_time = "2026-05-29T20:57:05.749Z" }, +] + +[[package]] +name = "hypothesis-jsonschema" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hypothesis" }, + { name = "jsonschema" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/ad/2073dd29d8463a92c243d0c298370e50e0d4082bc67f156dc613634d0ec4/hypothesis-jsonschema-0.23.1.tar.gz", hash = "sha256:f4ac032024342a4149a10253984f5a5736b82b3fe2afb0888f3834a31153f215", size = 42896, upload_time = "2024-02-28T20:33:50.209Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/44/635a8d2add845c9a2d99a93a379df77f7e70829f0a1d7d5a6998b61f9d01/hypothesis_jsonschema-0.23.1-py3-none-any.whl", hash = "sha256:a4d74d9516dd2784fbbae82e009f62486c9104ac6f4e3397091d98a1d5ee94a2", size = 29200, upload_time = "2024-02-28T20:33:48.744Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload_time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload_time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload_time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload_time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "import-linter" +version = "2.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/55b697a17bb15c6cb88d97d73716813f5427281527b90f02cc0a600abc6e/import_linter-2.11.tar.gz", hash = "sha256:5abc3394797a54f9bae315e7242dc98715ba485f840ac38c6d3192c370d0085e", size = 1153682, upload_time = "2026-03-06T12:11:38.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/aa/2ed2c89543632ded7196e0d93dcc6c7fe87769e88391a648c4a298ea864a/import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465", size = 637315, upload_time = "2026-03-06T12:11:36.599Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload_time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload_time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload_time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload_time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload_time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload_time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-rs" +version = "0.46.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/7c/ee82ad9250dc57de708e38439643dd16333072e36438bc71c1984fd7e2a0/jsonschema_rs-0.46.10.tar.gz", hash = "sha256:1a38763e38614d9cf50e0ea70addff0aacc1c01ddb8f266670cb46f0f4090810", size = 2013189, upload_time = "2026-07-05T19:52:34.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f7/3027e25bb449c12ff9dbde4a66b3e85663ca55d82609a0caa5e40da3a4d5/jsonschema_rs-0.46.10-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8940488ba535add4e5bf31dcb8193f4374aef87087178684fd03a7387ddb7cc3", size = 7459622, upload_time = "2026-07-05T19:51:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/64/6c/653e4ac266f679f8b35557bb5ef021f32debf865fc3dff3bb25b84ca1c84/jsonschema_rs-0.46.10-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a5b193c74659d8c3a4395dd31143dac7e78f39348d71c28288058e243e4dc255", size = 3894653, upload_time = "2026-07-05T19:51:56.095Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/34eb07443c1aa41b766eecb7bd90b1749cd56a12e49ecd5695ec9de40328/jsonschema_rs-0.46.10-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2b0c2794cfc29656d3b46704490bb303af8673efa9f1ba0302eb779db74208f", size = 3658154, upload_time = "2026-07-05T19:51:57.45Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d5/dcbf1ab682770f28caab30aca13da04cb4c131a5822c9fe0f1f6f0a35808/jsonschema_rs-0.46.10-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1d2b42af21afbfd13ea6c075bd53dba58474a761715a4f695c9187351d50bc3", size = 3994352, upload_time = "2026-07-05T19:51:58.676Z" }, + { url = "https://files.pythonhosted.org/packages/db/c2/315447fa2effc82a0b35478d2182146d0a42f6078de163d9780b9feec4e6/jsonschema_rs-0.46.10-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2d22ad0d5055dce6a2a886352623f79399495bb35eef4da67f21d178d8f2494f", size = 3672805, upload_time = "2026-07-05T19:52:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/f8d52b14bd8bd54c99f97ac18c501ced3108848bdb068500e68f24ca7b3a/jsonschema_rs-0.46.10-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:35cf45b680ef8d443b6f284a8d6fe64478fba9775d19d120937a242213c95610", size = 3866507, upload_time = "2026-07-05T19:52:01.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/01/175241d383d1254963037275f5a0d57bc7a055c85318f958e55ac0767cc8/jsonschema_rs-0.46.10-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c6beca393b1a618edcd9a52e2a3b85c07e6e5e0fdd9edd6e185b86d81cc53d82", size = 4221364, upload_time = "2026-07-05T19:52:03.11Z" }, + { url = "https://files.pythonhosted.org/packages/d5/77/9e6297b8e88107b142f5e49c9e25c9622367912139f6bbe9831d0b633ab3/jsonschema_rs-0.46.10-cp310-abi3-win32.whl", hash = "sha256:e3d58c692a8e1a721a9be10156af1884088d3baa41e32f6e07075df8064cef32", size = 3259178, upload_time = "2026-07-05T19:52:04.326Z" }, + { url = "https://files.pythonhosted.org/packages/38/2a/3747292d5519a88b258520d2402bb9974ea9a8adfca63cc5dc8b3354bce9/jsonschema_rs-0.46.10-cp310-abi3-win_amd64.whl", hash = "sha256:9dc27f9fed9b71daacae9f07dd598c9125a26629e2340168160c05a8fdcce921", size = 3827382, upload_time = "2026-07-05T19:52:06.502Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload_time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload_time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload_time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload_time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload_time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload_time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload_time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload_time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload_time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload_time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload_time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload_time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload_time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload_time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload_time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload_time = "2026-05-10T18:16:10.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload_time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload_time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload_time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload_time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload_time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload_time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload_time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload_time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload_time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload_time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload_time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload_time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload_time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload_time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload_time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload_time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload_time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload_time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload_time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload_time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload_time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload_time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload_time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload_time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload_time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload_time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nats-py" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/f0/fc5e93f2b0dd14a202590ad9d30eda1955ea872039b5204357348d0f4b1e/nats_py-2.15.0.tar.gz", hash = "sha256:6622c547d9a7d2313d9c147d46c386188f4ec2c7b5c9f9a0438a4d1b55f54a93", size = 75995, upload_time = "2026-06-05T07:34:03.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a8/b55606c7c621fb813c8ec78baf201d2c78bf6051091ec0c7ada572999e95/nats_py-2.15.0-py3-none-any.whl", hash = "sha256:9f8d36aa52a9926a88b8f1d70cf1fdce0ad387941479b500ee9ab3e51073cefd", size = 90334, upload_time = "2026-06-05T07:34:02.81Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload_time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload_time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "open-exposure-gateway" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "asyncpg" }, + { name = "fastapi", extra = ["standard"] }, + { name = "nats-py" }, + { name = "python-dotenv" }, + { name = "sqlalchemy" }, + { name = "structlog" }, +] + +[package.optional-dependencies] +dev = [ + { name = "asgi-lifespan" }, + { name = "import-linter" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "schemathesis" }, + { name = "testcontainers" }, +] + +[package.metadata] +requires-dist = [ + { name = "asgi-lifespan", marker = "extra == 'dev'", specifier = ">=2.1.0" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.135.1" }, + { name = "import-linter", marker = "extra == 'dev'", specifier = ">=2.11" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" }, + { name = "nats-py", specifier = ">=2.10.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.6" }, + { name = "schemathesis", marker = "extra == 'dev'", specifier = ">=4.22" }, + { name = "sqlalchemy", specifier = ">=2.0.48" }, + { name = "structlog", specifier = ">=25.5.0" }, + { name = "testcontainers", marker = "extra == 'dev'", specifier = ">=4.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload_time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload_time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload_time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload_time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload_time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload_time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload_time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload_time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload_time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload_time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload_time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload_time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload_time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload_time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload_time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload_time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload_time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload_time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload_time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload_time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload_time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload_time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload_time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload_time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload_time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload_time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload_time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload_time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload_time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload_time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload_time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload_time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload_time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload_time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload_time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload_time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyrate-limiter" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/27/e564f33ea085c63d5540f707b31aeb50a4992eac2da655dc02435a760a07/pyrate_limiter-4.4.0.tar.gz", hash = "sha256:2c0c720c4fa16c5d8199e4821bf34507fb49c007a25b786cec6fb94ffd0844aa", size = 90955, upload_time = "2026-06-14T10:52:03.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/77/2b5ea2e5e343fd7f74ba9c50a282d7cb66d1be3d12bd647510338d78fcf1/pyrate_limiter-4.4.0-py3-none-any.whl", hash = "sha256:f738dfa3c7ac1222a5ea3d31e00cfd31b5592b13ade4077afe9e8ac6293381f5", size = 43102, upload_time = "2026-06-14T10:52:01.334Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload_time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload_time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload_time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload_time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload_time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload_time = "2026-05-12T20:53:34.969Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload_time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload_time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload_time = "2026-05-17T17:29:47.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload_time = "2026-05-17T17:29:45.69Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload_time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload_time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload_time = "2026-06-04T07:49:34.244Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload_time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload_time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload_time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload_time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload_time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload_time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload_time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload_time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload_time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload_time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload_time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload_time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload_time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload_time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload_time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload_time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload_time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.19.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/10/dc6e64e85244971671981dc26b09353a1564f5e61b977c80180dc42ad90b/rich_toolkit-0.19.9.tar.gz", hash = "sha256:fce5c6f41f79382ecf60a79851b2543f627568e3e07c78ab4b8542e1ca247d1c", size = 197653, upload_time = "2026-05-13T09:55:04.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/60/5a7de329d0b5b619757c169bbf8a5146c20fe49bd4d74045937fcd45a7d0/rich_toolkit-0.19.9-py3-none-any.whl", hash = "sha256:a1341f88feed5f295f001bb1c6b6cf1e208674187dd900416a30fd9d6f74fcce", size = 33711, upload_time = "2026-05-13T09:55:05.345Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload_time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload_time = "2025-11-05T20:42:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload_time = "2025-11-05T20:42:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload_time = "2025-11-05T20:40:52.613Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload_time = "2025-11-05T20:41:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload_time = "2025-11-05T20:41:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload_time = "2025-11-05T20:41:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload_time = "2025-11-05T20:42:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload_time = "2025-11-05T20:41:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload_time = "2025-11-05T21:40:13.463Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload_time = "2025-11-05T21:40:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload_time = "2025-11-05T21:40:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload_time = "2025-11-05T21:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload_time = "2025-11-05T21:41:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload_time = "2025-11-05T21:41:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload_time = "2025-11-05T21:41:25.305Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload_time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload_time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload_time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload_time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload_time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload_time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload_time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload_time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload_time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload_time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload_time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload_time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload_time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload_time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload_time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload_time = "2026-06-30T07:15:34.778Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload_time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload_time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload_time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload_time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload_time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload_time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload_time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload_time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload_time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload_time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload_time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload_time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload_time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload_time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload_time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload_time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload_time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload_time = "2026-05-14T13:44:33.026Z" }, +] + +[[package]] +name = "schemathesis" +version = "4.22.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "harfile" }, + { name = "hypothesis" }, + { name = "hypothesis-graphql" }, + { name = "hypothesis-jsonschema" }, + { name = "jsonschema-rs" }, + { name = "pyrate-limiter" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "starlette-testclient" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/50/4ca419862719dde14130595a3654aa16780d225be9074e42d6b90fe3ec7b/schemathesis-4.22.3.tar.gz", hash = "sha256:10c0fb27ed1940030133cf61ba32d1e85f71b85d3570d7ee54d1768b14d64f77", size = 2349247, upload_time = "2026-07-02T08:31:59.656Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/13/886a9d15ebaa19dbd98b527781893bab5967152c7fdd5d0f4464eb42a66f/schemathesis-4.22.3-py3-none-any.whl", hash = "sha256:5c99e3375225ba5a299251e11ba955f3875ae7596126318e94d164660e0e7d6a", size = 785083, upload_time = "2026-07-02T08:31:57.843Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload_time = "2026-05-13T13:34:52.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload_time = "2026-05-13T13:34:50.259Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload_time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload_time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload_time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload_time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload_time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload_time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload_time = "2026-04-03T16:53:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload_time = "2026-04-03T17:07:40Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload_time = "2026-04-03T17:12:23.374Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload_time = "2026-04-03T17:07:41.949Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload_time = "2026-04-03T17:12:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload_time = "2026-04-03T17:05:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload_time = "2026-04-03T17:05:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload_time = "2026-04-03T16:53:44.135Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload_time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload_time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "starlette-testclient" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/64/6debec8fc6e9abde0c7042145dc27a562bd1cd79350a55b80bf612a10ccb/starlette_testclient-0.4.1.tar.gz", hash = "sha256:9e993ffe12fab45606116257813986612262fe15c1bb6dc9e39cc68693ac1fc5", size = 12480, upload_time = "2024-04-29T10:54:28.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/44/f5209b889a344b1331a103aec4e9f906c7f67f9295fd287fdaa818179d95/starlette_testclient-0.4.1-py3-none-any.whl", hash = "sha256:dcf0eb237dc47f062ef5925f98330af46f67e547cb587119c9ae78c17ae6c1d1", size = 8143, upload_time = "2024-04-29T10:54:25.728Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload_time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload_time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload_time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload_time = "2026-03-18T05:19:15.29Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload_time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload_time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload_time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload_time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload_time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload_time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload_time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload_time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload_time = "2026-05-14T18:16:54.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload_time = "2026-05-14T18:16:51.762Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload_time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload_time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload_time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload_time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload_time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload_time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload_time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload_time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload_time = "2026-05-13T18:01:27.815Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload_time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload_time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload_time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload_time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload_time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload_time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload_time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload_time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload_time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload_time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload_time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload_time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload_time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload_time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload_time = "2026-05-18T04:30:22.23Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload_time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload_time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload_time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload_time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload_time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload_time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload_time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload_time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload_time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload_time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload_time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload_time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload_time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload_time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload_time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload_time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload_time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload_time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload_time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload_time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload_time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload_time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload_time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload_time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload_time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload_time = "2026-06-20T23:49:42.966Z" }, +]