Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions backend/apps/common/routes/get.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""GET request handlers."""

from flask import request
from kubernetes.client.rest import ApiException

from kubeflow.kubeflow.crud_backend import api, logging

Expand Down Expand Up @@ -188,6 +189,76 @@ def get_inference_graph_events(namespace, name):
)


# The API server may serve either LLMInferenceService version. The first
# request probes for the served version; every later request reuses the
# detected value. Each worker process probes at most twice.
_detected_llm_inference_service_version = None


def _llm_inference_service_group_version_kind(namespace):
"""Return the group, version and kind, detecting the served version."""
global _detected_llm_inference_service_version

if _detected_llm_inference_service_version is not None:
return versions.llm_inference_service_group_version_kind(
_detected_llm_inference_service_version
)

last_error = None
for candidate in versions.LLM_INFERENCE_SERVICE_VERSIONS:
group_version_kind = versions.llm_inference_service_group_version_kind(
candidate
)
try:
api.list_custom_rsrc(**group_version_kind, namespace=namespace)
except ApiException as error:
if error.status != 404:
raise
last_error = error
continue
_detected_llm_inference_service_version = candidate
log.info("Detected LLMInferenceService API version: %s", candidate)
return group_version_kind

raise last_error


@bp.route("/api/namespaces/<namespace>/llminferenceservices")
def get_llm_inference_services(namespace):
"""Return a list of LLMInferenceService custom resources."""
group_version_kind = _llm_inference_service_group_version_kind(namespace)
llm_inference_services = api.list_custom_rsrc(
**group_version_kind, namespace=namespace
)

return api.success_response("llmInferenceServices", llm_inference_services["items"])


@bp.route("/api/namespaces/<namespace>/llminferenceservices/<name>")
def get_llm_inference_service(namespace, name):
"""Return a single LLMInferenceService custom resource."""
llm_inference_service = api.get_custom_rsrc(
**_llm_inference_service_group_version_kind(namespace),
namespace=namespace,
name=name,
)

return api.success_response("llmInferenceService", llm_inference_service)


@bp.route("/api/namespaces/<namespace>/llminferenceservices/<name>/events")
def get_llm_inference_service_events(namespace, name):
"""Return events that relate to an LLMInferenceService."""
field_selector = api.events_field_selector("LLMInferenceService", name)

events = api.events.list_events(namespace, field_selector).items

return api.success_response(
"events",
api.serialize(events),
)


# Standard mode endpoints
@bp.route("/api/namespaces/<namespace>/deployments/<name>")
def get_kubernetes_deployment(namespace, name):
Expand Down
187 changes: 187 additions & 0 deletions backend/apps/common/routes/get_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Unit tests for the LLMInferenceService version-detection fallback.

Run directly with:
python3 backend/apps/common/routes/get_test.py

The version-detection helper tries v1alpha2 first and falls back to
v1alpha1 only when the API server reports the resource as not found, then
caches whichever version answered. This exact fallback also had a real
consequence during development of this feature: a cluster that grants a
service account no permission on llminferenceservices raises a 403, not a
404, and the helper must surface that error immediately rather than
mistaking a permission problem for an absent API version.
"""

import importlib.util
import logging as python_logging
from pathlib import Path
import sys
import types
import unittest
from unittest.mock import Mock

from kubernetes.client.rest import ApiException


def _load_get_routes_module():
"""Load get.py with lightweight stubs for external dependencies.

The real versions.py is loaded as well, rather than stubbed, so the
tests also exercise its actual version ordering and group, version and
kind shape instead of assuming them.
"""
module_names = (
"backend",
"backend.apps",
"backend.apps.common",
"backend.apps.common.routes",
"backend.apps.common.utils",
"backend.apps.common.versions",
"flask",
"kubeflow",
"kubeflow.kubeflow",
"kubeflow.kubeflow.crud_backend",
)
original_modules = {name: sys.modules.get(name) for name in module_names}

backend = types.ModuleType("backend")
apps = types.ModuleType("backend.apps")
common = types.ModuleType("backend.apps.common")
routes_package = types.ModuleType("backend.apps.common.routes")
utils = types.ModuleType("backend.apps.common.utils")
flask = types.ModuleType("flask")
kubeflow = types.ModuleType("kubeflow")
kubeflow_kubeflow = types.ModuleType("kubeflow.kubeflow")
crud_backend = types.ModuleType("kubeflow.kubeflow.crud_backend")

backend.__path__ = []
apps.__path__ = []
common.__path__ = []
routes_package.__path__ = []
routes_package.bp = Mock()
flask.request = types.SimpleNamespace()
# versions.py imports current_app at module scope for a function this
# test file does not exercise; it only needs to exist to satisfy the
# import.
flask.current_app = Mock()
crud_backend.api = types.SimpleNamespace(
list_custom_rsrc=Mock(),
get_custom_rsrc=Mock(),
success_response=Mock(),
serialize=Mock(),
events=types.SimpleNamespace(list_events=Mock()),
events_field_selector=Mock(),
)
crud_backend.logging = types.SimpleNamespace(
getLogger=lambda name: python_logging.getLogger(name)
)

