diff --git a/CLAUDE.md b/CLAUDE.md index b914168..104a819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,8 @@ Virtuality — простая замена Proxmox для дома и малог `finish_operation`, `active_operations_for`, `interrupt_orphaned_operations`), `render`, `require_auth`, `valid_vm_name`, шаблоны Jinja. Импорт `core` выставляет `LC_ALL=C.UTF-8` (virsh иначе переводится). - `web/features/*.py` — модули с `router = APIRouter()`, подключаются в конце `app.py`: - `setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `downloads` (скачивание - по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка). + `setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `backup_schedule` (копии + по расписанию; запускает их `virtuality-backup.timer`, а не панель), `downloads` (скачивание по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка). POST-маршруты модулей — минимум 4 сегмента (`/vm/{name}/<модуль>/<действие>`), иначе их перехватит `@app.post("/vm/{name}/{action}")`. - `web/nodectl.py` — факты об узле и действия через `scripts/virtuality-ctl` (единая точка для diff --git a/README.md b/README.md index a54f073..1ea88d1 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,8 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел **Снимки и копии** - **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой. -- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием; копию можно делать и по расписанию из командной строки. +- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием. +- **Копии по расписанию** — каждый день или раз в неделю в выбранное время, для всех машин или только отмеченных. Сервер хранит последние N автоматических копий каждой машины и удаляет старые только после удачной новой; ручные копии не трогает. **Образы ОС** @@ -317,7 +318,7 @@ sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины, /var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки) /var/lib/virtuality/images # диски машин (qcow2) /var/lib/virtuality/backups # резервные копии машин и архивы настроек -/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, tls/ +/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, backup_schedule.json, tls/ /var/lib/virtuality/network # port_forwards.json, virtuality-nat.xml /var/lib/virtuality/tmp # временные файлы загрузок и cloud-init /var/log/virtuality # firstboot.log, update.log, virtuality-ctl.log, install_*.log diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e25b78c..2b38ccc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,7 +26,8 @@ virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2) | `features/setup.py` | Мастер первичной настройки `/setup`: шаги install → welcome → account → access → network → storage → updates → finish, страница «Применяем настройки», ожидание перестройки сети в мост. | | `features/settings.py` | Страница «Настройки»: доступ (HTTPS/порт), пароль, часовой пояс, обновления, сеть машин, хранилище, перезагрузка/выключение, архив настроек, отчёт для поддержки. | | `features/snapshots.py` | Снимки: внутренние снимки libvirt (`virsh snapshot-*`), до 8 на машину, создание и возврат — фоновые задачи. | -| `features/backups.py` | Резервные копии: `vm.xml` + копии дисков + `meta.json` в `backups/<машина>/<ГГГГММДД-ЧЧММ>/`; восстановление под тем же или новым именем; запуск как скрипта для расписания. | +| `features/backups.py` | Резервные копии: `vm.xml` + копии дисков + `meta.json` в `backups/<машина>/<ГГГГММДД-ЧЧММ>/`; восстановление под тем же или новым именем; автокопии помечены `auto: true`. | +| `features/backup_schedule.py` | Копии по расписанию: настройка `config/backup_schedule.json` (каждый день или по дню недели, время, машины, сколько хранить), итог запуска `config/backup_schedule_state.json`. Копирует служба `virtuality-backup.service` (таймер раз в 15 минут), а не панель, поэтому перезапуск панели копию не обрывает. Пропущенное время догоняется только в течение 2 часов; после удачной копии у машины остаются последние N автокопий, ручные не удаляются. | | `features/downloads.py` | Скачивание ISO и образов по ссылке и из каталога — фоновые задачи типа `download` с прогрессом, проверкой контрольной суммы и отменой. | | `features/vmops.py` | Увеличение диска, клонирование (`virt-clone`), пауза, спящий режим (`virsh managedsave`), заметки (title/description машины), живая нагрузка (`virsh domstats`). | | `nodectl.py` | Факты о ноде (сеть, диски, оборудование, часовые пояса, состояние первой загрузки, выбор мастера) и действия через `virtuality-ctl`. Действия, которые перезапускают панель, запускаются отложенно через `systemd-run`. | @@ -44,7 +45,7 @@ virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2) ### Фоновые задачи -Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/.json` (статус, прогресс, сообщение, метаданные) и `.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется» после перезапуска. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300. +Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/.json` (статус, прогресс, сообщение, метаданные) и `.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. У каждой задачи записан процесс-владелец (`owner`: pid, время его запуска и boot id). При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется», если их процесс уже не жив; задачи живого чужого процесса (копии по расписанию из `virtuality-backup.service`) не трогает. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300. --- @@ -73,6 +74,7 @@ virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2) | `virtuality-redirect.service` | Только при TLS: HTTP → HTTPS. | | `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). | | `virtuality-auto-update.timer` / `.service` | Ночная проверка обновлений (`scripts/auto_update_check.sh`), 04:00 ± 30 минут; включён при `VIRTUALITY_AUTO_UPDATE=1`. | +| `virtuality-backup.timer` / `.service` | Раз в 15 минут запускает `features/backup_schedule.py`: если подошло время по расписанию, копирует машины по очереди и удаляет лишние автокопии. Итог пишется в журнал службы и показывается на странице «Резервные копии». | | `virtuality-maintenance.timer` / `.service` | Ежедневное обслуживание: старые задачи, временные файлы, недокачанные загрузки, `journalctl --vacuum-size=300M`, запись занятости диска в `config/health.json`. | | `libvirt-guests.service` | При выключении сервера машины выключаются корректно (`ON_SHUTDOWN=shutdown`, до 180 секунд), при загрузке не запускаются сами — только те, у кого включён автозапуск. | @@ -120,7 +122,7 @@ libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`, ├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images ├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера) ├── backups/ резервные копии машин (<машина>/<ГГГГММДД-ЧЧММ>/) и архивы настроек config-*.tar.gz -├── config/ web.env, setup.json, setup_done, wizard.json, host_profile.json, +├── config/ web.env, setup.json, setup_done, wizard.json, host_profile.json, backup_schedule*.json, │ session_secret, ports.state, health.json, tls/, netplan-backup/ ├── network/ port_forwards.json, ufw_rules.json, virtuality-nat.xml ├── update/ state.json, last_check.json, временные файлы обновления diff --git a/docs/FIRST_VM.md b/docs/FIRST_VM.md index 99d9ef1..619318c 100644 --- a/docs/FIRST_VM.md +++ b/docs/FIRST_VM.md @@ -121,7 +121,9 @@ 3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз. 4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`. -Копию можно делать и по расписанию из терминала сервера — например, задачей cron: +Копии можно делать и автоматически: **Резервные копии** → **Настроить** в карточке «Автоматические копии». Выберите, как часто (каждый день или раз в неделю), время, машины и сколько копий хранить. Сервер будет сам копировать машины в это время и удалять старые автоматические копии, а ручные оставит. + +Одну копию можно сделать и из терминала сервера: ```bash sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8d17eff..a105a82 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,9 +24,12 @@ Virtuality — панель для одного домашнего сервер - HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек. - Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты. +## Сделано в 0.12 + +- Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранение последних N автокопий. Копии делает отдельная служба сервера, перезапуск панели их не прерывает. + ## Планируется -- **Резервные копии по расписанию из панели** — сейчас копия по расписанию делается только командой в терминале; нужен график и хранение N последних копий. - **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`. - **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox. - **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину. diff --git a/docs/screenshots/backups.png b/docs/screenshots/backups.png index 3b88d40..512a475 100644 Binary files a/docs/screenshots/backups.png and b/docs/screenshots/backups.png differ diff --git a/scripts/virtuality-ctl b/scripts/virtuality-ctl index 62d01ec..de82538 100755 --- a/scripts/virtuality-ctl +++ b/scripts/virtuality-ctl @@ -235,6 +235,38 @@ RandomizedDelaySec=1h Persistent=true Unit=virtuality-maintenance.service +[Install] +WantedBy=timers.target +EOF + + # Scheduled VM backups run in their own unit: a panel restart (nightly update) must not cut a copy short. + cat > "${UNIT_DIR}/virtuality-backup.service" < "${UNIT_DIR}/virtuality-backup.timer" </dev/null 2>&1 || true + systemctl enable virtuality-web.service virtuality-network.service virtuality-maintenance.timer virtuality-backup.timer >/dev/null 2>&1 || true systemctl enable libvirt-guests.service >/dev/null 2>&1 || true if [[ "$tls" == "1" ]]; then systemctl enable virtuality-redirect.service >/dev/null 2>&1 || true @@ -305,7 +337,7 @@ restart_panel() { if [[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]]; then systemctl restart virtuality-redirect.service || true fi - systemctl enable --now virtuality-maintenance.timer >/dev/null 2>&1 || true + systemctl enable --now virtuality-maintenance.timer virtuality-backup.timer >/dev/null 2>&1 || true if [[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]]; then systemctl enable --now virtuality-auto-update.timer >/dev/null 2>&1 || true fi @@ -689,7 +721,7 @@ cmd_uninstall() { [[ "$answer" == "YES" ]] || { echo "Отменено."; exit 0; } fi local unit - for unit in virtuality-web.service virtuality-redirect.service virtuality-network.service virtuality-auto-update.timer virtuality-auto-update.service virtuality-maintenance.timer virtuality-maintenance.service virtuality-console-dashboard.service virtuality-firstboot.service; do + for unit in virtuality-web.service virtuality-redirect.service virtuality-network.service virtuality-auto-update.timer virtuality-auto-update.service virtuality-maintenance.timer virtuality-maintenance.service virtuality-backup.timer virtuality-backup.service virtuality-console-dashboard.service virtuality-firstboot.service; do systemctl disable --now "$unit" >/dev/null 2>&1 || true rm -f "${UNIT_DIR}/${unit}" done diff --git a/tests/dev_server.py b/tests/dev_server.py index 17c198a..0a35dea 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -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) diff --git a/tests/test_backup_schedule.py b/tests/test_backup_schedule.py new file mode 100644 index 0000000..e9d7576 --- /dev/null +++ b/tests/test_backup_schedule.py @@ -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 == "" diff --git a/tests/test_backups.py b/tests/test_backups.py index 943469c..4121242 100644 --- a/tests/test_backups.py +++ b/tests/test_backups.py @@ -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) diff --git a/web/app.py b/web/app.py index 2f480a7..54bbc9a 100644 --- a/web/app.py +++ b/web/app.py @@ -2083,7 +2083,7 @@ def api_health(request: Request): # Feature modules register template globals into app.templates, so they are imported once it exists. -from features import backups, downloads, settings, setup, snapshots, vmops # noqa: E402 +from features import backup_schedule, backups, downloads, settings, setup, snapshots, vmops # noqa: E402 @app.on_event("startup") def close_orphaned_operations() -> None: @@ -2095,6 +2095,7 @@ def close_orphaned_operations() -> None: app.include_router(setup.router) app.include_router(settings.router) app.include_router(snapshots.router) +app.include_router(backup_schedule.router) app.include_router(backups.router) app.include_router(downloads.router) app.include_router(vmops.router) diff --git a/web/features/backup_schedule.py b/web/features/backup_schedule.py new file mode 100644 index 0000000..b37dae3 --- /dev/null +++ b/web/features/backup_schedule.py @@ -0,0 +1,316 @@ +"""Резервные копии по расписанию. + +Настройка лежит в CONFIG_DIR/backup_schedule.json, итог последнего запуска — +в CONFIG_DIR/backup_schedule_state.json. Копии делает не панель, а таймер +virtuality-backup.timer раз в 15 минут: +`/opt/virtuality/venv/bin/python /opt/virtuality/web/features/backup_schedule.py`. +Так ночной перезапуск панели при обновлении не обрывает копию на середине +и не оставляет машину выключенной. + +Автокопии помечены в meta.json (auto: true); после каждой удачной копии +у машины остаются последние N автокопий. Ручные копии расписание не трогает. +""" +import fcntl +import json +import re +import sys +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +if __name__ == "__main__": # запуск как скрипта: нужен web/ в sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from fastapi import APIRouter, Form, Request # noqa: E402 + +import core # noqa: E402 +from features import backups # noqa: E402 + +router = APIRouter() + +TIME_RE = re.compile(r"([01]\d|2[0-3]):([0-5]\d)") +CATCH_UP = timedelta(hours=2) # сервер был выключен в назначенное время: догоняем не позже чем через 2 часа, иначе ждём следующего раза +KEEP_MIN, KEEP_MAX = 1, 60 +WEEKDAYS = ("по понедельникам", "по вторникам", "по средам", "по четвергам", "по пятницам", "по субботам", "по воскресеньям") +WEEKDAY_NAMES = ("Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье") +DEFAULT: dict[str, Any] = {"enabled": False, "frequency": "daily", "weekday": 6, "time": "03:00", "all_vms": True, "vms": [], "keep": 7} +NOTE = "По расписанию" + + +# ---------------------------------------------------------------- хранение +def schedule_path() -> Path: + return core.CONFIG_DIR / "backup_schedule.json" + + +def state_path() -> Path: + return core.CONFIG_DIR / "backup_schedule_state.json" + + +def lock_path() -> Path: + return core.CONFIG_DIR / "backup_schedule.lock" + + +def read_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def write_json(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(".tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + tmp_path.replace(path) + + +def is_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def clean_schedule(raw: dict[str, Any]) -> dict[str, Any]: + """Настройка всегда полная и корректная: испорченные значения заменяются значениями по умолчанию.""" + schedule = dict(DEFAULT) + schedule["enabled"] = raw.get("enabled") is True + if raw.get("frequency") in ("daily", "weekly"): + schedule["frequency"] = raw["frequency"] + if is_int(raw.get("weekday")) and 0 <= raw["weekday"] <= 6: + schedule["weekday"] = raw["weekday"] + if isinstance(raw.get("time"), str) and TIME_RE.fullmatch(raw["time"]): + schedule["time"] = raw["time"] + schedule["all_vms"] = raw.get("all_vms") is not False + vms = raw.get("vms") + schedule["vms"] = sorted({vm for vm in vms if isinstance(vm, str) and core.valid_vm_name(vm)}) if isinstance(vms, list) else [] + if is_int(raw.get("keep")) and KEEP_MIN <= raw["keep"] <= KEEP_MAX: + schedule["keep"] = raw["keep"] + return schedule + + +def load_schedule() -> dict[str, Any]: + return clean_schedule(read_json(schedule_path())) + + +# ---------------------------------------------------------------- когда копировать +def step(schedule: dict[str, Any]) -> timedelta: + return timedelta(days=7 if schedule["frequency"] == "weekly" else 1) + + +def last_slot(schedule: dict[str, Any], now: datetime) -> datetime: + """Последний назначенный момент копирования, не позже now (время сервера).""" + hour, minute = map(int, schedule["time"].split(":")) + slot = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if schedule["frequency"] == "weekly": + slot -= timedelta(days=(slot.weekday() - schedule["weekday"]) % 7) + if slot > now: + slot -= step(schedule) + return slot + + +def next_slot(schedule: dict[str, Any], now: datetime) -> datetime: + return last_slot(schedule, now) + step(schedule) + + +def slot_key(slot: datetime) -> str: + return slot.strftime("%Y-%m-%d %H:%M") + + +def due(schedule: dict[str, Any], state: dict[str, Any], now: datetime) -> tuple[str, datetime] | None: + """("run", момент) — пора копировать; ("missed", момент) — время прошло, пока сервер был выключен; None — нечего делать.""" + if not schedule["enabled"]: + return None + slot = last_slot(schedule, now) + if state.get("slot") == slot_key(slot): + return None + return ("run" if now - slot <= CATCH_UP else "missed"), slot + + +def selected_vms(schedule: dict[str, Any]) -> list[str]: + existing = backups.existing_vm_names() + return sorted(existing if schedule["all_vms"] else existing & set(schedule["vms"])) + + +# ---------------------------------------------------------------- запуск +def prune(vm: str, keep: int) -> list[str]: + """Удалить автокопии машины сверх последних keep. Ручные копии и копии, с которыми идёт работа, остаются.""" + autos = [item for item in backups.list_backups(vm) if item["auto"] and item["complete"]] # новые сверху + removed = [] + for item in autos[keep:]: + try: + backups.delete_backup(vm, item["id"]) + except backups.BackupError: + continue + removed.append(item["id"]) + return removed + + +def run_due(now: datetime | None = None) -> dict[str, Any] | None: + """Сделать копии, если подошло время. Возвращает итог запуска или None, если делать нечего.""" + lock_path().parent.mkdir(parents=True, exist_ok=True) + with lock_path().open("w") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + return None # предыдущий запуск ещё копирует + now = now or datetime.now() + schedule = load_schedule() + state = read_json(state_path()) + found = due(schedule, state, now) + if not found: + return None + kind, slot = found + if kind == "missed": + state.update(slot=slot_key(slot), missed=slot_key(slot)) + write_json(state_path(), state) + return None + state = {"slot": slot_key(slot), "status": "running", "started_at": core.utc_now(), "results": []} + write_json(state_path(), state) + for vm in selected_vms(schedule): + result: dict[str, Any] = {"vm": vm} + try: + operation = backups.create_backup(vm, NOTE, wait=True, auto=True) + except Exception as exc: # noqa: BLE001 — одна машина не должна сорвать копии остальных + result.update(ok=False, message=str(exc)[:240]) + else: + result.update(ok=operation.get("status") == "success", message=str(operation.get("message") or "")[:240], operation_id=operation["id"]) + if result["ok"]: + result["removed"] = prune(vm, schedule["keep"]) + state["results"].append(result) + write_json(state_path(), state) + state.update(status="done", finished_at=core.utc_now()) + write_json(state_path(), state) + return state + + +# ---------------------------------------------------------------- как показать +def format_slot(slot: datetime) -> str: + return f"{slot.day} {backups.MONTHS[slot.month - 1]}, {slot:%H:%M}" + + +def format_key(key: str) -> str: + try: + return format_slot(datetime.strptime(key, "%Y-%m-%d %H:%M")) + except (TypeError, ValueError): + return "" + + +def when_text(schedule: dict[str, Any]) -> str: + day = "каждый день" if schedule["frequency"] == "daily" else WEEKDAYS[schedule["weekday"]] + return f"{day} в {schedule['time']}" + + +def overview(now: datetime | None = None) -> dict[str, Any]: + """Всё для карточки на странице «Резервные копии» и на странице машины.""" + now = now or datetime.now() + schedule = load_schedule() + state = read_json(state_path()) + results = [item for item in state.get("results") or [] if isinstance(item, dict)] + failed = [item for item in results if not item.get("ok")] + last = None + if state.get("started_at"): + last = { + "when": format_key(str(state.get("slot") or "")), + "running": state.get("status") == "running", + "count": len(results), + "ok": len(results) - len(failed), + "failed": failed, + } + return { + **schedule, + "when": when_text(schedule), + "next": format_slot(next_slot(schedule, now)) if schedule["enabled"] else "", + "last": last, + "missed": format_key(str(state.get("missed") or "")) if state.get("missed") and state.get("missed") == state.get("slot") else "", + } + + +def covers(schedule: dict[str, Any], vm: str) -> bool: + return schedule["enabled"] and (schedule["all_vms"] or vm in schedule["vms"]) + + +# ---------------------------------------------------------------- страницы +def form_page(request: Request, form: dict[str, Any], error: str = "", status_code: int = 200): + names = sorted(backups.existing_vm_names() | set(form["vms"])) + context = {"page_title": "Расписание копий", "form": form, "vm_names": names, "weekdays": WEEKDAY_NAMES, "keep_max": KEEP_MAX, "error": error} + return core.render(request, "backup_schedule.html", context, status_code=status_code) + + +@router.get("/backups/schedule") +def schedule_page(request: Request): + auth = core.require_auth(request) + if auth: + return auth + return form_page(request, load_schedule()) + + +def read_form(enabled: str, frequency: str, weekday: str, time: str, machines: str, vms: list[str], keep: str) -> dict[str, Any]: + return { + "enabled": enabled == "1", + "frequency": frequency if frequency in ("daily", "weekly") else "daily", + "weekday": int(weekday) if weekday.isdigit() and int(weekday) <= 6 else DEFAULT["weekday"], + "time": time.strip(), + "all_vms": machines != "chosen", + "vms": sorted({vm for vm in vms if core.valid_vm_name(vm)}), + "keep": int(keep) if keep.strip().isdigit() else keep.strip(), + } + + +def form_error(form: dict[str, Any]) -> str: + if not TIME_RE.fullmatch(form["time"]): + return "Укажите время в виде ЧЧ:ММ, например 03:00" + if not is_int(form["keep"]) or not KEEP_MIN <= form["keep"] <= KEEP_MAX: + return f"Сколько копий хранить: число от {KEEP_MIN} до {KEEP_MAX}" + if form["enabled"] and not form["all_vms"] and not form["vms"]: + return "Отметьте хотя бы одну машину или выберите «Все машины»" + return "" + + +def same_timing(old: dict[str, Any], new: dict[str, Any]) -> bool: + return old["enabled"] and new["enabled"] and all(old[key] == new[key] for key in ("frequency", "weekday", "time")) + + +@router.post("/backups/schedule") +def schedule_save( + request: Request, + enabled: str = Form(""), + frequency: str = Form("daily"), + weekday: str = Form("6"), + time: str = Form("03:00"), + machines: str = Form("all"), + vms: list[str] = Form([]), + keep: str = Form("7"), +): + auth = core.require_auth(request) + if auth: + return auth + form = read_form(enabled, frequency, weekday, time, machines, vms, keep) + error = form_error(form) + if error: + return form_page(request, form, error, status_code=400) + old, schedule = load_schedule(), clean_schedule(form) + write_json(schedule_path(), schedule) + now = datetime.now() + if not same_timing(old, schedule): + # Уже прошедший момент нового расписания не догоняем: первая копия — в следующее назначенное время. + state = read_json(state_path()) + state["slot"] = slot_key(last_slot(schedule, now)) + write_json(state_path(), state) + if not schedule["enabled"]: + return core.redirect_with_message("/backups", "backup_message", "Автоматические копии выключены") + message = f"Расписание сохранено: {when_text(schedule)}, следующая копия — {format_slot(next_slot(schedule, now))}" + return core.redirect_with_message("/backups", "backup_message", message) + + +def main() -> None: + """Точка входа virtuality-backup.service: итог пишется в журнал службы.""" + outcome = run_due() + if outcome: + done = sum(1 for item in outcome["results"] if item.get("ok")) + print(f"Копии по расписанию {outcome['slot']}: {done} из {len(outcome['results'])} успешно") + for item in outcome["results"]: + if not item.get("ok"): + print(f" {item['vm']}: {item.get('message')}") + + +if __name__ == "__main__": # запускает virtuality-backup.timer + main() diff --git a/web/features/backups.py b/web/features/backups.py index 4c4e2a3..117de7e 100644 --- a/web/features/backups.py +++ b/web/features/backups.py @@ -227,6 +227,7 @@ def describe_backup(vm: str, backup_id: str, path: Path, meta: dict[str, Any]) - "size_bytes": size, "size": format_bytes(size), "version": str(meta.get("version") or ""), + "auto": meta.get("auto") is True, "complete": complete, } @@ -358,12 +359,13 @@ def reserve_backup_dir(vm_name: str) -> str: # ---------------------------------------------------------------- создание копии -def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str, Any]: +def create_backup(vm_name: str, note: str = "", wait: bool = False, auto: bool = False) -> dict[str, Any]: """Поставить создание копии в очередь и вернуть операцию. Проверки (машина есть, диски есть, места хватает) выполняются сразу и поднимают BackupError с понятным текстом. С wait=True копия делается - в текущем потоке — удобно для расписания.""" + в текущем потоке — так её делает расписание (auto=True: такие копии + расписание само удаляет, оставляя последние N).""" cancel_late_restart(vm_name) if not core.valid_vm_name(vm_name): raise BackupError("Некорректное имя машины") @@ -387,7 +389,8 @@ def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str, raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space") backup_id = reserve_backup_dir(vm_name) try: - operation = core.new_operation("backup", f"Резервная копия {vm_name}", "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need) + title = f"Резервная копия {vm_name}" + (" по расписанию" if auto else "") + operation = core.new_operation("backup", title, "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need, auto=auto) except Exception: remove_backup_dir(backup_root() / vm_name / backup_id) raise @@ -555,7 +558,7 @@ def backup_worker(operation: dict[str, Any]) -> None: log(f"qemu-img завершился с кодом {code}.") raise BackupError(f"Не удалось скопировать диск {disk['target']} — подробности в журнале задачи") size = sum(item.stat().st_size for item in dest.iterdir() if item.is_file()) - meta = {"vm": vm, "created_at": core.utc_now(), "disks": plan, "size_bytes": size, "version": core.APP_VERSION, "note": operation.get("note", "")} + meta = {"vm": vm, "created_at": core.utc_now(), "disks": plan, "size_bytes": size, "version": core.APP_VERSION, "note": operation.get("note", ""), "auto": operation.get("auto") is True} (dest / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") log(f"Копия готова: {format_bytes(size)} (сжатие включено).") except Exception as exc: # noqa: BLE001 — любая ошибка = операция с понятным текстом @@ -777,8 +780,11 @@ def backups_page(request: Request): auth = core.require_auth(request) if auth: return auth + from features import backup_schedule # расписание само импортирует этот модуль + items = list_backups() context = { + "schedule": backup_schedule.overview(), "groups": group_by_vm(items, existing_vm_names()), "storage": storage_summary(items), "running": [op for op in core.running_operations() if op.get("type") in ("backup", "restore")], @@ -841,8 +847,11 @@ def vm_backups_page(request: Request, name: str): return auth if not core.valid_vm_name(name) or not core.vm_exists(name): return RedirectResponse(url="/", status_code=303) + from features import backup_schedule + items = list_backups(name) - context = {"vm": vm_summary(name), "backups": items, "storage": storage_summary(), "active": active_operation(name), **page_messages(request)} + schedule = backup_schedule.overview() + context = {"vm": vm_summary(name), "backups": items, "storage": storage_summary(), "active": active_operation(name), "schedule": schedule, "scheduled": backup_schedule.covers(schedule, name), **page_messages(request)} return core.render(request, "vm_backups.html", context) diff --git a/web/static/app.css b/web/static/app.css index ed0667a..6be33fd 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -333,6 +333,9 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } .option input:checked + .option-body .option-check { border: 5px solid var(--brand); } .option-title { font-weight: 650; font-size: 14px; } .option-text { color: var(--text-2); font-size: 12.5px; } +/* Расписание копий: день недели и список машин видны, только когда нужны (без :has — видны всегда). */ +.schedule-form:has(input[name=frequency][value=daily]:checked) [data-when=weekly], +.schedule-form:has(input[name=machines][value=all]:checked) [data-when=chosen] { display: none; } .option-specs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; } /* segmented */ diff --git a/web/static/icons.svg b/web/static/icons.svg index d13d805..2c7e0d6 100644 --- a/web/static/icons.svg +++ b/web/static/icons.svg @@ -61,4 +61,6 @@ + + diff --git a/web/templates/backup_schedule.html b/web/templates/backup_schedule.html new file mode 100644 index 0000000..338eac4 --- /dev/null +++ b/web/templates/backup_schedule.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if error %}{{ alert('danger', error) }}{% endif %} +{% endblock %} + +{% block content %} +
+
+
+
+ + +
+ Как часто +
+ + +
+
+ +
+
+ + +
+
+ + + По часам сервера. Лучше ночью, когда машинами никто не пользуется. +
+
+ +
+ Какие машины копировать +
+ + +
+
+ {% for name in vm_names %} + + {% else %} +

