Commit ef59a2ac authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

Fix QoD issues found in the NEF coresim end-to-end test

- QoD activate reports active once the NEF created the subscription, as
  traffic influence does (TF-SDK-1).
- QoD without a notification destination is rejected with ValidationError
  instead of sending "None" to the NEF (TF-SDK-2). The NEF then no longer
  stores "None", which also made reading the session back fail.
- Open5GCore no longer overwrites the UE IPv4 with a hardcoded address
  (TF-SDK-3).
- Validation errors keep their reason: the client's message, or the field
  and rule of a pydantic error (TF-SDK-6).
- The OAI client no longer logs the UE's IP and external id with a
  non-ASCII arrow; on a non-UTF-8 console that log line failed the
  location request.

Original-Author: Igli <kociaj4@gmail.com>
Reviewed-Commit: 149fdb0e5a2fcbc11ec2bebab8cae207c26e0ac5
parent 31ad6b27
Loading
Loading
Loading
Loading
+17 −11
Changes for src/openop_tfsdk/contract/adapters/network.py: 17 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -66,12 +66,6 @@ _SURFACES = {
    "traffic_influence": ("traffic_influence", TRAFFIC_INFLUENCE_ACTIONS),
}

_LIFECYCLE_STATES = {
    "REQUESTED": LifecycleState.PROVISIONING,
    "AVAILABLE": LifecycleState.ACTIVE,
    "UNAVAILABLE": LifecycleState.FAILED,
}

_RUNTIME_STATES = {
    "AVAILABLE": RuntimeState.ACTIVE,
    "UNAVAILABLE": RuntimeState.FAILED,
@@ -132,9 +126,10 @@ def _qod_session(request: CapabilityLifecycleRequest, notification_url: Optional
        session["devicePorts"] = {"ports": [filters.source.port]}
    if filters and filters.destination and filters.destination.port:
        session["applicationServerPorts"] = {"ports": [filters.destination.port]}
    # Without a sink the client sends notificationDestination "None".
    # The NEF requires a notification destination; without one the client sends "None".
    sink = notification_url or parameters.extra.get("sink")
    if sink:
    if not sink:
        raise ValidationError("qod_session needs notification_url in the control path")
    session["sink"] = sink
    return session

@@ -190,6 +185,14 @@ def _location(location) -> LocationQueryResult:
    return LocationQueryResult(last_location_time=location.lastLocationTime.root, area=canonical)


def _reason(error) -> str:
    # Pydantic messages can echo request values, so only their fields and rules are kept.
    if isinstance(error, pydantic.ValidationError):
        problems = validation_diagnostics(error, vars(schemas).values())["validation_errors"]
        return ", ".join(f"{problem['field']} {problem['reason']}" for problem in problems)
    return str(error)


class NetworkCapabilityAdapter:
    """Executes network capabilities on a network core through its NetworkManager client."""

@@ -219,10 +222,11 @@ class NetworkCapabilityAdapter:
                external_id=_resource_id(resource.get("trafficInfluenceID")),
            )
        session = self._call("create_qod_session", _qod_session(request, self._notification_url))
        # The NEF created the subscription; the clients report no later state than that.
        return CapabilityLifecycleResult(
            operation_id=request.operation_id,
            capability_kind=request.capability_kind,
            state=_LIFECYCLE_STATES.get(session["qosStatus"], LifecycleState.PROVISIONING),
            state=LifecycleState.ACTIVE,
            external_id=session["sessionId"],
        )

@@ -309,7 +313,7 @@ class NetworkCapabilityAdapter:
                **validation_diagnostics(error, vars(schemas).values()),
            )
            raise ValidationError(
                f"location_context: invalid request for {self.adapter_type}"
                f"location_context: invalid request for {self.adapter_type}: {_reason(error)}"
            ) from error
        location = self._call("create_monitoring_event_subscription", location_request)
        return CapabilityQueryResult(
@@ -333,7 +337,9 @@ class NetworkCapabilityAdapter:
                error_type=type(error).__name__,
                **validation_diagnostics(error, vars(schemas).values()),
            )
            raise ValidationError(f"{method}: invalid request for {self.adapter_type}") from error
            raise ValidationError(
                f"{method}: invalid request for {self.adapter_type}: {_reason(error)}"
            ) from error
        except CoreHttpError as error:
            cause = error.__cause__
            if isinstance(cause, requests.HTTPError) and cause.response is not None:
