Skip to content

Commit 2457d9e

Browse files
authored
Merge branch 'main' into fix_code_smell
2 parents d5023ee + 2b3a900 commit 2457d9e

7 files changed

Lines changed: 299 additions & 0 deletions

File tree

conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ def pytest_addoption(parser: Parser) -> None:
122122
default=os.environ.get("TRITON_RUNTIME_IMAGE"),
123123
help="Specify the runtime image to use for the tests",
124124
)
125+
runtime_group.addoption(
126+
"--ovms-runtime-image",
127+
default=os.environ.get("OVMS_RUNTIME_IMAGE"),
128+
help="Specify the OVMS runtime image to use for the tests",
129+
)
125130

126131
# OCI Registry options
127132
ociregistry_group.addoption(

tests/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
MinIo,
4949
OCIRegistry,
5050
Protocols,
51+
RuntimeTemplates,
5152
Timeout,
5253
)
5354
from utilities.data_science_cluster_utils import update_components_in_dsc
@@ -65,6 +66,7 @@
6566
from utilities.mariadb_utils import wait_for_mariadb_operator_deployments
6667
from utilities.minio import create_minio_data_connection_secret
6768
from utilities.operator_utils import get_cluster_service_version, get_csv_related_images
69+
from utilities.serving_runtime import get_runtime_image_from_template
6870
from utilities.user_utils import get_byoidc_issuer_url, get_oidc_tokens
6971

7072
LOGGER = get_logger(name=__name__)
@@ -328,6 +330,20 @@ def triton_runtime_image(pytestconfig: pytest.Config) -> str:
328330
return runtime_image
329331

330332

333+
@pytest.fixture(scope="session")
334+
def ovms_runtime_image(pytestconfig: pytest.Config, admin_client: DynamicClient) -> str:
335+
"""Return OVMS runtime image from --ovms-runtime-image or cluster template."""
336+
runtime_image = pytestconfig.option.ovms_runtime_image
337+
if runtime_image:
338+
return runtime_image
339+
namespace = py_config["applications_namespace"]
340+
return get_runtime_image_from_template(
341+
client=admin_client,
342+
template_name=RuntimeTemplates.OVMS_KSERVE,
343+
namespace=namespace,
344+
)
345+
346+
331347
@pytest.fixture(scope="session")
332348
def use_unprivileged_client(pytestconfig: pytest.Config) -> bool:
333349
_use_unprivileged_client = py_config.get("use_unprivileged_client")

