fix(sys-ctl-cln-bkps): prevent infinite cleanup loop and add regression tests

Stop cleanup script from busy-looping when disk usage is above threshold but no
backup versions exist by breaking early when average_version_directories == 0.

Add unit tests to verify:
- script exits immediately when disk usage is below threshold
- script breaks safely when disk usage is high but no backups are present

This prevents CI hangs and excessive CPU usage caused by infinite iterations.

https://chatgpt.com/share/6962de45-3750-800f-bc8d-5ba205beeaaf
This commit is contained in:
kevinveenbirkenbach committed 2026-01-11 01:22:29 +01:00
1 parent e4baa6d7ab
commit b694f9335f
4 files changed
+121

No files matched your search

+5
View File
@@ -204,6 +204,11 @@ while isSmallerThenMaximumBackupSize(maximum_backup_size_percent, backup_dir):
average_version_directories = average_version_directories_per_application(
backup_dir
)
if average_version_directories <= 0:
print(
"No backup versions found to delete (average_version_directories=0). Exiting."
)
break
print(
f"Average version directories per application directory: {average_version_directories}"
)
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,116 @@
# tests/unit/roles/sys-ctl-cln-bkps/files/test_script.py
from __future__ import annotations
import io
import runpy
import sys
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
# CI checkout path (as shown in your logs)
SCRIPT_PATH = Path("/opt/src/infinito/roles/sys-ctl-cln-bkps/files/script.py")
def _fake_psutil(percent: int) -> ModuleType:
"""Create a fake psutil module with disk_usage()."""
fake = ModuleType("psutil")
fake.disk_usage = lambda _: SimpleNamespace(percent=percent)
return fake
class TestSysCtlClnBkpsScript(unittest.TestCase):
def _run_script(
self,
argv: list[str],
disk_percent: int,
listdir_map: dict[str, list[str]],
) -> tuple[str, object]:
"""
Execute the script as __main__ with provided argv, returning stdout and rmtree mock.
Fully mocks psutil and os.listdir so the test does not depend on system packages or FS.
"""
if not SCRIPT_PATH.is_file():
raise FileNotFoundError(
f"script.py not found at expected path: {SCRIPT_PATH}"
)
fake_psutil = _fake_psutil(disk_percent)
def listdir_side_effect(path: str) -> list[str]:
return listdir_map.get(path, [])
buf = io.StringIO()
with (
patch.dict(sys.modules, {"psutil": fake_psutil}),
patch.object(sys, "argv", argv),
patch("os.listdir", side_effect=listdir_side_effect),
patch("shutil.rmtree") as rmtree_mock,
patch("time.time", return_value=0),
redirect_stdout(buf),
):
runpy.run_path(str(SCRIPT_PATH), run_name="__main__")
return buf.getvalue(), rmtree_mock
def test_exits_immediately_when_disk_usage_below_threshold(self) -> None:
"""
If disk usage is already <= threshold, the while-loop must not run.
Script should print final usage and finish message.
"""
backup_dir = "/var/lib/infinito/backup"
argv = [
"script.py",
"--maximum-backup-size-percent",
"75",
"--backups-folder-path",
backup_dir,
]
out, rmtree_mock = self._run_script(
argv=argv,
disk_percent=10, # below threshold
listdir_map={backup_dir: []},
)
self.assertIn("Cleaning up finished.", out)
self.assertIn(f"% of disk {backup_dir} are used", out)
rmtree_mock.assert_not_called()
def test_breaks_when_no_versions_found_even_if_disk_usage_high(self) -> None:
"""
Regression test for the busy-loop:
If disk usage is above threshold but no backup structure exists,
average_version_directories_per_application() returns 0 and the script
must break out.
"""
backup_dir = "/var/lib/infinito/backup"
argv = [
"script.py",
"--maximum-backup-size-percent",
"75",
"--backups-folder-path",
backup_dir,
]
out, rmtree_mock = self._run_script(
argv=argv,
disk_percent=90, # above threshold
listdir_map={
backup_dir: [], # no host dirs -> total_app_directories == 0 -> average == 0
},
)
self.assertIn("Delete Iteration: 1", out)
self.assertIn(
"No backup versions found to delete (average_version_directories=0). Exiting.",
out,
)
self.assertIn("Cleaning up finished.", out)
rmtree_mock.assert_not_called()
if __name__ == "__main__":
unittest.main()