210 строки
11 KiB
Python
210 строки
11 KiB
Python
"""Резервные копии по расписанию: когда запускать, какие машины копировать, сколько хранить, форма и страницы."""
|
|
import fcntl
|
|
import json
|
|
from datetime import datetime
|
|
from urllib.parse import unquote
|
|
|
|
import pytest
|
|
|
|
import core
|
|
from features import backup_schedule as bs
|
|
from features import backups
|
|
from test_backups import make_backup
|
|
|
|
|
|
def schedule(**changes):
|
|
return bs.clean_schedule({**bs.DEFAULT, "enabled": True, **changes})
|
|
|
|
|
|
# ---------------------------------------------------------------- настройка и время
|
|
def test_clean_schedule_replaces_broken_values():
|
|
assert bs.clean_schedule({}) == bs.DEFAULT
|
|
broken = {"enabled": "yes", "frequency": "hourly", "weekday": True, "time": "25:00", "vms": ["web01", "bad name", 5, "web01"], "keep": 0}
|
|
assert bs.clean_schedule(broken) == {**bs.DEFAULT, "vms": ["web01"]}
|
|
assert bs.clean_schedule({"all_vms": False, "keep": 61})["all_vms"] is False
|
|
assert bs.clean_schedule({"keep": 61})["keep"] == bs.DEFAULT["keep"]
|
|
|
|
|
|
def test_slots_daily():
|
|
daily = schedule(time="03:00")
|
|
assert bs.last_slot(daily, datetime(2026, 9, 27, 2, 59)) == datetime(2026, 9, 26, 3, 0)
|
|
assert bs.last_slot(daily, datetime(2026, 9, 27, 3, 0)) == datetime(2026, 9, 27, 3, 0)
|
|
assert bs.next_slot(daily, datetime(2026, 9, 27, 3, 0)) == datetime(2026, 9, 28, 3, 0)
|
|
|
|
|
|
def test_slots_weekly():
|
|
sunday = schedule(frequency="weekly", weekday=6, time="04:30") # 27.09.2026 — воскресенье
|
|
assert bs.last_slot(sunday, datetime(2026, 9, 30, 12, 0)) == datetime(2026, 9, 27, 4, 30)
|
|
assert bs.last_slot(sunday, datetime(2026, 9, 27, 4, 0)) == datetime(2026, 9, 20, 4, 30)
|
|
assert bs.next_slot(sunday, datetime(2026, 9, 27, 4, 0)) == datetime(2026, 9, 27, 4, 30)
|
|
|
|
|
|
def test_due_runs_once_and_does_not_catch_up_hours_later():
|
|
daily = schedule(time="03:00")
|
|
assert bs.due(daily, {}, datetime(2026, 9, 27, 3, 10)) == ("run", datetime(2026, 9, 27, 3, 0))
|
|
assert bs.due(daily, {"slot": "2026-09-27 03:00"}, datetime(2026, 9, 27, 3, 25)) is None
|
|
assert bs.due(daily, {}, datetime(2026, 9, 27, 5, 0)) == ("run", datetime(2026, 9, 27, 3, 0))
|
|
assert bs.due(daily, {}, datetime(2026, 9, 27, 5, 1)) == ("missed", datetime(2026, 9, 27, 3, 0))
|
|
assert bs.due({**daily, "enabled": False}, {}, datetime(2026, 9, 27, 3, 10)) is None
|
|
|
|
|
|
# ---------------------------------------------------------------- запуск
|
|
@pytest.fixture()
|
|
def fake_copies(data_dirs, monkeypatch):
|
|
"""create_backup сразу кладёт готовую копию; для машин из failing — отказ с текстом."""
|
|
calls = []
|
|
failing = {}
|
|
minutes = iter(range(1, 60))
|
|
|
|
def create(vm, note="", wait=False, auto=False):
|
|
calls.append((vm, note, wait, auto))
|
|
if vm in failing:
|
|
raise backups.BackupError(failing[vm])
|
|
backup_id = f"20260927-03{next(minutes):02d}"
|
|
make_backup(data_dirs, vm=vm, backup_id=backup_id, note=note, auto=auto)
|
|
return {"id": f"op-{backup_id}", "status": "success", "message": "Резервная копия создана: 2.5 ГБ"}
|
|
|
|
monkeypatch.setattr(backups, "create_backup", create)
|
|
monkeypatch.setattr(backups, "existing_vm_names", lambda: {"web01", "db01", "test"})
|
|
create.calls, create.failing = calls, failing
|
|
return create
|
|
|
|
|
|
def test_run_copies_every_machine_and_keeps_last_n_auto_copies(data_dirs, fake_copies):
|
|
bs.write_json(bs.schedule_path(), schedule(time="03:00", keep=2))
|
|
for backup_id in ("20260920-0300", "20260921-0300", "20260922-0300"):
|
|
make_backup(data_dirs, vm="web01", backup_id=backup_id, auto=True)
|
|
make_backup(data_dirs, vm="web01", backup_id="20260901-1200", note="перед обновлением")
|
|
|
|
state = bs.run_due(datetime(2026, 9, 27, 3, 5))
|
|
assert [call[0] for call in fake_copies.calls] == ["db01", "test", "web01"]
|
|
assert {call[1:] for call in fake_copies.calls} == {("По расписанию", True, True)}
|
|
assert state["status"] == "done" and state["slot"] == "2026-09-27 03:00" and all(item["ok"] for item in state["results"])
|
|
web01 = state["results"][-1]
|
|
assert web01["removed"] == ["20260921-0300", "20260920-0300"]
|
|
# Ручная копия осталась, из автоматических — две последние.
|
|
assert [item["id"] for item in backups.list_backups("web01")] == ["20260927-0303", "20260922-0300", "20260901-1200"]
|
|
assert bs.read_json(bs.state_path()) == state
|
|
|
|
assert bs.run_due(datetime(2026, 9, 27, 3, 20)) is None # этот момент уже отработан
|
|
assert len(fake_copies.calls) == 3
|
|
|
|
|
|
def test_failed_copy_keeps_old_copies_and_others_continue(data_dirs, fake_copies):
|
|
bs.write_json(bs.schedule_path(), schedule(all_vms=False, vms=["web01", "db01", "gone"], keep=1))
|
|
make_backup(data_dirs, vm="web01", backup_id="20260920-0300", auto=True)
|
|
make_backup(data_dirs, vm="web01", backup_id="20260921-0300", auto=True)
|
|
fake_copies.failing["web01"] = "Недостаточно места для копии"
|
|
|
|
state = bs.run_due(datetime(2026, 9, 27, 3, 5))
|
|
assert [call[0] for call in fake_copies.calls] == ["db01", "web01"] # машины gone на сервере нет
|
|
results = {item["vm"]: item for item in state["results"]}
|
|
assert results["db01"]["ok"] and not results["web01"]["ok"] and "места" in results["web01"]["message"]
|
|
assert len(backups.list_backups("web01")) == 2
|
|
|
|
|
|
def test_missed_slot_is_recorded_not_copied(data_dirs, fake_copies):
|
|
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
|
assert bs.run_due(datetime(2026, 9, 27, 9, 0)) is None
|
|
assert fake_copies.calls == []
|
|
state = bs.read_json(bs.state_path())
|
|
assert state["slot"] == state["missed"] == "2026-09-27 03:00"
|
|
assert bs.overview(datetime(2026, 9, 27, 9, 0))["missed"] == "27 сентября, 03:00"
|
|
|
|
|
|
def test_run_waits_for_previous_run(data_dirs, fake_copies):
|
|
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
|
with bs.lock_path().open("w") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
assert bs.run_due(datetime(2026, 9, 27, 3, 5)) is None
|
|
assert fake_copies.calls == []
|
|
assert bs.run_due(datetime(2026, 9, 27, 3, 20))["status"] == "done"
|
|
|
|
|
|
def test_prune_leaves_copy_that_is_being_restored(data_dirs):
|
|
make_backup(data_dirs, vm="web01", backup_id="20260920-0300", auto=True)
|
|
make_backup(data_dirs, vm="web01", backup_id="20260921-0300", auto=True)
|
|
core.new_operation("restore", "Восстановление web01", vm_name="web01-copy", source_vm="web01", backup_id="20260920-0300")
|
|
assert bs.prune("web01", 1) == []
|
|
assert len(backups.list_backups("web01")) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------- форма и страницы
|
|
FORM = {"enabled": "1", "frequency": "daily", "weekday": "6", "time": "03:00", "machines": "all", "keep": "7"}
|
|
|
|
|
|
def test_schedule_form_saves_and_does_not_start_right_away(logged_in, data_dirs, monkeypatch):
|
|
monkeypatch.setattr(backups, "existing_vm_names", lambda: {"web01", "db01"})
|
|
page = logged_in.get("/backups/schedule")
|
|
assert page.status_code == 200 and "Расписание копий" in page.text and 'value="db01"' in page.text
|
|
|
|
data = {**FORM, "frequency": "weekly", "time": "04:30", "machines": "chosen", "vms": ["web01"], "keep": "4"}
|
|
response = logged_in.post("/backups/schedule", data=data, follow_redirects=False)
|
|
assert response.status_code == 303 and "по воскресеньям в 04:30" in unquote(response.headers["location"])
|
|
saved = bs.load_schedule()
|
|
assert saved == {"enabled": True, "frequency": "weekly", "weekday": 6, "time": "04:30", "all_vms": False, "vms": ["web01"], "keep": 4}
|
|
assert bs.due(saved, bs.read_json(bs.state_path()), datetime.now()) is None # прошедший момент не догоняется
|
|
|
|
response = logged_in.post("/backups/schedule", data={**FORM, "enabled": ""}, follow_redirects=False)
|
|
assert "выключены" in unquote(response.headers["location"]) and bs.load_schedule()["enabled"] is False
|
|
|
|
|
|
def test_saving_same_time_keeps_pending_copy(logged_in, data_dirs):
|
|
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
|
bs.write_json(bs.state_path(), {"slot": "2026-09-20 03:00"})
|
|
logged_in.post("/backups/schedule", data={**FORM, "keep": "3"}, follow_redirects=False)
|
|
assert bs.load_schedule()["keep"] == 3
|
|
assert bs.read_json(bs.state_path())["slot"] == "2026-09-20 03:00"
|
|
|
|
|
|
@pytest.mark.parametrize("changes, error", [
|
|
({"time": "3 часа"}, "ЧЧ:ММ"),
|
|
({"keep": "0"}, "от 1 до 60"),
|
|
({"keep": "много"}, "от 1 до 60"),
|
|
({"machines": "chosen"}, "хотя бы одну машину"),
|
|
])
|
|
def test_schedule_form_errors(logged_in, data_dirs, changes, error):
|
|
response = logged_in.post("/backups/schedule", data={**FORM, **changes})
|
|
assert response.status_code == 400 and error in response.text
|
|
assert not bs.schedule_path().exists()
|
|
|
|
|
|
def test_schedule_requires_login(client):
|
|
assert client.get("/backups/schedule", follow_redirects=False).status_code in (302, 303)
|
|
assert client.post("/backups/schedule", data=FORM, follow_redirects=False).status_code in (302, 303, 403)
|
|
assert not bs.schedule_path().exists()
|
|
|
|
|
|
def test_pages_show_schedule_and_last_run(logged_in, data_dirs):
|
|
page = logged_in.get("/backups")
|
|
assert "Автоматические копии" in page.text and "Настроить" in page.text
|
|
|
|
bs.write_json(bs.schedule_path(), schedule(time="03:00", keep=5))
|
|
results = [{"vm": "web01", "ok": True}, {"vm": "db01", "ok": False, "message": "Недостаточно места для копии"}]
|
|
bs.write_json(bs.state_path(), {"slot": "2026-09-27 03:00", "status": "done", "started_at": "2026-09-27 03:00:05", "results": results})
|
|
page = logged_in.get("/backups")
|
|
assert "Каждый день в 03:00" in page.text and "хранить 5 последних копий" in page.text
|
|
assert "скопировано 1 из 2 машин" in page.text and "Недостаточно места для копии" in page.text
|
|
assert "Машина копируется каждый день в 03:00" in logged_in.get("/vm/web01/backups").text
|
|
|
|
bs.write_json(bs.schedule_path(), schedule(all_vms=False, vms=["db01"]))
|
|
assert "не копируется автоматически" in logged_in.get("/vm/web01/backups").text
|
|
|
|
|
|
def test_main_prints_summary(data_dirs, fake_copies, monkeypatch, capsys):
|
|
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
|
fake_copies.failing["test"] = "Машина не выключилась"
|
|
|
|
class Night(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return cls(2026, 9, 27, 3, 5)
|
|
|
|
monkeypatch.setattr(bs, "datetime", Night)
|
|
bs.main()
|
|
out = capsys.readouterr().out
|
|
assert "2 из 3 успешно" in out and "test: Машина не выключилась" in out
|
|
assert json.loads(bs.state_path().read_text())["status"] == "done"
|
|
bs.main() # второй запуск в тот же момент молчит
|
|
assert capsys.readouterr().out == ""
|