mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
189 lines
6.2 KiB
Python
189 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import itertools
|
|
import os
|
|
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
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
|
|
def _resolved_repo_root() -> Path:
|
|
"""Repository root resolution for the script-under-test path.
|
|
|
|
The ``INFINITO_REPO_ROOT`` env var still wins when explicitly set
|
|
(used by some CI fixtures); otherwise the canonical ``PROJECT_ROOT``
|
|
constant from the package ``__init__.py`` is returned.
|
|
"""
|
|
env = os.environ.get("INFINITO_REPO_ROOT") # nocheck: test-fixture
|
|
if env:
|
|
return Path(env).expanduser().resolve()
|
|
return PROJECT_ROOT
|
|
|
|
|
|
SCRIPT_PATH = _resolved_repo_root() / "roles/sys-ctl-cln-bkps/files/python/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]],
|
|
time_source: object = None,
|
|
) -> 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.
|
|
|
|
Args:
|
|
time_source: iterable of monotonically rising values for time.time(); when
|
|
omitted the clock is frozen at 0, which lets the script's own hour-long
|
|
time limit never trigger.
|
|
"""
|
|
if not SCRIPT_PATH.is_file():
|
|
raise FileNotFoundError(
|
|
f"script.py not found at expected path: {SCRIPT_PATH} (repo_root={_resolved_repo_root()})"
|
|
)
|
|
|
|
fake_psutil = _fake_psutil(disk_percent)
|
|
|
|
def listdir_side_effect(path: str) -> list[str]:
|
|
return listdir_map.get(path, [])
|
|
|
|
time_patch = (
|
|
patch("time.time", return_value=0)
|
|
if time_source is None
|
|
else patch("time.time", side_effect=time_source)
|
|
)
|
|
|
|
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,
|
|
time_patch,
|
|
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 = os.environ["INFINITO_DIR_BACKUPS"]
|
|
argv = [
|
|
"script.py",
|
|
"--maximum-backup-size-percent",
|
|
"75",
|
|
"--backups-folder-path",
|
|
backup_dir,
|
|
]
|
|
|
|
out, rmtree_mock = self._run_script(
|
|
argv=argv,
|
|
disk_percent=10,
|
|
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 = os.environ["INFINITO_DIR_BACKUPS"]
|
|
argv = [
|
|
"script.py",
|
|
"--maximum-backup-size-percent",
|
|
"75",
|
|
"--backups-folder-path",
|
|
backup_dir,
|
|
]
|
|
|
|
out, rmtree_mock = self._run_script(
|
|
argv=argv,
|
|
disk_percent=90,
|
|
listdir_map={
|
|
backup_dir: [],
|
|
},
|
|
)
|
|
|
|
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()
|
|
|
|
def test_stops_when_a_pass_cannot_delete_anything(self) -> None:
|
|
"""
|
|
Regression test for the busy-loop in its surviving shape:
|
|
every application holds exactly one version, which is_directory_deletable()
|
|
always skips as the last one, so average_version_directories is 1 and the
|
|
`average <= 0` break never fires. The disk stays above the threshold no
|
|
matter how often the pass repeats, so a pass that deletes nothing must end it.
|
|
"""
|
|
backup_dir = os.environ["INFINITO_DIR_BACKUPS"]
|
|
argv = [
|
|
"script.py",
|
|
"--maximum-backup-size-percent",
|
|
"75",
|
|
"--backups-folder-path",
|
|
backup_dir,
|
|
]
|
|
host_path = str(Path(backup_dir) / "host1")
|
|
versions_path = str(Path(host_path) / "app1")
|
|
original_is_dir = Path.is_dir
|
|
|
|
def fake_is_dir(self: Path) -> bool:
|
|
if str(self).startswith(backup_dir):
|
|
return True
|
|
return original_is_dir(self)
|
|
|
|
with patch("pathlib.Path.is_dir", fake_is_dir):
|
|
out, rmtree_mock = self._run_script(
|
|
argv=argv,
|
|
disk_percent=90,
|
|
listdir_map={
|
|
backup_dir: ["host1"],
|
|
host_path: ["app1"],
|
|
versions_path: ["20260810012433"],
|
|
versions_path + "/": ["20260810012433"],
|
|
},
|
|
time_source=itertools.count(0, 100),
|
|
)
|
|
|
|
self.assertIn("contains the last version of the backup. Skipped.", out)
|
|
self.assertIn("No deletable backup versions left", out)
|
|
self.assertIn("Cleaning up finished.", out)
|
|
self.assertNotIn("Delete Iteration: 2", out)
|
|
rmtree_mock.assert_not_called()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|