Skip to content

Commit a266fe3

Browse files
lugi0dbasunag
andauthored
add test to validate container images for MR (#311)
* feat: add test to validate container images, improve ODH compatibility Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: Skip sidecar image check Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: fail if MR is Managed in RHOAI Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: Remove most ODH logic Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: ns reference in fixture Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: generalize pod getters and image verification Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: address review comments Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: add wait for single pod returned Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: mr_namespace constant removal Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: refactoring code Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: address comment and refactor Signed-off-by: lugi0 <lgiorgi@redhat.com> * feat: parse related image refs in a fixture Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: typo in fixtue name Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: address review comments Signed-off-by: lugi0 <lgiorgi@redhat.com> * fix: address review comments Signed-off-by: lugi0 <lgiorgi@redhat.com> --------- Signed-off-by: lugi0 <lgiorgi@redhat.com> Co-authored-by: Debarati Basu-Nag <dbasunag@redhat.com>
1 parent 4743739 commit a266fe3

File tree

7 files changed

+244
-16
lines changed

7 files changed

+244
-16
lines changed

tests/model_registry/conftest.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import pytest
2-
import re
32
import schemathesis
43
from typing import Generator, Any
5-
from kubernetes.dynamic.exceptions import ResourceNotFoundError
64
from ocp_resources.pod import Pod
75
from ocp_resources.secret import Secret
86
from ocp_resources.namespace import Namespace
@@ -32,6 +30,7 @@
3230
MODEL_REGISTRY_DB_SECRET_STR_DATA,
3331
MODEL_REGISTRY_DB_SECRET_ANNOTATIONS,
3432
)
33+
from utilities.constants import Labels
3534
from tests.model_registry.utils import (
3635
get_endpoint_from_mr_service,
3736
get_mr_service_by_label,
@@ -44,6 +43,7 @@
4443
from semver import Version
4544
from utilities.infra import get_product_version
4645
from utilities.operator_utils import get_cluster_service_version, validate_operator_subscription_channel
46+
from utilities.general import wait_for_pods_by_labels
4747

4848
LOGGER = get_logger(name=__name__)
4949

@@ -229,10 +229,12 @@ def updated_dsc_component_state_scope_class(
229229
request: FixtureRequest, dsc_resource: DataScienceCluster, admin_client: DynamicClient
230230
) -> Generator[DataScienceCluster, Any, Any]:
231231
original_components = dsc_resource.instance.spec.components
232-
with ResourceEditor(patches={dsc_resource: {"spec": {"components": request.param["component_patch"]}}}):
233-
for component_name in request.param["component_patch"]:
232+
component_patch = request.param["component_patch"]
233+
234+
with ResourceEditor(patches={dsc_resource: {"spec": {"components": component_patch}}}):
235+
for component_name in component_patch:
234236
dsc_resource.wait_for_condition(condition=DscComponents.COMPONENT_MAPPING[component_name], status="True")
235-
if request.param["component_patch"].get(DscComponents.MODELREGISTRY):
237+
if component_patch.get(DscComponents.MODELREGISTRY):
236238
namespace = Namespace(
237239
name=dsc_resource.instance.spec.components.modelregistry.registriesNamespace, ensure_exists=True
238240
)
@@ -244,7 +246,7 @@ def updated_dsc_component_state_scope_class(
244246
)
245247
yield dsc_resource
246248

247-
for component_name, value in request.param["component_patch"].items():
249+
for component_name, value in component_patch.items():
248250
LOGGER.info(f"Waiting for component {component_name} to be updated.")
249251
if original_components[component_name]["managementState"] == DscComponents.ManagementState.MANAGED:
250252
dsc_resource.wait_for_condition(condition=DscComponents.COMPONENT_MAPPING[component_name], status="True")
@@ -288,15 +290,25 @@ def registered_model(request: FixtureRequest, model_registry_client: ModelRegist
288290

289291

290292
@pytest.fixture()
291-
def model_registry_operator_pod(admin_client: DynamicClient) -> Pod:
292-
model_registry_operator_pods = [
293-
pod
294-
for pod in Pod.get(dyn_client=admin_client, namespace=py_config["applications_namespace"])
295-
if re.match(MR_OPERATOR_NAME, pod.name)
296-
]
297-
if not model_registry_operator_pods:
298-
raise ResourceNotFoundError("Model registry operator pod not found")
299-
return model_registry_operator_pods[0]
293+
def model_registry_operator_pod(admin_client: DynamicClient) -> Generator[Pod, Any, Any]:
294+
"""Get the model registry operator pod."""
295+
yield wait_for_pods_by_labels(
296+
admin_client=admin_client,
297+
namespace=py_config["applications_namespace"],
298+
label_selector=f"{Labels.OpenDataHubIo.NAME}={MR_OPERATOR_NAME}",
299+
expected_num_pods=1,
300+
)[0]
301+
302+
303+
@pytest.fixture()
304+
def model_registry_instance_pod(admin_client: DynamicClient) -> Generator[Pod, Any, Any]:
305+
"""Get the model registry instance pod."""
306+
yield wait_for_pods_by_labels(
307+
admin_client=admin_client,
308+
namespace=py_config["model_registry_namespace"],
309+
label_selector=f"app={MR_INSTANCE_NAME}",
310+
expected_num_pods=1,
311+
)[0]
300312

301313

302314
@pytest.fixture(scope="package", autouse=True)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import pytest
2+
from typing import Set
3+
from kubernetes.dynamic import DynamicClient
4+
from utilities.operator_utils import get_csv_related_images
5+
6+
7+
@pytest.fixture()
8+
def related_images_refs(admin_client: DynamicClient) -> Set[str]:
9+
related_images = get_csv_related_images(admin_client=admin_client)
10+
related_images_refs = {img["image"] for img in related_images}
11+
return related_images_refs
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import pytest
2+
from typing import Self, Set
3+
from simple_logger.logger import get_logger
4+
from kubernetes.dynamic import DynamicClient
5+
from pytest_testconfig import config as py_config
6+
7+
from utilities.constants import DscComponents
8+
from utilities.general import (
9+
validate_container_images,
10+
)
11+
from ocp_resources.model_registry import ModelRegistry
12+
from ocp_resources.pod import Pod
13+
14+
LOGGER = get_logger(name=__name__)
15+
16+
17+
@pytest.mark.parametrize(
18+
"updated_dsc_component_state_scope_class",
19+
[
20+
{
21+
"component_patch": {
22+
DscComponents.MODELREGISTRY: {
23+
"managementState": DscComponents.ManagementState.MANAGED,
24+
"registriesNamespace": py_config["model_registry_namespace"],
25+
}
26+
}
27+
}
28+
],
29+
indirect=True,
30+
)
31+
@pytest.mark.usefixtures("updated_dsc_component_state_scope_class")
32+
class TestModelRegistryImages:
33+
"""
34+
Tests to verify that all Model Registry component images (operator and instance container images)
35+
meet the requirements:
36+
1. Images are hosted in registry.redhat.io
37+
2. Images use sha256 digest instead of tags
38+
3. Images are listed in the CSV's relatedImages section
39+
"""
40+
41+
@pytest.mark.smoke
42+
def test_verify_model_registry_images(
43+
self: Self,
44+
admin_client: DynamicClient,
45+
model_registry_instance: ModelRegistry,
46+
model_registry_operator_pod: Pod,
47+
model_registry_instance_pod: Pod,
48+
related_images_refs: Set[str],
49+
):
50+
validation_errors = []
51+
for pod in [model_registry_operator_pod, model_registry_instance_pod]:
52+
validation_errors.extend(
53+
validate_container_images(
54+
pod=pod, valid_image_refs=related_images_refs, skip_patterns=["openshift-service-mesh"]
55+
)
56+
)
57+
58+
if validation_errors:
59+
pytest.fail("\n".join(validation_errors))

utilities/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ class Notebook:
176176

177177
class OpenDataHubIo:
178178
MANAGED: str = Annotations.OpenDataHubIo.MANAGED
179+
NAME: str = f"component.{ApiGroups.OPENDATAHUB_IO}/name"
179180

180181
class Openshift:
181182
APP: str = "app"

utilities/general.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import base64
2+
import re
3+
from typing import List, Tuple
24

35
from kubernetes.dynamic import DynamicClient
6+
from kubernetes.dynamic.exceptions import ResourceNotFoundError
47
from ocp_resources.inference_service import InferenceService
58
from ocp_resources.pod import Pod
69
from simple_logger.logger import get_logger
710

811
import utilities.infra
912
from utilities.constants import Annotations, KServeDeploymentType, MODELMESH_SERVING
13+
from utilities.exceptions import UnexpectedResourceCountError
14+
from ocp_resources.resource import Resource
15+
from timeout_sampler import retry
16+
17+
# Constants for image validation
18+
SHA256_DIGEST_PATTERN = r"@sha256:[a-f0-9]{64}$"
1019

1120
LOGGER = get_logger(name=__name__)
1221

@@ -171,3 +180,110 @@ def create_isvc_label_selector_str(isvc: InferenceService, resource_type: str, r
171180

172181
else:
173182
raise ValueError(f"Unknown deployment mode {deployment_mode}")
183+
184+
185+
def get_pod_images(pod: Pod) -> List[str]:
186+
"""Get all container images from a pod.
187+
188+
Args:
189+
pod: The pod to get images from
190+
191+
Returns:
192+
List of container image strings
193+
"""
194+
return [container.image for container in pod.instance.spec.containers]
195+
196+
197+
def validate_image_format(image: str) -> Tuple[bool, str]:
198+
"""Validate image format according to requirements.
199+
200+
Args:
201+
image: The image string to validate
202+
203+
Returns:
204+
Tuple of (is_valid, error_message)
205+
"""
206+
if not image.startswith(Resource.ApiGroup.IMAGE_REGISTRY):
207+
return False, f"Image {image} is not from {Resource.ApiGroup.IMAGE_REGISTRY}"
208+
209+
if not re.search(SHA256_DIGEST_PATTERN, image):
210+
return False, f"Image {image} does not use sha256 digest"
211+
212+
return True, ""
213+
214+
215+
@retry(
216+
wait_timeout=60,
217+
sleep=5,
218+
exceptions_dict={ResourceNotFoundError: [], UnexpectedResourceCountError: []},
219+
)
220+
def wait_for_pods_by_labels(
221+
admin_client: DynamicClient,
222+
namespace: str,
223+
label_selector: str,
224+
expected_num_pods: int,
225+
) -> list[Pod]:
226+
"""
227+
Get pods by label selector in a namespace.
228+
229+
Args:
230+
admin_client: The admin client to use for pod retrieval
231+
namespace: The namespace to search in
232+
label_selector: The label selector to filter pods
233+
expected_num_pods: The expected number of pods to be found
234+
Returns:
235+
List of matching pods
236+
237+
Raises:
238+
ResourceNotFoundError: If no pods are found
239+
"""
240+
pods = list(
241+
Pod.get(
242+
dyn_client=admin_client,
243+
namespace=namespace,
244+
label_selector=label_selector,
245+
)
246+
)
247+
if not pods:
248+
raise ResourceNotFoundError(f"No pods found with label selector {label_selector} in namespace {namespace}")
249+
if len(pods) != expected_num_pods:
250+
raise UnexpectedResourceCountError(f"Expected {expected_num_pods} pods, found {len(pods)}")
251+
return pods
252+
253+
254+
def validate_container_images(
255+
pod: Pod,
256+
valid_image_refs: set[str],
257+
skip_patterns: list[str] | None = None,
258+
) -> list[str]:
259+
"""
260+
Validate all container images in a pod against a set of valid image references.
261+
262+
Args:
263+
pod: The pod whose images to validate
264+
valid_image_refs: Set of valid image references to check against
265+
skip_patterns: List of patterns to skip validation for (e.g. ["openshift-service-mesh"])
266+
267+
Returns:
268+
List of validation error messages, empty if all validations pass
269+
"""
270+
validation_errors = []
271+
skip_patterns = skip_patterns or []
272+
273+
pod_images = get_pod_images(pod=pod)
274+
for image in pod_images:
275+
# Skip images matching any skip patterns
276+
if any(pattern in image for pattern in skip_patterns):
277+
LOGGER.warning(f"Skipping image {image} as it matches skip patterns")
278+
continue
279+
280+
# Validate image format
281+
is_valid, error_msg = validate_image_format(image=image)
282+
if not is_valid:
283+
validation_errors.append(f"Pod {pod.name} image validation failed: {error_msg}")
284+
285+
# Check if image is in valid references
286+
if image not in valid_image_refs:
287+
validation_errors.append(f"Pod {pod.name} image {image} is not in valid image references")
288+
289+
return validation_errors

utilities/infra.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,7 @@ def get_operator_distribution(client: DynamicClient, dsc_name: str = "default-ds
807807
dsc_name (str): DSC name
808808
809809
Returns:
810-
str: Operator distribution.
810+
str: Operator distribution. One of Open Data Hub or OpenShift AI.
811811
812812
Raises:
813813
ValueError: If DSC release name not found

utilities/operator_utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
from ocp_resources.cluster_service_version import ClusterServiceVersion
66
from ocp_resources.subscription import Subscription
77
from utilities.exceptions import ResourceMismatchError
8+
from utilities.infra import get_product_version
9+
from pytest_testconfig import config as py_config
10+
11+
from typing import List, Dict
812

913
LOGGER = get_logger(name=__name__)
1014

@@ -46,3 +50,28 @@ def validate_operator_subscription_channel(
4650
f"For Operator {operator_name}, Subscription points to {subscription_channel}, expected: {channel_name}"
4751
)
4852
LOGGER.info(f"Operator {operator_name} subscription channel is {subscription_channel}")
53+
54+
55+
def get_csv_related_images(admin_client: DynamicClient, csv_name: str | None = None) -> List[Dict[str, str]]:
56+
"""Get relatedImages from the CSV.
57+
58+
Args:
59+
admin_client: The kubernetes client
60+
csv_name: Optional CSV name. If not provided, will use {operator_name}.{version}
61+
where operator_name is determined by the distribution (rhods-operator for OpenShift AI,
62+
opendatahub-operator for Open Data Hub)
63+
64+
Returns:
65+
List of related images from the CSV
66+
"""
67+
68+
if csv_name is None:
69+
distribution = py_config["distribution"]
70+
operator_name = "opendatahub-operator" if distribution == "upstream" else "rhods-operator"
71+
csv_name = f"{operator_name}.{get_product_version(admin_client=admin_client)}"
72+
73+
return get_cluster_service_version(
74+
client=admin_client,
75+
prefix=csv_name,
76+
namespace=py_config["applications_namespace"],
77+
).instance.spec.relatedImages

0 commit comments

Comments
 (0)