@@ -2275,7 +2275,10 @@ def test_post_backup_exec_variables_for_rsync_failure(
22752275 assert os .environ .get ("NB_EXEC_CONTAINERS_FAILED" ) == "container1"
22762276 assert os .environ .get ("NB_EXEC_CONTAINER_SKIP_REASONS" ) == "container1=rsync_failed"
22772277 assert os .environ .get ("NB_EXEC_CONTAINER_FAILURE_REASONS" ) == "container1=rsync_failed"
2278- assert os .environ .get ("NB_EXEC_ERROR_MESSAGES" ) == "rsync exited with code 23 for container1"
2278+ assert (
2279+ os .environ .get ("NB_EXEC_ERROR_MESSAGES" )
2280+ == "rsync exited with code 23 for container1 (symlinks may have been skipped — use RSYNC_CUSTOM_ARGS=--no-links to suppress)"
2281+ )
22792282
22802283 @mock .patch ("subprocess.run" )
22812284 @pytest .mark .parametrize (
@@ -2912,6 +2915,94 @@ def test_start_container_read_timeout(
29122915 mock_container1 .stop .assert_called ()
29132916 mock_container1 .start .assert_called ()
29142917
2918+ @mock .patch ("subprocess.run" )
2919+ @pytest .mark .parametrize (
2920+ "mock_container1" ,
2921+ [
2922+ {
2923+ "name" : "container1" ,
2924+ "id" : "123456789" ,
2925+ # Reads: stop-check, after-stop, pre-rsync, start-attempt-1,
2926+ # after-c.start (restarting), retry-2 (restarting), retry-3 (running)
2927+ "status_side_effect" : ["running" , "exited" , "exited" , "exited" , "restarting" , "restarting" , "running" ],
2928+ }
2929+ ],
2930+ indirect = True ,
2931+ )
2932+ def test_start_container_transitional_state_eventually_running (
2933+ self ,
2934+ mock_subprocess_run : MagicMock ,
2935+ mock_docker_client : MagicMock ,
2936+ mock_container1 : MagicMock ,
2937+ ):
2938+ """Container in transitional state (e.g. 'restarting') after c.start() should be retried
2939+ until it reaches 'running', and the backup should be marked as completed — not failed."""
2940+ mock_subprocess_run .return_value .returncode = 0
2941+
2942+ mock_docker_client .containers .list .return_value = [mock_container1 ]
2943+ nb = NauticalBackup (mock_docker_client )
2944+ nb .backup ()
2945+
2946+ mock_container1 .stop .assert_called ()
2947+ mock_container1 .start .assert_called ()
2948+ assert "container1" in nb .containers_completed
2949+ assert "container1" not in nb .containers_failed
2950+
2951+ @mock .patch ("subprocess.run" )
2952+ @pytest .mark .parametrize (
2953+ "mock_container1" ,
2954+ [
2955+ {
2956+ "name" : "container1" ,
2957+ "id" : "123456789" ,
2958+ # Reads: stop-check, after-stop, pre-rsync, start-attempt-1,
2959+ # after-c.start (not running yet), start-attempt-2, after-c.start-2 (running)
2960+ "status_side_effect" : ["running" , "exited" , "exited" , "exited" , "exited" , "exited" , "running" ],
2961+ }
2962+ ],
2963+ indirect = True ,
2964+ )
2965+ def test_start_container_retry_success_propagates (
2966+ self ,
2967+ mock_subprocess_run : MagicMock ,
2968+ mock_docker_client : MagicMock ,
2969+ mock_container1 : MagicMock ,
2970+ ):
2971+ """When container start fails on attempt 1 but succeeds on attempt 2, the backup should be
2972+ marked completed (not failed). This tests the missing-return bug fix."""
2973+ mock_subprocess_run .return_value .returncode = 0
2974+
2975+ mock_docker_client .containers .list .return_value = [mock_container1 ]
2976+ nb = NauticalBackup (mock_docker_client )
2977+ nb .backup ()
2978+
2979+ assert mock_container1 .start .call_count == 2
2980+ assert "container1" in nb .containers_completed
2981+ assert "container1" not in nb .containers_failed
2982+
2983+ @mock .patch ("subprocess.run" )
2984+ @pytest .mark .parametrize ("mock_container1" , [{"name" : "container1" , "id" : "123456789" }], indirect = True )
2985+ def test_rsync_exit_code_23_includes_symlink_hint (
2986+ self ,
2987+ mock_subprocess_run : MagicMock ,
2988+ mock_docker_client : MagicMock ,
2989+ mock_container1 : MagicMock ,
2990+ ):
2991+ """rsync exit code 23 (partial transfer) should include a hint about symlinks and the
2992+ RSYNC_CUSTOM_ARGS=--no-links workaround in the error message."""
2993+ rsync_result = MagicMock ()
2994+ rsync_result .returncode = 23
2995+ mock_subprocess_run .return_value = rsync_result
2996+
2997+ mock_docker_client .containers .list .return_value = [mock_container1 ]
2998+ nb = NauticalBackup (mock_docker_client )
2999+ nb .backup ()
3000+
3001+ assert len (nb .error_messages ) == 1
3002+ assert "--no-links" in nb .error_messages [0 ]
3003+ assert "symlinks" in nb .error_messages [0 ]
3004+ assert "container1" in nb .containers_failed
3005+
29153006 # -------------------------------------------------------------------------
29163007 # Retention policy tests
29173008 # -------------------------------------------------------------------------
0 commit comments