"""Tests for the config backup executor. These replace a set that arrived with the portainer-core extraction and had never passed in this repo: they patched the `Path` class wholesale, asserted `"backed up" in result` against a function that returns `"Backup completed: …"`, and one wrapped its only call in `except Exception: pass` with its assertion commented out. Seven were red from the initial commit onward, and there is no CI test gate here to notice (see CLAUDE.md). The replacements use real directories and real archives under `tmp_path`. A backup executor's whole job is what ends up inside the tar, and mocking `tarfile` means nothing is checked. """ import asyncio import os import tarfile import time from pathlib import Path import pytest from src.config import Settings from src.executors import config_backup_executor def _make_source(root: Path) -> Path: """A source tree with one file per exclude pattern the deployment uses.""" src = root / "docker-data" (src / "ollama" / "models" / "blobs").mkdir(parents=True) (src / "svc" / "cache").mkdir(parents=True) (src / "svc" / "logs").mkdir(parents=True) (src / "keep.conf").write_text("keep me") (src / "svc" / "settings.json").write_text("keep me too") (src / "ollama" / "models" / "blobs" / "sha256-abc").write_text("many GB in reality") (src / "svc" / "cache" / "junk.bin").write_text("disposable") (src / "svc" / "logs" / "app.log").write_text("noisy") return src def _members(archive: Path) -> set: """Names inside the inner per-source tar of a combined backup archive.""" with tarfile.open(archive, "r:gz") as outer: inner_name = outer.getnames()[0] fh = outer.extractfile(inner_name) with tarfile.open(fileobj=fh, mode="r:gz") as inner: return set(inner.getnames()) @pytest.mark.executor @pytest.mark.unit class TestConfigBackup: @pytest.mark.asyncio async def test_no_sources_is_rejected(self, test_settings: Settings, tmp_path: Path): with pytest.raises(ValueError, match="No backup sources"): await config_backup_executor._run({"backup_dir": str(tmp_path)}, test_settings) def test_archive_contains_the_source(self, tmp_path: Path): src = _make_source(tmp_path) out = tmp_path / "backups" summary = config_backup_executor._backup({ "sources": [{"path": str(src), "name": "docker-data", "excludes": []}], "backup_dir": str(out), }) assert "Backup completed" in summary archives = list(out.glob("docker-configs-*.tar.gz")) assert len(archives) == 1 assert "docker-data/keep.conf" in _members(archives[0]) def test_excludes_keep_matching_members_out(self, tmp_path: Path): """The patterns here are the ones the deployed task actually carries.""" src = _make_source(tmp_path) out = tmp_path / "backups" config_backup_executor._backup({ "sources": [{ "path": str(src), "name": "docker-data", "excludes": ["*/cache/*", ".log", "ollama/models/*"], }], "backup_dir": str(out), }) names = _members(next(iter(out.glob("docker-configs-*.tar.gz")))) assert "docker-data/keep.conf" in names assert "docker-data/svc/settings.json" in names assert "docker-data/svc/cache/junk.bin" not in names assert "docker-data/svc/logs/app.log" not in names assert "docker-data/ollama/models/blobs/sha256-abc" not in names def test_missing_source_is_skipped_not_fatal(self, tmp_path: Path): src = _make_source(tmp_path) out = tmp_path / "backups" summary = config_backup_executor._backup({ "sources": [ {"path": str(tmp_path / "does-not-exist"), "name": "gone", "excludes": []}, {"path": str(src), "name": "docker-data", "excludes": []}, ], "backup_dir": str(out), }) assert "docker-data" in summary assert "gone" not in summary def test_cleanup_removes_only_expired_backups(self, tmp_path: Path): out = tmp_path / "backups" out.mkdir() old = out / "docker-configs-20200101-000000.tar.gz" recent = out / "docker-configs-20991231-000000.tar.gz" unrelated = out / "notes.txt" for f in (old, recent, unrelated): f.write_text("x") long_ago = time.time() - (30 * 86400) os.utime(old, (long_ago, long_ago)) config_backup_executor._cleanup_old_backups(out, retention_days=7) assert not old.exists() assert recent.exists() assert unrelated.exists(), "cleanup must only touch files it wrote" @pytest.mark.executor @pytest.mark.unit class TestEventLoopIsNotBlocked: """T-74. The scheduler serves its own API from the loop that runs executors. This job spends ~21 minutes in tarfile and zlib, so calling it inline made the whole service unreachable 03:05-03:25 every night. The hourly health check runs at :35 and so never once observed it — the outage was invisible for as long as it existed. """ @pytest.mark.asyncio async def test_run_yields_to_the_loop_while_backing_up( self, test_settings: Settings, monkeypatch ): blocked_for = 0.4 monkeypatch.setattr( config_backup_executor, "_backup", lambda config: (time.sleep(blocked_for), "Backup completed: fake")[1], ) ticks = 0 async def heartbeat(): nonlocal ticks while True: await asyncio.sleep(0.01) ticks += 1 hb = asyncio.create_task(heartbeat()) try: result = await config_backup_executor._run({}, test_settings) finally: hb.cancel() assert result == "Backup completed: fake" # Held inline, the loop gets no scheduling opportunity at all and this is # 0. Off the loop it is ~40. The bar is low on purpose: the distinction # being drawn is "the loop ran" versus "the loop was dead", and a loaded # CI box should not turn that into a flake. assert ticks >= 5, f"event loop starved during backup: {ticks} ticks" @pytest.mark.executor @pytest.mark.unit class TestExcludeMatching: """Substring matching, and why it must stay that way. `_create_tar_filter` reduces each pattern to a literal core and asks whether it appears anywhere in the member name. That reads like an unfinished glob, and the obvious "improvement" is `fnmatch`. These tests exist to make that change fail loudly, because the deployed config is written against these semantics and `fnmatch` would silently stop excluding the largest things in the tree. """ def test_mid_path_fragment_matches_anywhere(self): """`ollama/models/*` must exclude a member named `docker-data/ollama/...`. Under fnmatch the pattern anchors at the start of the name, does not match, and many GB of model blobs enter the nightly archive. """ f = config_backup_executor._create_tar_filter(["ollama/models/*"]) class TI: name = "docker-data/ollama/models/blobs/sha256-abc" assert f(TI()) is None def test_bare_suffix_matches_every_file_carrying_it(self): """The deployment excludes logs by the bare string `.log`, not `*.log`. Under fnmatch this matches only a file named exactly `.log`, so every real log file starts being archived. """ f = config_backup_executor._create_tar_filter([".log"]) class TI: name = "docker-data/svc/logs/app.log" assert f(TI()) is None def test_wrapped_pattern_is_reduced_to_its_core(self): f = config_backup_executor._create_tar_filter(["*/cache/*"]) class Cache: name = "docker-data/svc/cache/junk.bin" class Normal: name = "docker-data/svc/settings.json" normal = Normal() assert f(Cache()) is None assert f(normal) is normal def test_a_glob_star_is_not_interpreted(self): """`*.log` is a literal here — it is not a suffix match. This is the sharp edge of substring matching and the reason the deployed config spells the pattern `.log`. Pinned so the behaviour is documented rather than discovered. """ f = config_backup_executor._create_tar_filter(["*.log"]) class TI: name = "app.log" ti = TI() assert f(ti) is ti def test_no_excludes_keeps_everything(self): f = config_backup_executor._create_tar_filter([]) class TI: name = "anything/at/all" ti = TI() assert f(ti) is ti