+1 −1
Changes for src/openop_tfsdk/network/adapters/oai/client.py: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -169,7 +169,7 @@ class NetworkManager(BaseNetworkClient):
            raise NetworkPlatformError(
                f"NEF UE-ID API did not return externalId for IP {ip_address}: {data}"
            )
        log.info(f"Resolved UE IP {ip_address} → externalId {external_id}")
        log.info("Resolved UE ExternalId via NEF UE-ID API")
        return external_id

    def core_specific_monitoring_event_validation(
+0 −1
Changes for src/openop_tfsdk/network/adapters/open5gcore/client.py: 0 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -59,4 +59,3 @@ class NetworkManager(BaseNetworkClient):
    ) -> None:
        flow_id = qos_support_map[session_info.qosProfile.root.strip().lower()]
        subscription.flowInfo = build_flows(flow_id, session_info)
        subscription.ueIpv4Addr = "192.168.6.1"  # ToDo
+113 −0
Changes for tests/contract/test_network_qod.py: 113 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""QoD through the network adapters: the state, notification destination, UE IP and error reason."""

import json
from ipaddress import IPv4Address, IPv4Network, IPv6Address

import pytest

from openop_tfsdk.contract.adapters import build_adapter
from openop_tfsdk.contract.errors import ValidationError
from openop_tfsdk.contract.execution_models import (
    CapabilityLifecycleRequest,
    LifecycleState,
)

NOTIFY = {"notification_url": "http://srm.example/notify"}


class _NefResponse:
    status_code = 201

    def __init__(self, body):
        self._body = body
        self.content = json.dumps(body).encode()

    def json(self):
        return self._body

    def raise_for_status(self):
        pass


@pytest.fixture
def nef_bodies(monkeypatch):
    bodies = []

    def request(method, url, headers=None, data=None):
        body = json.loads(data) if data else {}
        bodies.append(body)
        session_id = "2b0e0c1e-6a4e-4a9b-9f4e-1c2d3e4f5a6b"
        return _NefResponse({**body, "self": f"http://nef/subscriptions/{session_id}"})

    monkeypatch.setattr("openop_tfsdk.network.core.common.requests.request", request)
    return bodies


def _request(profile="qos-e", ipv4="10.0.0.1", ipv6=None):
    target = {
        "zone_ref": "z",
        "zone_kind": "resource",
        "domain_ref": "d",
        "domain_kind": "network",
        "capability_kind": "qod_session",
        "control_path_ref": "nef",
        "action": "activate",
    }
    device = {
        "ipv4": IPv4Address(ipv4) if ipv4 else None,
        "ipv6": IPv6Address(ipv6) if ipv6 else None,
    }
    return CapabilityLifecycleRequest.model_validate(
        {
            "operation_id": "op",
            "tenant_id": "t",
            "capability_kind": "qod_session",
            "target": target,
            "parameters": {
                "schema_version": "srm.params/v1",
                "target": {
                    "device": device,
                    "application_server": {"ipv4": IPv4Network("10.0.0.5/32")},
                },
                "parameters": {"profile_ref": profile, "duration_seconds": 600, "extra": {}},
                "source_spec": {"family": "camara", "api": "quality-on-demand", "version": "1.1.0"},
            },
        }
    )


def _adapter(kind, config=None):
    return build_adapter(kind, {"base_url": "http://nef", "scs_as_id": "af", **(config or {})})


def test_created_session_is_active_and_notifies_the_control_path(nef_bodies):
    result = _adapter("oai", NOTIFY).activate(_request())
    assert result.state == LifecycleState.ACTIVE
    assert nef_bodies[0]["notificationDestination"] == NOTIFY["notification_url"]


def test_missing_notification_url_is_rejected_before_the_nef(nef_bodies):
    with pytest.raises(ValidationError, match="notification_url"):
        _adapter("oai").activate(_request())
    assert nef_bodies == []


def test_open5gcore_sends_the_device_ip(nef_bodies):
    _adapter("open5gcore", NOTIFY).activate(_request())
    assert nef_bodies[0]["ueIpv4Addr"] == "10.0.0.1"


@pytest.mark.parametrize(
    "kind, request_kwargs, reason",
    [
        ("oai", {"profile": "QOS_PREMIUM"}, "not supported by OAI"),
        ("oai", {"ipv4": None, "ipv6": "2001:db8::1"}, "OAI requires UE IPv4 Address"),
        ("open5gs", {"profile": "qos-xx"}, "Open5Gs only supports"),
        ("open5gs", {"profile": "qos e"}, "qosProfile string_pattern_mismatch"),
    ],
)
def test_validation_error_names_the_reason(nef_bodies, kind, request_kwargs, reason):
    with pytest.raises(ValidationError) as raised:
        _adapter(kind, NOTIFY).activate(_request(**request_kwargs))
    assert reason in raised.value.message
    assert "qos e" not in raised.value.message