Commit 481fc88e authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

fix: align app manifest validation with CAMARA

Validate POST /apps against a CAMARA-aligned app manifest model, require UUID appId and requiredResources, keep optional fields optional, and accept CAMARA-style repository URIs without over-constraining appRepo metadata.
parent 6a16b9df
Loading
Loading
Loading
Loading
+9 −7
Original line number Diff line number Diff line
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
@@ -98,6 +99,7 @@ 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)
+66 −22
Original line number Diff line number Diff line
@@ -5,8 +5,8 @@
#from edge_cloud_management_api.models.edge_cloud_models import EdgeCloudZone


from pydantic import BaseModel, HttpUrl, Field, UUID4
from typing import Any, List, Optional
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

@@ -51,12 +51,54 @@ class AppRepo(BaseModel):
        HTTP_BEARER = "HTTP_BEARER"
        NONE = "NONE"

    type: str  # PRIVATEREPO or PUBLICREPO
    imagePath: HttpUrl
    userName: Optional[str]
    credentials: Optional[str]  # max 128 characters
    authType: Optional[AppRepoAuthType]
    checksum: Optional[str]
    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):
@@ -65,6 +107,7 @@ class AppManifest(BaseModel):
        OVA = "OVA"
        CONTAINER = "CONTAINER"
        HELM = "HELM"
        CSAR = "CSAR"

    class OperatingSystem(BaseModel):
        architecture: str  # x86_64, x86
@@ -72,13 +115,14 @@ class AppManifest(BaseModel):
        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]
    operatingSystem: Optional[OperatingSystem] = None
    appRepo: AppRepo
    requiredResources: Optional[Any]  # Could be KubernetesResources, ContainerResources, etc.
    requiredResources: RequiredResources
    componentSpec: List[ComponentSpec]


+82 −1
Original line number Diff line number Diff line
@@ -41,6 +41,87 @@ def test_resolve_federated_app_identity_normalizes_app_and_provider_ids():
    assert provider_id == "providerplaygroundoegnginx"


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")
@@ -226,7 +307,7 @@ def test_dedupe_app_instances_prefers_richer_zone_identity():
    }]


@patch("edge_cloud_management_api.controllers.app_controllers.get_zone")
@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",