16 Коммитов
Автор SHA1 Сообщение Дата
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
viktor 40978d1c2b Версия была обновлена до 0.11.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
2026-09-27 15:31:43 +09:00
viktor 440d8fc562 Проект переехал в Gitea StackWorks: обновления, установка одной командой и ISO берут код с git.stackworks.ru, запасной ZIP скачивается оттуда же, ноды со старым адресом GitHub переключаются сами 2026-09-27 15:31:43 +09:00
viktor 83511d6b09 История копии из Gitea (0.9.6 и разбор старого README) была присоединена, содержимое остаётся от GitHub-версии 0.11
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
2026-09-27 14:47:52 +09:00
viktor 83300b6c5a Резервные копии: наблюдатель позднего выключения перепроверяет отмену после опроса машины и не запускает её, если «Выключить» нажали в этот момент 2026-09-26 09:34:57 +09:00
viktor 657d54fc75 Тесты vmops больше не зависят от свободного места во временной папке (на tmpfs падали 8 тестов) 2026-09-26 09:34:57 +09:00
viktor 5f2bdd921d docs: подробный README (разбор проекта при переносе в Gitea)
Старый README сохранён как README.upstream.md.
2026-08-20 04:18:36 +09:00
41 изменённых файлов: 1399 добавлений и 124 удалений
+7 -4
Просмотреть файл
@@ -7,6 +7,9 @@ Virtuality — простая замена Proxmox для дома и малог
**Общайся с владельцем проекта по-русски.** Весь текст интерфейса — на простом русском, без жаргона
(термины вроде qcow2, libvirt, nvram — только в раскрывающихся блоках «Для специалистов»).
Репозиторий: https://git.stackworks.ru/viktor/virtuality (Gitea StackWorks). С GitHub проект переехал в сентябре 2026 года;
ноды со старым адресом переключаются сами (`update_core.migrate_remote`, `migrate_update_remote` в `scripts/update_target.sh`).
## Устройство репозитория
- `web/app.py` — основное приложение FastAPI (машины, ISO, образы дисков, сеть, журналы, обновления, консоль).
@@ -14,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` (единая точка для
@@ -55,8 +58,8 @@ make iso # ISO в dist/ (нужны xorriso, git, curl, p
- Работаем прямо в `main` (так решил владелец). Перед каждым пушем — `make check`; не пушить
недоделанное: ноды на канале «Ранний доступ» (`main`) подтягивают main каждую ночь.
- Узлы на канале `stable` (по умолчанию) обновляются только до тегов `vX.Y.Z`. Релиз: поднять `VERSION`,
добавить запись в `updates/versions.json`, запушить, поставить тег `vX.Y.Z` на main — тег же запускает
сборку ISO в GitHub Actions (artifacts `virtuality-iso-amd64/arm64`).
добавить запись в `updates/versions.json`, запушить, поставить тег `vX.Y.Z` на main. Раннера Actions нет:
ISO собирается вручную `make iso`, CI заменяет `make check` перед пушем.
- Секреты и пароли не логировать; пароль cloud-init хранится только как SHA-512 crypt.
- Не возвращать удалённое: Cockpit, консольный dashboard, эмуляцию ARM64 на x86, PXE, смену
архитектуры машины, скрипты bootstrap/clean_install/setup_bridge_br0/create_test_vm.
+11 -11
Просмотреть файл
@@ -2,7 +2,7 @@
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
Текущая версия: **0.11.0** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Текущая версия: **0.12.2** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Что умеет:
@@ -70,14 +70,13 @@
Где взять образ:
- **GitHub Actions.** Откройте вкладку Actions репозитория → workflow **Build ISO image** → последний успешный запуск → раздел Artifacts → `virtuality-iso-amd64` (или `virtuality-iso-arm64`). Внутри архива — `.iso` и файл `.sha256`. Артефакты хранятся 30 дней; новый образ собирается при каждом выпуске (тег `vX.Y.Z`), запустить сборку вручную можно кнопкой Run workflow.
- **Собрать самому** на любой Linux-машине (root не нужен):
```bash
sudo apt install -y xorriso git curl python3-pip
git clone https://github.com/viktor138irk/virtuality.git
git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality
make iso # dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso
make iso # dist/virtuality-0.12.2-ubuntu-26.04-amd64.iso
```
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
@@ -87,7 +86,7 @@
Подходит для уже установленного Ubuntu Server 24.04 / 26.04 или Debian 13. Работает и под `root`, и под обычным пользователем с `sudo`:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | bash
```
Скрипт проверит требования, поставит пакеты KVM/QEMU/libvirt, команду `vhealth` и панель, а в конце покажет адрес панели. Логин — тот пользователь, от которого запущена установка (через `sudo` — ваш пользователь, под `root` — `root`); пароль — его пароль Linux. Если пароль ещё не задан: `sudo passwd <пользователь>`.
@@ -105,7 +104,7 @@ VIRTUALITY_INSTALL_VERBOSE=1 # показывать вывод команд вм
Пример:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | VIRTUALITY_USER=admin bash
```
При первом входе в панель откроется мастер настройки — тот же, что после установки с ISO.
@@ -116,7 +115,7 @@ curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/instal
```bash
sudo mkdir -p /opt/virtuality
sudo git clone https://github.com/viktor138irk/virtuality.git /opt/virtuality/source
sudo git clone https://git.stackworks.ru/viktor/virtuality.git /opt/virtuality/source
cd /opt/virtuality/source
sudo bash install_virtuality_node.sh # пакеты KVM/QEMU/libvirt, пулы, firewall
sudo bash scripts/install_healthcheck_command.sh # команда sudo vhealth
@@ -178,7 +177,8 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел
**Снимки и копии**
- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой.
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием; копию можно делать и по расписанию из командной строки.
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием.
- **Копии по расписанию** — каждый день или раз в неделю в выбранное время, для всех машин или только отмеченных. Сервер хранит последние N автоматических копий каждой машины и удаляет старые только после удачной новой; ручные копии не трогает.
**Образы ОС**
@@ -265,7 +265,7 @@ sudo virtuality-ctl uninstall [--purge] [--yes] # удалить Virtuality
- **Из панели**: раздел **Обновления** → «Проверить сейчас» → «Обновить». Машины во время обновления продолжают работать. Что изменилось — видно на той же странице.
- **Автоматически**: таймер `virtuality-auto-update.timer` проверяет обновления каждую ночь около 4:00 по времени сервера. Включается и выключается в «Настройках» или командой `sudo virtuality-ctl set VIRTUALITY_AUTO_UPDATE=0`.
- **Каналы**: `stable` — только выпущенные версии (последний тег `vX.Y.Z`; пока тегов нет, стабильный канал следует ветке `main`), `main` — все изменения ветки `main`. Нода никогда не откатывается на более старую версию сама.
- **Вручную из терминала**: `sudo bash /opt/virtuality/source/scripts/apply_github_update.sh` — тот же сценарий, что и в панели (git fetch, при недоступности git — ZIP с GitHub, затем `scripts/install_web_panel.sh` и перезапуск панели). Журнал: `/var/log/virtuality/update.log`.
- **Вручную из терминала**: `sudo bash /opt/virtuality/source/scripts/apply_github_update.sh` — тот же сценарий, что и в панели (git fetch, при недоступности git — ZIP-архив с git.stackworks.ru, затем `scripts/install_web_panel.sh` и перезапуск панели). Журнал: `/var/log/virtuality/update.log`.
- **Откат**: перед каждым обновлением предыдущая версия панели сохраняется в `/opt/virtuality/web.prev`. Если новая версия не запустилась: `sudo virtuality-ctl rollback`.
---
@@ -318,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
@@ -353,7 +353,7 @@ VIRTUALITY_DEV_SETUP=finished python3 tests/dev_server.py # настроен
Устройство кода описано в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Интерфейс: шаблоны `web/templates` (каркас `base.html`, компоненты `_ui.html`), стили `web/static/app.css`, поведение `web/static/panel.js`; шрифт и иконки лежат в `web/static`. Скриншоты страниц: `node tests/screenshots.mjs` (нужен Playwright).
GitHub Actions: `ci.yml` запускает pyflakes, shellcheck и тесты на Python 3.11–3.14; `image.yml` собирает ISO по тегу `v*` и вручную. Ветка `main` — рабочая ветка нод на канале `main`; выпуск для стабильного канала — тег `vX.Y.Z`.
Репозиторий живёт в Gitea StackWorks: https://git.stackworks.ru/viktor/virtuality (до сентября 2026 года был на GitHub; ноды со старым адресом переключаются на новый сами при первой проверке обновлений). Ветка `main` — рабочая ветка нод на канале `main`; выпуск для стабильного канала — тег `vX.Y.Z`. Раннер Actions не подключён: перед каждым пушем запускается `make check`, ISO собирается вручную `make iso`. Файлы `.github/workflows` (`ci.yml` — проверки на Python 3.11–3.14, `image.yml` — сборка ISO) Gitea тоже понимает, они заработают, если раннер появится.
---
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.11.0
0.12.2
+9 -5
Просмотреть файл
@@ -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, временные файлы обновления
@@ -146,9 +148,11 @@ libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`,
## Обновления
Ноды обновляются из `origin` своего `/opt/virtuality/source` — по умолчанию https://git.stackworks.ru/viktor/virtuality.git. Проект переехал туда с GitHub в сентябре 2026 года; ноду со старым адресом GitHub `update_core.migrate_remote()` и `migrate_update_remote` в `scripts/update_target.sh` переключают на новый перед `git fetch`.
1. **Канал** (`VIRTUALITY_UPDATE_CHANNEL` в `web.env`): `stable` — последний тег `vX.Y.Z` (пока тегов нет — ветка `main`); `main` — ветка `main`. Логика одна и та же в `web/update_core.py` и `scripts/update_target.sh`.
2. **Проверка** (`/update/check`, ночной `scripts/auto_update_check.sh`): `git fetch --tags`, сравнение текущего коммита с целевым; обновление предлагается, только если целевой коммит — потомок текущего (нода никогда не откатывается сама). Список изменений берётся из `updates/versions.json` целевой версии.
3. **Применение** (`scripts/apply_github_update.sh`, запускается через `systemd-run`): `git checkout -B main <цель>` и `git clean`; если git недоступен — скачивание ZIP-архива с GitHub и `rsync`; затем `scripts/install_web_panel.sh` (панель копируется в `web.new`, старая переименовывается в `web.prev`, venv обновляется) и `systemctl restart virtuality-web`. Состояние — `/var/lib/virtuality/update/state.json`, журнал — `/var/log/virtuality/update.log`.
3. **Применение** (`scripts/apply_github_update.sh`, запускается через `systemd-run`): `git checkout -B main <цель>` и `git clean`; если git недоступен — скачивание ZIP-архива того же репозитория (`<адрес>/archive/<ветка или тег>.zip`, так отдают и Gitea, и GitHub) и `rsync`; затем `scripts/install_web_panel.sh` (панель копируется в `web.new`, старая переименовывается в `web.prev`, venv обновляется) и `systemctl restart virtuality-web`. Состояние — `/var/lib/virtuality/update/state.json`, журнал — `/var/log/virtuality/update.log`.
4. **Откат**: `virtuality-ctl rollback` меняет местами `web` и `web.prev`.
Установщики и скрипты вне `web/`:
@@ -169,4 +173,4 @@ image/build-iso.sh, autoinstall.yaml, files/, grub/ сборка ISO
## Тесты и CI
`tests/conftest.py` подменяет `run_cmd`, так что `virsh`, `systemctl`, `qemu-img` и остальные команды имитируются — тесты идут на любой машине без libvirt. `tests/dev_server.py` поднимает демо-панель с теми же подменами. `make check` = pyflakes + shellcheck + pytest; `.github/workflows/ci.yml` запускает это на Python 3.11–3.14, `.github/workflows/image.yml` собирает ISO.
`tests/conftest.py` подменяет `run_cmd`, так что `virsh`, `systemctl`, `qemu-img` и остальные команды имитируются — тесты идут на любой машине без libvirt. `tests/dev_server.py` поднимает демо-панель с теми же подменами. `make check` = pyflakes + shellcheck + pytest, запускается перед каждым пушем. `.github/workflows/ci.yml` (то же на Python 3.11–3.14) и `.github/workflows/image.yml` (сборка ISO) заработают в Gitea Actions, когда будет подключён раннер.
+3 -1
Просмотреть файл
@@ -121,7 +121,9 @@
3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз.
4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`.
Копию можно делать и по расписанию из терминала сервера — например, задачей cron:
Копии можно делать и автоматически: **Резервные копии** → **Настроить** в карточке «Автоматические копии». Выберите, как часто (каждый день или раз в неделю), время, машины и сколько копий хранить. Сервер будет сам копировать машины в это время и удалять старые автоматические копии, а ручные оставит.
Одну копию можно сделать и из терминала сервера:
```bash
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
+12 -16
Просмотреть файл
@@ -20,13 +20,7 @@
## Где взять готовый образ
Workflow `.github/workflows/image.yml` собирает ISO в GitHub Actions:
- автоматически при выпуске — пуш тега `v*` (amd64 и arm64);
- при pull request, который меняет `image/**`, `scripts/virtuality_firstboot.sh`, `web/requirements.txt` или сам workflow;
- вручную: Actions → **Build ISO image** → Run workflow → архитектура `amd64`, `arm64` или `both`.
После сборки workflow проверяет, что в образе есть загрузочная запись UEFI, `autoinstall.yaml` разбирается как YAML, а в `grub.cfg` есть пункт Virtuality. Образы Ubuntu Server больше лимита GitHub Releases (2 ГБ на файл), поэтому ISO публикуются как **artifacts** запуска (`virtuality-iso-amd64`, `virtuality-iso-arm64`) и хранятся 30 дней. В артефакте — `.iso` и `.sha256`.
Образ собирается вручную командой `make iso` (см. ниже): раннер Actions в Gitea не подключён. Workflow `.github/workflows/image.yml` остаётся в репозитории и заработает, как только раннер появится: он собирает ISO по тегу `v*` и вручную, проверяет загрузочную запись UEFI, `autoinstall.yaml` и пункт Virtuality в `grub.cfg` и сохраняет `.iso` и `.sha256` как artifacts запуска.
---
@@ -41,7 +35,7 @@ sudo apt install -y xorriso git curl python3-pip
Сборка:
```bash
git clone https://github.com/viktor138irk/virtuality.git
git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality
make iso # amd64
make iso ARCH=arm64 # ARM64-серверы с UEFI
@@ -50,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.0-ubuntu-26.04-amd64.iso
dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso.sha256
dist/virtuality-0.12.2-ubuntu-26.04-amd64.iso
dist/virtuality-0.12.2-ubuntu-26.04-amd64.iso.sha256
```
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
@@ -104,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.0** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Меню загрузки: **Установить Virtuality 0.12.2** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
@@ -165,14 +161,14 @@ cat /var/log/virtuality/firstboot.log
Сверьте контрольную сумму скачанного или собранного образа:
```bash
sha256sum -c virtuality-0.11.0-ubuntu-26.04-amd64.iso.sha256
sha256sum -c virtuality-0.12.2-ubuntu-26.04-amd64.iso.sha256
```
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
```bash
xorriso -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
xorriso -indev virtuality-0.12.2-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.12.2-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
```
---
@@ -186,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -extract /au
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
```bash
sudo dd if=dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
sudo dd if=dist/virtuality-0.12.2-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
```
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
+1 -1
Просмотреть файл
@@ -59,7 +59,7 @@ VIRTUALITY_SKIP_REQUIREMENTS=1 # отключить проверки совс
Например, для тестового стенда с маленьким диском:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
```
Установка с ISO эти пороги не применяет: диск и память проверяются мастером настройки, который покажет предупреждение, но не остановит установку.
+5 -2
Просмотреть файл
@@ -6,7 +6,7 @@ Virtuality — панель для одного домашнего сервер
**Установка и настройка**
- Установочный ISO на базе Ubuntu Server 26.04 LTS (amd64, arm64), сборка `make iso` и в GitHub Actions, полностью автоматический режим.
- Установочный ISO на базе Ubuntu Server 26.04 LTS (amd64, arm64), сборка `make iso`, полностью автоматический режим.
- Первая загрузка: панель ставится первой и показывает ход установки в браузере, при ошибке продолжает сама.
- Мастер настройки: часовой пояс, пароль, HTTPS, сеть машин (NAT или домашняя сеть с автооткатом), отдельный диск под машины, обновления.
- Страница «Настройки», команда `virtuality-ctl`, установка одной командой на Ubuntu 24.04 / 26.04 и Debian 13.
@@ -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

+3 -2
Просмотреть файл
@@ -21,7 +21,7 @@ UBUNTU_ISO=""
CACHE_DIR="${VIRTUALITY_ISO_CACHE:-${REPO_ROOT}/.cache/iso}"
OUTPUT_DIR="${REPO_ROOT}/dist"
GIT_REF="HEAD"
REPO_URL="https://github.com/viktor138irk/virtuality.git"
REPO_URL="https://git.stackworks.ru/viktor/virtuality.git"
WEB_PORT="8088"
AUTO_UPDATE="1"
UNATTENDED="0"
@@ -47,7 +47,7 @@ Options:
--ref GIT_REF Git ref of Virtuality to bundle (default: ${GIT_REF})
--repo-url URL Update remote configured on installed nodes (default: ${REPO_URL})
--web-port PORT Web panel port on installed nodes (default: ${WEB_PORT})
--no-auto-update Disable the nightly GitHub auto-update on installed nodes
--no-auto-update Disable the nightly auto-update on installed nodes
--locale LOCALE Installer/system locale (default: ${LOCALE})
--keyboard LAYOUT Keyboard layout (default: ${KEYBOARD})
--timezone TZ System timezone (default: ${TIMEZONE} — detected by the installer; the wizard can change it)
@@ -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
+1 -1
Просмотреть файл
@@ -1,6 +1,6 @@
[Unit]
Description=Virtuality first boot setup
Documentation=https://github.com/viktor138irk/virtuality/blob/main/docs/IMAGE.md
Documentation=https://git.stackworks.ru/viktor/virtuality/src/branch/main/docs/IMAGE.md
Wants=network-online.target
After=network-online.target
ConditionPathExists=!/var/lib/virtuality/.firstboot-done
+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
+2 -2
Просмотреть файл
@@ -6,7 +6,7 @@ set -euo pipefail
# Public install entrypoint for clean Ubuntu/Debian servers.
# ==========================================================
SELF_INSTALL_URL="${VIRTUALITY_INSTALL_URL:-https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh}"
SELF_INSTALL_URL="${VIRTUALITY_INSTALL_URL:-https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh}"
if [[ "${EUID}" -ne 0 ]]; then
if command -v sudo >/dev/null 2>&1; then
@@ -27,7 +27,7 @@ if [[ "${EUID}" -ne 0 ]]; then
exit 1
fi
REPO_URL="${VIRTUALITY_REPO_URL:-https://github.com/viktor138irk/virtuality.git}"
REPO_URL="${VIRTUALITY_REPO_URL:-https://git.stackworks.ru/viktor/virtuality.git}"
DEFAULT_INSTALL_USER="${SUDO_USER:-root}"
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
+9 -8
Просмотреть файл
@@ -10,11 +10,11 @@ BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
TMP_DIR="${STATE_DIR}/tmp"
# shellcheck source=scripts/update_target.sh
. "$(dirname "${BASH_SOURCE[0]}")/update_target.sh"
mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")"
MOVED_REMOTE="$(migrate_update_remote)"
resolve_update_target
REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-$TARGET_ZIP_URL}"
mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")"
now() { date '+%Y-%m-%d %H:%M:%S'; }
json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])'; }
write_state() {
@@ -58,7 +58,7 @@ fetch_zip_update() {
log "Fallback update via ZIP started"
log "ZIP URL: $REPO_ZIP_URL"
write_state "running" "GitHub git fetch не сработал, пробуем ZIP fallback"
write_state "running" "git fetch не сработал, пробуем скачать ZIP-архив"
if command -v curl >/dev/null 2>&1; then
if ! try_run curl -L --connect-timeout 20 --max-time 240 --retry 3 --retry-delay 3 -o "$zip_file" "$REPO_ZIP_URL"; then
@@ -111,7 +111,8 @@ fetch_zip_update() {
STARTED_AT="$(now)"
write_state "running" "Обновление запущено"
log "============================================================"
log "Virtuality GitHub update started"
log "Virtuality update started"
[ -n "$MOVED_REMOTE" ] && log "$MOVED_REMOTE"
log "source: $SOURCE_DIR"
log "remote: $REMOTE"
log "branch: $BRANCH"
@@ -129,7 +130,7 @@ log "before version: $BEFORE_VERSION"
GIT_UPDATED=0
if [ -d "$SOURCE_DIR/.git" ]; then
cd "$SOURCE_DIR" || exit 1
write_state "running" "Получаем изменения из GitHub через git"
write_state "running" "Получаем изменения через git"
if try_run git fetch --tags --force "$REMOTE" "$BRANCH"; then
resolve_update_target
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}"
@@ -140,7 +141,7 @@ if [ -d "$SOURCE_DIR/.git" ]; then
log "Local changes before update:"
printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE"
fi
write_state "running" "Синхронизируем исходники с GitHub через git reset"
write_state "running" "Синхронизируем исходники через git reset"
log "Deploy mode: git checkout -B ${BRANCH} ${TARGET_LABEL}"
if try_run git checkout -q -f -B "$BRANCH" "$TARGET_COMMIT" && try_run git clean -fd; then
GIT_UPDATED=1
@@ -159,7 +160,7 @@ fi
if [ "$GIT_UPDATED" != "1" ]; then
if ! fetch_zip_update; then
write_state "error" "Не удалось обновиться ни через git, ни через ZIP fallback. Проверь доступ к github.com, curl/wget/unzip и DNS."
write_state "error" "Не удалось обновиться ни через git, ни через ZIP fallback. Проверьте доступ сервера к ${REPO_ZIP_URL%%/archive/*}, curl/wget/unzip и DNS."
exit 1
fi
fi
@@ -180,6 +181,6 @@ run_step bash scripts/install_web_panel.sh
write_state "running" "Перезапускаем virtuality-web"
run_step systemctl restart virtuality-web
log "Virtuality GitHub update finished successfully"
log "Virtuality update finished successfully"
write_state "success" "Обновление завершено: ${BEFORE_VERSION} → ${AFTER_VERSION}"
exit 0
+9 -2
Просмотреть файл
@@ -35,10 +35,14 @@ JSON
}
diag_network() {
local web repo_host
web="$(repo_web_url)"
repo_host="${web#https://}"
repo_host="${repo_host%%/*}"
log "diagnostics: host=$(hostname 2>/dev/null || echo unknown)"
log "diagnostics: date=$(date -Is 2>/dev/null || date)"
if command -v getent >/dev/null 2>&1; then
getent hosts github.com >> "$LOG_FILE" 2>&1 || log "diagnostics: DNS github.com failed"
getent hosts "$repo_host" >> "$LOG_FILE" 2>&1 || log "diagnostics: DNS $repo_host failed"
fi
if command -v git >/dev/null 2>&1; then
git --version >> "$LOG_FILE" 2>&1 || true
@@ -47,7 +51,7 @@ diag_network() {
log "diagnostics: git not found"
fi
if command -v curl >/dev/null 2>&1; then
curl -I -L --connect-timeout 10 --max-time 25 https://github.com/ >> "$LOG_FILE" 2>&1 || log "diagnostics: curl github.com failed"
curl -I -L --connect-timeout 10 --max-time 25 "$web" >> "$LOG_FILE" 2>&1 || log "diagnostics: curl $repo_host failed"
else
log "diagnostics: curl not found"
fi
@@ -89,6 +93,9 @@ diag_network() {
current="unknown"
fi
moved="$(migrate_update_remote)"
[ -n "$moved" ] && log "$moved"
if [ -d "$SOURCE_DIR/.git" ]; then
if git fetch --quiet --tags --force "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
resolve_update_target
+33 -3
Просмотреть файл
@@ -4,6 +4,10 @@
# main — every change on the main branch
# Needs SOURCE_DIR, REMOTE and BRANCH; sets UPDATE_CHANNEL, TARGET_REF, TARGET_LABEL, TARGET_ZIP_URL.
# Where the project lives. Nodes installed before the move from GitHub are switched over by migrate_update_remote.
DEFAULT_REPO_URL="https://git.stackworks.ru/viktor/virtuality.git"
LEGACY_REPO_URLS=("https://github.com/viktor138irk/virtuality.git" "https://github.com/viktor138irk/virtuality")
update_channel() {
local value="${VIRTUALITY_UPDATE_CHANNEL:-}"
if [[ -z "$value" && -f /var/lib/virtuality/config/web.env ]]; then
@@ -12,18 +16,44 @@ update_channel() {
[[ "$value" == "main" ]] && echo main || echo stable
}
remote_url() {
git -C "$SOURCE_DIR" remote get-url "$REMOTE" 2>/dev/null || true
}
migrate_update_remote() {
[[ -d "${SOURCE_DIR}/.git" ]] || return 0
local url legacy
url="$(remote_url)"
for legacy in "${LEGACY_REPO_URLS[@]}"; do
if [[ "$url" == "$legacy" ]]; then
git -C "$SOURCE_DIR" remote set-url "$REMOTE" "$DEFAULT_REPO_URL" && echo "update remote moved: $url -> $DEFAULT_REPO_URL"
return 0
fi
done
}
# Web address of the repository for ZIP downloads: the remote itself when it is https, otherwise the default.
# Gitea and GitHub both serve <repo>/archive/<branch or tag>.zip.
repo_web_url() {
local url=""
[[ -d "${SOURCE_DIR}/.git" ]] && url="$(remote_url)"
[[ "$url" == https://* ]] || url="$DEFAULT_REPO_URL"
echo "${url%.git}"
}
resolve_update_target() {
local tag=""
local tag="" web
web="$(repo_web_url)"
UPDATE_CHANNEL="$(update_channel)"
TARGET_REF="${REMOTE}/${BRANCH}"
TARGET_LABEL="${BRANCH}"
TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip"
TARGET_ZIP_URL="${web}/archive/${BRANCH}.zip"
if [[ "$UPDATE_CHANNEL" == "stable" && -d "${SOURCE_DIR}/.git" ]]; then
tag="$(git -C "$SOURCE_DIR" tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+(\.[0-9]+)*$' | head -n1 || true)"
if [[ -n "$tag" ]]; then
TARGET_REF="refs/tags/${tag}"
TARGET_LABEL="$tag"
TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/tags/${tag}.zip"
TARGET_ZIP_URL="${web}/archive/${tag}.zip"
fi
fi
}
+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")
+68
Просмотреть файл
@@ -1,3 +1,5 @@
import uuid
from pathlib import Path
from urllib.parse import unquote
import pytest
@@ -186,3 +188,69 @@ 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
+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 == ""
+29 -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)
@@ -643,3 +653,20 @@ def test_late_restart_watch_is_cancelled_by_user_action(fast_worker, monkeypatch
watchers[0].join(timeout=5)
assert not watchers[0].is_alive() and ["virsh", "start", "web01"] not in virsh.calls
assert "Наблюдение снято" in core.read_operation(operation["id"])["log_tail"]
def test_late_restart_rechecks_cancel_after_shutdown(fast_worker, monkeypatch):
virsh = FakeVirsh(running=True, shuts_down=False)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 5.0)
operation = core.new_operation("backup", "Резервная копия web01", vm_name="web01")
def shut_down_by_user(vm):
backups.cancel_late_restart(vm) # «Выключить» нажали, пока наблюдатель спрашивал состояние машины
return False
monkeypatch.setattr(core, "vm_is_running", shut_down_by_user)
backups.restart_after_late_shutdown(operation, "web01")
assert ["virsh", "start", "web01"] not in virsh.calls
assert "Наблюдение снято" in core.read_operation(operation["id"])["log_tail"]
assert "web01" not in backups.LATE_WATCHES
+64
Просмотреть файл
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
import network_core
from conftest import ROOT
import restore_network
import update_core
@@ -104,6 +105,59 @@ def test_update_not_offered_when_local_is_newer(tmp_path, monkeypatch, data_dirs
assert update_core.check_updates(fetch=True)["has_update"] is True
GITEA = "https://git.stackworks.ru/viktor/virtuality"
def make_source(path):
import subprocess
def git(*args):
return subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=path, check=True, capture_output=True, text=True).stdout.strip()
path.mkdir()
git("init", "-q", "-b", "main")
git("commit", "-q", "--allow-empty", "-m", "base")
git("remote", "add", "origin", "https://github.com/viktor138irk/virtuality.git")
return git
def test_migrate_remote_moves_only_the_old_github_address(tmp_path, monkeypatch, data_dirs):
git = make_source(tmp_path / "source")
monkeypatch.setattr(update_core, "SOURCE_DIR", tmp_path / "source")
assert update_core.migrate_remote() is True
assert git("remote", "get-url", "origin") == GITEA + ".git"
assert update_core.migrate_remote() is False
git("remote", "set-url", "origin", "https://example.org/me/fork.git")
assert update_core.migrate_remote() is False
assert git("remote", "get-url", "origin") == "https://example.org/me/fork.git"
def test_update_target_script_downloads_from_gitea(tmp_path):
import os
import subprocess
source = tmp_path / "source"
git = make_source(source)
def target(channel, script="resolve_update_target"):
env = {**os.environ, "VIRTUALITY_UPDATE_CHANNEL": channel}
code = f'SOURCE_DIR="{source}"; REMOTE=origin; BRANCH=main; . "{ROOT}/scripts/update_target.sh"; {script}; echo "$TARGET_LABEL $TARGET_ZIP_URL"'
return subprocess.run(["bash", "-c", code], env=env, check=True, capture_output=True, text=True).stdout.strip().splitlines()
moved, result = target("main", "migrate_update_remote; resolve_update_target")
assert moved == f"update remote moved: https://github.com/viktor138irk/virtuality.git -> {GITEA}.git"
assert result == f"main {GITEA}/archive/main.zip"
assert git("remote", "get-url", "origin") == GITEA + ".git"
git("tag", "v0.11.1")
assert target("stable") == [f"v0.11.1 {GITEA}/archive/v0.11.1.zip"]
assert target("main") == [f"main {GITEA}/archive/main.zip"]
# A fork downloads from itself; a non-https remote falls back to the project's address.
git("remote", "set-url", "origin", "https://example.org/me/fork.git")
assert target("stable", "migrate_update_remote; resolve_update_target") == ["v0.11.1 https://example.org/me/fork/archive/v0.11.1.zip"]
git("remote", "set-url", "origin", "git@example.org:me/fork.git")
assert target("stable") == [f"v0.11.1 {GITEA}/archive/v0.11.1.zip"]
def test_stable_channel_follows_release_tags(tmp_path, monkeypatch, data_dirs):
import subprocess
@@ -140,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
+5 -1
Просмотреть файл
@@ -35,13 +35,17 @@ class Recorder:
@pytest.fixture()
def record(data_dirs, monkeypatch):
"""Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины)."""
"""Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины).
Свободное место тоже подменено: временная папка тестов бывает маленькой (tmpfs),
а тесты про нехватку места задают его сами."""
import app
recorder = Recorder()
for module in (core, app):
monkeypatch.setattr(module, "run_cmd", recorder)
monkeypatch.setattr(vmops, "_samples", {})
monkeypatch.setattr(vmops, "free_space", lambda path: 500 * GIB)
return recorder
+53
Просмотреть файл
@@ -1,5 +1,58 @@
{
"versions": [
{
"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",
"title": "Переезд обновлений на git.stackworks.ru",
"summary": "Проект переехал с GitHub на собственный сервер git.stackworks.ru, и обновления теперь приходят оттуда. Сервер, который до сих пор получал обновления с GitHub, переключится на новый адрес сам при следующей проверке. Заодно исправлена редкая ошибка резервных копий: машина могла снова запуститься, хотя её только что выключили вручную.",
"features": [
"Обновления, установка одной командой и установочный ISO берут код с git.stackworks.ru",
"Сервер со старым адресом GitHub сам переключается на новый при проверке обновлений",
"Запасное обновление архивом ZIP скачивается с того же адреса, откуда сервер получает обновления",
"Резервные копии: если машину выключили вручную, пока панель ждала её выключения, панель больше не запускает её снова",
"Версия обновлена до 0.11.1"
]
},
{
"version": "0.11.0",
"date": "2026-09-25",
+93 -39
Просмотреть файл
@@ -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}")
@@ -1257,10 +1290,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 +1339,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 +1376,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 +1384,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 +1393,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 +1411,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 +1421,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 +1434,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 +1467,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 +1477,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 +1498,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 +1550,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 +1577,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 +1658,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 +1698,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 +1743,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 +1754,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 +1791,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 +1823,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 +1846,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)):
@@ -1890,10 +1947,12 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
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]
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 +1964,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 +2014,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 +2142,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()
+17 -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
@@ -473,6 +476,9 @@ def restart_after_late_shutdown(operation: dict[str, Any], vm: str) -> None:
core.append_operation_log(operation["id"], "Наблюдение снято: машиной занялись вручную или новой задачей.")
return
if not core.vm_is_running(vm):
if cancelled.is_set(): # «Выключить» нажали, пока virsh отвечал на вопрос о состоянии
core.append_operation_log(operation["id"], "Наблюдение снято: машиной занялись вручную или новой задачей.")
return
if any(other["id"] != operation["id"] for other in core.active_operations_for(vm, OPERATION_TYPES)):
core.append_operation_log(operation["id"], "Машина выключилась, но с ней уже работает другая задача — не запускаем.")
return
@@ -552,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 — любая ошибка = операция с понятным текстом
@@ -774,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")],
@@ -838,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>
+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>
+1 -1
Просмотреть файл
@@ -41,7 +41,7 @@
</article>
<div class="alert info" data-update-running hidden>{{ icon('refresh-cw') }}<div><strong>Идёт обновление</strong><span data-update-state></span></div></div>
{% if not info.fetch_ok %}{{ alert('warning', 'Не удалось связаться с GitHub: ' ~ (info.fetch_error or 'нет ответа') ~ '. Проверьте доступ сервера в интернет.', 'Проверка не удалась') }}{% endif %}
{% if not info.fetch_ok %}{{ alert('warning', 'Не удалось связаться с сервером обновлений: ' ~ (info.fetch_error or 'нет ответа') ~ '. Проверьте доступ сервера в интернет.', 'Проверка не удалась') }}{% endif %}
<article class="card">
<div class="card-header"><div><h2>Что нового</h2><p>{{ 'Изменения, которые вы получите' if info.missing_versions else 'Последние изменения' }}</p></div></div>
+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>
+11
Просмотреть файл
@@ -14,6 +14,9 @@ DEFAULT_BRANCH = os.environ.get('VIRTUALITY_UPDATE_BRANCH', 'main')
REMOTE = os.environ.get('VIRTUALITY_UPDATE_REMOTE', 'origin')
NODE_ENV = Path(os.environ.get('VIRTUALITY_NODE_ENV', '/var/lib/virtuality/config/web.env'))
CHANNELS = {'stable': 'Стабильный — выпущенные версии', 'main': 'Ранний доступ — все изменения'}
# Проект переехал с GitHub в Gitea StackWorks; ноды со старым адресом переключаются сами (как и scripts/update_target.sh).
DEFAULT_REPO_URL = 'https://git.stackworks.ru/viktor/virtuality.git'
LEGACY_REPO_URLS = ('https://github.com/viktor138irk/virtuality.git', 'https://github.com/viktor138irk/virtuality')
class UpdateError(Exception):
@@ -193,6 +196,13 @@ def git_log_between(base: str, head: str, limit: int = 50) -> list[dict[str, str
return rows
def migrate_remote() -> bool:
result = run_cmd(['git', 'remote', 'get-url', REMOTE], cwd=SOURCE_DIR)
if not result['ok'] or result['stdout'] not in LEGACY_REPO_URLS:
return False
return run_cmd(['git', 'remote', 'set-url', REMOTE, DEFAULT_REPO_URL], cwd=SOURCE_DIR)['ok']
def check_updates(fetch: bool = True) -> dict[str, Any]:
ensure_state_dir()
if not source_ready():
@@ -200,6 +210,7 @@ def check_updates(fetch: bool = True) -> dict[str, Any]:
fetch_result = {'ok': True, 'stdout': '', 'stderr': '', 'cmd': 'skip'}
if fetch:
migrate_remote()
fetch_result = run_cmd(['git', 'fetch', '--quiet', '--tags', '--force', REMOTE, DEFAULT_BRANCH], cwd=SOURCE_DIR, timeout=60)
channel = update_channel()