Commit 55b67ac5 authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

Merge branch 'fix/oai-qod-profiles' into 'refactor/tfsdk-architecture-alignment'

fix: configure OAI QoD profiles per control path

See merge request !207
parents 2cff2af2 47d6d23d
Loading
Loading
Loading
Loading
+41 −0
Changes for README.md: 41 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -260,3 +260,44 @@ refs fail locally. Discovery does not construct execution adapters or resolve
credentials. Keep runtime config out of normalized responses and persisted
topology. The deployment owns mounts; the SDK does not parse a secret URI.
See `tests/contract/bootstrap_example.yaml` for an explicit file-mode example.

## OAI QoD provider profiles

Optionally restrict profile identifiers in an OAI control path's runtime
infrastructure. For example, when the NEF offers these names:

```yaml
control_paths:
  - ref: oai-nef-lab
    adapter_family: network
    adapter_type: oai
    adapter_contract_version: v1
    infrastructure:
      base_url: http://nef.example.test:8080
      scs_as_id: openop
      notification_url: http://notifications.example.test/nef
      qos_profiles: [qos-e, qos2, qos3, qos4]
```

The existing bootstrap runtime accessor and SRM registry pass this configuration
to the SDK factory. The adapter snapshots the list for its own client. Profiles
are opaque provider names: they reach the NEF unchanged, with no inferred aliases
or QoS policy mapping. Configure the actual names and policies offered by that
NEF; the example does not assert a universal OAI default.

Omitting qos_profiles applies no local allowlist: syntactically valid profile
identifiers reach the NEF unchanged. The NEF decides whether a profile exists
and can be applied. There are no built-in provider profile names. An explicit
null, empty, duplicate or malformed list fails adapter construction with
ValidationError. Requests outside a configured list fail before HTTP. Each
control path keeps its own optional restriction. Backend refusals remain typed
execution failures even for profiles allowed locally. The
existing notification destination requirement still applies; configuring profiles
does not implement a receiver or establish data-plane QoS guarantees.

Network DomainExecutionFailed errors include the existing allowlisted backend
status/reason diagnostics in details when available. HTTP400 carries
`{"backend_status": 400, "reason": "bad_request"}`; transport errors carry a safe
reason. Raw response bodies, URLs and exception text are not copied. Error
classes, retryable flags and wrapped HTTP404 classification are unchanged.
SRM's existing bridge can preserve these details without a new contract field.
+1 −1
Changes for src/openop_tfsdk/_diagnostics.py: 1 added line, 1 removed line.
Original line number Diff line number Diff line
"""Small log-only summaries; never copy backend text, payloads or input values."""
"""Allowlisted diagnostic summaries; never copy backend text, payloads or input values."""

from typing import get_args

+32 −7
Changes for src/openop_tfsdk/contract/adapters/network.py: 32 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -247,11 +247,15 @@ class NetworkCapabilityAdapter:
    def __init__(self, infrastructure: Optional[dict] = None):
        infrastructure = infrastructure or {}
        self._scope = ControlPathScope(infrastructure.get("control_path_ref"))
        self._client = self.client_class(
            base_url=infrastructure.get("base_url"), scs_as_id=infrastructure.get("scs_as_id")
        )
        self._client = self.client_class(**self._client_kwargs(infrastructure))
        self._notification_url = infrastructure.get("notification_url")

    def _client_kwargs(self, infrastructure: dict) -> dict:
        return {
            "base_url": infrastructure.get("base_url"),
            "scs_as_id": infrastructure.get("scs_as_id"),
        }

    def activate(self, request: CapabilityLifecycleRequest) -> CapabilityLifecycleResult:
        self._scope.check(request)
        self._require(request.capability_kind, SupportedAction.ACTIVATE)
@@ -405,23 +409,25 @@ class NetworkCapabilityAdapter:
            if isinstance(cause, requests.HTTPError) and cause.response is not None:
                if cause.response.status_code == 404:
                    raise ResourceNotFound(f"{method}: not found") from error
            diagnostics = backend_diagnostics(error)
            log.error(
                "backend_call_failed",
                method=method,
                error_code=DomainExecutionFailed.error_code,
                error_type=type(error).__name__,
                **backend_diagnostics(error),
                **diagnostics,
            )
            raise DomainExecutionFailed(f"{method} failed") from error
            raise DomainExecutionFailed(f"{method} failed", details=diagnostics or None) from error
        except Exception as error:
            diagnostics = backend_diagnostics(error)
            log.error(
                "backend_call_failed",
                method=method,
                error_code=DomainExecutionFailed.error_code,
                error_type=type(error).__name__,
                **backend_diagnostics(error),
                **diagnostics,
            )
            raise DomainExecutionFailed(f"{method} failed") from error
            raise DomainExecutionFailed(f"{method} failed", details=diagnostics or None) from error


class OaiCapabilityAdapter(NetworkCapabilityAdapter):
@@ -429,6 +435,25 @@ class OaiCapabilityAdapter(NetworkCapabilityAdapter):
    client_class = OaiClient
    location_identifiers = ("network_access_id", "ipv4")

    def _client_kwargs(self, infrastructure: dict) -> dict:
        options = super()._client_kwargs(infrastructure)
        if "qos_profiles" not in infrastructure:
            return options
        profiles = infrastructure["qos_profiles"]
        message = "qos_profiles must contain nonempty, unique, valid QoS profile names"
        if (
            not isinstance(profiles, (list, tuple))
            or not profiles
            or not all(isinstance(name, str) for name in profiles)
            or len(set(profiles)) != len(profiles)
        ):
            raise ValidationError(message)
        try:
            options["qos_profiles"] = tuple(schemas.QosProfileName(name).root for name in profiles)
        except pydantic.ValidationError:
            raise ValidationError(message) from None
        return options

    @staticmethod
    def capability_surface(capability_kind: str, config: Optional[dict] = None):
        return _surface(OaiClient, capability_kind)
