Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранятся последние N автокопий; копии делает служба virtuality-backup, а не панель
Этот коммит содержится в:
@@ -106,11 +106,11 @@ def demo_run_cmd(cmd, timeout=12, **kwargs):
|
||||
|
||||
|
||||
def seed_backups(backups_dir: Path) -> None:
|
||||
"""Несколько копий, как после пары недель работы: web01 — три, db01 — одна."""
|
||||
"""Несколько копий, как после пары недель работы: web01 — три (две по расписанию), db01 — одна."""
|
||||
vm_xml = conftest.DUMPXML_MIGRATABLE
|
||||
demo = [
|
||||
("web01", "20260924-0300", 2_684_354_560, "", [("vda", 6_443_499_520)]),
|
||||
("web01", "20260917-0300", 2_598_000_000, "", [("vda", 6_300_000_000)]),
|
||||
("web01", "20260924-0300", 2_684_354_560, "По расписанию", [("vda", 6_443_499_520)]),
|
||||
("web01", "20260917-0300", 2_598_000_000, "По расписанию", [("vda", 6_300_000_000)]),
|
||||
("web01", "20260912-1842", 2_412_000_000, "Перед обновлением сайта", [("vda", 6_100_000_000)]),
|
||||
("db01", "20260921-2215", 9_126_805_504, "После переноса базы", [("vda", 12_884_901_888), ("vdb", 8_589_934_592)]),
|
||||
]
|
||||
@@ -121,7 +121,7 @@ def seed_backups(backups_dir: Path) -> None:
|
||||
for target, _ in disks:
|
||||
with (path / f"{target}.qcow2").open("wb") as handle:
|
||||
handle.truncate(1024 * 1024)
|
||||
meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note}
|
||||
meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note, "auto": note == "По расписанию"}
|
||||
(path / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
@@ -133,6 +133,11 @@ def seed(root: Path) -> None:
|
||||
core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR, core.STORAGE_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], root
|
||||
core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"]
|
||||
seed_backups(dirs["backups"])
|
||||
from features import backup_schedule
|
||||
|
||||
backup_schedule.write_json(backup_schedule.schedule_path(), {**backup_schedule.DEFAULT, "enabled": True, "frequency": "weekly", "weekday": 3})
|
||||
results = [{"vm": "db01", "ok": True}, {"vm": "web01", "ok": True, "removed": []}, {"vm": "win11", "ok": True}]
|
||||
backup_schedule.write_json(backup_schedule.state_path(), {"slot": "2026-09-24 03:00", "status": "done", "started_at": "2026-09-24 03:00:04", "finished_at": "2026-09-24 03:41:10", "results": results})
|
||||
for name in DEMO_VMS: # диски машин лежат в хранилище, иначе форма увеличения не появится
|
||||
with (dirs["images"] / f"{name}.qcow2").open("wb") as handle:
|
||||
handle.truncate(4 * 1024 * 1024)
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Резервные копии по расписанию: когда запускать, какие машины копировать, сколько хранить, форма и страницы."""
|
||||
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 == ""
|
||||
@@ -12,14 +12,14 @@ from conftest import DOMBLKLIST_DETAILS, DUMPXML_MIGRATABLE, QEMU_IMG_INFO, fake
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True) -> Path:
|
||||
def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True, auto=False) -> Path:
|
||||
path = dirs["backups"] / vm / backup_id
|
||||
path.mkdir(parents=True)
|
||||
(path / "vm.xml").write_text(DUMPXML_MIGRATABLE)
|
||||
for target in disks:
|
||||
if complete:
|
||||
(path / f"{target}.qcow2").write_bytes(b"QFI\xfb" + b"\0" * 60)
|
||||
meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note}
|
||||
meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note, "auto": auto}
|
||||
(path / "meta.json").write_text(json.dumps(meta))
|
||||
return path
|
||||
|
||||
@@ -291,6 +291,16 @@ def test_create_backup_wait_returns_finished_operation(fast_worker, monkeypatch)
|
||||
assert operation["status"] == "success" and operation["type"] == "backup"
|
||||
|
||||
|
||||
def test_scheduled_backup_is_marked_auto(fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.create_backup("db01", "По расписанию", wait=True, auto=True)
|
||||
assert operation["status"] == "success" and operation["title"] == "Резервная копия db01 по расписанию"
|
||||
assert backups.list_backups("db01")[0]["auto"] is True
|
||||
backups.create_backup("web01", wait=True)
|
||||
assert backups.list_backups("web01")[0]["auto"] is False
|
||||
|
||||
|
||||
def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True, shuts_down=False)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
|
||||
Ссылка в новой задаче
Block a user