1111from ocp_resources .namespace import Namespace
1212from ocp_resources .persistent_volume_claim import PersistentVolumeClaim
1313from ocp_resources .notebook import Notebook
14+ from ocp_resources .pod import Pod
1415
15- from utilities .constants import Labels
16+ from utilities .constants import Labels , Timeout
1617from utilities import constants
1718from utilities .constants import INTERNAL_IMAGE_REGISTRY_PATH
1819from utilities .infra import check_internal_image_registry_available
20+ from utilities .general import collect_pod_information
1921
2022LOGGER = get_logger (name = __name__ )
2123
@@ -44,10 +46,64 @@ def minimal_image() -> Generator[str, None, None]:
4446
4547
4648@pytest .fixture (scope = "function" )
47- def default_notebook (
49+ def notebook_image (
4850 request : pytest .FixtureRequest ,
4951 admin_client : DynamicClient ,
5052 minimal_image : str ,
53+ ) -> str :
54+ """
55+ Resolves the notebook image path.
56+
57+ Priority:
58+ 1. 'custom_image' provided via indirect parametrization
59+ 2. Default 'minimal_image' (with automatic registry resolution)
60+ """
61+ # SAFELY get parameters. If test doesn't parameterize this fixture, default to empty dict.
62+ params = getattr (request , "param" , {})
63+ custom_image = params .get ("custom_image" )
64+
65+ # Case A: Custom Image (Explicit)
66+ if custom_image :
67+ custom_image = custom_image .strip ()
68+ if not custom_image :
69+ raise ValueError ("custom_image cannot be empty or whitespace" )
70+
71+ # Validation Logic: Only digest references are accepted
72+ _ERR_INVALID_CUSTOM_IMAGE = (
73+ "custom_image must be a valid OCI image reference with a digest (@sha256:digest), "
74+ "e.g., 'quay.io/org/image@sha256:abc123...', "
75+ "got: '{custom_image}'"
76+ )
77+ # Check for valid digest: @sha256: must be followed by non-empty content
78+ digest_marker = "@sha256:"
79+ has_valid_digest = False
80+ if digest_marker in custom_image :
81+ digest_index = custom_image .rfind (digest_marker )
82+ digest_end = digest_index + len (digest_marker )
83+ has_valid_digest = digest_end < len (custom_image )
84+
85+ if not has_valid_digest :
86+ raise ValueError (_ERR_INVALID_CUSTOM_IMAGE .format (custom_image = custom_image ))
87+
88+ LOGGER .info (f"Using custom workbench image: { custom_image } " )
89+ return custom_image
90+
91+ # Case B: Default Image (Implicit / Good Default)
92+ # This runs for all standard tests in test_spawning.py
93+ internal_image_registry = check_internal_image_registry_available (admin_client = admin_client )
94+
95+ return (
96+ f"{ INTERNAL_IMAGE_REGISTRY_PATH } /{ py_config ['applications_namespace' ]} /{ minimal_image } "
97+ if internal_image_registry
98+ else minimal_image
99+ )
100+
101+
102+ @pytest .fixture (scope = "function" )
103+ def default_notebook (
104+ request : pytest .FixtureRequest ,
105+ admin_client : DynamicClient ,
106+ notebook_image : str ,
51107) -> Generator [Notebook , None , None ]:
52108 """Returns a new Notebook CR for a given namespace, name, and image"""
53109 namespace = request .param ["namespace" ]
@@ -60,15 +116,8 @@ def default_notebook(
60116 username = get_username (dyn_client = admin_client )
61117 assert username , "Failed to determine username from the cluster"
62118
63- # Check internal image registry availability
64- internal_image_registry = check_internal_image_registry_available (admin_client = admin_client )
65-
66- # Set the image path based on internal image registry status
67- minimal_image_path = (
68- f"{ INTERNAL_IMAGE_REGISTRY_PATH } /{ py_config ['applications_namespace' ]} /{ minimal_image } "
69- if internal_image_registry
70- else ":" + minimal_image .rsplit (":" , maxsplit = 1 )[1 ]
71- )
119+ # Set the image path based on the resolved notebook_image
120+ image_path = notebook_image
72121
73122 probe_config = {
74123 "failureThreshold" : 3 ,
@@ -90,7 +139,7 @@ def default_notebook(
90139 "annotations" : {
91140 Labels .Notebook .INJECT_AUTH : "true" ,
92141 "opendatahub.io/accelerator-name" : "" ,
93- "notebooks.opendatahub.io/last-image-selection" : minimal_image ,
142+ "notebooks.opendatahub.io/last-image-selection" : image_path ,
94143 # Add any additional annotations if provided
95144 ** auth_annotations ,
96145 },
@@ -124,9 +173,9 @@ def default_notebook(
124173 " "
125174 "--ServerApp.quit_button=False\n " ,
126175 },
127- {"name" : "JUPYTER_IMAGE" , "value" : minimal_image_path },
176+ {"name" : "JUPYTER_IMAGE" , "value" : image_path },
128177 ],
129- "image" : minimal_image_path ,
178+ "image" : image_path ,
130179 "imagePullPolicy" : "Always" ,
131180 "livenessProbe" : probe_config ,
132181 "name" : name ,
@@ -167,3 +216,70 @@ def default_notebook(
167216
168217 with Notebook (kind_dict = notebook ) as nb :
169218 yield nb
219+
220+
221+ @pytest .fixture (scope = "function" )
222+ def notebook_pod (
223+ unprivileged_client : DynamicClient ,
224+ default_notebook : Notebook ,
225+ ) -> Pod :
226+ """
227+ Returns a notebook pod in Ready state.
228+
229+ This fixture:
230+ - Creates a Pod object for the notebook
231+ - Waits for pod to exist
232+ - Waits for pod to reach Ready state (10-minute timeout)
233+ - Provides detailed diagnostics on failure
234+
235+ Args:
236+ unprivileged_client: Client for interacting with the cluster
237+ default_notebook: The notebook CR to get the pod for
238+
239+ Returns:
240+ Pod object in Ready state
241+
242+ Raises:
243+ AssertionError: If pod fails to reach Ready state or is not created
244+ """
245+ # Error messages
246+ _ERR_POD_NOT_READY = (
247+ "Pod '{pod_name}-0' failed to reach Ready state within 10 minutes.\n "
248+ "Pod Phase: {pod_phase}\n "
249+ "Original Error: {original_error}\n "
250+ "Pod information collected to must-gather directory for debugging."
251+ )
252+ _ERR_POD_NOT_CREATED = "Pod '{pod_name}-0' was not created. Check notebook controller logs."
253+
254+ # Create pod object
255+ notebook_pod = Pod (
256+ client = unprivileged_client ,
257+ namespace = default_notebook .namespace ,
258+ name = f"{ default_notebook .name } -0" ,
259+ )
260+
261+ try :
262+ notebook_pod .wait ()
263+ notebook_pod .wait_for_condition (
264+ condition = Pod .Condition .READY ,
265+ status = Pod .Condition .Status .TRUE ,
266+ timeout = Timeout .TIMEOUT_10MIN ,
267+ )
268+ except (TimeoutError , RuntimeError ) as e :
269+ if notebook_pod .exists :
270+ # Collect pod information for debugging purposes (YAML + logs saved to must-gather dir)
271+ collect_pod_information (notebook_pod )
272+ pod_status = notebook_pod .instance .status
273+ pod_phase = pod_status .phase
274+ raise AssertionError (
275+ _ERR_POD_NOT_READY .format (
276+ pod_name = default_notebook .name ,
277+ pod_phase = pod_phase ,
278+ original_error = e ,
279+ )
280+ ) from e
281+ else :
282+ # Pod was never created
283+ raise AssertionError (_ERR_POD_NOT_CREATED .format (pod_name = default_notebook .name )) from e
284+
285+ return notebook_pod
0 commit comments