@@ -243,7 +243,8 @@ def test_launch_success(self, executor, mock_k8s_clients):
243243 mock_custom .create_namespaced_custom_object .return_value = {}
244244
245245 job_name , state = executor .launch ("test-job" , ["/bin/bash" , "-c" , "echo hi" ])
246- assert job_name == "test-job"
246+ # TrainJob names are <base>-<uuid6> (RFC-1123 safe, unique per launch)
247+ assert job_name .startswith ("test-job-" ) and len (job_name ) == len ("test-job-" ) + 6
247248 assert state == KubeflowJobState .CREATED
248249 mock_custom .create_namespaced_custom_object .assert_called_once ()
249250
@@ -272,12 +273,18 @@ def test_launch_wait_timeout(self, executor, mock_k8s_clients):
272273 with pytest .raises (RuntimeError , match = "did not reach RUNNING" ):
273274 executor .launch ("test-job" , ["echo" ], wait = True , timeout = - 1 )
274275
275- def test_launch_conflict (self , executor , mock_k8s_clients ):
276+ def test_launch_conflict_recreates (self , executor , mock_k8s_clients ):
276277 mock_custom , _ = mock_k8s_clients
277- mock_custom .create_namespaced_custom_object .side_effect = ApiException (status = 409 )
278+ # A 409 means a stale TrainJob from a prior attempt lingers; launch cancels
279+ # it and recreates so the caller's retry makes progress (idempotent launch).
280+ mock_custom .create_namespaced_custom_object .side_effect = [ApiException (status = 409 ), {}]
278281
279- with pytest .raises (RuntimeError , match = "already exists" ):
280- executor .launch ("test-job" , ["/bin/bash" , "-c" , "echo hi" ])
282+ with patch .object (executor , "cancel" ) as mock_cancel :
283+ _ , state = executor .launch ("test-job" , ["/bin/bash" , "-c" , "echo hi" ])
284+
285+ mock_cancel .assert_called_once ()
286+ assert mock_custom .create_namespaced_custom_object .call_count == 2
287+ assert state == KubeflowJobState .CREATED
281288
282289 def test_status_running (self , executor , mock_k8s_clients ):
283290 mock_custom , _ = mock_k8s_clients
@@ -346,34 +353,38 @@ def test_cancel_with_wait_timeout(self, executor, mock_k8s_clients):
346353
347354 # ── Logs ─────────────────────────────────────────────────────────────────────
348355
349- def test_fetch_logs_no_follow (self , executor , mock_k8s_clients ):
356+ def test_fetch_logs_no_follow (self , executor , mock_k8s_clients , tmp_path ):
357+ executor .job_dir = str (tmp_path )
350358 with patch ("subprocess.run" ) as mock_run :
351359 mock_run .return_value = MagicMock (stdout = "line1\n line2\n " )
352- lines = list (executor .fetch_logs ("my-job" , stream = False , lines = 50 ))
353-
354- mock_run .assert_called_once ()
355- called_cmd = mock_run .call_args [0 ][0 ]
356- assert "--tail" in called_cmd
357- assert "50" in called_cmd
358- label_arg = " " .join (called_cmd )
359- assert "jobset.sigs.k8s.io/jobset-name=my-job" in label_arg
360- assert "-f" not in called_cmd
361- assert lines == ["line1" , "line2" ]
362-
363- def test_fetch_logs_follow (self , executor , mock_k8s_clients ):
360+ list (executor .fetch_logs ("my-job" , stream = False , lines = 50 ))
361+
362+ # the kubectl logs call (distinct from the pod-index lookup) targets the
363+ # jobset and does not follow.
364+ log_cmd = next (c .args [0 ] for c in mock_run .call_args_list if "logs" in c .args [0 ])
365+ assert "jobset.sigs.k8s.io/jobset-name=my-job" in " " .join (log_cmd )
366+ assert "--tail" in log_cmd and "-f" not in log_cmd
367+ # every rank is persisted to the all-ranks log
368+ assert (tmp_path / "log-allranks_0.out" ).read_text () == "line1\n line2\n "
369+
370+ def test_fetch_logs_follow (self , executor , mock_k8s_clients , tmp_path ):
364371 import io
365372
373+ executor .job_dir = str (tmp_path )
366374 mock_proc = MagicMock ()
367375 mock_proc .stdout = io .StringIO ("line1\n line2\n " )
368376 mock_proc .poll .return_value = None # still running; loop exits when readline() hits EOF
369377
370- with patch ("subprocess.Popen" , return_value = mock_proc ) as mock_popen :
371- lines = list (executor .fetch_logs ("my-job" , stream = True , lines = 100 ))
378+ with (
379+ patch ("subprocess.Popen" , return_value = mock_proc ) as mock_popen ,
380+ patch ("time.sleep" ),
381+ patch .object (executor , "status" , return_value = KubeflowJobState .SUCCEEDED ),
382+ ):
383+ list (executor .fetch_logs ("my-job" , stream = True , lines = 100 ))
372384
373- mock_popen .assert_called_once ()
374- called_cmd = mock_popen .call_args [0 ][0 ]
375- assert "-f" in called_cmd
376- assert lines == ["line1\n " , "line2\n " ]
385+ assert "-f" in mock_popen .call_args .args [0 ]
386+ # every rank is persisted to the all-ranks log
387+ assert (tmp_path / "log-allranks_0.out" ).read_text () == "line1\n line2\n "
377388
378389 def test_status_unknown_when_empty (self , mock_k8s_clients ):
379390 mock_custom , _ = mock_k8s_clients
@@ -473,10 +484,12 @@ def test_pull_results_syncs_from_pvc(self, workdir_executor, mock_k8s_clients):
473484 mock_core .create_namespaced_pod .assert_called_once ()
474485 assert mock_check_call .call_count == 1 # kubectl cp only (no mkdir for pull)
475486 cp_args = mock_check_call .call_args [0 ][0 ]
476- # kubectl cp <ns>/<pod>:<remote> <local>
487+ # kubectl cp <ns>/<pod>:<remote> <local>; the data-mover pod is named off the
488+ # <base>-<uuid6> TrainJob name.
477489 assert "kubectl" in cp_args
478490 assert "cp" in cp_args
479- assert f"test-job-data-mover:{ workdir_executor .code_dir } " in cp_args
491+ dm = next (a for a in cp_args if "-data-mover:" in a )
492+ assert dm .startswith ("test-job-" ) and dm .endswith (f"-data-mover:{ workdir_executor .code_dir } " )
480493
481494 def test_pull_results_noop_without_workdir_pvc (self , mock_k8s_clients ):
482495 e = KubeflowExecutor (image = "test:latest" )
@@ -590,11 +603,12 @@ def test_launch_wait_exits_on_failed(self, executor, mock_k8s_clients):
590603
591604 # ── fetch_logs streaming: retry until terminal state ─────────────────────
592605
593- def test_fetch_logs_stream_retries_until_terminal_state (self , executor , mock_k8s_clients ):
606+ def test_fetch_logs_stream_retries_until_terminal_state (self , executor , mock_k8s_clients , tmp_path ):
594607 """First Popen yields nothing and job is RUNNING; second yields a line and job is
595608 SUCCEEDED — loop exits on terminal status."""
596609 import io
597610
611+ executor .job_dir = str (tmp_path )
598612 empty_proc = MagicMock ()
599613 empty_proc .stdout = io .StringIO ("" )
600614 empty_proc .poll .return_value = None
@@ -614,13 +628,15 @@ def test_fetch_logs_stream_retries_until_terminal_state(self, executor, mock_k8s
614628 side_effect = [KubeflowJobState .RUNNING , KubeflowJobState .SUCCEEDED ],
615629 ),
616630 ):
617- lines = list (executor .fetch_logs ("my-job" , stream = True ))
631+ list (executor .fetch_logs ("my-job" , stream = True ))
618632
619- assert "some output\n " in lines
633+ # forwarded stdout is rank-0/last only, but every rank lands in the all-ranks log
634+ assert "some output" in (tmp_path / "log-allranks_0.out" ).read_text ()
620635
621- def test_fetch_logs_stream_handles_exception (self , executor , mock_k8s_clients ):
636+ def test_fetch_logs_stream_handles_exception (self , executor , mock_k8s_clients , tmp_path ):
622637 """Exception inside the readline loop is caught; loop exits when job is terminal."""
623638
639+ executor .job_dir = str (tmp_path )
624640 mock_proc = MagicMock ()
625641 mock_proc .stdout .readline .side_effect = OSError ("read error" )
626642 mock_proc .poll .return_value = None
0 commit comments