Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранятся последние N автокопий; копии делает служба virtuality-backup, а не панель

Этот коммит содержится в:
2026-09-27 15:58:31 +09:00
родитель 1ad557d1ab
Коммит fc76f3f1fa
18 изменённых файлов: 787 добавлений и 25 удалений
+2 -2
Просмотреть файл
@@ -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` (единая точка для
+3 -2
Просмотреть файл
@@ -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
+5 -3
Просмотреть файл
@@ -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/<id>.json` (статус, прогресс, сообщение, метаданные) и `<id>.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется» после перезапуска. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300.
Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/<id>.json` (статус, прогресс, сообщение, метаданные) и `<id>.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, временные файлы обновления
+3 -1
Просмотреть файл
@@ -121,7 +121,9 @@
3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз.
4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`.
Копию можно делать и по расписанию из терминала сервера — например, задачей cron:
Копии можно делать и автоматически: **Резервные копии** → **Настроить** в карточке «Автоматические копии». Выберите, как часто (каждый день или раз в неделю), время, машины и сколько копий хранить. Сервер будет сам копировать машины в это время и удалять старые автоматические копии, а ручные оставит.
Одну копию можно сделать и из терминала сервера:
```bash
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
+4 -1
Просмотреть файл
@@ -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-ключи, диски целиком в машину.
Двоичные данные
Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 146 KiB

После

Ширина:  |  Высота:  |  Размер: 182 KiB

+35 -3
Просмотреть файл
@@ -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" <<EOF
[Unit]
Description=Virtuality scheduled VM backups
After=libvirtd.service
[Service]
Type=oneshot
TimeoutStartSec=infinity
User=root
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/features/backup_schedule.py
Environment=PYTHONUNBUFFERED=1
Environment=LC_ALL=C.UTF-8
Environment=VIRTUALITY_AUTH_USER=${auth_user}
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
Environment=VIRTUALITY_NODE_ENV=${NODE_ENV}
Environment=TMPDIR=${DATA_DIR}/tmp
EOF
cat > "${UNIT_DIR}/virtuality-backup.timer" <<EOF
[Unit]
Description=Virtuality scheduled VM backups (checks the schedule every 15 minutes)
[Timer]
OnCalendar=*:0/15
AccuracySec=1min
Unit=virtuality-backup.service
[Install]
WantedBy=timers.target
EOF
@@ -265,7 +297,7 @@ EOF
fi
systemctl daemon-reload
systemctl enable virtuality-web.service virtuality-network.service virtuality-maintenance.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
+9 -4
Просмотреть файл
@@ -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)
+209
Просмотреть файл
@@ -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 -2
Просмотреть файл
@@ -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)
+2 -1
Просмотреть файл
@@ -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)
+316
Просмотреть файл
@@ -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()
+14 -5
Просмотреть файл
@@ -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)
+3
Просмотреть файл
@@ -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 */
+2
Просмотреть файл
@@ -61,4 +61,6 @@
<symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></symbol>
<symbol id="i-notebook-pen" viewBox="0 0 24 24"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></symbol>
<symbol id="i-expand" viewBox="0 0 24 24"><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path d="M21 8V3h-5" /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path d="M9 9 3 3" /></symbol>
<symbol id="i-calendar" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
<symbol id="i-calendar-days" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /><path d="M8 13h.01" /><path d="M12 13h.01" /><path d="M16 13h.01" /><path d="M8 17h.01" /><path d="M12 17h.01" /><path d="M16 17h.01" /></symbol>
</svg>

До

Ширина:  |  Высота:  |  Размер: 12 KiB

После

Ширина:  |  Высота:  |  Размер: 12 KiB

+109
Просмотреть файл
@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, alert %}
{% block page_header %}
<div class="page-header">
<div>
<div class="eyebrow"><a href="/backups">{{ icon('arrow-left', 'icon-sm') }} Резервные копии</a></div>
<h1>Расписание копий</h1>
<p class="lead">Сервер сам делает копии машин в выбранное время и хранит только последние, чтобы копии не заняли весь диск.</p>
</div>
</div>
{% endblock %}
{% block alerts %}
{% if error %}{{ alert('danger', error) }}{% endif %}
{% endblock %}
{% block content %}
<section class="grid grid-main">
<article class="card">
<form method="post" action="/backups/schedule" class="schedule-form">
<div class="card-body form-grid">
<label class="switch-row" style="cursor:pointer">
<span class="switch-text"><strong>Делать копии автоматически</strong><span>Выключите, чтобы приостановить расписание. Уже сделанные копии останутся.</span></span>
<input type="checkbox" name="enabled" value="1" {% if form.enabled %}checked{% endif %} style="width:20px;height:20px;accent-color:var(--brand)">
</label>
<div class="field">
<span class="label">Как часто</span>
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
<label class="option">
<input type="radio" name="frequency" value="daily" {% if form.frequency == 'daily' %}checked{% endif %}>
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('calendar') }}</span></span><span class="option-title">Каждый день</span><span class="option-text">Для машин, в которых что-то меняется каждый день: сайты, базы, файлы.</span></span>
</label>
<label class="option">
<input type="radio" name="frequency" value="weekly" {% if form.frequency == 'weekly' %}checked{% endif %}>
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('calendar-days') }}</span></span><span class="option-title">Раз в неделю</span><span class="option-text">Если машины меняются редко или места под копии немного.</span></span>
</label>
</div>
</div>
<div class="grid grid-2" style="align-items:start">
<div class="field" data-when="weekly">
<label for="weekday">День недели</label>
<select class="select" id="weekday" name="weekday">
{% for day in weekdays %}<option value="{{ loop.index0 }}" {% if form.weekday == loop.index0 %}selected{% endif %}>{{ day }}</option>{% endfor %}
</select>
</div>
<div class="field">
<label for="time">Время</label>
<input class="input" id="time" type="time" name="time" value="{{ form.time }}" required>
<span class="hint">По часам сервера. Лучше ночью, когда машинами никто не пользуется.</span>
</div>
</div>
<div class="field">
<span class="label">Какие машины копировать</span>
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
<label class="option">
<input type="radio" name="machines" value="all" {% if form.all_vms %}checked{% endif %}>
<span class="option-body"><span class="option-title">Все машины</span><span class="option-text">Новые машины тоже попадут в копии сами.</span></span>
</label>
<label class="option">
<input type="radio" name="machines" value="chosen" {% if not form.all_vms %}checked{% endif %}>
<span class="option-body"><span class="option-title">Только отмеченные</span><span class="option-text">Например, без тестовых машин, которые не жалко.</span></span>
</label>
</div>
<div class="stack-sm" data-when="chosen">
{% for name in vm_names %}
<label class="check"><input type="checkbox" name="vms" value="{{ name }}" {% if name in form.vms %}checked{% endif %}><span><strong>{{ name }}</strong></span></label>
{% else %}
<p class="subtle small">Машин на сервере пока нет.</p>
{% endfor %}
</div>
</div>
<div class="field" style="max-width:280px">
<label for="keep">Сколько копий хранить</label>
<input class="input" id="keep" type="number" name="keep" value="{{ form.keep }}" min="1" max="{{ keep_max }}" required>
<span class="hint">Для каждой машины. Более старые автоматические копии удаляются сами.</span>
</div>
{{ alert('info', 'На время копирования работающая машина аккуратно выключается, а потом запускается снова. Машины копируются по очереди, так что каждая стоит только пока копируется она сама.') }}
</div>
<div class="card-footer">
<a class="btn" href="/backups">Отмена</a>
<button type="submit" class="btn btn-primary">{{ icon('check') }}Сохранить</button>
</div>
</form>
</article>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Как это работает</h2></div>
<div class="checklist">
{% set steps = [
('Старые копии удаляются сами', 'После удачной новой копии у машины остаются последние копии по расписанию, сколько вы указали. Копии, сделанные вручную, не удаляются.'),
('Неудачная копия ничего не удаляет', 'Если места не хватило или машина не выключилась, прежние копии остаются. Итог виден на странице «Резервные копии».'),
('Если сервер был выключен', 'Пропущенную копию сервер сделает в течение двух часов после включения. Если включили позже, копия будет в следующий раз.'),
('Обновления не мешают', 'Копии делает отдельная служба сервера, а не панель. Перезапуск панели их не прерывает.'),
] %}
{% for title, text in steps %}
<div class="check-item success">{{ icon('circle-check') }}<div><strong>{{ title }}</strong><span>{{ text }}</span></div></div>
{% endfor %}
</div>
</article>
</aside>
</section>
{% endblock %}
+48 -1
Просмотреть файл
@@ -39,6 +39,53 @@
</article>
</section>
<article class="card mt-3" id="schedule">
<div class="card-header">
<div class="row">
<span class="vm-avatar">{{ icon('clock') }}</span>
<div>
<h2>Автоматические копии {{ badge('включены', 'success', cls='hide-sm') if schedule.enabled else badge('выключены', 'neutral', False, cls='hide-sm') }}</h2>
{% if schedule.enabled %}
<p>{{ schedule.when|capitalize }} · {{ 'все машины' if schedule.all_vms else schedule.vms|join(', ') }} · хранить {{ schedule.keep }} {{ plural(schedule.keep, 'последнюю копию', 'последние копии', 'последних копий') }}</p>
{% else %}
<p>Сервер может сам копировать машины каждую ночь или раз в неделю и удалять старые копии.</p>
{% endif %}
</div>
</div>
<a class="btn btn-sm" href="/backups/schedule">{{ icon('settings', 'icon-sm') }}{{ 'Изменить' if schedule.enabled else 'Настроить' }}</a>
</div>
{% if schedule.enabled or schedule.last %}
<div class="list">
{% if schedule.enabled %}
<div class="list-item">
<span class="list-icon brand">{{ icon('clock') }}</span>
<div class="grow"><div class="title">Следующая копия: {{ schedule.next }}</div><div class="meta">Работающие машины на время копирования выключаются и потом запускаются снова</div></div>
</div>
{% endif %}
{% if schedule.missed %}
<div class="list-item">
<span class="list-icon info">{{ icon('info') }}</span>
<div class="grow"><div class="title">Копия {{ schedule.missed }} пропущена</div><div class="meta">В это время сервер был выключен, копия будет сделана в следующий раз</div></div>
</div>
{% endif %}
{% if schedule.last %}
{% set last = schedule.last %}
<div class="list-item">
<span class="list-icon {{ 'info' if last.running else 'danger' if last.failed else 'success' }}">{{ icon('activity' if last.running else 'triangle-alert' if last.failed else 'circle-check') }}</span>
<div class="grow">
<div class="title">
{% if last.running %}Идёт копирование по расписанию {{ last.when }}: готово {{ last.count }}
{% elif not last.count %}Запуск {{ last.when }}: машин для копирования не было
{% else %}Запуск {{ last.when }}: скопировано {{ last.ok }} из {{ last.count }} {{ plural(last.count, 'машины', 'машин', 'машин') }}{% endif %}
</div>
{% for item in last.failed %}<div class="meta"><strong>{{ item.vm }}</strong>: {{ item.message }}</div>{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
</article>
{% if groups %}
<div class="stack mt-3">
{% for g in groups %}
@@ -75,7 +122,7 @@
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('info') }}<h3>Как это работает</h3></div>
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>.</p>
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>. Копии по расписанию сервер удаляет сам, оставляя последние. Копии, сделанные вручную, лежат, пока вы их не удалите.</p>
</div>
</article>
</section>
+11
Просмотреть файл
@@ -70,6 +70,17 @@
</div>
</form>
</article>
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('history') }}<h3>Автоматические копии</h3></div>
{% if scheduled %}
<p class="muted small">Машина копируется {{ schedule.when }}, сервер хранит {{ schedule.keep }} {{ plural(schedule.keep, 'последнюю копию', 'последние копии', 'последних копий') }}. Следующая: {{ schedule.next }}.</p>
{% else %}
<p class="muted small">Эта машина не копируется автоматически.</p>
{% endif %}
<a class="btn btn-sm" href="/backups/schedule">{{ icon('settings', 'icon-sm') }}Расписание копий</a>
</div>
</article>
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('clock') }}<h3>Сколько это займёт</h3></div>