feat(template): skip the nightly backup when the template hasn't changed

template backup ran a full multi-GB, multi-minute 7z pass over every
template every night regardless of whether anything under it had actually
changed -- the systemd unit's own comment already noted the resulting
archives were byte-identical run to run. Now compares the template dir's
newest file mtime against its most recent existing backup and skips the
compress step (still honoring --keep-count via prune) when nothing is
newer. --force opts back into an unconditional backup.
This commit is contained in:
2026-07-26 02:32:19 +02:00
parent 39abfcef80
commit 567f9da619
3 changed files with 249 additions and 4 deletions
+196
View File
@@ -202,6 +202,202 @@ def test_what_if_no_copy(template_dir: Path, backup_dir: Path, _no_systemctl: No
assert "WhatIf" in result.output
# ── skip-if-unchanged ────────────────────────────────────────────────────────
def test_backup_skips_when_unchanged_since_last_backup(
template_dir: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The nightly timer runs every night; most nights nothing in a template
changed, so the (very expensive) 7z pass over tens of GB is pure waste."""
past = time.time() - 7200
for f in template_dir.iterdir():
os.utime(f, (past, past))
# This backup's own mtime is "now" (just written) -- newer than every
# file the template directory holds.
existing = backup_dir / "Template_20250101_000000.7z"
existing.write_bytes(b"old-archive")
compress_calls: list[Path] = []
monkeypatch.setattr(
tmpl_mod, "_compress_7z", lambda src, dst: compress_calls.append(src)
)
monkeypatch.setattr(tmpl_mod, "_stop_runner", lambda **_kw: False)
monkeypatch.setattr(tmpl_mod, "_start_runner", lambda **_kw: None)
result = CliRunner().invoke(
cli,
[
"template",
"backup",
"--template-path",
str(template_dir),
"--backup-base-dir",
str(backup_dir),
"--skip-runner-stop",
],
)
assert result.exit_code == 0, result.output
assert compress_calls == []
assert "unchanged" in result.output.lower()
archives = [f for f in backup_dir.iterdir() if f.suffix == ".7z"]
assert archives == [existing]
def test_backup_runs_when_changed_since_last_backup(
template_dir: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
old_backup = backup_dir / "Template_20250101_000000.7z"
old_backup.write_bytes(b"old-archive")
old_time = time.time() - 7200
os.utime(old_backup, (old_time, old_time))
# template_dir's files keep their fixture-default "now" mtime -- newer
# than the existing backup.
compress_calls: list[Path] = []
monkeypatch.setattr(
tmpl_mod,
"_compress_7z",
lambda src, dst: (compress_calls.append(src), dst.write_bytes(b"fake"))[-1],
)
monkeypatch.setattr(tmpl_mod, "_stop_runner", lambda **_kw: False)
monkeypatch.setattr(tmpl_mod, "_start_runner", lambda **_kw: None)
result = CliRunner().invoke(
cli,
[
"template",
"backup",
"--template-path",
str(template_dir),
"--backup-base-dir",
str(backup_dir),
"--skip-runner-stop",
],
)
assert result.exit_code == 0, result.output
assert compress_calls == [template_dir]
def test_force_flag_backs_up_even_when_unchanged(
template_dir: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
past = time.time() - 7200
for f in template_dir.iterdir():
os.utime(f, (past, past))
existing = backup_dir / "Template_20250101_000000.7z"
existing.write_bytes(b"old-archive")
compress_calls: list[Path] = []
monkeypatch.setattr(
tmpl_mod,
"_compress_7z",
lambda src, dst: (compress_calls.append(src), dst.write_bytes(b"fake"))[-1],
)
monkeypatch.setattr(tmpl_mod, "_stop_runner", lambda **_kw: False)
monkeypatch.setattr(tmpl_mod, "_start_runner", lambda **_kw: None)
result = CliRunner().invoke(
cli,
[
"template",
"backup",
"--template-path",
str(template_dir),
"--backup-base-dir",
str(backup_dir),
"--skip-runner-stop",
"--force",
],
)
assert result.exit_code == 0, result.output
assert compress_calls == [template_dir]
def test_what_if_reports_skip_when_unchanged(
template_dir: Path, backup_dir: Path, _no_systemctl: None
) -> None:
past = time.time() - 7200
for f in template_dir.iterdir():
os.utime(f, (past, past))
existing = backup_dir / "Template_20250101_000000.7z"
existing.write_bytes(b"old-archive")
result = CliRunner().invoke(
cli,
[
"template",
"backup",
"--template-path",
str(template_dir),
"--backup-base-dir",
str(backup_dir),
"--skip-runner-stop",
"--what-if",
],
)
assert result.exit_code == 0, result.output
assert "WhatIf" in result.output
assert "unchanged" in result.output.lower()
archives = [f for f in backup_dir.iterdir() if f.suffix == ".7z"]
assert archives == [existing]
def test_all_templates_skips_unchanged_and_backs_up_changed(
tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Skip/run is decided per template, not for the whole ``--all-templates``
pass at once."""
tmpl_root = tmp_path / "templates"
unchanged = tmpl_root / "LinuxBuild2404"
changed = tmpl_root / "WinBuild2025"
for d in (unchanged, changed):
d.mkdir(parents=True)
(d / f"{d.name}.vmx").write_text("vmx")
past = time.time() - 7200
os.utime(unchanged / "LinuxBuild2404.vmx", (past, past))
# `changed`'s file keeps its just-created "now" mtime.
existing_unchanged = backup_dir / "Template_LinuxBuild2404_20250101_000000.7z"
existing_unchanged.write_bytes(b"old")
existing_changed = backup_dir / "Template_WinBuild2025_20250101_000000.7z"
existing_changed.write_bytes(b"old")
# Newer than `unchanged`'s file, older than `changed`'s.
mid_time = time.time() - 3600
os.utime(existing_unchanged, (mid_time, mid_time))
os.utime(existing_changed, (mid_time, mid_time))
monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
compress_calls: list[Path] = []
monkeypatch.setattr(
tmpl_mod,
"_compress_7z",
lambda src, dst: (compress_calls.append(src), dst.write_bytes(b"fake"))[-1],
)
monkeypatch.setattr(tmpl_mod, "_stop_runner", lambda **_kw: False)
monkeypatch.setattr(tmpl_mod, "_start_runner", lambda **_kw: None)
result = CliRunner().invoke(
cli,
[
"template",
"backup",
"--all-templates",
"--backup-base-dir",
str(backup_dir),
"--skip-runner-stop",
],
)
assert result.exit_code == 0, result.output
assert compress_calls == [changed]
# ── runner stop/start ─────────────────────────────────────────────────────────