|
| 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