22import json
33import os
44import signal
5+ import stat
56import subprocess
67import tempfile
78import unittest
89from contextlib import redirect_stdout
910from pathlib import Path
11+ from types import SimpleNamespace
1012from unittest .mock import Mock , patch
1113
1214import container_batch as container
@@ -29,6 +31,19 @@ def setUp(self):
2931 script .touch ()
3032 self .output = self .root / "reports"
3133 self .output .mkdir ()
34+ devices = patch .object (
35+ container ,
36+ "gpu_device_paths" ,
37+ return_value = [
38+ Path ("/dev/nvidia4" ),
39+ Path ("/dev/nvidia7" ),
40+ Path ("/dev/nvidiactl" ),
41+ Path ("/dev/nvidia-uvm" ),
42+ Path ("/dev/nvidia-uvm-tools" ),
43+ ],
44+ )
45+ devices .start ()
46+ self .addCleanup (devices .stop )
3247
3348 def command (self , ** env ):
3449 return container .create_command (
@@ -48,6 +63,17 @@ def test_uuid_filter_and_logical_device_mapping(self):
4863 self .assertIn ("CUDA_VISIBLE_DEVICES=0,1" , command )
4964 self .assertIn (f"NVIDIA_VISIBLE_DEVICES={ GPU_A } ,{ GPU_B } " , command )
5065 self .assertEqual (command [- 2 :], ["5" , "0,1" ])
66+ devices = [command [index + 1 ] for index , value in enumerate (command ) if value == "--device" ]
67+ self .assertEqual (
68+ devices ,
69+ [
70+ "/dev/nvidia4:/dev/nvidia4:rw" ,
71+ "/dev/nvidia7:/dev/nvidia7:rw" ,
72+ "/dev/nvidiactl:/dev/nvidiactl:rw" ,
73+ "/dev/nvidia-uvm:/dev/nvidia-uvm:rw" ,
74+ "/dev/nvidia-uvm-tools:/dev/nvidia-uvm-tools:rw" ,
75+ ],
76+ )
5177
5278 def test_only_source_and_reports_are_mounted (self ):
5379 command = self .command (COLOSSAL_VENV = "/host/private/venv" , SECRET_TOKEN = "not-for-container" )
@@ -75,6 +101,7 @@ def test_resource_limits(self):
75101 ("--shm-size" , "4g" ),
76102 ):
77103 self .assertEqual (command [command .index (flag ) + 1 ], expected )
104+ self .assertEqual (command [command .index ("--tmpfs" ) + 1 ], "/tmp:rw,exec,nosuid,nodev,size=32g,mode=1777" )
78105 for env in (
79106 {"COLOSSAL_CI_CPUS" : "0" },
80107 {"COLOSSAL_CI_MEMORY" : "unlimited" },
@@ -208,6 +235,92 @@ def test_cleanup_failure_cannot_report_success(self):
208235 with self .assertRaisesRegex (RuntimeError , "daemon unavailable" ):
209236 self .run_batch (Mock (return_value = 0 ))
210237
238+ def test_cleanup_failure_preserves_execution_error (self ):
239+ result = {}
240+ with (
241+ patch .object (container .subprocess , "run" ),
242+ patch .object (container , "remove_owned_container" , side_effect = RuntimeError ("overlay2 removal failed" )),
243+ ):
244+ with self .assertRaisesRegex (RuntimeError , "original execution failed" ):
245+ container .run_container (
246+ "5" ,
247+ [(4 , GPU_A ), (7 , GPU_B )],
248+ self .root ,
249+ self .output ,
250+ IMAGE ,
251+ {"CI_CONTAINER_SCOPE" : SCOPE },
252+ Mock (side_effect = RuntimeError ("original execution failed" )),
253+ result ,
254+ )
255+ self .assertEqual (result ["container_execution_error" ], "original execution failed" )
256+ self .assertIn ("overlay2 removal failed" , result ["container_cleanup" ])
257+
258+
259+ class GPUDeviceTests (unittest .TestCase ):
260+ @staticmethod
261+ def inventory (* pairs ):
262+ return (
263+ "<nvidia_smi_log>"
264+ + "" .join (f"<gpu><uuid>{ gpu } </uuid><minor_number>{ minor } </minor_number></gpu>" for gpu , minor in pairs )
265+ + "</nvidia_smi_log>"
266+ )
267+
268+ def test_devices_are_resolved_by_uuid_and_minor_not_inventory_order (self ):
269+ metadata = [
270+ SimpleNamespace (st_mode = stat .S_IFCHR , st_rdev = os .makedev (195 , 7 )),
271+ SimpleNamespace (st_mode = stat .S_IFCHR , st_rdev = os .makedev (195 , 4 )),
272+ * [SimpleNamespace (st_mode = stat .S_IFCHR , st_rdev = 0 ) for _ in range (3 )],
273+ ]
274+ with (
275+ patch .object (container .subprocess , "run" , return_value = Mock (stdout = self .inventory ((GPU_B , 4 ), (GPU_A , 7 )))),
276+ patch .object (Path , "stat" , side_effect = metadata ),
277+ ):
278+ devices = container .gpu_device_paths ([GPU_A , GPU_B ])
279+ self .assertEqual ([str (path ) for path in devices [:2 ]], ["/dev/nvidia7" , "/dev/nvidia4" ])
280+ self .assertEqual (len (devices ), 5 )
281+
282+ def test_missing_or_ambiguous_allocations_fail_before_device_access (self ):
283+ for inventory in (
284+ self .inventory ((GPU_A , 4 )),
285+ self .inventory ((GPU_A , 4 ), (GPU_A , 7 )),
286+ self .inventory ((GPU_A , 4 ), (GPU_B , 4 )),
287+ self .inventory ((GPU_A , "N/A" )),
288+ "malformed XML" ,
289+ ):
290+ with (
291+ self .subTest (inventory = inventory ),
292+ patch .object (container .subprocess , "run" , return_value = Mock (stdout = inventory )),
293+ patch .object (Path , "stat" ) as metadata ,
294+ ):
295+ with self .assertRaises (RuntimeError ):
296+ container .gpu_device_paths ([GPU_A , GPU_B ])
297+ metadata .assert_not_called ()
298+
299+ def test_non_device_and_mismatched_minor_are_rejected (self ):
300+ for metadata in (
301+ SimpleNamespace (st_mode = stat .S_IFREG , st_rdev = 0 ),
302+ SimpleNamespace (st_mode = stat .S_IFCHR , st_rdev = os .makedev (195 , 9 )),
303+ ):
304+ with (
305+ self .subTest (metadata = metadata ),
306+ patch .object (container .subprocess , "run" , return_value = Mock (stdout = self .inventory ((GPU_A , 4 )))),
307+ patch .object (Path , "stat" , return_value = metadata ),
308+ ):
309+ with self .assertRaises (RuntimeError ):
310+ container .gpu_device_paths ([GPU_A ])
311+
312+ def test_missing_control_device_fails_closed (self ):
313+ with (
314+ patch .object (container .subprocess , "run" , return_value = Mock (stdout = self .inventory ((GPU_A , 4 )))),
315+ patch .object (
316+ Path ,
317+ "stat" ,
318+ side_effect = [SimpleNamespace (st_mode = stat .S_IFCHR , st_rdev = os .makedev (195 , 4 )), FileNotFoundError ()],
319+ ),
320+ ):
321+ with self .assertRaises (FileNotFoundError ):
322+ container .gpu_device_paths ([GPU_A ])
323+
211324
212325class ContainerOwnershipTests (unittest .TestCase ):
213326 def test_absent_container_needs_no_removal (self ):
0 commit comments