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:
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user