tests/model_serving/model_runtime/openvino/conftest.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@
66
- Creating inference services and related Kubernetes resources
77
- Managing S3 secrets and service accounts
88
- Providing test utilities like snapshots and pod resources
9+
- OVMS smoke test Pod and ConfigMap for in-cluster script execution
910
"""
1011

1112
import copy
1213
from collections.abc import Generator
14+
from pathlib import Path
1315
from typing import Any, cast
1416

1517
import pytest
1618
from kubernetes.dynamic import DynamicClient
1719
from kubernetes.dynamic.exceptions import ResourceNotFoundError
20+
from ocp_resources.config_map import ConfigMap
1821
from ocp_resources.inference_service import InferenceService
1922
from ocp_resources.namespace import Namespace
2023
from ocp_resources.pod import Pod
@@ -36,6 +39,12 @@
3639

3740
LOGGER = get_logger(name=__name__)
3841

42+
OVMS_SMOKE_SCRIPTS_DIR = Path(__file__).parent / "smoke"
43+
OVMS_SMOKE_SCRIPT_NAMES = ("ovms_smoketest.py", "smoke.py")
44+
OVMS_SMOKE_CONFIGMAP_NAME = "ovms-smoke-scripts"
45+
OVMS_SMOKE_POD_NAME = "ovms-smoke-pod"
46+
OVMS_SMOKE_SCRIPTS_MOUNT_PATH = "/scripts"
47+
3948

4049
@pytest.fixture(scope="class")
4150
def openvino_serving_runtime(
@@ -189,3 +198,108 @@ def openvino_pod_resource(
189198
if not pods:
190199
raise ResourceNotFoundError(f"No pods found for InferenceService {openvino_inference_service.name}")
191200
return pods[0]
201+
202+
203+
def _load_ovms_smoke_scripts_data() -> dict[str, str]:
204+
"""Load smoke script file contents for ConfigMap data."""
205+
data: dict[str, str] = {}
206+
for name in OVMS_SMOKE_SCRIPT_NAMES:
207+
path = OVMS_SMOKE_SCRIPTS_DIR / name
208+
data[name] = path.read_text()
209+
return data
210+
211+
212+
@pytest.fixture(scope="class")
213+
def ovms_smoke_scripts_configmap(
214+
admin_client: DynamicClient,
215+
model_namespace: Namespace,
216+
) -> Generator[ConfigMap]:
217+
"""
218+
ConfigMap containing OVMS smoke test scripts to run inside the container.
219+
220+
Args:
221+
admin_client: Kubernetes dynamic client.
222+
model_namespace: Namespace for the ConfigMap.
223+
224+
Yields:
225+
ConfigMap: ConfigMap with ovms_smoketest.py and smoke.py data.
226+
"""
227+
data = _load_ovms_smoke_scripts_data()
228+
with ConfigMap(
229+
client=admin_client,
230+
name=OVMS_SMOKE_CONFIGMAP_NAME,
231+
namespace=model_namespace.name,
232+
data=data,
233+
) as cm:
234+
yield cm
235+
236+
237+
@pytest.fixture(scope="class")
238+
def ovms_smoke_pod(
239+
admin_client: DynamicClient,
240+
model_namespace: Namespace,
241+
ovms_runtime_image: str,
242+
ovms_smoke_scripts_configmap: ConfigMap,
243+
) -> Generator[Pod]:
244+
"""
245+
Pod that runs OVMS smoke scripts inside OpenShift using the OVMS runtime image.
246+
247+
The smoke scripts are mounted read-only via ConfigMap (not copied).
248+
The container runs both scripts in sequence; the Pod succeeds only if both exit 0.
249+
250+
Args:
251+
admin_client: Kubernetes dynamic client.
252+
model_namespace: Namespace for the Pod.
253+
ovms_runtime_image: Container image for OVMS runtime (from CLI or template).
254+
ovms_smoke_scripts_configmap: ConfigMap with smoke script contents.
255+
256+
Yields:
257+
Pod: The completed Pod resource (phase Succeeded when both scripts exit 0).
258+
"""
259+
run_cmd = (
260+
f"python {OVMS_SMOKE_SCRIPTS_MOUNT_PATH}/ovms_smoketest.py && python {OVMS_SMOKE_SCRIPTS_MOUNT_PATH}/smoke.py"
261+
)
262+
# Use writable dirs under /tmp so non-root container can cache models and configs.
263+
# HF_HOME is the preferred cache for Hugging Face (TRANSFORMERS_CACHE is deprecated in v5).
264+
env_vars = [
265+
{"name": "HOME", "value": "/tmp"},
266+
{"name": "HF_HOME", "value": "/tmp/hf_cache"},
267+
{"name": "MPLCONFIGDIR", "value": "/tmp/matplotlib"},
268+
]
269+
with Pod(
270+
client=admin_client,
271+
name=OVMS_SMOKE_POD_NAME,
272+
namespace=model_namespace.name,
273+
restart_policy="Never",
274+
containers=[
275+
{
276+
"name": "ovms-smoke",
277+
"image": ovms_runtime_image,
278+
"command": ["/bin/sh", "-c"],
279+
"args": [run_cmd],
280+
"env": env_vars,
281+
"volumeMounts": [
282+
{
283+
"name": "smoke-scripts",
284+
"mountPath": OVMS_SMOKE_SCRIPTS_MOUNT_PATH,
285+
"readOnly": True,
286+
}
287+
],
288+
"securityContext": {
289+
"allowPrivilegeEscalation": False,
290+
"capabilities": {"drop": ["ALL"]},
291+
"runAsNonRoot": True,
292+
"seccompProfile": {"type": "RuntimeDefault"},
293+
},
294+
}
295+
],
296+
volumes=[
297+
{
298+
"name": "smoke-scripts",
299+
"configMap": {"name": ovms_smoke_scripts_configmap.name},
300+
}
301+
],
302+
) as pod:
303+
LOGGER.info("Waiting for OVMS smoke Pod to complete")
304+
pod.wait_for_status(status=Pod.Status.SUCCEEDED, timeout=300)
305+
yield pod
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from optimum.intel.openvino import OVModelForCausalLM
2+
from transformers import AutoTokenizer
3+
4+
#
5+
# Explanation of what's being tested:
6+
# - transformers integration:
7+
# * AutoTokenizer ensures tokenization functionality from the
8+
# Transformers library is correctly integrated.
9+
#
10+
# - optimum.intel.openvino integration:
11+
# * OVModelForCausalLM ensures Transformers models can be loaded,
12+
# converted, and executed with OpenVINO optimizations via optimum.intel.
13+
#
14+
15+
# Model name compatible with OpenVINO optimizations
16+
model_name = "gpt2"
17+
18+
# Load tokenizer (Transformers API)
19+
tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path=model_name)
20+
tokenizer.pad_token = tokenizer.eos_token
21+
22+
# Load optimized model (Optimum Intel API with OpenVINO backend)
23+
model = OVModelForCausalLM.from_pretrained(model_id=model_name, export=True)
24+
25+
# Prepare input text
26+
prompt = "Testing transformers and optimum.intel integration"
27+
inputs = tokenizer(text=prompt, return_tensors="pt", padding=True)
28+
input_ids = inputs.input_ids
29+
attention_mask = inputs.attention_mask
30+
31+
# Generate output (testing both transformers tokenization & OpenVINO inference)
32+
output_ids = model.generate(input_ids=input_ids, attention_mask=attention_mask, max_length=40)
33+
generated_text = tokenizer.decode(token_ids=output_ids[0], skip_special_tokens=True)
34+
35+
print("Prompt:", prompt)
36+
print("Generated text:", generated_text)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
2+
3+
#
4+
# Explanation of What This Verifies:
5+
#
6+
# * Tokenizer compatibility: ensures tokenization and decoding work properly.
7+
# * Core model loading: confirms transformers properly load models and weights.
8+
# * Inference via pipeline: ensures the transformers pipeline mechanism
9+
# is working properly.
10+
#
11+
12+
# Load tokenizer and model directly from transformers
13+
model_name = "gpt2"
14+
tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path=model_name)
15+
model = AutoModelForCausalLM.from_pretrained(pretrained_model_name_or_path=model_name)
16+
17+
# Test tokenization explicitly
18+
test_text = "The transformers library on RHEL 9"
19+
20+
encoded = tokenizer.encode(text=test_text, return_tensors="pt")
21+
decoded = tokenizer.decode(token_ids=encoded[0])
22+
23+
24+
print("Original text:", test_text)
25+
print("Decoded text after tokenization:", decoded)
26+
27+
# Test text-generation pipeline
28+
text_generator = pipeline(task="text-generation", model=model, tokenizer=tokenizer)
29+
generated_text = text_generator(text_inputs=test_text, max_length=30, num_return_sequences=1)
30+
31+
print("\nGenerated text example:")
32+
print(generated_text[0]["generated_text"])
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
OVMS smoke test: run smoke scripts inside OpenShift using the OVMS runtime image.
3+
4+
How the Pod works:
5+
- A Pod is created in the test namespace with restart_policy=Never.
6+
- The smoke scripts (ovms_smoketest.py, smoke.py) are mounted read-only at /scripts
7+
via a ConfigMap populated from the repo files.
8+
- The container runs: python /scripts/ovms_smoketest.py && python /scripts/smoke.py.
9+
- If both scripts exit 0, the Pod phase becomes Succeeded.
10+
- If either script fails (non-zero exit or exception), the Pod fails and the test fails.
11+
- The test asserts Pod phase Succeeded; logs available via oc logs for debugging.
12+
13+
Note:
14+
This test requires internet access to download models from Hugging Face (e.g., "gpt2").
15+
It will fail in disconnected/air-gapped environments where external model downloads are not available.
16+
"""
17+
18+
import pytest
19+
from ocp_resources.pod import Pod
20+
21+
22+
@pytest.mark.smoke
23+
@pytest.mark.parametrize(
24+
"model_namespace",
25+
[pytest.param({"name": "ovms-smoke"}, id="ovms-smoke")],
26+
indirect=["model_namespace"],
27+
)
28+
class TestOVMSSmokeInOpenShift:
29+
"""
30+
Test class for OVMS smoke execution inside OpenShift.
31+
32+
Runs ovms_smoketest.py and smoke.py inside a Pod using the OVMS runtime image,
33+
with optional image override via --ovms-runtime-image.
34+
"""
35+
36+
def test_ovms_smoke_runs_in_openshift(self, ovms_smoke_pod: Pod) -> None:
37+
"""
38+
OVMS smoke scripts run successfully inside an OpenShift Pod.
39+
40+
Given the OVMS runtime image (from --ovms-runtime-image or template),
41+
when the smoke Pod runs ovms_smoketest.py and smoke.py in the container,
42+
then the Pod completes with phase Succeeded and the test passes.
43+
44+
Note:
45+
This test requires internet access to download models from Hugging Face.
46+
It will fail in disconnected/air-gapped environments.
47+
48+
Args:
49+
ovms_smoke_pod: The completed Kubernetes Pod that ran the smoke scripts.
50+
"""
51+
assert ovms_smoke_pod.instance.status.phase == "Succeeded", (
52+
f"OVMS smoke Pod did not succeed: phase={ovms_smoke_pod.instance.status.phase}"
53+
)