try:
sys.modules["backend"] = backend
sys.modules["backend.apps"] = apps
sys.modules["backend.apps.common"] = common
sys.modules["backend.apps.common.routes"] = routes_package
sys.modules["backend.apps.common.utils"] = utils
sys.modules["flask"] = flask
sys.modules["kubeflow"] = kubeflow
sys.modules["kubeflow.kubeflow"] = kubeflow_kubeflow
sys.modules["kubeflow.kubeflow.crud_backend"] = crud_backend

versions_path = Path(__file__).parent.parent / "versions.py"
versions_spec = importlib.util.spec_from_file_location(
"backend.apps.common.versions", versions_path
)
versions_module = importlib.util.module_from_spec(versions_spec)
versions_spec.loader.exec_module(versions_module)
sys.modules["backend.apps.common.versions"] = versions_module

get_path = Path(__file__).with_name("get.py")
get_spec = importlib.util.spec_from_file_location(
"backend.apps.common.routes.get_under_test", get_path
)
get_module = importlib.util.module_from_spec(get_spec)
get_spec.loader.exec_module(get_module)
return get_module
finally:
for name, original_module in original_modules.items():
if original_module is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original_module


class LlmInferenceServiceVersionDetectionTest(unittest.TestCase):
def test_detects_v1alpha2_on_the_first_probe(self):
get_routes = _load_get_routes_module()
get_routes.api.list_custom_rsrc = Mock(return_value={"items": []})

group_version_kind = get_routes._llm_inference_service_group_version_kind(
"kubeflow-user"
)

self.assertEqual(
group_version_kind,
{
"group": "serving.kserve.io",
"version": "v1alpha2",
"kind": "llminferenceservices",
},
)
self.assertEqual(get_routes.api.list_custom_rsrc.call_count, 1)

def test_caches_the_detected_version_and_does_not_probe_again(self):
get_routes = _load_get_routes_module()
get_routes.api.list_custom_rsrc = Mock(return_value={"items": []})

get_routes._llm_inference_service_group_version_kind("kubeflow-user")
get_routes._llm_inference_service_group_version_kind("kubeflow-user")

self.assertEqual(get_routes.api.list_custom_rsrc.call_count, 1)

def test_falls_back_to_v1alpha1_when_v1alpha2_is_not_found(self):
get_routes = _load_get_routes_module()
get_routes.api.list_custom_rsrc = Mock(
side_effect=[ApiException(status=404), {"items": []}]
)

group_version_kind = get_routes._llm_inference_service_group_version_kind(
"kubeflow-user"
)

self.assertEqual(group_version_kind["version"], "v1alpha1")
self.assertEqual(get_routes.api.list_custom_rsrc.call_count, 2)

def test_reraises_a_permission_error_without_trying_the_fallback_version(self):
"""
A service account without permission on llminferenceservices
receives a 403, not a 404. Falling back to v1alpha1 in that case
would mistake a permission problem for an absent API version and
mask the real cause.
"""
get_routes = _load_get_routes_module()
forbidden = ApiException(status=403)
get_routes.api.list_custom_rsrc = Mock(side_effect=forbidden)

with self.assertRaises(ApiException) as raised:
get_routes._llm_inference_service_group_version_kind("kubeflow-user")

self.assertIs(raised.exception, forbidden)
self.assertEqual(get_routes.api.list_custom_rsrc.call_count, 1)

def test_reraises_the_last_not_found_error_when_no_version_is_served(self):
get_routes = _load_get_routes_module()
first_not_found = ApiException(status=404)
second_not_found = ApiException(status=404)
get_routes.api.list_custom_rsrc = Mock(
side_effect=[first_not_found, second_not_found]
)

with self.assertRaises(ApiException) as raised:
get_routes._llm_inference_service_group_version_kind("kubeflow-user")

self.assertIs(raised.exception, second_not_found)
self.assertEqual(get_routes.api.list_custom_rsrc.call_count, 2)


if __name__ == "__main__":
unittest.main()
21 changes: 21 additions & 0 deletions backend/apps/common/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ def inference_service_gvk():
}


LLM_INFERENCE_SERVICE_GROUP = "serving.kserve.io"
# Newest first: v1alpha2 is the storage version on current KServe releases,
# while older clusters may only serve v1alpha1.
LLM_INFERENCE_SERVICE_VERSIONS = ["v1alpha2", "v1alpha1"]


def llm_inference_service_group_version_kind(version):
"""
Return the group, version and kind for an LLMInferenceService.

The caller supplies the API version because clusters differ in which
versions of the resource the API server serves; the route layer detects
the served version at runtime and passes it here.
"""
return {
"group": LLM_INFERENCE_SERVICE_GROUP,
"version": version,
"kind": "llminferenceservices",
}


def inference_graph_gvk():
"""
Return the GVK needed for an InferenceGraph.
Expand Down
4 changes: 3 additions & 1 deletion frontend/__mocks__/kubeflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ export interface Status {
}

// Interface for Kubernetes object
// The kind field is optional to match the real library declaration in
// node_modules/kubeflow/lib/utils/kubernetes.model.d.ts.
export interface K8sObject {
kind: string;
kind?: string;
apiVersion?: string;
metadata?: {
name?: string;
Expand Down
Loading
Loading