Машин на сервере пока нет.

+ {% endfor %} +
+
+ +
+ + + Для каждой машины. Более старые автоматические копии удаляются сами. +
+ + {{ alert('info', 'На время копирования работающая машина аккуратно выключается, а потом запускается снова. Машины копируются по очереди, так что каждая стоит только пока копируется она сама.') }} +
+ +
+
+ + +
+{% endblock %} diff --git a/web/templates/backups.html b/web/templates/backups.html index 44f87ff..683bd7a 100644 --- a/web/templates/backups.html +++ b/web/templates/backups.html @@ -39,6 +39,53 @@ +
+
+
+ {{ icon('clock') }} +
+

Автоматические копии {{ badge('включены', 'success', cls='hide-sm') if schedule.enabled else badge('выключены', 'neutral', False, cls='hide-sm') }}

+ {% if schedule.enabled %} +

{{ schedule.when|capitalize }} · {{ 'все машины' if schedule.all_vms else schedule.vms|join(', ') }} · хранить {{ schedule.keep }} {{ plural(schedule.keep, 'последнюю копию', 'последние копии', 'последних копий') }}

+ {% else %} +

Сервер может сам копировать машины каждую ночь или раз в неделю и удалять старые копии.

+ {% endif %} +
+
+ {{ icon('settings', 'icon-sm') }}{{ 'Изменить' if schedule.enabled else 'Настроить' }} +
+ {% if schedule.enabled or schedule.last %} +
+ {% if schedule.enabled %} +
+ {{ icon('clock') }} +
Следующая копия: {{ schedule.next }}
Работающие машины на время копирования выключаются и потом запускаются снова
+
+ {% endif %} + {% if schedule.missed %} +
+ {{ icon('info') }} +
Копия {{ schedule.missed }} пропущена
В это время сервер был выключен, копия будет сделана в следующий раз
+
+ {% endif %} + {% if schedule.last %} + {% set last = schedule.last %} +
+ {{ icon('activity' if last.running else 'triangle-alert' if last.failed else 'circle-check') }} +
+
+ {% if last.running %}Идёт копирование по расписанию {{ last.when }}: готово {{ last.count }} + {% elif not last.count %}Запуск {{ last.when }}: машин для копирования не было + {% else %}Запуск {{ last.when }}: скопировано {{ last.ok }} из {{ last.count }} {{ plural(last.count, 'машины', 'машин', 'машин') }}{% endif %} +
+ {% for item in last.failed %}
{{ item.vm }}: {{ item.message }}
{% endfor %} +
+
+ {% endif %} +
+ {% endif %} +
+ {% if groups %}
{% for g in groups %} @@ -75,7 +122,7 @@
{{ icon('info') }}

Как это работает

-

Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке /var/lib/virtuality/backups.

+

Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке /var/lib/virtuality/backups. Копии по расписанию сервер удаляет сам, оставляя последние. Копии, сделанные вручную, лежат, пока вы их не удалите.

diff --git a/web/templates/vm_backups.html b/web/templates/vm_backups.html index a3e4907..32d5c41 100644 --- a/web/templates/vm_backups.html +++ b/web/templates/vm_backups.html @@ -70,6 +70,17 @@
+
+
+
{{ icon('history') }}

Автоматические копии

+ {% if scheduled %} +

Машина копируется {{ schedule.when }}, сервер хранит {{ schedule.keep }} {{ plural(schedule.keep, 'последнюю копию', 'последние копии', 'последних копий') }}. Следующая: {{ schedule.next }}.

+ {% else %} +

Эта машина не копируется автоматически.

+ {% endif %} + {{ icon('settings', 'icon-sm') }}Расписание копий +
+
{{ icon('clock') }}

Сколько это займёт