utilities/serving_runtime.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,49 @@
1010
from utilities.constants import ApiGroups, PortNames, Protocols, vLLM_CONFIG
1111

1212

13+
def get_runtime_image_from_template(
14+
client: DynamicClient,
15+
template_name: str,
16+
namespace: str,
17+
) -> str:
18+
"""
19+
Get the runtime image from a serving runtime template.
20+
21+
Args:
22+
client: Kubernetes dynamic client
23+
template_name: Name of the template
24+
namespace: Namespace where the template exists
25+
26+
Returns:
27+
str: Container image from the first container in the template
28+
29+
Raises:
30+
ResourceNotFoundError: If the template is not found, has no objects, or has no containers
31+
"""
32+
template = Template(
33+
client=client,
34+
name=template_name,
35+
namespace=namespace,
36+
)
37+
if not template.exists:
38+
raise ResourceNotFoundError(f"{template_name} template not found in namespace {namespace}")
39+
40+
objects = template.instance.objects
41+
if not objects:
42+
raise ResourceNotFoundError(f"{template_name} template has no objects")
43+
model_dict: dict[str, Any] = objects[0].to_dict()
44+
containers = model_dict.get("spec", {}).get("containers", [])
45+
46+
if not containers:
47+
raise ResourceNotFoundError(f"{template_name} template has no containers")
48+
49+
image = containers[0].get("image")
50+
if not image:
51+
raise ResourceNotFoundError(f"{template_name} template container has no image")
52+
53+
return image
54+
55+
1356
class ServingRuntimeFromTemplate(ServingRuntime):
1457
def __init__(
1558
self,

0 commit comments

Comments
 (0)