Skip to content

Commit 8b25fda

Browse files
authored
fix start handling (#686)
1 parent a3a8ba9 commit 8b25fda

2 files changed

Lines changed: 121 additions & 6 deletions

File tree

app/backup.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -473,14 +473,27 @@ def _stop_container(self, c: Container, attempt=1) -> bool:
473473

474474
def _start_container(self, c: Container, attempt=1) -> bool:
475475
c.reload() # Refresh the status for this container
476+
status = c.status # Read once to avoid consuming multiple mock cycles
476477

477-
if c.status != "exited":
478-
self.log_this(f"Container {c.name} was not stopped. No need to start.", "DEBUG")
478+
if status == "running":
479+
if attempt == 1:
480+
self.log_this(f"Container {c.name} was not stopped. No need to start.", "DEBUG")
479481
return True
480482

483+
if status != "exited":
484+
# Transitional state: Docker statuses are created/restarting/running/paused/exited/dead
485+
if attempt <= 3:
486+
self.log_this(
487+
f"Container {c.name} is in '{status}' state, waiting for it to stabilize (Attempt {attempt}/3)",
488+
"WARN",
489+
)
490+
time.sleep(2)
491+
return self._start_container(c, attempt=attempt + 1)
492+
return False
493+
481494
try:
482495
self.log_this(f"Starting {c.name}...")
483-
c.start() # * Actually stop the container
496+
c.start() # * Actually start the container
484497
except ReadTimeout:
485498
self.log_this(f"Timed out waiting for {c.name} to start. Checking container status...", "WARN")
486499
# Fall through to c.reload() — container may have started despite the timeout
@@ -489,12 +502,13 @@ def _start_container(self, c: Container, attempt=1) -> bool:
489502
return False
490503

491504
c.reload() # Refresh the status for this container
505+
status = c.status # Read once to avoid consuming multiple mock cycles
492506

493-
if c.status == "running":
507+
if status == "running":
494508
return True
495509
elif attempt <= 3:
496510
self.log_this(f"Container {c.name} was not in running state. Trying again (Attempt {attempt}/3)", "ERROR")
497-
self._start_container(c, attempt=attempt + 1)
511+
return self._start_container(c, attempt=attempt + 1)
498512
return False
499513

500514
def _get_src_dir(self, c: Container, log=False) -> Tuple[Path, str]:
@@ -735,6 +749,16 @@ def _run_rsync(self, c: Optional[Container], rsync_args: str, src_dir: Path, des
735749
if out.returncode != 0:
736750
name = c.name if c else "unknown"
737751
message = f"rsync exited with code {out.returncode} for {name}"
752+
if out.returncode == 23:
753+
# Exit code 23 = partial transfer; commonly caused by symlinks on filesystems
754+
# that don't support them (e.g. SMB/FAT). Regular files are still backed up.
755+
hint = " (symlinks may have been skipped — use RSYNC_CUSTOM_ARGS=--no-links to suppress)"
756+
if c:
757+
self._record_container_failed(c, "rsync_failed", message + hint)
758+
else:
759+
self._record_error(message + hint)
760+
self.log_this(message + hint, "ERROR")
761+
return False
738762
if c:
739763
self._record_container_failed(c, "rsync_failed", message)
740764
else:

pytest/test_backup.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)