Skip to content

Commit c8db8c7

Browse files
authored
Merge branch 'main' into move_functions
2 parents 3d0383d + d03a8b2 commit c8db8c7

5 files changed

Lines changed: 373 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Serving runtime image validation tests."""
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""
2+
Fixtures for serving runtime image validation tests.
3+
4+
Creates minimal ServingRuntime + InferenceService so that deployments/pods
5+
are created and their spec.containers[*].image can be validated against
6+
the CSV relatedImages (registry.redhat.io, sha256 digest).
7+
"""
8+
9+
from collections.abc import Generator
10+
from typing import Any
11+
12+
import pytest
13+
from kubernetes.dynamic import DynamicClient
14+
from ocp_resources.namespace import Namespace
15+
from ocp_resources.pod import Pod
16+
from timeout_sampler import TimeoutSampler
17+
18+
from tests.model_serving.model_runtime.image_validation.constant import PLACEHOLDER_STORAGE_URI
19+
from utilities.constants import KServeDeploymentType
20+
from utilities.inference_utils import create_isvc
21+
from utilities.infra import create_ns, wait_for_isvc_pods
22+
from utilities.serving_runtime import ServingRuntimeFromTemplate
23+
24+
25+
@pytest.fixture(scope="class")
26+
def serving_runtime_image_validation_namespace(
27+
admin_client: DynamicClient,
28+
) -> Generator[Namespace, Any, Any]:
29+
"""
30+
A dedicated namespace for serving runtime image validation.
31+
32+
Ensures deployments/pods created by the test have a clean namespace
33+
that is torn down after the test.
34+
"""
35+
name = "runtime-verification"
36+
with create_ns(admin_client=admin_client, name=name, teardown=True) as ns:
37+
yield ns
38+
39+
40+
@pytest.fixture(scope="function")
41+
def serving_runtime_pods_for_runtime(
42+
request: pytest.FixtureRequest,
43+
admin_client: DynamicClient,
44+
serving_runtime_image_validation_namespace: Namespace,
45+
) -> Generator[tuple[list[Pod], str], Any, Any]:
46+
"""
47+
For a given runtime config (parametrized), create ServingRuntime + InferenceService,
48+
wait for pods, yield (pods, display_name) for validation. Teardown after test.
49+
"""
50+
config = request.param
51+
display_name = config["name"]
52+
name_slug = display_name.replace("_", "-")
53+
namespace_name = serving_runtime_image_validation_namespace.name
54+
runtime_name = f"{name_slug}-runtime"
55+
isvc_name = f"{name_slug}-isvc"
56+
57+
with ServingRuntimeFromTemplate(
58+
client=admin_client,
59+
name=runtime_name,
60+
namespace=namespace_name,
61+
template_name=config["template"],
62+
deployment_type="raw",
63+
) as serving_runtime:
64+
# Get model format from the runtime for the InferenceService spec.
65+
model_format = serving_runtime.instance.spec.supportedModelFormats[0].name
66+
with create_isvc(
67+
client=admin_client,
68+
name=isvc_name,
69+
namespace=namespace_name,
70+
model_format=model_format,
71+
runtime=runtime_name,
72+
storage_uri=PLACEHOLDER_STORAGE_URI,
73+
deployment_mode=KServeDeploymentType.RAW_DEPLOYMENT,
74+
wait=False,
75+
wait_for_predictor_pods=False,
76+
timeout=120,
77+
teardown=True,
78+
) as isvc:
79+
# Wait for pods to be created (300 seconds timeout)
80+
for pods in TimeoutSampler(
81+
wait_timeout=300,
82+
sleep=5,
83+
func=wait_for_isvc_pods,
84+
client=admin_client,
85+
isvc=isvc,
86+
runtime_name=runtime_name,
87+
):
88+
if pods:
89+
yield (pods, display_name)
90+
return
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Constants for serving runtime image validation tests."""
2+
3+
from utilities.constants import RuntimeTemplates
4+
5+
# Placeholder storage URI so the controller creates Deployment/Pod with runtime image.
6+
# No actual model or inference is required; pod phase does not need to be Ready.
7+
PLACEHOLDER_STORAGE_URI = "s3://dummy-bucket/dummy/"
8+
9+
# Runtime configs: display name (for "name : passed") and template name.
10+
# For each we create ServingRuntime + InferenceService, wait for pod(s), validate, then teardown.
11+
RUNTIME_CONFIGS = [
12+
{"name": "odh_openvino_model_server_image", "template": RuntimeTemplates.OVMS_KSERVE},
13+
{"name": "odh_vllm_cpu_image", "template": RuntimeTemplates.VLLM_CPU_x86},
14+
{"name": "odh_vllm_gaudi_image", "template": RuntimeTemplates.VLLM_GAUDI},
15+
{"name": "odh_mlserver_image", "template": RuntimeTemplates.MLSERVER},
16+
{"name": "rhaiis_vllm_cuda_image", "template": RuntimeTemplates.VLLM_CUDA},
17+
{"name": "rhaiis_vllm_rocm_image", "template": RuntimeTemplates.VLLM_ROCM},
18+
{"name": "rhaiis_vllm_spyre_image", "template": RuntimeTemplates.VLLM_SPYRE},
19+
]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""
2+
Tests to verify that serving runtime component images meet the requirements:
3+
1. Images are hosted in registry.redhat.io
4+
2. Images use sha256 digest instead of tags
5+
3. Images are listed in the CSV's relatedImages section
6+
7+
For each runtime template we create ServingRuntime + InferenceService, wait for pod(s),
8+
then validate the pod's container images against the cluster CSV (relatedImages) at runtime.
9+
No hardcoded image SHAs—validation uses whatever CSV is installed (e.g. rhods-operator.3.3.0).
10+
"""
11+
12+
from typing import Self
13+
14+
import pytest
15+
from ocp_resources.pod import Pod
16+
from simple_logger.logger import get_logger
17+
18+
from tests.model_serving.model_runtime.image_validation.constant import RUNTIME_CONFIGS
19+
from utilities.general import validate_container_images
20+
21+
LOGGER = get_logger(name=__name__)
22+
23+
pytestmark = [
24+
pytest.mark.downstream_only,
25+
pytest.mark.skip_must_gather,
26+
pytest.mark.smoke,
27+
]
28+
29+
30+
@pytest.mark.parametrize("serving_runtime_pods_for_runtime", RUNTIME_CONFIGS, indirect=True)
31+
class TestServingRuntimeImagesPerTemplate:
32+
"""
33+
For each runtime template: create ServingRuntime + InferenceService, wait for pod(s),
34+
validate pod images (registry.redhat.io, sha256, CSV), output runtimename : passed, then teardown.
35+
"""
36+
37+
def test_verify_serving_runtime_pod_images_from_template(
38+
self: Self,
39+
serving_runtime_pods_for_runtime: tuple[list[Pod], str],
40+
related_images_refs: set[str],
41+
) -> None:
42+
"""
43+
For the parametrized runtime: create SR+ISVC from template, validate pod images, report name : passed.
44+
"""
45+
pods, runtime_name = serving_runtime_pods_for_runtime
46+
validation_errors = []
47+
for pod in pods:
48+
LOGGER.info(f"Validating {pod.name} in {pod.namespace}")
49+
validation_errors.extend(
50+
validate_container_images(
51+
pod=pod,
52+
valid_image_refs=related_images_refs,
53+
)
54+
)
55+
56+
if validation_errors:
57+
pytest.fail("\n".join(validation_errors))
58+
LOGGER.info(f"{runtime_name} : passed")
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""ImageStream health checks for workbench-related images."""
2+
3+
from typing import Any
4+
5+
import pytest
6+
from kubernetes.dynamic import DynamicClient
7+
from ocp_resources.image_stream import ImageStream
8+
from pytest_testconfig import config as py_config
9+
from simple_logger.logger import get_logger
10+
11+
pytestmark = [pytest.mark.smoke]
12+
LOGGER = get_logger(name=__name__)
13+
IMPORT_SUCCESS_CONDITION_TYPE = "ImportSuccess"
14+
15+
16+
def _validate_imagestream_tag_health(
17+
imagestream_name: str,
18+
tag_name: str,
19+
tag_data: dict[str, Any],
20+
) -> list[str]:
21+
"""
22+
Validate one ImageStream status tag and return all discovered errors.
23+
24+
A tag is considered healthy when it has at least one resolved item in
25+
`status.tags[].items`, each item points to a digest-based image reference,
26+
and an optional `ImportSuccess` condition (when present) is `True`.
27+
28+
Args:
29+
imagestream_name: Name of the parent ImageStream (for error reporting).
30+
tag_name: Name of the ImageStream tag being validated.
31+
tag_data: Raw `status.tags[]` payload for the tag.
32+
33+
Returns:
34+
List of validation error messages. Empty list means the tag is healthy.
35+
"""
36+
errors: list[str] = []
37+
38+
raw_tag_items = tag_data.get("items")
39+
tag_items = raw_tag_items if isinstance(raw_tag_items, list) else []
40+
import_conditions = [
41+
condition
42+
for condition in (tag_data.get("conditions") or [])
43+
if condition.get("type") == IMPORT_SUCCESS_CONDITION_TYPE
44+
]
45+
latest_import_condition = (
46+
max(import_conditions, key=lambda condition: condition.get("generation", -1)) if import_conditions else None
47+
)
48+
import_status = latest_import_condition.get("status") if latest_import_condition else "N/A"
49+
LOGGER.info(
50+
f"Checked ImageStream tag {imagestream_name}:{tag_name} "
51+
f"(items_count={len(tag_items)}, import_success={import_status})"
52+
)
53+
54+
# A tag is considered unresolved if no image item exists.
55+
# In that case we expect an ImportSuccess=False condition to explain the failure reason.
56+
if not tag_items:
57+
failure_details = (
58+
"no ImportSuccess condition was reported"
59+
if not latest_import_condition
60+
else (
61+
f"status={latest_import_condition.get('status')}, "
62+
f"reason={latest_import_condition.get('reason')}, "
63+
f"message={latest_import_condition.get('message')}"
64+
)
65+
)
66+
errors.append(
67+
f"ImageStream {imagestream_name} tag {tag_name} has unresolved status.tags.items; "
68+
f"ImportSuccess details: {failure_details}"
69+
)
70+
return errors
71+
72+
for item_index, item in enumerate(tag_items):
73+
docker_image_reference = str(item.get("dockerImageReference", ""))
74+
if "@sha256:" not in docker_image_reference:
75+
errors.append(
76+
f"ImageStream {imagestream_name} tag {tag_name} item #{item_index} "
77+
"has unresolved dockerImageReference: "
78+
f"{docker_image_reference}"
79+
)
80+
81+
image_reference = str(item.get("image", ""))
82+
if not image_reference.startswith("sha256:"):
83+
errors.append(
84+
f"ImageStream {imagestream_name} tag {tag_name} item #{item_index} has unresolved image reference: "
85+
f"{image_reference}"
86+
)
87+
88+
# If the tag resolved to items but ImportSuccess exists and reports failure, this is still an error.
89+
if latest_import_condition and latest_import_condition.get("status") != "True":
90+
errors.append(
91+
f"ImageStream {imagestream_name} tag {tag_name} has resolved items but ImportSuccess is not True: "
92+
f"status={latest_import_condition.get('status')}, "
93+
f"reason={latest_import_condition.get('reason')}, "
94+
f"message={latest_import_condition.get('message')}"
95+
)
96+
97+
return errors
98+
99+
100+
def _validate_imagestreams_with_label(
101+
imagestreams: list[ImageStream],
102+
label_selector: str,
103+
expected_count: int,
104+
) -> None:
105+
"""
106+
Validate ImageStreams selected by label and fail the test if unhealthy.
107+
108+
This helper enforces:
109+
- expected ImageStream count for the selector
110+
- every tag declared in `spec.tags` appears in `status.tags`
111+
- per-tag resolution/import checks via `_validate_imagestream_tag_health`
112+
113+
Args:
114+
imagestreams: ImageStreams fetched for the label selector.
115+
label_selector: Label selector used to fetch ImageStreams.
116+
expected_count: Expected number of matching ImageStreams.
117+
118+
Raises:
119+
pytest.fail: When any validation error is found.
120+
"""
121+
errors: list[str] = []
122+
actual_count = len(imagestreams)
123+
LOGGER.info(
124+
f"Checking ImageStreams for label selector '{label_selector}': "
125+
f"expected_count={expected_count}, actual_count={actual_count}"
126+
)
127+
if imagestreams:
128+
LOGGER.info(
129+
f"ImageStreams matched for '{label_selector}': {', '.join(sorted(is_obj.name for is_obj in imagestreams))}"
130+
)
131+
if actual_count != expected_count:
132+
imagestream_names = ", ".join(sorted(imagestream.name for imagestream in imagestreams))
133+
errors.append(
134+
f"Expected {expected_count} ImageStreams with label '{label_selector}', found {actual_count}. "
135+
f"Found: [{imagestream_names}]"
136+
)
137+
138+
for imagestream in imagestreams:
139+
imagestream_data: dict[str, Any] = imagestream.instance.to_dict()
140+
imagestream_name = imagestream_data.get("metadata", {}).get("name", imagestream.name)
141+
LOGGER.info(f"Validating ImageStream {imagestream_name} (label selector: {label_selector})")
142+
143+
spec_tag_names = {
144+
str(spec_tag.get("name"))
145+
for spec_tag in imagestream_data.get("spec", {}).get("tags", [])
146+
if spec_tag.get("name")
147+
}
148+
status_tags = imagestream_data.get("status", {}).get("tags", [])
149+
status_tag_names = {str(status_tag.get("tag")) for status_tag in status_tags if status_tag.get("tag")}
150+
151+
missing_status_tags = sorted(spec_tag_names - status_tag_names)
152+
LOGGER.info(
153+
f"ImageStream {imagestream_name} tag coverage: "
154+
f"spec_tags={sorted(spec_tag_names)}, status_tags={sorted(status_tag_names)}"
155+
)
156+
errors.extend([
157+
f"ImageStream {imagestream_name} spec tag {missing_tag} is missing from status.tags "
158+
f"(label selector: {label_selector})"
159+
for missing_tag in missing_status_tags
160+
])
161+
162+
for status_tag in status_tags:
163+
tag_name = str(status_tag.get("tag", "<missing-tag-name>"))
164+
errors.extend(
165+
_validate_imagestream_tag_health(
166+
imagestream_name=imagestream_name,
167+
tag_name=tag_name,
168+
tag_data=status_tag,
169+
)
170+
)
171+
172+
if errors:
173+
pytest.fail("\n".join(errors))
174+
175+
176+
@pytest.mark.parametrize(
177+
"label_selector, expected_imagestream_count",
178+
[
179+
pytest.param("opendatahub.io/notebook-image=true", 11, id="notebook_imagestreams"),
180+
pytest.param("opendatahub.io/runtime-image=true", 7, id="runtime_imagestreams"),
181+
],
182+
)
183+
def test_workbench_imagestreams_health(
184+
admin_client: DynamicClient,
185+
label_selector: str,
186+
expected_imagestream_count: int,
187+
) -> None:
188+
"""
189+
Given workbench-related ImageStreams in the applications namespace.
190+
When ImageStreams are listed by the expected workbench labels.
191+
Then all expected ImageStreams exist and each tag is imported and resolved successfully.
192+
"""
193+
imagestreams = list(
194+
ImageStream.get(
195+
client=admin_client,
196+
namespace=py_config["applications_namespace"],
197+
label_selector=label_selector,
198+
)
199+
)
200+
201+
_validate_imagestreams_with_label(
202+
imagestreams=imagestreams,
203+
label_selector=label_selector,
204+
expected_count=expected_imagestream_count,
205+
)

0 commit comments

Comments
 (0)