Skip to content

Commit b8a709f

Browse files
authored
Add negative test for model server (#1120)
Signed-off-by: Milind Waykole <mwaykole@redhat.com>
1 parent dc4aada commit b8a709f

4 files changed

Lines changed: 299 additions & 0 deletions

File tree

tests/model_serving/model_server/kserve/negative/__init__.py

Whitespace-only changes.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from typing import Any, Generator
2+
3+
from urllib.parse import urlparse
4+
5+
import pytest
6+
from _pytest.fixtures import FixtureRequest
7+
from kubernetes.dynamic import DynamicClient
8+
from ocp_resources.inference_service import InferenceService
9+
from ocp_resources.namespace import Namespace
10+
from ocp_resources.secret import Secret
11+
from ocp_resources.serving_runtime import ServingRuntime
12+
13+
from utilities.constants import (
14+
KServeDeploymentType,
15+
RuntimeTemplates,
16+
)
17+
from utilities.inference_utils import create_isvc
18+
from utilities.infra import get_pods_by_isvc_label
19+
from utilities.serving_runtime import ServingRuntimeFromTemplate
20+
21+
22+
@pytest.fixture(scope="class")
23+
def ovms_serving_runtime(
24+
admin_client: DynamicClient,
25+
unprivileged_model_namespace: Namespace,
26+
) -> Generator[ServingRuntime, Any, Any]:
27+
"""Create OVMS serving runtime for negative tests."""
28+
with ServingRuntimeFromTemplate(
29+
client=admin_client,
30+
name="negative-test-ovms-runtime",
31+
namespace=unprivileged_model_namespace.name,
32+
template_name=RuntimeTemplates.OVMS_KSERVE,
33+
multi_model=False,
34+
enable_http=True,
35+
enable_grpc=False,
36+
) as runtime:
37+
yield runtime
38+
39+
40+
@pytest.fixture(scope="class")
41+
def negative_test_ovms_isvc(
42+
request: FixtureRequest,
43+
admin_client: DynamicClient,
44+
unprivileged_model_namespace: Namespace,
45+
ovms_serving_runtime: ServingRuntime,
46+
ci_s3_bucket_name: str,
47+
ci_endpoint_s3_secret: Secret,
48+
) -> Generator[InferenceService, Any, Any]:
49+
"""Create InferenceService with OVMS runtime for negative tests."""
50+
storage_uri = f"s3://{ci_s3_bucket_name}/{request.param['model-dir']}/"
51+
supported_formats = ovms_serving_runtime.instance.spec.supportedModelFormats
52+
if not supported_formats:
53+
raise ValueError(f"ServingRuntime '{ovms_serving_runtime.name}' has no supportedModelFormats")
54+
55+
with create_isvc(
56+
client=admin_client,
57+
name="negative-test-ovms-isvc",
58+
namespace=unprivileged_model_namespace.name,
59+
runtime=ovms_serving_runtime.name,
60+
storage_key=ci_endpoint_s3_secret.name,
61+
storage_path=urlparse(storage_uri).path,
62+
model_format=supported_formats[0].name,
63+
deployment_mode=KServeDeploymentType.RAW_DEPLOYMENT,
64+
external_route=True,
65+
) as isvc:
66+
yield isvc
67+
68+
69+
@pytest.fixture(scope="class")
70+
def initial_pod_state(
71+
admin_client: DynamicClient,
72+
negative_test_ovms_isvc: InferenceService,
73+
) -> dict[str, dict[str, Any]]:
74+
"""Capture initial pod state (UIDs, restart counts) before tests run.
75+
76+
Returns:
77+
A dictionary mapping pod UIDs to their initial state including
78+
name, restart counts per container.
79+
"""
80+
pods = get_pods_by_isvc_label(
81+
client=admin_client,
82+
isvc=negative_test_ovms_isvc,
83+
)
84+
85+
pod_state: dict[str, dict[str, Any]] = {}
86+
for pod in pods:
87+
uid = pod.instance.metadata.uid
88+
container_restart_counts = {
89+
container.name: container.restartCount for container in (pod.instance.status.containerStatuses or [])
90+
}
91+
pod_state[uid] = {
92+
"name": pod.name,
93+
"restart_counts": container_restart_counts,
94+
}
95+
96+
return pod_state
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Tests for invalid inference requests handling.
2+
3+
This module verifies that KServe properly handles inference requests with
4+
unsupported Content-Type headers, returning appropriate error responses.
5+
6+
Jira: RHOAIENG-48283
7+
"""
8+
9+
from http import HTTPStatus
10+
from typing import Any
11+
12+
import pytest
13+
from kubernetes.dynamic import DynamicClient
14+
from ocp_resources.inference_service import InferenceService
15+
16+
from tests.model_serving.model_server.kserve.negative.utils import (
17+
send_inference_request_with_content_type,
18+
)
19+
from utilities.infra import get_pods_by_isvc_label
20+
21+
22+
pytestmark = pytest.mark.usefixtures("valid_aws_config")
23+
24+
25+
@pytest.mark.jira("RHOAIENG-48283", run=False)
26+
@pytest.mark.tier1
27+
@pytest.mark.rawdeployment
28+
@pytest.mark.parametrize(
29+
"unprivileged_model_namespace, negative_test_ovms_isvc",
30+
[
31+
pytest.param(
32+
{"name": "negative-test-content-type"},
33+
{"model-dir": "test-dir"},
34+
)
35+
],
36+
indirect=True,
37+
)
38+
class TestUnsupportedContentType:
39+
"""Test class for verifying error handling when using unsupported Content-Type headers.
40+
41+
Preconditions:
42+
- InferenceService deployed and ready
43+
- Model accepts application/json content type
44+
45+
Test Steps:
46+
1. Create InferenceService with OVMS runtime
47+
2. Wait for InferenceService status = Ready
48+
3. Send POST to inference endpoint with header Content-Type: text/xml
49+
4. Send POST with header Content-Type: application/x-www-form-urlencoded
50+
5. Capture responses for both requests
51+
6. Verify model pod health status
52+
53+
Expected Results:
54+
- HTTP Status Code: 415 Unsupported Media Type for invalid Content-Types
55+
- Error indicates expected content type is application/json
56+
- Model pod remains healthy (Running, no restarts)
57+
"""
58+
59+
VALID_INFERENCE_BODY: dict[str, Any] = {
60+
"inputs": [
61+
{
62+
"name": "Input3",
63+
"shape": [1, 1, 28, 28],
64+
"datatype": "FP32",
65+
"data": [0.0] * 784,
66+
}
67+
]
68+
}
69+
70+
@pytest.mark.parametrize(
71+
"content_type",
72+
[
73+
pytest.param("text/xml", id="text_xml"),
74+
pytest.param("application/x-www-form-urlencoded", id="form_urlencoded"),
75+
],
76+
)
77+
def test_unsupported_content_type_returns_415(
78+
self,
79+
negative_test_ovms_isvc: InferenceService,
80+
content_type: str,
81+
) -> None:
82+
"""Verify that unsupported Content-Type headers return 415 status code.
83+
84+
Given an InferenceService is deployed and ready
85+
When sending a POST request with an unsupported Content-Type header
86+
Then the response should have HTTP status code 415 (Unsupported Media Type)
87+
"""
88+
status_code, response_body = send_inference_request_with_content_type(
89+
inference_service=negative_test_ovms_isvc,
90+
content_type=content_type,
91+
body=self.VALID_INFERENCE_BODY,
92+
)
93+
94+
assert status_code == HTTPStatus.UNSUPPORTED_MEDIA_TYPE, (
95+
f"Expected 415 Unsupported Media Type for Content-Type '{content_type}', "
96+
f"got {status_code}. Response: {response_body}"
97+
)
98+
99+
def test_model_pod_remains_healthy_after_invalid_requests(
100+
self,
101+
admin_client: DynamicClient,
102+
negative_test_ovms_isvc: InferenceService,
103+
initial_pod_state: dict[str, dict[str, Any]],
104+
) -> None:
105+
"""Verify that the model pod remains healthy after receiving invalid requests.
106+
107+
Given an InferenceService is deployed and ready
108+
When sending requests with unsupported Content-Type headers
109+
Then the same pods (by UID) should still be running without additional restarts
110+
"""
111+
send_inference_request_with_content_type(
112+
inference_service=negative_test_ovms_isvc,
113+
content_type="text/xml",
114+
body=self.VALID_INFERENCE_BODY,
115+
)
116+
117+
current_pods = get_pods_by_isvc_label(
118+
client=admin_client,
119+
isvc=negative_test_ovms_isvc,
120+
)
121+
122+
assert len(current_pods) > 0, "No pods found for the InferenceService"
123+
124+
current_pod_uids = {pod.instance.metadata.uid for pod in current_pods}
125+
initial_pod_uids = set(initial_pod_state.keys())
126+
127+
assert current_pod_uids == initial_pod_uids, (
128+
f"Pod UIDs changed after invalid requests. "
129+
f"Initial: {initial_pod_uids}, Current: {current_pod_uids}. "
130+
f"This indicates pods were recreated."
131+
)
132+
133+
for pod in current_pods:
134+
uid = pod.instance.metadata.uid
135+
initial_state = initial_pod_state[uid]
136+
137+
assert pod.instance.status.phase == "Running", (
138+
f"Pod {pod.name} is not running, status: {pod.instance.status.phase}"
139+
)
140+
141+
container_statuses = pod.instance.status.containerStatuses or []
142+
for container in container_statuses:
143+
initial_restart_count = initial_state["restart_counts"].get(container.name, 0)
144+
assert container.restartCount == initial_restart_count, (
145+
f"Container {container.name} in pod {pod.name} restarted after invalid requests. "
146+
f"Initial count: {initial_restart_count}, Current count: {container.restartCount}"
147+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Utility functions for negative inference tests."""
2+
3+
import json
4+
import shlex
5+
from typing import Any
6+
7+
from ocp_resources.inference_service import InferenceService
8+
from pyhelper_utils.shell import run_command
9+
10+
11+
def send_inference_request_with_content_type(
12+
inference_service: InferenceService,
13+
content_type: str,
14+
body: dict[str, Any],
15+
) -> tuple[int, str]:
16+
"""Send an inference request with a specific Content-Type header.
17+
18+
This function is used for negative testing to verify error handling
19+
when sending requests with unsupported Content-Type headers.
20+
21+
Args:
22+
inference_service: The InferenceService to send the request to.
23+
content_type: The Content-Type header value to use.
24+
body: The request body to send.
25+
26+
Returns:
27+
A tuple of (status_code, response_body).
28+
29+
Raises:
30+
ValueError: If the InferenceService has no URL or curl output is malformed.
31+
"""
32+
url = inference_service.instance.status.url
33+
if not url:
34+
raise ValueError(f"InferenceService '{inference_service.name}' has no URL; is it Ready?")
35+
36+
endpoint = f"{url}/v2/models/{inference_service.name}/infer"
37+
38+
cmd = (
39+
f"curl -s -w '\\n%{{http_code}}' "
40+
f"-X POST {endpoint} "
41+
f"-H 'Content-Type: {content_type}' "
42+
f"-d '{json.dumps(body)}' "
43+
f"--insecure"
44+
)
45+
46+
_, out, _ = run_command(command=shlex.split(cmd), verify_stderr=False, check=False)
47+
48+
lines = out.strip().split("\n")
49+
try:
50+
status_code = int(lines[-1])
51+
except ValueError as exc:
52+
raise ValueError(f"Could not parse HTTP status code from curl output: {out!r}") from exc
53+
54+
response_body = "\n".join(lines[:-1])
55+
56+
return status_code, response_body

0 commit comments

Comments
 (0)