14 Коммитов
Автор SHA1 Сообщение Дата
viktor f31b4ade1d Версия была обновлена до 0.12.4
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:45:03 +09:00
viktor 2474ad3dc2 Машина с ISO после установки перезагружается сама: создаётся без отдельной фазы установки (no_install), диск первым в порядке загрузки 2026-09-27 17:45:03 +09:00
viktor daeec06ec6 Версия была обновлена до 0.12.3
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:32:45 +09:00
viktor 209e83044a Экран машины снова подключается: noVNC 1.6 строил адрес WebSocket от своей страницы и уходил в /novnc/console/ws, путь теперь работает и с 1.6, и с 1.3 2026-09-27 17:32:45 +09:00
viktor 5d4525f78d Версия была обновлена до 0.12.2
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:24:01 +09:00
viktor 984eb85a5a Неудачное создание машины: панель больше не предлагает экран несуществующей машины, даёт «Создать заново» с тем же образом и удаляет недосозданный диск 2026-09-27 17:24:01 +09:00
viktor 48ac535c3a Версия была обновлена до 0.12.1
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 16:43:13 +09:00
viktor cc3abe3f4a Создание машины: диск больше свободного места создаётся только с подтверждением, в шаге «Мощность» видно свободное место; раньше установка падала с ошибкой virt-install 2026-09-27 16:43:13 +09:00
viktor 2a4bffc7df Версия была обновлена до 0.12.0
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 16:23:20 +09:00
viktor af9cae85de Загрузка файлов с компьютера снова работает: форма собиралась уже после блокировки поля файла, и браузер отправлял пустой запрос (ошибка 422) 2026-09-27 16:20:58 +09:00
viktor b7c87867fb Старт панели переведён с устаревшего on_event на lifespan, шаблоны вызываются как TemplateResponse(request, name): ушла сотня предупреждений FastAPI 2026-09-27 16:02:10 +09:00
viktor fc76f3f1fa Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранятся последние N автокопий; копии делает служба virtuality-backup, а не панель 2026-09-27 15:58:31 +09:00
viktor 1ad557d1ab Задачи помнят процесс-владельца: при старте панель больше не объявляет прерванными задачи, которые ведёт другой живой процесс 2026-09-27 15:58:31 +09:00
viktor d50a4314aa Сборка ISO не принимает системные имена администратора (admin, root, kvm): установщик Ubuntu их отвергает, и автоматическая установка падала на первом шаге
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 15:54:01 +09:00
34 изменённых файлов: 1276 добавлений и 93 удалений
+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` (единая точка для
+5 -4
Просмотреть файл
@@ -2,7 +2,7 @@
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
Текущая версия: **0.11.1** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Текущая версия: **0.12.4** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Что умеет:
@@ -76,7 +76,7 @@
sudo apt install -y xorriso git curl python3-pip
git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality
make iso # dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso
make iso # dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso
```
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.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
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.11.1
0.12.4
+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, временные файлы обновления
+4 -2
Просмотреть файл
@@ -60,7 +60,7 @@
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
3. После установки на странице машины откройте вкладку **Диски и загрузка** → **Привод CD/DVD** и извлеките установочный образ, чтобы машина больше не предлагала загрузку с него.
3. После установки машина перезагрузится уже с диска: для машин из ISO панель ставит диск первым в порядке загрузки, пока он пустой, машина загружается с образа. Сам образ можно извлечь во вкладке **Диски и загрузка** → **Привод CD/DVD**.
Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**.
@@ -121,7 +121,9 @@
3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз.
4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`.
Копию можно делать и по расписанию из терминала сервера — например, задачей cron:
Копии можно делать и автоматически: **Резервные копии** → **Настроить** в карточке «Автоматические копии». Выберите, как часто (каждый день или раз в неделю), время, машины и сколько копий хранить. Сервер будет сам копировать машины в это время и удалять старые автоматические копии, а ручные оставит.
Одну копию можно сделать и из терминала сервера:
```bash
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
+10 -8
Просмотреть файл
@@ -44,8 +44,8 @@ make iso ARCH=arm64 # ARM64-серверы с UEFI
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
```text
dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso
dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso.sha256
dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso
dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso.sha256
```
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
@@ -98,16 +98,18 @@ make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow"
Для массовой раскатки:
```bash
make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl passwd -6)' --hostname node01"
make iso ISO_ARGS="--unattended --username sysadmin --password-hash '$(openssl passwd -6)' --hostname node01"
```
Системные имена вроде `admin`, `root` или `kvm` установщик Ubuntu не принимает, и установка остановится на первом шаге. Поэтому `build-iso.sh` сразу отказывается собирать образ с таким `--username`. Полный список лежит в `image/reserved-usernames`, это копия списка из установщика Ubuntu.
> ⚠️ Такой образ сам уходит в установку через 10 секунд в меню и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`, пункты меню — пометку «автоматически, диск будет стёрт». Не оставляйте такую флешку в серверах.
---
## Что спрашивает установщик
Меню загрузки: **Установить Virtuality 0.11.1** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Меню загрузки: **Установить Virtuality 0.12.4** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
@@ -159,14 +161,14 @@ cat /var/log/virtuality/firstboot.log
Сверьте контрольную сумму скачанного или собранного образа:
```bash
sha256sum -c virtuality-0.11.1-ubuntu-26.04-amd64.iso.sha256
sha256sum -c virtuality-0.12.4-ubuntu-26.04-amd64.iso.sha256
```
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
```bash
xorriso -indev virtuality-0.11.1-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.11.1-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
xorriso -indev virtuality-0.12.4-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.12.4-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
```
---
@@ -180,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.11.1-ubuntu-26.04-amd64.iso -extract /au
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
```bash
sudo dd if=dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
sudo dd if=dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
```
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
+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

После

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

+1
Просмотреть файл
@@ -106,6 +106,7 @@ esac
[[ "$WEB_PORT" =~ ^[0-9]+$ ]] && (( WEB_PORT >= 1 && WEB_PORT <= 65535 )) || die "invalid --web-port: $WEB_PORT"
if [[ "$UNATTENDED" == "1" ]]; then
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--unattended needs a valid --username"
! grep -qxF "$USERNAME" "${IMAGE_DIR}/reserved-usernames" || die "--username ${USERNAME} is reserved by the Ubuntu installer (like admin, root, kvm); pick another name"
[[ "$PASSWORD_HASH" == \$* ]] || die "--unattended needs --password-hash (generate: openssl passwd -6)"
[[ "$HOSTNAME_VALUE" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$ ]] || die "invalid --hostname"
fi
+127
Просмотреть файл
@@ -0,0 +1,127 @@
# Имена, которые установщик Ubuntu (subiquity) не даёт выбрать для первого пользователя.
# Копия https://github.com/canonical/subiquity/blob/main/reserved-usernames (27.09.2026).
# build-iso.sh проверяет по ней --username, иначе автоматическая установка падает на первом шаге.
# Static users from base-passwd/passwd.master (3.5.41).
root
daemon
bin
sys
sync
games
man
lp
mail
news
uucp
proxy
www-data
backup
list
irc
gnats
nobody
# Other static groups from base-passwd/group.master (3.5.41).
adm
tty
disk
kmem
dialout
fax
voice
cdrom
floppy
tape
sudo
audio
dip
operator
src
shadow
utmp
video
sasl
plugdev
staff
users
nogroup
# Reserved usernames listed in base-passwd/README (3.5.41).
netplan
ftn
mysql
tac-plus
alias
qmail
qmaild
qmails
qmailr
qmailq
qmaill
qmailp
asterisk
vpopmail
vchkpw
slurm
hacluster
haclient
grsec-tpe
grsec-sock-all
grsec-sock-clt
grsec-sock-srv
grsec-proc
ceph
opensrf
libvirt-qemu
# Ubuntu creates the admin group and adds the first user to it in order to
# grant them sudo privileges.
admin
# Other miscellaneous system users/groups created by common packages. While
# it's useful to add things here that people might run into, it's not
# absolutely critical; the worst that will happen is that the installation
# will fail at some later point.
Debian-exim
bind
crontab
cupsys
dcc
dhcp
dictd
dnsmasq
dovecot
fetchmail
firebird
ftp
fuse
gdm
haldaemon
hplilp
identd
input
jwhois
klog
kvm
lpadmin
maas
messagebus
mythtv
netdev
powerdev
radvd
render
saned
sbuild
scanner
sgx
slocate
ssh
sshd
ssl-cert
sslwrap
statd
syslog
telnetd
tftpd
+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
+1
Просмотреть файл
@@ -171,6 +171,7 @@ def data_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
monkeypatch.setattr(app, "images_free_bytes", lambda: 500 * 1024 ** 3) # временная папка тестов бывает маленькой (tmpfs)
for module in (app, core, network_core, host_profile, *feature_modules()):
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
return dirs
+10 -5
Просмотреть файл
@@ -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)
@@ -217,5 +222,5 @@ if __name__ == "__main__":
seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-")))
# The demo operations are seeded as "running": keep them instead of closing them as orphaned on startup.
app.app.router.on_startup.clear()
app.run_startup_tasks = lambda: None
uvicorn.run(app.app, host="127.0.0.1", port=int(sys.argv[1]) if len(sys.argv) > 1 else 8765, log_level="warning")
+89 -1
Просмотреть файл
@@ -1,3 +1,5 @@
import uuid
from pathlib import Path
from urllib.parse import unquote
import pytest
@@ -140,7 +142,10 @@ def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch):
assert cmd[0] == "virt-install"
assert "--cdrom" in cmd and str(iso) in cmd
assert any(part.startswith("network=virtuality-nat,model=virtio,mac=52:54:00:") for part in cmd)
assert captured["operation"]["boot_order"] == "cdrom_disk"
assert captured["operation"]["boot_order"] == "disk_cdrom"
# После установки гость перезагружается с диска: без особого режима установки и с диском первым в очереди.
assert cmd[cmd.index("--boot") + 1] == "hd,cdrom"
assert cmd[cmd.index("--install") + 1] == "no_install=yes"
def test_vm_create_validation_error(logged_in):
@@ -186,3 +191,86 @@ def test_live_status(logged_in):
data = logged_in.get("/live/status").json()
assert data["ok"] is True
assert {vm["name"] for vm in data["vms"]} == {"web01", "db01"}
def test_uploader_collects_file_before_disabling_input():
"""Заблокированное поле браузер не отправляет: загрузка уходила пустой (44 байта) и получала 422."""
script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text()
uploader = script[script.index("function wireUploader"):script.index("/* ---------------------------------------------------- operation page */")]
assert "input.disabled = active" in uploader
assert uploader.index("new FormData(form)") < uploader.index("setActive(true)")
assert "xhr.send(data)" in uploader
def test_vm_create_disk_bigger_than_free_space_needs_confirmation(logged_in, data_dirs, monkeypatch):
"""virt-install отказывал с английской ошибкой; теперь панель предупреждает сама и создаёт только с подтверждением."""
iso = data_dirs["iso"] / "smk.iso"
iso.write_bytes(b"iso")
started = []
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: started.append(cmd))
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
monkeypatch.setattr(app, "images_free_bytes", lambda: 25 * 1024 ** 3)
form = {"name": "smk", "memory": 4096, "vcpus": 2, "disk_size": 40, "iso_path": str(iso), "source_type": "iso", "network_mode": "nat"}
page = logged_in.get("/vm/create").text
assert "Свободно на сервере: 25" in page and 'name="allow_overcommit"' not in page
response = logged_in.post("/vm/create", data=form)
assert response.status_code == 400 and started == []
assert "Диск на 40 ГБ больше, чем свободно на сервере" in response.text and 'name="allow_overcommit"' in response.text
response = logged_in.post("/vm/create", data={**form, "allow_overcommit": "1"}, follow_redirects=False)
assert response.status_code == 303, response.text[:300]
assert started[0][-2:] == ["--check", "disk_size=off"]
response = logged_in.post("/vm/create", data={**form, "name": "small", "disk_size": 20}, follow_redirects=False)
assert response.status_code == 303 and "--check" not in started[1]
def failed_create(data_dirs, name, disk=True, **extra):
"""Операция создания машины, у которой virt-install завершился с ошибкой."""
disk_path = data_dirs["images"] / f"{name}.qcow2"
if disk:
disk_path.write_bytes(b"QFI\xfb")
operation = {"id": str(uuid.uuid4()), "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "", "created_at": app.utc_now(), "updated_at": app.utc_now(), "vm_name": name, "disk_path": str(disk_path), "network_mode": "bridge", **extra}
app.write_operation(operation)
app.run_operation_worker(operation["id"], ["bash", "-c", "echo 'ERROR The requested volume capacity will exceed the available pool space'; exit 1"])
return app.read_operation(operation["id"]), disk_path
def test_failed_vm_create_cleans_up_and_offers_retry(logged_in, data_dirs):
"""Страница упавшей задачи предлагала «Открыть экран машины», хотя машины нет; недосозданный диск мешал повтору."""
operation, disk_path = failed_create(data_dirs, "smk", iso_path=str(data_dirs["iso"] / "smk-server-_1.iso"))
assert operation["status"] == "error" and "ничего не осталось" in operation["message"]
assert not disk_path.exists() and "Недосозданный диск smk.qcow2 удалён" in operation["log_tail"]
html = logged_in.get(f"/operations/{operation['id']}").text
assert 'data-op-done="success" hidden' in html
assert 'href="/vm/create?iso=smk-server-_1.iso"' in html and "Создать заново" in html
def test_failed_create_of_existing_vm_keeps_its_disk(logged_in, data_dirs):
operation, disk_path = failed_create(data_dirs, "web01") # машина определена в libvirt: её диск не трогаем
assert disk_path.exists() and "ничего не осталось" not in operation["message"]
html = logged_in.get(f"/operations/{operation['id']}").text
assert "Создать заново" not in html and 'href="/vm/web01">Перейти к машине' in html
def test_operation_page_reveals_only_buttons_for_its_result():
script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text()
assert '$$(`[data-op-done="${status}"]`)' in script and "$$('[data-op-done]')" not in script
def test_console_websocket_path_works_with_old_and_new_novnc(tmp_path, monkeypatch):
"""noVNC 1.6 (Ubuntu 26.04) считает path от /novnc/vnc.html: без "../" экран стучался в /novnc/console/ws/… и не подключался."""
import posixpath
from urllib.parse import parse_qs, urljoin, urlsplit
(tmp_path / "vnc.html").write_text("<html></html>")
monkeypatch.setattr(app, "NOVNC_DIR", tmp_path)
monkeypatch.setattr(app, "vm_vnc_display", lambda name: "127.0.0.1:0")
info = app.console_info("web01")
page = "https://panel.test:8443" + info["url"]
path = parse_qs(urlsplit(page).query)["path"][0]
token = path.rsplit("/", 1)[1]
assert app.console_serializer.loads(token) == {"vm": "web01", "port": 5900}
assert urlsplit(urljoin(page, path)).path == f"/console/ws/{token}" # noVNC 1.6: new URL(path, location.href)
assert posixpath.normpath("/" + path) == f"/console/ws/{token}" # noVNC 1.3: "wss://host:port/" + path
+36
Просмотреть файл
@@ -139,6 +139,42 @@ def test_orphaned_operations_are_closed_on_startup(data_dirs):
assert core.active_operations_for("web01") == []
def test_panel_startup_closes_orphaned_operations(data_dirs):
"""Стартовые задачи панели запускает lifespan (FastAPI on_event устарел)."""
import core
from fastapi.testclient import TestClient
running = core.new_operation("snapshot_create", "Снимок web01", vm_name="web01")
core.update_operation(running, status="running")
with TestClient(app.app, base_url="http://panel.test"):
assert core.read_operation(running["id"])["interrupted"] is True
def test_interrupt_leaves_operations_of_other_live_processes(data_dirs):
"""Копии по расписанию идут в отдельной службе: перезапуск панели не должен объявлять их прерванными."""
import subprocess
import core
sleeper = subprocess.Popen(["sleep", "30"])
finished = subprocess.Popen(["sleep", "0.1"])
finished_owner = core.process_owner(finished.pid)
finished.wait()
try:
scheduled = core.new_operation("backup", "Резервная копия web01 по расписанию", vm_name="web01")
core.update_operation(scheduled, status="running", owner=core.process_owner(sleeper.pid))
dead = core.new_operation("backup", "Резервная копия db01", vm_name="db01")
core.update_operation(dead, status="running", owner=finished_owner)
legacy = core.new_operation("backup", "Задача старой версии без владельца", vm_name="db01")
core.update_operation(legacy, status="running", owner=None)
assert core.interrupt_orphaned_operations() == 2
assert core.read_operation(scheduled["id"])["status"] == "running"
assert core.read_operation(dead["id"])["interrupted"] and core.read_operation(legacy["id"])["interrupted"]
finally:
sleeper.kill()
sleeper.wait()
def test_snapshot_names_with_spaces_are_listed():
from features import snapshots
+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)
+10
Просмотреть файл
@@ -194,3 +194,13 @@ def test_stable_channel_follows_release_tags(tmp_path, monkeypatch, data_dirs):
(tmp_path / "web.env").write_text("VIRTUALITY_UPDATE_CHANNEL=main\n")
data = update_core.check_updates(fetch=True)
assert data["channel"] == "main" and data["target"] == "main" and data["has_update"]
@pytest.mark.parametrize("username", ["admin", "root", "kvm", "libvirt-qemu"])
def test_build_iso_refuses_names_the_ubuntu_installer_rejects(username):
"""Установщик Ubuntu не принимает системные имена: автоматический образ с таким именем падал на первом шаге."""
import subprocess
result = subprocess.run(["bash", str(ROOT / "image" / "build-iso.sh"), "--unattended", "--username", username, "--password-hash", "$6$salt$hash"], capture_output=True, text=True)
assert result.returncode != 0 and "reserved by the Ubuntu installer" in result.stderr
+2 -2
Просмотреть файл
@@ -118,7 +118,7 @@ def test_windows11_gets_uefi_tpm_sata_and_e1000e(logged_in, data_dirs, monkeypat
assert response.status_code == 303
cmd = captured["cmd"]
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
assert cmd[cmd.index("--boot") + 1] == "uefi,cdrom,hd"
assert cmd[cmd.index("--boot") + 1] == "uefi,hd,cdrom"
assert "--tpm" in cmd and "backend.version=2.0" in cmd[cmd.index("--tpm") + 1]
assert "bus=sata" in cmd[cmd.index("--disk") + 1] and "model=e1000e" in cmd[cmd.index("--network") + 1]
assert "--channel" not in cmd # Windows has no guest agent without extra drivers
@@ -129,7 +129,7 @@ def test_linux_installer_detects_os_and_uses_virtio(logged_in, data_dirs, monkey
iso.write_bytes(b"iso")
response, captured = create(logged_in, monkeypatch, iso_form(iso))
cmd = captured["cmd"]
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "cdrom,hd"
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "hd,cdrom"
assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd
assert cmd[cmd.index("--channel") + 1] == "unix,target.type=virtio,target.name=org.qemu.guest_agent.0"
assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400
+61
Просмотреть файл
@@ -1,5 +1,66 @@
{
"versions": [
{
"version": "0.12.4",
"date": "2026-09-27",
"title": "Машина с установочного образа после установки перезагружается сама",
"summary": "Раньше машина, созданная с ISO, после установки системы выключалась вместо перезагрузки, а при следующем запуске снова загружалась с установочного образа. Теперь она создаётся сразу в рабочем виде, диск стоит первым в порядке загрузки, и после установки машина перезагружается уже в установленную систему.",
"features": [
"После установки с ISO машина перезагружается сама, а не выключается",
"Для машин с ISO диск стоит первым в порядке загрузки: пока он пустой, машина загружается с образа, а после установки с диска и повторно в установщик не попадает",
"Версия обновлена до 0.12.4"
]
},
{
"version": "0.12.3",
"date": "2026-09-27",
"title": "Экран машины снова подключается",
"summary": "На Ubuntu 26.04 экран машины в браузере не подключался: новая версия noVNC обращалась не по тому адресу. Теперь экран работает и с новой, и со старой версией noVNC.",
"features": [
"Экран машины в браузере подключается на Ubuntu 26.04 и Debian 13 (noVNC 1.6), как и на Ubuntu 24.04 (noVNC 1.3)",
"Версия обновлена до 0.12.3"
]
},
{
"version": "0.12.2",
"date": "2026-09-27",
"title": "Понятный итог, если машину создать не удалось",
"summary": "Если создание машины сорвалось, панель больше не предлагает открыть экран несуществующей машины. Она пишет, что на сервере от неё ничего не осталось, и даёт кнопку «Создать заново» с тем же образом. Недосозданный диск удаляется сам, поэтому то же название можно использовать сразу.",
"features": [
"Кнопки «Открыть экран машины» и «Перейти к машине» появляются только после успешного создания",
"После неудачного создания: кнопка «Создать заново» с тем же установочным или готовым образом",
"Недосозданный диск удаляется сам, повторное создание под тем же названием больше не упирается в «Диск уже существует»",
"Версия обновлена до 0.12.2"
]
},
{
"version": "0.12.1",
"date": "2026-09-27",
"title": "Понятное предупреждение, когда диск машины больше свободного места",
"summary": "Если диск новой машины больше, чем свободно на сервере, панель больше не запускает установку, которая падала с ошибкой на английском. Она объясняет, в чём риск, и создаёт такую машину только после подтверждения: диск занимает место постепенно, по мере заполнения.",
"features": [
"В шаге «Мощность» видно, сколько места свободно на сервере",
"Диск больше свободного места создаётся только с подтверждением, раньше установка падала с ошибкой virt-install",
"Версия обновлена до 0.12.1"
]
},
{
"version": "0.12.0",
"date": "2026-09-27",
"title": "Резервные копии по расписанию",
"summary": "Сервер сам копирует машины каждый день или раз в неделю в выбранное время и хранит только последние копии, чтобы они не заняли весь диск. Копии делает отдельная служба сервера, поэтому ночное обновление панели их не прерывает. Исправлена загрузка ISO и образов дисков с компьютера.",
"features": [
"Загрузка ISO и образов дисков с компьютера снова работает: браузер отправлял пустой запрос, и сервер отвечал ошибкой 422",
"Расписание копий: каждый день или в выбранный день недели, время, все машины или только отмеченные",
"Сервер хранит последние N копий по расписанию для каждой машины и удаляет старые только после удачной новой",
"Копии, сделанные вручную, расписание не удаляет",
"Если сервер был выключен в назначенное время, копия делается в течение двух часов после включения, иначе в следующий раз",
"На странице «Резервные копии» видно следующую копию и итог последнего запуска по каждой машине",
"Перезапуск панели больше не объявляет прерванными задачи, которые ведёт служба копий",
"Установочный образ не собирается с именем администратора, которое запрещает установщик Ubuntu (admin, root, kvm)",
"Версия обновлена до 0.12.0"
]
},
{
"version": "0.11.1",
"date": "2026-09-27",
+103 -44
Просмотреть файл
@@ -18,6 +18,7 @@ from pathlib import Path
from typing import Any
from urllib.parse import quote, urlsplit
import xml.etree.ElementTree as ET
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
@@ -45,7 +46,22 @@ OPERATIONS_DIR = Path("/var/log/virtuality/operations")
DEFAULT_BRIDGE = "br0"
NOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)
app = FastAPI(title="Virtuality Panel")
def run_startup_tasks() -> None:
"""После перезапуска панели: закрыть задачи, оборванные перезапуском, и убрать недокачанные загрузки."""
import core
from features import downloads
core.interrupt_orphaned_operations()
downloads.close_stale_downloads()
@asynccontextmanager
async def lifespan(_app: FastAPI):
run_startup_tasks()
yield
app = FastAPI(title="Virtuality Panel", lifespan=lifespan)
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
static_dir = BASE_DIR / "static"
static_dir.mkdir(exist_ok=True)
@@ -278,6 +294,17 @@ def progress_from_line(current: int, line: str) -> int:
return current
def remove_unfinished_disk(operation_id: str, disk_path: str) -> None:
"""Машина не появилась, а её диск (из готового образа он создаётся до virt-install) остался:
удаляем его, иначе повторное создание под тем же названием упрётся в «Диск уже существует»."""
disk = Path(disk_path)
if not disk_path or disk.parent.resolve() != IMAGES_DIR.resolve() or not disk.is_file():
return
disk.unlink(missing_ok=True)
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
append_operation_log(operation_id, f"Недосозданный диск {disk.name} удалён.")
def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
operation = read_operation(operation_id)
if not operation:
@@ -306,7 +333,13 @@ def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}")
if fresh.get("vm_name") and fresh.get("network_mode") == "nat":
network_core.release_nat_address(fresh["vm_name"])
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"virt-install завершился с ошибкой: {exit_code}", finished_at=utc_now())
vm_left = bool(fresh.get("vm_name")) and vm_exists(fresh["vm_name"])
if not vm_left:
remove_unfinished_disk(operation_id, fresh.get("disk_path") or "")
message = "Машину создать не удалось, причина в журнале ниже."
if not vm_left:
message += " На сервере от неё ничего не осталось, её можно создать заново под тем же названием."
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=message, finished_at=utc_now())
except Exception as exc:
fresh = read_operation(operation_id) or operation
append_operation_log(operation_id, f"Ошибка запуска операции: {exc}")
@@ -906,7 +939,9 @@ def console_info(name: str) -> dict[str, Any]:
url = None
if port and has_novnc:
token = console_serializer.dumps({"vm": name, "port": port})
url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}"
# "../": noVNC 1.6 (Ubuntu 26.04) строит адрес от своей страницы /novnc/vnc.html, а 1.3 (Ubuntu 24.04)
# приклеивает path к корню сайта; в обоих случаях получается /console/ws/<токен>.
url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=../console/ws/{token}"
return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}
@@ -1237,12 +1272,12 @@ def vm_boot_order_options() -> list[dict[str, str]]:
def normalize_boot_order(value: str, source_type: str) -> str:
"""auto для ISO — сначала диск, потом CD, как в Proxmox: пустой диск не загрузочный, и машина стартует
с установщика, а после установки — уже с диска, и повторно в установщик не попадает."""
value = (value or "auto").strip()
if value == "auto":
return "cdrom_disk" if source_type == "iso" else "disk"
if value in ("disk", "cdrom_disk", "disk_cdrom"):
return value
return "cdrom_disk" if source_type == "iso" else "disk"
return "disk_cdrom" if source_type == "iso" else "disk"
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
@@ -1257,10 +1292,20 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
return value
def images_free_bytes() -> int:
"""Свободное место там, где лежат диски машин."""
for path in (IMAGES_DIR, STORAGE_DIR, Path("/")):
try:
return shutil.disk_usage(path).free
except OSError:
continue
return 0
def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200):
profile = host_profile.load_host_profile()
default_mode = profile.get("recommended_network", "nat")
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
return templates.TemplateResponse(request, "vm_create.html", {"app_name": APP_NAME, "user": AUTH_USER, "images_free": presenters.format_bytes(images_free_bytes()), "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
# Virtuality noVNC console patch
@@ -1296,20 +1341,20 @@ async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWri
def login_page(request: Request):
if get_current_user(request):
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER})
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER})
@app.post("/login", response_class=HTMLResponse)
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
if not is_configured():
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500)
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500)
key = client_key(request)
retry_after = login_throttle.retry_after(key)
if retry_after:
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Повтори через {retry_after} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429, headers={"Retry-After": str(retry_after)})
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Повтори через {retry_after} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429, headers={"Retry-After": str(retry_after)})
if not verify_linux_password(username, password):
login_throttle.record_failure(key)
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401)
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401)
login_throttle.record_success(key)
token = serializer.dumps({"user": AUTH_USER})
response = RedirectResponse(url="/", status_code=303)
@@ -1333,7 +1378,7 @@ def dashboard(request: Request):
return RedirectResponse(url="/setup", status_code=303)
services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")}
service_rows = [{"key": key, "name": presenters.SERVICE_LABELS.get(key, key), "state": state, **presenters.service_state(state)} for key, state in services.items()]
return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()})
return templates.TemplateResponse(request, "dashboard.html", {"app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()})
@app.get("/help", response_class=HTMLResponse)
@@ -1341,7 +1386,7 @@ def help_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("help.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "host_ip": system_summary()["ip"]})
return templates.TemplateResponse(request, "help.html", {"app_name": APP_NAME, "user": AUTH_USER, "host_ip": system_summary()["ip"]})
@app.get("/host", response_class=HTMLResponse)
@@ -1350,7 +1395,7 @@ def host_page(request: Request):
if auth_redirect:
return auth_redirect
profile = host_profile.load_host_profile()
return templates.TemplateResponse("host.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "profile": profile, "profile_json": json.dumps(profile, ensure_ascii=False, indent=2)})
return templates.TemplateResponse(request, "host.html", {"app_name": APP_NAME, "user": AUTH_USER, "profile": profile, "profile_json": json.dumps(profile, ensure_ascii=False, indent=2)})
@app.post("/host/refresh")
@@ -1368,7 +1413,7 @@ def iso_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error})
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error})
@app.post("/iso/upload", response_class=HTMLResponse)
@@ -1378,11 +1423,11 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)):
return auth_redirect
safe_name = safe_iso_filename(iso_file.filename or "")
if not safe_name:
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
ISO_DIR.mkdir(parents=True, exist_ok=True)
target = ISO_DIR / safe_name
if target.exists():
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400)
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400)
tmp_target = ISO_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
@@ -1391,7 +1436,7 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)):
refresh_iso_pool()
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)
return RedirectResponse(url="/iso", status_code=303)
@@ -1424,7 +1469,7 @@ def disk_images_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error})
return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error})
@app.post("/disk-images/upload", response_class=HTMLResponse)
@@ -1434,7 +1479,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
return auth_redirect
safe_name = safe_disk_upload_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
saved_paths: list[Path] = []
@@ -1455,7 +1500,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
tmp_target.unlink(missing_ok=True)
for path in saved_paths:
path.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
operations = []
for path in saved_paths:
@@ -1507,8 +1552,7 @@ def safe_network_template(request: Request, error: str | None = None, status_cod
"nft_rules": "",
}
error = error or f"Ошибка чтения сетевого состояния: {exc}"
return templates.TemplateResponse("network.html", {
"request": request,
return templates.TemplateResponse(request, "network.html", {
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
@@ -1535,7 +1579,7 @@ def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: st
return RedirectResponse(url="/network", status_code=303)
manual_ip = (manual_ip or "").strip()
if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}", manual_ip):
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400)
return templates.TemplateResponse(request, "network.html", {"app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400)
path = Path("/var/lib/virtuality/network/vm_ips.json")
path.parent.mkdir(parents=True, exist_ok=True)
try:
@@ -1616,8 +1660,7 @@ def network_diagnose(request: Request, vm_name: str = Form(...), external_port:
diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol)
except NetworkError as exc:
error = str(exc)
return templates.TemplateResponse("network.html", {
"request": request,
return templates.TemplateResponse(request, "network.html", {
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
@@ -1657,7 +1700,7 @@ def update_page(request: Request):
}
current = str(info.get("current_version", ""))
current_release = next((item for item in update_core.load_manifest().get("versions", []) if str(item.get("version")) == current), None)
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error, "current_release": current_release})
return templates.TemplateResponse(request, "update.html", {"app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error, "current_release": current_release})
@app.get("/update/status")
@@ -1702,7 +1745,7 @@ def operations_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("operations.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css})
return templates.TemplateResponse(request, "operations.html", {"app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css})
@app.get("/operations/{operation_id}", response_class=HTMLResponse)
@@ -1713,7 +1756,18 @@ def operation_detail_page(request: Request, operation_id: str):
operation = read_operation(operation_id)
if not operation:
return RedirectResponse(url="/operations", status_code=303)
return templates.TemplateResponse("operation_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css})
vm_name = str(operation.get("vm_name") or "")
vm_present = valid_vm_name(vm_name) and vm_exists(vm_name)
return templates.TemplateResponse(request, "operation_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css, "vm_present": vm_present, "retry_url": vm_create_retry_url(operation)})
def vm_create_retry_url(operation: dict[str, Any]) -> str:
"""Форма создания машины с тем же установочным образом или готовым образом, что у неудачной попытки."""
if operation.get("iso_path"):
return "/vm/create?iso=" + quote(Path(operation["iso_path"]).name)
if operation.get("disk_image_path"):
return "/vm/create?image=" + quote(Path(operation["disk_image_path"]).name)
return "/vm/create"
@app.get("/api/operations")
@@ -1739,8 +1793,7 @@ def logs_page(request: Request, source: str = "web", lines: int = 220):
if auth_redirect:
return auth_redirect
selected = read_log_source(source, lines)
return templates.TemplateResponse("logs.html", {
"request": request,
return templates.TemplateResponse(request, "logs.html", {
"app_name": APP_NAME,
"user": AUTH_USER,
"sources": LOG_SOURCES,
@@ -1772,12 +1825,16 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""):
@app.post("/vm/create", response_class=HTMLResponse)
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), os_type: str = Form("linux"), cloud_init: str = Form("0"), ci_user: str = Form(""), ci_password: str = Form(""), ci_ssh_key: str = Form("")):
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), os_type: str = Form("linux"), cloud_init: str = Form("0"), ci_user: str = Form(""), ci_password: str = Form(""), ci_ssh_key: str = Form(""), allow_overcommit: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ci_user = ci_user.strip()
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge, "os_type": os_type, "cloud_init": cloud_init, "ci_user": ci_user, "ci_ssh_key": ci_ssh_key}
# Диск qcow2 занимает место по мере заполнения, поэтому больше свободного места его создать можно,
# но только осознанно: если машина его заполнит, место на сервере кончится у всех машин.
free = images_free_bytes()
too_big = disk_size * 1024 ** 3 > free
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge, "os_type": os_type, "cloud_init": cloud_init, "ci_user": ci_user, "ci_ssh_key": ci_ssh_key, "overcommit": too_big, "allow_overcommit": allow_overcommit == "1"}
error = None
use_cloud_init = source_type == "disk_image" and cloud_init == "1"
ssh_keys = cloudinit.parse_ssh_keys(ci_ssh_key) if use_cloud_init else []
@@ -1791,6 +1848,8 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
error = "CPU должен быть от 1 до 128 vCPU."
elif disk_size < 4 or disk_size > 4096:
error = "Диск должен быть от 4 GB до 4096 GB."
elif too_big and allow_overcommit != "1":
error = f"Диск на {disk_size} ГБ больше, чем свободно на сервере ({presenters.format_bytes(free)}). Уменьшите диск или подтвердите создание в шаге «Мощность»."
elif network_mode not in ("nat", "bridge"):
error = "Некорректный режим сети."
elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
@@ -1889,11 +1948,16 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
steps.append(" ".join(shlex.quote(part) for part in virt))
cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
# no_install: машина сразу создаётся в рабочем виде, с образом в приводе. Без этого virt-install ставит
# на время установки «перезагрузка = выключение» и сам должен перезапустить машину, а он не ждёт
# (--noautoconsole) — после установки машина просто выключалась.
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--install", "no_install=yes", "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
if too_big:
cmd += ["--check", "disk_size=off"] # подтверждено пользователем: virt-install иначе откажется
success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "")
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "os_type": os_type, "cloud_init_user": ci_user if use_cloud_init else "", "nat_ip": nat_ip or "", "seed_file": seed_file, "success_message": success_message, "cmd": " ".join(cmd)}
operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "disk_image_path": disk_image_path if source_type == "disk_image" else "", "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "os_type": os_type, "cloud_init_user": ci_user if use_cloud_init else "", "nat_ip": nat_ip or "", "seed_file": seed_file, "success_message": success_message, "cmd": " ".join(cmd)}
start_background_operation(operation, cmd)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@@ -1905,7 +1969,7 @@ def vm_console_page(request: Request, name: str):
return auth_redirect
if not valid_vm_name(name) or not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})
return templates.TemplateResponse(request, "console.html", {"app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})
@app.websocket("/console/ws/{token}")
@@ -1955,7 +2019,7 @@ def vm_detail_page(request: Request, name: str):
return auth_redirect
if not valid_vm_name(name) or not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse("vm_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})
return templates.TemplateResponse(request, "vm_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})
@app.post("/vm/{name}/resources")
@@ -2083,18 +2147,13 @@ 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
@app.on_event("startup")
def close_orphaned_operations() -> None:
import core
core.interrupt_orphaned_operations()
from features import backup_schedule, backups, downloads, settings, setup, snapshots, vmops # noqa: E402
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)
+24 -3
Просмотреть файл
@@ -112,7 +112,7 @@ def render(request: Request, template: str, context: dict[str, Any] | None = Non
data = {"request": request, "app_name": APP_NAME, "user": AUTH_USER}
data.update(context or {})
data.update(extra)
return templates.TemplateResponse(template, data, status_code=status_code)
return templates.TemplateResponse(request, template, data, status_code=status_code)
# ---------------------------------------------------------------- auth
@@ -278,9 +278,27 @@ def list_operations(limit: int = 25) -> list[dict[str, Any]]:
return operations
def process_owner(pid: int | None = None) -> dict[str, Any] | None:
"""Who runs an operation: pid plus its start time and boot id, so a reused pid is not mistaken for it."""
pid = pid or os.getpid()
try:
stat = Path(f"/proc/{pid}/stat").read_text()
boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
except OSError:
return None
return {"pid": pid, "start": stat.rsplit(")", 1)[1].split()[19], "boot": boot}
def owner_alive(owner: Any) -> bool:
if not isinstance(owner, dict) or not isinstance(owner.get("pid"), int):
return False
return process_owner(owner["pid"]) == owner
def interrupt_orphaned_operations() -> int:
"""Operations run in threads of the panel process: after a restart the ones still marked
queued/running will never finish. Close them so they do not block the next attempt."""
queued/running will never finish. Close them so they do not block the next attempt.
Operations of another live process (scheduled backups run from a timer) are left alone."""
if not OPERATIONS_DIR.exists():
return 0
count = 0
@@ -291,6 +309,9 @@ def interrupt_orphaned_operations() -> int:
continue
if data.get("status") not in ("queued", "running"):
continue
owner = data.get("owner")
if owner_alive(owner) and owner["pid"] != os.getpid():
continue
data.pop("log_tail", None)
message = "Прервано: панель перезапускалась во время операции. Проверьте состояние машины и запустите действие снова."
update_operation(data, status="error", progress=100, message=message, finished_at=utc_now(), interrupted=True)
@@ -334,7 +355,7 @@ def update_operation(operation: dict[str, Any], **changes: Any) -> None:
def new_operation(kind: str, title: str, message: str = "Операция поставлена в очередь", **meta: Any) -> dict[str, Any]:
operation = {"id": str(uuid.uuid4()), "type": kind, "title": title, "status": "queued", "progress": 0, "message": message, "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER}
operation = {"id": str(uuid.uuid4()), "type": kind, "title": title, "status": "queued", "progress": 0, "message": message, "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "owner": process_owner()}
operation.update(meta)
write_operation(operation)
append_operation_log(operation["id"], "Операция поставлена в очередь.")
+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)
+2 -2
Просмотреть файл
@@ -783,9 +783,9 @@ def api_cancel_operation(request: Request, operation_id: str):
return {"ok": True, "operation": read_operation(operation_id) or operation}
@router.on_event("startup")
def close_stale_downloads() -> None:
"""После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part."""
"""После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part.
Вызывается из app.run_startup_tasks() при старте панели."""
reap_stale_downloads()
+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

+4 -2
Просмотреть файл
@@ -300,6 +300,8 @@
const file = input.files[0];
if (!valid(file)) { setError('Выберите файл'); return; }
const started = Date.now();
// FormData собираем до setActive: заблокированное поле файла браузер в отправку не включает.
const data = new FormData(form);
xhr = new XMLHttpRequest();
setActive(true); progress.hidden = false; progress.classList.remove('success', 'danger');
statusEl.textContent = 'Загрузка на сервер…';
@@ -337,7 +339,7 @@
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.send(new FormData(form));
xhr.send(data);
});
showFile();
}
@@ -381,7 +383,7 @@
messageEl.textContent = op.message || '';
if (logEl) { const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; logEl.textContent = op.log_tail || ''; if (atBottom) logEl.scrollTop = logEl.scrollHeight; }
render();
if (done()) { clearInterval(timer); clearInterval(tick); if (!wasDone) { toast(status === 'success' ? 'Операция завершена' : 'Операция завершилась с ошибкой', status === 'success' ? 'success' : 'danger'); $$('[data-op-done]').forEach((el) => { el.hidden = false; }); } }
if (done()) { clearInterval(timer); clearInterval(tick); if (!wasDone) { toast(status === 'success' ? 'Операция завершена' : 'Операция завершилась с ошибкой', status === 'success' ? 'success' : 'danger'); $$(`[data-op-done="${status}"]`).forEach((el) => { el.hidden = false; }); if (status === 'error' && $('[data-op-done="error"][data-op-reload]')) setTimeout(() => location.reload(), 1500); } }
} catch (_) {}
};
const timer = setInterval(poll, 1500);
+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>
+1 -1
Просмотреть файл
@@ -38,7 +38,7 @@
<ul>
<li>Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.</li>
<li>Если курсор «застрял» внутри экрана, нажмите <code>Ctrl + Alt</code>.</li>
<li>После установки системы извлеките установочный диск на странице машины → «Диски и загрузка».</li>
<li>После установки машина перезагрузится уже с диска: он стоит первым в порядке загрузки. Установочный образ можно извлечь на странице машины → «Диски и загрузка».</li>
</ul>
</div></article>
+8 -1
Просмотреть файл
@@ -25,10 +25,17 @@
<div class="progress lg {{ 'success' if operation.status == 'success' else 'danger' if operation.status == 'error' else '' }}" data-op="progress" style="height:10px"><span data-op="bar" style="width:{{ operation.progress|default(0) }}%"></span></div>
<p class="muted" data-op="message">{{ operation.message }}</p>
{% if operation.vm_name %}
<div class="row" data-op-done {% if operation.status != 'success' %}hidden{% endif %}>
<div class="row" data-op-done="success" {% if operation.status != 'success' %}hidden{% endif %}>
<a class="btn btn-primary" href="/vm/{{ operation.vm_name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран машины</a>
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
</div>
<div class="row" data-op-done="error" {% if operation.type == 'vm_create' %}data-op-reload{% endif %} {% if operation.status != 'error' %}hidden{% endif %}>
{% if operation.type == 'vm_create' and not vm_present %}
<a class="btn btn-primary" href="{{ retry_url }}">{{ icon('rotate-cw') }}Создать заново</a>
{% elif vm_present %}
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
{% endif %}
</div>
{% endif %}
</div>
</article>
+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>
+7
Просмотреть файл
@@ -122,6 +122,13 @@
<div class="field"><label for="memory">Память, МБ</label><input class="input" id="memory" type="number" name="memory" min="512" max="262144" step="256" value="{{ form.memory }}" required></div>
<div class="field"><label for="disk_size">Диск, ГБ</label><input class="input" id="disk_size" type="number" name="disk_size" min="4" max="4096" value="{{ form.disk_size }}" required></div>
</div>
<p class="subtle small">Свободно на сервере: {{ images_free }}. Диск машины занимает место постепенно, по мере заполнения.</p>
{% if form.overcommit %}
<label class="check danger">
<input type="checkbox" name="allow_overcommit" value="1" {% if form.allow_overcommit %}checked{% endif %}>
<span><strong>Всё равно создать диск на {{ form.disk_size }} ГБ</strong><span>На сервере свободно {{ images_free }}. Сразу столько места диск не займёт, поэтому создать его можно. Но если система внутри машины заполнит больше, чем есть на сервере, место закончится у всех машин и они остановятся. Надёжнее уменьшить диск или освободить место.</span></span>
</label>
{% endif %}
{% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %}
</div>
</article>