+11 −4
Changes for src/openop_tfsdk/network/adapters/oai/client.py: 11 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@
#   - Giulio Carota (giulio.carota@eurecom.fr)
##
from datetime import datetime, timedelta, timezone
from typing import Optional, Sequence

import requests

@@ -27,7 +28,6 @@ from openop_tfsdk.network.core.schemas import (
)

log = logger.get_logger(__name__)
supportedQos = ["qos-e", "qos-s", "qos-m", "qos-l"]


class NetworkManager(BaseNetworkClient):
@@ -38,11 +38,14 @@ class NetworkManager(BaseNetworkClient):

    capabilities = {"qod", "traffic_influence", "location_retrieval"}

    def __init__(self, base_url: str, scs_as_id: str = None):
    def __init__(
        self, base_url: str, scs_as_id: str = None, *, qos_profiles: Optional[Sequence[str]] = None
    ):
        try:
            super().__init__()
            self.base_url = base_url
            self.scs_as_id = scs_as_id
            self._qos_profiles = None if qos_profiles is None else tuple(qos_profiles)
            self.identity_service_url = f"{base_url.rstrip('/')}/3gpp-ueid/v1"
            log.info(
                f"Initialized OaiNefClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}"
@@ -63,9 +66,13 @@ class NetworkManager(BaseNetworkClient):
        raises:
            ValidationError: If the session information does not meet core-specific requirements.
        """
        if session_info.qosProfile.root not in supportedQos:
        if (
            self._qos_profiles is not None
            and session_info.qosProfile.root not in self._qos_profiles
        ):
            raise OaiValidationError(
                f"QoS profile {session_info.qosProfile} not supported by OAI, supported profiles are {supportedQos}"
                f"QoS profile {session_info.qosProfile} not allowed by configured qos_profiles, "
                f"allowed profiles are {list(self._qos_profiles)}"
            )

        if session_info.device is None or session_info.device.ipv4Address is None:
+86 −0
Changes for tests/contract/test_network_error_details.py: 86 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""Safe network diagnostics reach consumers through the existing error contract."""

from types import SimpleNamespace

import pytest
import requests
from structlog.testing import capture_logs

from openop_tfsdk.contract.adapters.network import NetworkCapabilityAdapter
from openop_tfsdk.contract.errors import DomainExecutionFailed, ResourceNotFound
from openop_tfsdk.network.core.common import CoreHttpError


@pytest.mark.parametrize("wrapped", [False, True])
@pytest.mark.parametrize("status, reason", [(400, "bad_request"), (503, "service_unavailable")])
def test_http_details_are_allowlisted_and_preserve_error_contract(wrapped, status, reason):
    marker = "PRIVATE_BACKEND_BODY_AND_URL"
    response = requests.Response()
    response.status_code = status
    response.url = "http://nef.invalid/" + marker
    response._content = marker.encode()
    error = requests.HTTPError(marker, response=response)

    def operation():
        if wrapped:
            raise CoreHttpError(marker) from error
        raise error

    adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter)
    adapter._client = SimpleNamespace(operation=operation)
    with capture_logs() as events, pytest.raises(DomainExecutionFailed) as caught:
        adapter._call("operation")
    expected = {"backend_status": status, "reason": reason}
    assert caught.value.details == expected
    assert caught.value.error_code == "DOMAIN_EXECUTION_FAILED"
    assert caught.value.retryable is False
    assert caught.value.message == "operation failed"
    assert caught.value.__cause__ is not None
    assert all(events[0][key] == value for key, value in expected.items())
    assert marker not in str(events) + str(caught.value) + str(caught.value.details)


@pytest.mark.parametrize("wrapped", [False, True])
@pytest.mark.parametrize(
    "error_type, reason",
    [(requests.Timeout, "timeout"), (requests.ConnectionError, "connection_failed")],
)
def test_transport_details_keep_only_safe_reason(wrapped, error_type, reason):
    error = error_type("PRIVATE_TRANSPORT_DESTINATION")

    def operation():
        if wrapped:
            raise CoreHttpError("PRIVATE_WRAPPER_TEXT") from error
        raise error

    adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter)
    adapter._client = SimpleNamespace(operation=operation)
    with pytest.raises(DomainExecutionFailed) as caught:
        adapter._call("operation")
    assert caught.value.details == {"reason": reason}
    assert caught.value.retryable is False


def test_unknown_failure_does_not_invent_details():
    def operation():
        raise RuntimeError("PRIVATE_UNRECOGNIZED_TEXT")

    adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter)
    adapter._client = SimpleNamespace(operation=operation)
    with pytest.raises(DomainExecutionFailed) as caught:
        adapter._call("operation")
    assert caught.value.details is None


def test_wrapped_http_404_remains_resource_not_found():
    response = requests.Response()
    response.status_code = 404

    def operation():
        raise CoreHttpError("private") from requests.HTTPError(response=response)

    adapter = NetworkCapabilityAdapter.__new__(NetworkCapabilityAdapter)
    adapter._client = SimpleNamespace(operation=operation)
    with pytest.raises(ResourceNotFound) as caught:
        adapter._call("operation")
    assert caught.value.details is None
Loading