Сравнить коммиты
15
Коммитов
v0.11.1
...
ca5d49266c
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
ca5d49266c | ||
|
|
ec9428c2eb | ||
|
|
f31b4ade1d | ||
|
|
2474ad3dc2 | ||
|
|
daeec06ec6 | ||
|
|
209e83044a | ||
|
|
5d4525f78d | ||
|
|
984eb85a5a | ||
|
|
48ac535c3a | ||
|
|
cc3abe3f4a | ||
|
|
2a4bffc7df | ||
|
|
af9cae85de | ||
|
|
b7c87867fb | ||
|
|
fc76f3f1fa | ||
|
|
1ad557d1ab |
+2
-2
@@ -17,8 +17,8 @@ Virtuality — простая замена Proxmox для дома и малог
|
|||||||
`finish_operation`, `active_operations_for`, `interrupt_orphaned_operations`), `render`, `require_auth`,
|
`finish_operation`, `active_operations_for`, `interrupt_orphaned_operations`), `render`, `require_auth`,
|
||||||
`valid_vm_name`, шаблоны Jinja. Импорт `core` выставляет `LC_ALL=C.UTF-8` (virsh иначе переводится).
|
`valid_vm_name`, шаблоны Jinja. Импорт `core` выставляет `LC_ALL=C.UTF-8` (virsh иначе переводится).
|
||||||
- `web/features/*.py` — модули с `router = APIRouter()`, подключаются в конце `app.py`:
|
- `web/features/*.py` — модули с `router = APIRouter()`, подключаются в конце `app.py`:
|
||||||
`setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `downloads` (скачивание
|
`setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `backup_schedule` (копии
|
||||||
по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка).
|
по расписанию; запускает их `virtuality-backup.timer`, а не панель), `downloads` (скачивание по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка).
|
||||||
POST-маршруты модулей — минимум 4 сегмента (`/vm/{name}/<модуль>/<действие>`), иначе их перехватит
|
POST-маршруты модулей — минимум 4 сегмента (`/vm/{name}/<модуль>/<действие>`), иначе их перехватит
|
||||||
`@app.post("/vm/{name}/{action}")`.
|
`@app.post("/vm/{name}/{action}")`.
|
||||||
- `web/nodectl.py` — факты об узле и действия через `scripts/virtuality-ctl` (единая точка для
|
- `web/nodectl.py` — факты об узле и действия через `scripts/virtuality-ctl` (единая точка для
|
||||||
|
|||||||
+6
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
||||||
|
|
||||||
Текущая версия: **0.11.1** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
Текущая версия: **0.12.5** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
||||||
|
|
||||||
Что умеет:
|
Что умеет:
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
sudo apt install -y xorriso git curl python3-pip
|
sudo apt install -y xorriso git curl python3-pip
|
||||||
git clone https://git.stackworks.ru/viktor/virtuality.git
|
git clone https://git.stackworks.ru/viktor/virtuality.git
|
||||||
cd virtuality
|
cd virtuality
|
||||||
make iso # dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso
|
make iso # dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
|
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
|
||||||
@@ -168,7 +168,7 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел
|
|||||||
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
|
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
|
||||||
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
|
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
|
||||||
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
|
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
|
||||||
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками.
|
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается).
|
||||||
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
||||||
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
|
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
|
||||||
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
|
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
|
||||||
@@ -177,7 +177,8 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел
|
|||||||
**Снимки и копии**
|
**Снимки и копии**
|
||||||
|
|
||||||
- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой.
|
- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой.
|
||||||
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием; копию можно делать и по расписанию из командной строки.
|
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием.
|
||||||
|
- **Копии по расписанию** — каждый день или раз в неделю в выбранное время, для всех машин или только отмеченных. Сервер хранит последние N автоматических копий каждой машины и удаляет старые только после удачной новой; ручные копии не трогает.
|
||||||
|
|
||||||
**Образы ОС**
|
**Образы ОС**
|
||||||
|
|
||||||
@@ -317,7 +318,7 @@ sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины,
|
|||||||
/var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки)
|
/var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки)
|
||||||
/var/lib/virtuality/images # диски машин (qcow2)
|
/var/lib/virtuality/images # диски машин (qcow2)
|
||||||
/var/lib/virtuality/backups # резервные копии машин и архивы настроек
|
/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/network # port_forwards.json, virtuality-nat.xml
|
||||||
/var/lib/virtuality/tmp # временные файлы загрузок и cloud-init
|
/var/lib/virtuality/tmp # временные файлы загрузок и cloud-init
|
||||||
/var/log/virtuality # firstboot.log, update.log, virtuality-ctl.log, install_*.log
|
/var/log/virtuality # firstboot.log, update.log, virtuality-ctl.log, install_*.log
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.11.1
|
0.12.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/setup.py` | Мастер первичной настройки `/setup`: шаги install → welcome → account → access → network → storage → updates → finish, страница «Применяем настройки», ожидание перестройки сети в мост. |
|
||||||
| `features/settings.py` | Страница «Настройки»: доступ (HTTPS/порт), пароль, часовой пояс, обновления, сеть машин, хранилище, перезагрузка/выключение, архив настроек, отчёт для поддержки. |
|
| `features/settings.py` | Страница «Настройки»: доступ (HTTPS/порт), пароль, часовой пояс, обновления, сеть машин, хранилище, перезагрузка/выключение, архив настроек, отчёт для поддержки. |
|
||||||
| `features/snapshots.py` | Снимки: внутренние снимки libvirt (`virsh snapshot-*`), до 8 на машину, создание и возврат — фоновые задачи. |
|
| `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/downloads.py` | Скачивание ISO и образов по ссылке и из каталога — фоновые задачи типа `download` с прогрессом, проверкой контрольной суммы и отменой. |
|
||||||
| `features/vmops.py` | Увеличение диска, клонирование (`virt-clone`), пауза, спящий режим (`virsh managedsave`), заметки (title/description машины), живая нагрузка (`virsh domstats`). |
|
| `features/vmops.py` | Увеличение диска, клонирование (`virt-clone`), пауза, спящий режим (`virsh managedsave`), заметки (title/description машины), живая нагрузка (`virsh domstats`). |
|
||||||
| `nodectl.py` | Факты о ноде (сеть, диски, оборудование, часовые пояса, состояние первой загрузки, выбор мастера) и действия через `virtuality-ctl`. Действия, которые перезапускают панель, запускаются отложенно через `systemd-run`. |
|
| `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-redirect.service` | Только при TLS: HTTP → HTTPS. |
|
||||||
| `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). |
|
| `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). |
|
||||||
| `virtuality-auto-update.timer` / `.service` | Ночная проверка обновлений (`scripts/auto_update_check.sh`), 04:00 ± 30 минут; включён при `VIRTUALITY_AUTO_UPDATE=1`. |
|
| `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`. |
|
| `virtuality-maintenance.timer` / `.service` | Ежедневное обслуживание: старые задачи, временные файлы, недокачанные загрузки, `journalctl --vacuum-size=300M`, запись занятости диска в `config/health.json`. |
|
||||||
| `libvirt-guests.service` | При выключении сервера машины выключаются корректно (`ON_SHUTDOWN=shutdown`, до 180 секунд), при загрузке не запускаются сами — только те, у кого включён автозапуск. |
|
| `libvirt-guests.service` | При выключении сервера машины выключаются корректно (`ON_SHUTDOWN=shutdown`, до 180 секунд), при загрузке не запускаются сами — только те, у кого включён автозапуск. |
|
||||||
|
|
||||||
@@ -120,7 +122,7 @@ libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`,
|
|||||||
├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images
|
├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images
|
||||||
├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера)
|
├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера)
|
||||||
├── backups/ резервные копии машин (<машина>/<ГГГГММДД-ЧЧММ>/) и архивы настроек config-*.tar.gz
|
├── 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/
|
│ session_secret, ports.state, health.json, tls/, netplan-backup/
|
||||||
├── network/ port_forwards.json, ufw_rules.json, virtuality-nat.xml
|
├── network/ port_forwards.json, ufw_rules.json, virtuality-nat.xml
|
||||||
├── update/ state.json, last_check.json, временные файлы обновления
|
├── update/ state.json, last_check.json, временные файлы обновления
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
|
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
|
||||||
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
|
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
|
||||||
3. После установки на странице машины откройте вкладку **Диски и загрузка** → **Привод CD/DVD** и извлеките установочный образ, чтобы машина больше не предлагала загрузку с него.
|
3. После установки машина перезагрузится уже с диска: для машин из ISO панель ставит диск первым в порядке загрузки, пока он пустой, машина загружается с образа. Сам образ можно извлечь во вкладке **Диски и загрузка** → **Привод CD/DVD**.
|
||||||
|
|
||||||
Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**.
|
Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**.
|
||||||
|
|
||||||
@@ -121,7 +121,9 @@
|
|||||||
3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз.
|
3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз.
|
||||||
4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`.
|
4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`.
|
||||||
|
|
||||||
Копию можно делать и по расписанию из терминала сервера — например, задачей cron:
|
Копии можно делать и автоматически: **Резервные копии** → **Настроить** в карточке «Автоматические копии». Выберите, как часто (каждый день или раз в неделю), время, машины и сколько копий хранить. Сервер будет сам копировать машины в это время и удалять старые автоматические копии, а ручные оставит.
|
||||||
|
|
||||||
|
Одну копию можно сделать и из терминала сервера:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
|
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ make iso ARCH=arm64 # ARM64-серверы с UEFI
|
|||||||
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
|
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso
|
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
|
||||||
dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso.sha256
|
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
|
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
|
||||||
@@ -109,7 +109,7 @@ make iso ISO_ARGS="--unattended --username sysadmin --password-hash '$(openssl p
|
|||||||
|
|
||||||
## Что спрашивает установщик
|
## Что спрашивает установщик
|
||||||
|
|
||||||
Меню загрузки: **Установить Virtuality 0.11.1** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
|
Меню загрузки: **Установить Virtuality 0.12.5** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
|
||||||
|
|
||||||
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
|
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
|
||||||
|
|
||||||
@@ -161,14 +161,14 @@ cat /var/log/virtuality/firstboot.log
|
|||||||
Сверьте контрольную сумму скачанного или собранного образа:
|
Сверьте контрольную сумму скачанного или собранного образа:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum -c virtuality-0.11.1-ubuntu-26.04-amd64.iso.sha256
|
sha256sum -c virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xorriso -indev virtuality-0.11.1-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
xorriso -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
||||||
xorriso -osirrox on -indev virtuality-0.11.1-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
xorriso -osirrox on -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -182,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.11.1-ubuntu-26.04-amd64.iso -extract /au
|
|||||||
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
|
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo dd if=dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
sudo dd if=dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
||||||
```
|
```
|
||||||
|
|
||||||
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ Virtuality — панель для одного домашнего сервер
|
|||||||
- HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек.
|
- HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек.
|
||||||
- Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты.
|
- Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты.
|
||||||
|
|
||||||
|
## Сделано в 0.12
|
||||||
|
|
||||||
|
- Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранение последних N автокопий. Копии делает отдельная служба сервера, перезапуск панели их не прерывает.
|
||||||
|
|
||||||
## Планируется
|
## Планируется
|
||||||
|
|
||||||
- **Резервные копии по расписанию из панели** — сейчас копия по расписанию делается только командой в терминале; нужен график и хранение N последних копий.
|
|
||||||
- **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`.
|
- **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`.
|
||||||
- **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox.
|
- **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox.
|
||||||
- **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину.
|
- **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину.
|
||||||
|
|||||||
Двоичные данные
Двоичный файл не отображается.
|
До Ширина: | Высота: | Размер: 146 KiB После Ширина: | Высота: | Размер: 183 KiB |
@@ -235,6 +235,38 @@ RandomizedDelaySec=1h
|
|||||||
Persistent=true
|
Persistent=true
|
||||||
Unit=virtuality-maintenance.service
|
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]
|
[Install]
|
||||||
WantedBy=timers.target
|
WantedBy=timers.target
|
||||||
EOF
|
EOF
|
||||||
@@ -265,7 +297,7 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
systemctl daemon-reload
|
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
|
systemctl enable libvirt-guests.service >/dev/null 2>&1 || true
|
||||||
if [[ "$tls" == "1" ]]; then
|
if [[ "$tls" == "1" ]]; then
|
||||||
systemctl enable virtuality-redirect.service >/dev/null 2>&1 || true
|
systemctl enable virtuality-redirect.service >/dev/null 2>&1 || true
|
||||||
@@ -305,7 +337,7 @@ restart_panel() {
|
|||||||
if [[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]]; then
|
if [[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]]; then
|
||||||
systemctl restart virtuality-redirect.service || true
|
systemctl restart virtuality-redirect.service || true
|
||||||
fi
|
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
|
if [[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]]; then
|
||||||
systemctl enable --now virtuality-auto-update.timer >/dev/null 2>&1 || true
|
systemctl enable --now virtuality-auto-update.timer >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
@@ -689,7 +721,7 @@ cmd_uninstall() {
|
|||||||
[[ "$answer" == "YES" ]] || { echo "Отменено."; exit 0; }
|
[[ "$answer" == "YES" ]] || { echo "Отменено."; exit 0; }
|
||||||
fi
|
fi
|
||||||
local unit
|
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
|
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||||
rm -f "${UNIT_DIR}/${unit}"
|
rm -f "${UNIT_DIR}/${unit}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ def data_dirs(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
|
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
|
||||||
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
||||||
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
|
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()):
|
for module in (app, core, network_core, host_profile, *feature_modules()):
|
||||||
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
||||||
return dirs
|
return dirs
|
||||||
|
|||||||
@@ -106,11 +106,11 @@ def demo_run_cmd(cmd, timeout=12, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def seed_backups(backups_dir: Path) -> None:
|
def seed_backups(backups_dir: Path) -> None:
|
||||||
"""Несколько копий, как после пары недель работы: web01 — три, db01 — одна."""
|
"""Несколько копий, как после пары недель работы: web01 — три (две по расписанию), db01 — одна."""
|
||||||
vm_xml = conftest.DUMPXML_MIGRATABLE
|
vm_xml = conftest.DUMPXML_MIGRATABLE
|
||||||
demo = [
|
demo = [
|
||||||
("web01", "20260924-0300", 2_684_354_560, "", [("vda", 6_443_499_520)]),
|
("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", "20260917-0300", 2_598_000_000, "По расписанию", [("vda", 6_300_000_000)]),
|
||||||
("web01", "20260912-1842", 2_412_000_000, "Перед обновлением сайта", [("vda", 6_100_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)]),
|
("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:
|
for target, _ in disks:
|
||||||
with (path / f"{target}.qcow2").open("wb") as handle:
|
with (path / f"{target}.qcow2").open("wb") as handle:
|
||||||
handle.truncate(1024 * 1024)
|
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))
|
(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.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"]
|
core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"]
|
||||||
seed_backups(dirs["backups"])
|
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: # диски машин лежат в хранилище, иначе форма увеличения не появится
|
for name in DEMO_VMS: # диски машин лежат в хранилище, иначе форма увеличения не появится
|
||||||
with (dirs["images"] / f"{name}.qcow2").open("wb") as handle:
|
with (dirs["images"] / f"{name}.qcow2").open("wb") as handle:
|
||||||
handle.truncate(4 * 1024 * 1024)
|
handle.truncate(4 * 1024 * 1024)
|
||||||
@@ -217,5 +222,5 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-")))
|
seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-")))
|
||||||
# The demo operations are seeded as "running": keep them instead of closing them as orphaned on startup.
|
# 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")
|
uvicorn.run(app.app, host="127.0.0.1", port=int(sys.argv[1]) if len(sys.argv) > 1 else 8765, log_level="warning")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -140,7 +142,10 @@ def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch):
|
|||||||
assert cmd[0] == "virt-install"
|
assert cmd[0] == "virt-install"
|
||||||
assert "--cdrom" in cmd and str(iso) in cmd
|
assert "--cdrom" in cmd and str(iso) in cmd
|
||||||
assert any(part.startswith("network=virtuality-nat,model=virtio,mac=52:54:00:") for part in cmd)
|
assert any(part.startswith("network=virtuality-nat,model=virtio,mac=52:54:00:") for part in cmd)
|
||||||
assert captured["operation"]["boot_order"] == "cdrom_disk"
|
assert captured["operation"]["boot_order"] == "disk_cdrom"
|
||||||
|
# После установки гость перезагружается с диска: без особого режима установки и с диском первым в очереди.
|
||||||
|
assert cmd[cmd.index("--boot") + 1] == "hd,cdrom"
|
||||||
|
assert cmd[cmd.index("--install") + 1] == "no_install=yes"
|
||||||
|
|
||||||
|
|
||||||
def test_vm_create_validation_error(logged_in):
|
def test_vm_create_validation_error(logged_in):
|
||||||
@@ -186,3 +191,86 @@ def test_live_status(logged_in):
|
|||||||
data = logged_in.get("/live/status").json()
|
data = logged_in.get("/live/status").json()
|
||||||
assert data["ok"] is True
|
assert data["ok"] is True
|
||||||
assert {vm["name"] for vm in data["vms"]} == {"web01", "db01"}
|
assert {vm["name"] for vm in data["vms"]} == {"web01", "db01"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploader_collects_file_before_disabling_input():
|
||||||
|
"""Заблокированное поле браузер не отправляет: загрузка уходила пустой (44 байта) и получала 422."""
|
||||||
|
script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text()
|
||||||
|
uploader = script[script.index("function wireUploader"):script.index("/* ---------------------------------------------------- operation page */")]
|
||||||
|
assert "input.disabled = active" in uploader
|
||||||
|
assert uploader.index("new FormData(form)") < uploader.index("setActive(true)")
|
||||||
|
assert "xhr.send(data)" in uploader
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_create_disk_bigger_than_free_space_needs_confirmation(logged_in, data_dirs, monkeypatch):
|
||||||
|
"""virt-install отказывал с английской ошибкой; теперь панель предупреждает сама и создаёт только с подтверждением."""
|
||||||
|
iso = data_dirs["iso"] / "smk.iso"
|
||||||
|
iso.write_bytes(b"iso")
|
||||||
|
started = []
|
||||||
|
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: started.append(cmd))
|
||||||
|
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
|
||||||
|
monkeypatch.setattr(app, "images_free_bytes", lambda: 25 * 1024 ** 3)
|
||||||
|
form = {"name": "smk", "memory": 4096, "vcpus": 2, "disk_size": 40, "iso_path": str(iso), "source_type": "iso", "network_mode": "nat"}
|
||||||
|
|
||||||
|
page = logged_in.get("/vm/create").text
|
||||||
|
assert "Свободно на сервере: 25" in page and 'name="allow_overcommit"' not in page
|
||||||
|
response = logged_in.post("/vm/create", data=form)
|
||||||
|
assert response.status_code == 400 and started == []
|
||||||
|
assert "Диск на 40 ГБ больше, чем свободно на сервере" in response.text and 'name="allow_overcommit"' in response.text
|
||||||
|
|
||||||
|
response = logged_in.post("/vm/create", data={**form, "allow_overcommit": "1"}, follow_redirects=False)
|
||||||
|
assert response.status_code == 303, response.text[:300]
|
||||||
|
assert started[0][-2:] == ["--check", "disk_size=off"]
|
||||||
|
|
||||||
|
response = logged_in.post("/vm/create", data={**form, "name": "small", "disk_size": 20}, follow_redirects=False)
|
||||||
|
assert response.status_code == 303 and "--check" not in started[1]
|
||||||
|
|
||||||
|
|
||||||
|
def failed_create(data_dirs, name, disk=True, **extra):
|
||||||
|
"""Операция создания машины, у которой virt-install завершился с ошибкой."""
|
||||||
|
disk_path = data_dirs["images"] / f"{name}.qcow2"
|
||||||
|
if disk:
|
||||||
|
disk_path.write_bytes(b"QFI\xfb")
|
||||||
|
operation = {"id": str(uuid.uuid4()), "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "", "created_at": app.utc_now(), "updated_at": app.utc_now(), "vm_name": name, "disk_path": str(disk_path), "network_mode": "bridge", **extra}
|
||||||
|
app.write_operation(operation)
|
||||||
|
app.run_operation_worker(operation["id"], ["bash", "-c", "echo 'ERROR The requested volume capacity will exceed the available pool space'; exit 1"])
|
||||||
|
return app.read_operation(operation["id"]), disk_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_vm_create_cleans_up_and_offers_retry(logged_in, data_dirs):
|
||||||
|
"""Страница упавшей задачи предлагала «Открыть экран машины», хотя машины нет; недосозданный диск мешал повтору."""
|
||||||
|
operation, disk_path = failed_create(data_dirs, "smk", iso_path=str(data_dirs["iso"] / "smk-server-_1.iso"))
|
||||||
|
assert operation["status"] == "error" and "ничего не осталось" in operation["message"]
|
||||||
|
assert not disk_path.exists() and "Недосозданный диск smk.qcow2 удалён" in operation["log_tail"]
|
||||||
|
html = logged_in.get(f"/operations/{operation['id']}").text
|
||||||
|
assert 'data-op-done="success" hidden' in html
|
||||||
|
assert 'href="/vm/create?iso=smk-server-_1.iso"' in html and "Создать заново" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_create_of_existing_vm_keeps_its_disk(logged_in, data_dirs):
|
||||||
|
operation, disk_path = failed_create(data_dirs, "web01") # машина определена в libvirt: её диск не трогаем
|
||||||
|
assert disk_path.exists() and "ничего не осталось" not in operation["message"]
|
||||||
|
html = logged_in.get(f"/operations/{operation['id']}").text
|
||||||
|
assert "Создать заново" not in html and 'href="/vm/web01">Перейти к машине' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_page_reveals_only_buttons_for_its_result():
|
||||||
|
script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text()
|
||||||
|
assert '$$(`[data-op-done="${status}"]`)' in script and "$$('[data-op-done]')" not in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_console_websocket_path_works_with_old_and_new_novnc(tmp_path, monkeypatch):
|
||||||
|
"""noVNC 1.6 (Ubuntu 26.04) считает path от /novnc/vnc.html: без "../" экран стучался в /novnc/console/ws/… и не подключался."""
|
||||||
|
import posixpath
|
||||||
|
from urllib.parse import parse_qs, urljoin, urlsplit
|
||||||
|
|
||||||
|
(tmp_path / "vnc.html").write_text("<html></html>")
|
||||||
|
monkeypatch.setattr(app, "NOVNC_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(app, "vm_vnc_display", lambda name: "127.0.0.1:0")
|
||||||
|
info = app.console_info("web01")
|
||||||
|
page = "https://panel.test:8443" + info["url"]
|
||||||
|
path = parse_qs(urlsplit(page).query)["path"][0]
|
||||||
|
token = path.rsplit("/", 1)[1]
|
||||||
|
assert app.console_serializer.loads(token) == {"vm": "web01", "port": 5900}
|
||||||
|
assert urlsplit(urljoin(page, path)).path == f"/console/ws/{token}" # noVNC 1.6: new URL(path, location.href)
|
||||||
|
assert posixpath.normpath("/" + path) == f"/console/ws/{token}" # noVNC 1.3: "wss://host:port/" + path
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Regressions for the defects found by the 0.11 audit."""
|
"""Regressions for the defects found by the 0.11 audit."""
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
import app
|
import app
|
||||||
import conftest
|
import conftest
|
||||||
@@ -69,6 +69,46 @@ def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch):
|
|||||||
assert response.headers["location"].startswith("/vm/web01?error=")
|
assert response.headers["location"].startswith("/vm/web01?error=")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_delete_stops_paused_machine_first(logged_in, data_dirs, monkeypatch):
|
||||||
|
import core
|
||||||
|
|
||||||
|
calls = recording(monkeypatch, app)
|
||||||
|
recording(monkeypatch, core, [(["virsh", "domstate"], conftest.fake_result("paused"))])
|
||||||
|
logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||||
|
assert find(calls, "virsh", "destroy", "web01") and find(calls, "virsh", "undefine", "web01")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_delete_waits_for_running_task(logged_in, data_dirs, monkeypatch):
|
||||||
|
import core
|
||||||
|
|
||||||
|
calls = recording(monkeypatch, app)
|
||||||
|
backup = core.new_operation("backup", "Резервная копия web01", vm_name="web01")
|
||||||
|
core.update_operation(backup, status="running")
|
||||||
|
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||||
|
assert "Резервная копия web01" in unquote(response.headers["location"])
|
||||||
|
assert not find(calls, "virsh", "undefine") and not find(calls, "virsh", "destroy")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_delete_removes_its_port_forwards(logged_in, data_dirs, monkeypatch):
|
||||||
|
recording(monkeypatch, app)
|
||||||
|
rule = {"guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp"}
|
||||||
|
data_dirs["network"].joinpath("port_forwards.json").write_text(json.dumps([
|
||||||
|
{"id": "1", "vm_name": "web01", **rule},
|
||||||
|
{"id": "2", "vm_name": "db01", **rule, "external_port_start": 2223, "external_port_end": 2223, "guest_ip": "192.168.100.52"},
|
||||||
|
]))
|
||||||
|
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||||
|
assert "убраны пробросы портов: 1" in unquote(response.headers["location"])
|
||||||
|
assert [item["vm_name"] for item in network_core.load_port_forwards()] == ["db01"]
|
||||||
|
assert "2222" not in (data_dirs["nft"] / "virtuality.nft").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_is_offered_for_running_machine(logged_in):
|
||||||
|
html = logged_in.get("/vm/web01").text # в тестах web01 работает
|
||||||
|
assert 'action="/vm/web01/delete"' in html and "она будет сразу выключена" in html
|
||||||
|
dashboard = logged_in.get("/").text
|
||||||
|
assert 'action="/vm/web01/delete"' in dashboard and 'action="/vm/db01/delete"' in dashboard
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158)
|
# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158)
|
||||||
def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch):
|
def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch):
|
||||||
iso = data_dirs["iso"] / "debian.iso"
|
iso = data_dirs["iso"] / "debian.iso"
|
||||||
@@ -139,6 +179,42 @@ def test_orphaned_operations_are_closed_on_startup(data_dirs):
|
|||||||
assert core.active_operations_for("web01") == []
|
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():
|
def test_snapshot_names_with_spaces_are_listed():
|
||||||
from features import snapshots
|
from features import snapshots
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Резервные копии по расписанию: когда запускать, какие машины копировать, сколько хранить, форма и страницы."""
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import core
|
||||||
|
from features import backup_schedule as bs
|
||||||
|
from features import backups
|
||||||
|
from test_backups import make_backup
|
||||||
|
|
||||||
|
|
||||||
|
def schedule(**changes):
|
||||||
|
return bs.clean_schedule({**bs.DEFAULT, "enabled": True, **changes})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- настройка и время
|
||||||
|
def test_clean_schedule_replaces_broken_values():
|
||||||
|
assert bs.clean_schedule({}) == bs.DEFAULT
|
||||||
|
broken = {"enabled": "yes", "frequency": "hourly", "weekday": True, "time": "25:00", "vms": ["web01", "bad name", 5, "web01"], "keep": 0}
|
||||||
|
assert bs.clean_schedule(broken) == {**bs.DEFAULT, "vms": ["web01"]}
|
||||||
|
assert bs.clean_schedule({"all_vms": False, "keep": 61})["all_vms"] is False
|
||||||
|
assert bs.clean_schedule({"keep": 61})["keep"] == bs.DEFAULT["keep"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_slots_daily():
|
||||||
|
daily = schedule(time="03:00")
|
||||||
|
assert bs.last_slot(daily, datetime(2026, 9, 27, 2, 59)) == datetime(2026, 9, 26, 3, 0)
|
||||||
|
assert bs.last_slot(daily, datetime(2026, 9, 27, 3, 0)) == datetime(2026, 9, 27, 3, 0)
|
||||||
|
assert bs.next_slot(daily, datetime(2026, 9, 27, 3, 0)) == datetime(2026, 9, 28, 3, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slots_weekly():
|
||||||
|
sunday = schedule(frequency="weekly", weekday=6, time="04:30") # 27.09.2026 — воскресенье
|
||||||
|
assert bs.last_slot(sunday, datetime(2026, 9, 30, 12, 0)) == datetime(2026, 9, 27, 4, 30)
|
||||||
|
assert bs.last_slot(sunday, datetime(2026, 9, 27, 4, 0)) == datetime(2026, 9, 20, 4, 30)
|
||||||
|
assert bs.next_slot(sunday, datetime(2026, 9, 27, 4, 0)) == datetime(2026, 9, 27, 4, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_runs_once_and_does_not_catch_up_hours_later():
|
||||||
|
daily = schedule(time="03:00")
|
||||||
|
assert bs.due(daily, {}, datetime(2026, 9, 27, 3, 10)) == ("run", datetime(2026, 9, 27, 3, 0))
|
||||||
|
assert bs.due(daily, {"slot": "2026-09-27 03:00"}, datetime(2026, 9, 27, 3, 25)) is None
|
||||||
|
assert bs.due(daily, {}, datetime(2026, 9, 27, 5, 0)) == ("run", datetime(2026, 9, 27, 3, 0))
|
||||||
|
assert bs.due(daily, {}, datetime(2026, 9, 27, 5, 1)) == ("missed", datetime(2026, 9, 27, 3, 0))
|
||||||
|
assert bs.due({**daily, "enabled": False}, {}, datetime(2026, 9, 27, 3, 10)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- запуск
|
||||||
|
@pytest.fixture()
|
||||||
|
def fake_copies(data_dirs, monkeypatch):
|
||||||
|
"""create_backup сразу кладёт готовую копию; для машин из failing — отказ с текстом."""
|
||||||
|
calls = []
|
||||||
|
failing = {}
|
||||||
|
minutes = iter(range(1, 60))
|
||||||
|
|
||||||
|
def create(vm, note="", wait=False, auto=False):
|
||||||
|
calls.append((vm, note, wait, auto))
|
||||||
|
if vm in failing:
|
||||||
|
raise backups.BackupError(failing[vm])
|
||||||
|
backup_id = f"20260927-03{next(minutes):02d}"
|
||||||
|
make_backup(data_dirs, vm=vm, backup_id=backup_id, note=note, auto=auto)
|
||||||
|
return {"id": f"op-{backup_id}", "status": "success", "message": "Резервная копия создана: 2.5 ГБ"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(backups, "create_backup", create)
|
||||||
|
monkeypatch.setattr(backups, "existing_vm_names", lambda: {"web01", "db01", "test"})
|
||||||
|
create.calls, create.failing = calls, failing
|
||||||
|
return create
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_copies_every_machine_and_keeps_last_n_auto_copies(data_dirs, fake_copies):
|
||||||
|
bs.write_json(bs.schedule_path(), schedule(time="03:00", keep=2))
|
||||||
|
for backup_id in ("20260920-0300", "20260921-0300", "20260922-0300"):
|
||||||
|
make_backup(data_dirs, vm="web01", backup_id=backup_id, auto=True)
|
||||||
|
make_backup(data_dirs, vm="web01", backup_id="20260901-1200", note="перед обновлением")
|
||||||
|
|
||||||
|
state = bs.run_due(datetime(2026, 9, 27, 3, 5))
|
||||||
|
assert [call[0] for call in fake_copies.calls] == ["db01", "test", "web01"]
|
||||||
|
assert {call[1:] for call in fake_copies.calls} == {("По расписанию", True, True)}
|
||||||
|
assert state["status"] == "done" and state["slot"] == "2026-09-27 03:00" and all(item["ok"] for item in state["results"])
|
||||||
|
web01 = state["results"][-1]
|
||||||
|
assert web01["removed"] == ["20260921-0300", "20260920-0300"]
|
||||||
|
# Ручная копия осталась, из автоматических — две последние.
|
||||||
|
assert [item["id"] for item in backups.list_backups("web01")] == ["20260927-0303", "20260922-0300", "20260901-1200"]
|
||||||
|
assert bs.read_json(bs.state_path()) == state
|
||||||
|
|
||||||
|
assert bs.run_due(datetime(2026, 9, 27, 3, 20)) is None # этот момент уже отработан
|
||||||
|
assert len(fake_copies.calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_copy_keeps_old_copies_and_others_continue(data_dirs, fake_copies):
|
||||||
|
bs.write_json(bs.schedule_path(), schedule(all_vms=False, vms=["web01", "db01", "gone"], keep=1))
|
||||||
|
make_backup(data_dirs, vm="web01", backup_id="20260920-0300", auto=True)
|
||||||
|
make_backup(data_dirs, vm="web01", backup_id="20260921-0300", auto=True)
|
||||||
|
fake_copies.failing["web01"] = "Недостаточно места для копии"
|
||||||
|
|
||||||
|
state = bs.run_due(datetime(2026, 9, 27, 3, 5))
|
||||||
|
assert [call[0] for call in fake_copies.calls] == ["db01", "web01"] # машины gone на сервере нет
|
||||||
|
results = {item["vm"]: item for item in state["results"]}
|
||||||
|
assert results["db01"]["ok"] and not results["web01"]["ok"] and "места" in results["web01"]["message"]
|
||||||
|
assert len(backups.list_backups("web01")) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_missed_slot_is_recorded_not_copied(data_dirs, fake_copies):
|
||||||
|
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
||||||
|
assert bs.run_due(datetime(2026, 9, 27, 9, 0)) is None
|
||||||
|
assert fake_copies.calls == []
|
||||||
|
state = bs.read_json(bs.state_path())
|
||||||
|
assert state["slot"] == state["missed"] == "2026-09-27 03:00"
|
||||||
|
assert bs.overview(datetime(2026, 9, 27, 9, 0))["missed"] == "27 сентября, 03:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_waits_for_previous_run(data_dirs, fake_copies):
|
||||||
|
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
||||||
|
with bs.lock_path().open("w") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
assert bs.run_due(datetime(2026, 9, 27, 3, 5)) is None
|
||||||
|
assert fake_copies.calls == []
|
||||||
|
assert bs.run_due(datetime(2026, 9, 27, 3, 20))["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_leaves_copy_that_is_being_restored(data_dirs):
|
||||||
|
make_backup(data_dirs, vm="web01", backup_id="20260920-0300", auto=True)
|
||||||
|
make_backup(data_dirs, vm="web01", backup_id="20260921-0300", auto=True)
|
||||||
|
core.new_operation("restore", "Восстановление web01", vm_name="web01-copy", source_vm="web01", backup_id="20260920-0300")
|
||||||
|
assert bs.prune("web01", 1) == []
|
||||||
|
assert len(backups.list_backups("web01")) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- форма и страницы
|
||||||
|
FORM = {"enabled": "1", "frequency": "daily", "weekday": "6", "time": "03:00", "machines": "all", "keep": "7"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_form_saves_and_does_not_start_right_away(logged_in, data_dirs, monkeypatch):
|
||||||
|
monkeypatch.setattr(backups, "existing_vm_names", lambda: {"web01", "db01"})
|
||||||
|
page = logged_in.get("/backups/schedule")
|
||||||
|
assert page.status_code == 200 and "Расписание копий" in page.text and 'value="db01"' in page.text
|
||||||
|
|
||||||
|
data = {**FORM, "frequency": "weekly", "time": "04:30", "machines": "chosen", "vms": ["web01"], "keep": "4"}
|
||||||
|
response = logged_in.post("/backups/schedule", data=data, follow_redirects=False)
|
||||||
|
assert response.status_code == 303 and "по воскресеньям в 04:30" in unquote(response.headers["location"])
|
||||||
|
saved = bs.load_schedule()
|
||||||
|
assert saved == {"enabled": True, "frequency": "weekly", "weekday": 6, "time": "04:30", "all_vms": False, "vms": ["web01"], "keep": 4}
|
||||||
|
assert bs.due(saved, bs.read_json(bs.state_path()), datetime.now()) is None # прошедший момент не догоняется
|
||||||
|
|
||||||
|
response = logged_in.post("/backups/schedule", data={**FORM, "enabled": ""}, follow_redirects=False)
|
||||||
|
assert "выключены" in unquote(response.headers["location"]) and bs.load_schedule()["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_same_time_keeps_pending_copy(logged_in, data_dirs):
|
||||||
|
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
||||||
|
bs.write_json(bs.state_path(), {"slot": "2026-09-20 03:00"})
|
||||||
|
logged_in.post("/backups/schedule", data={**FORM, "keep": "3"}, follow_redirects=False)
|
||||||
|
assert bs.load_schedule()["keep"] == 3
|
||||||
|
assert bs.read_json(bs.state_path())["slot"] == "2026-09-20 03:00"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("changes, error", [
|
||||||
|
({"time": "3 часа"}, "ЧЧ:ММ"),
|
||||||
|
({"keep": "0"}, "от 1 до 60"),
|
||||||
|
({"keep": "много"}, "от 1 до 60"),
|
||||||
|
({"machines": "chosen"}, "хотя бы одну машину"),
|
||||||
|
])
|
||||||
|
def test_schedule_form_errors(logged_in, data_dirs, changes, error):
|
||||||
|
response = logged_in.post("/backups/schedule", data={**FORM, **changes})
|
||||||
|
assert response.status_code == 400 and error in response.text
|
||||||
|
assert not bs.schedule_path().exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_requires_login(client):
|
||||||
|
assert client.get("/backups/schedule", follow_redirects=False).status_code in (302, 303)
|
||||||
|
assert client.post("/backups/schedule", data=FORM, follow_redirects=False).status_code in (302, 303, 403)
|
||||||
|
assert not bs.schedule_path().exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pages_show_schedule_and_last_run(logged_in, data_dirs):
|
||||||
|
page = logged_in.get("/backups")
|
||||||
|
assert "Автоматические копии" in page.text and "Настроить" in page.text
|
||||||
|
|
||||||
|
bs.write_json(bs.schedule_path(), schedule(time="03:00", keep=5))
|
||||||
|
results = [{"vm": "web01", "ok": True}, {"vm": "db01", "ok": False, "message": "Недостаточно места для копии"}]
|
||||||
|
bs.write_json(bs.state_path(), {"slot": "2026-09-27 03:00", "status": "done", "started_at": "2026-09-27 03:00:05", "results": results})
|
||||||
|
page = logged_in.get("/backups")
|
||||||
|
assert "Каждый день в 03:00" in page.text and "хранить 5 последних копий" in page.text
|
||||||
|
assert "скопировано 1 из 2 машин" in page.text and "Недостаточно места для копии" in page.text
|
||||||
|
assert "Машина копируется каждый день в 03:00" in logged_in.get("/vm/web01/backups").text
|
||||||
|
|
||||||
|
bs.write_json(bs.schedule_path(), schedule(all_vms=False, vms=["db01"]))
|
||||||
|
assert "не копируется автоматически" in logged_in.get("/vm/web01/backups").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_prints_summary(data_dirs, fake_copies, monkeypatch, capsys):
|
||||||
|
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
||||||
|
fake_copies.failing["test"] = "Машина не выключилась"
|
||||||
|
|
||||||
|
class Night(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return cls(2026, 9, 27, 3, 5)
|
||||||
|
|
||||||
|
monkeypatch.setattr(bs, "datetime", Night)
|
||||||
|
bs.main()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "2 из 3 успешно" in out and "test: Машина не выключилась" in out
|
||||||
|
assert json.loads(bs.state_path().read_text())["status"] == "done"
|
||||||
|
bs.main() # второй запуск в тот же момент молчит
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
@@ -12,14 +12,14 @@ from conftest import DOMBLKLIST_DETAILS, DUMPXML_MIGRATABLE, QEMU_IMG_INFO, fake
|
|||||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
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 = dirs["backups"] / vm / backup_id
|
||||||
path.mkdir(parents=True)
|
path.mkdir(parents=True)
|
||||||
(path / "vm.xml").write_text(DUMPXML_MIGRATABLE)
|
(path / "vm.xml").write_text(DUMPXML_MIGRATABLE)
|
||||||
for target in disks:
|
for target in disks:
|
||||||
if complete:
|
if complete:
|
||||||
(path / f"{target}.qcow2").write_bytes(b"QFI\xfb" + b"\0" * 60)
|
(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))
|
(path / "meta.json").write_text(json.dumps(meta))
|
||||||
return path
|
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"
|
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):
|
def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch):
|
||||||
virsh = FakeVirsh(running=True, shuts_down=False)
|
virsh = FakeVirsh(running=True, shuts_down=False)
|
||||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ def test_windows11_gets_uefi_tpm_sata_and_e1000e(logged_in, data_dirs, monkeypat
|
|||||||
assert response.status_code == 303
|
assert response.status_code == 303
|
||||||
cmd = captured["cmd"]
|
cmd = captured["cmd"]
|
||||||
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
|
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
|
||||||
assert cmd[cmd.index("--boot") + 1] == "uefi,cdrom,hd"
|
assert cmd[cmd.index("--boot") + 1] == "uefi,hd,cdrom"
|
||||||
assert "--tpm" in cmd and "backend.version=2.0" in cmd[cmd.index("--tpm") + 1]
|
assert "--tpm" in cmd and "backend.version=2.0" in cmd[cmd.index("--tpm") + 1]
|
||||||
assert "bus=sata" in cmd[cmd.index("--disk") + 1] and "model=e1000e" in cmd[cmd.index("--network") + 1]
|
assert "bus=sata" in cmd[cmd.index("--disk") + 1] and "model=e1000e" in cmd[cmd.index("--network") + 1]
|
||||||
assert "--channel" not in cmd # Windows has no guest agent without extra drivers
|
assert "--channel" not in cmd # Windows has no guest agent without extra drivers
|
||||||
@@ -129,7 +129,7 @@ def test_linux_installer_detects_os_and_uses_virtio(logged_in, data_dirs, monkey
|
|||||||
iso.write_bytes(b"iso")
|
iso.write_bytes(b"iso")
|
||||||
response, captured = create(logged_in, monkeypatch, iso_form(iso))
|
response, captured = create(logged_in, monkeypatch, iso_form(iso))
|
||||||
cmd = captured["cmd"]
|
cmd = captured["cmd"]
|
||||||
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "cdrom,hd"
|
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "hd,cdrom"
|
||||||
assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd
|
assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd
|
||||||
assert cmd[cmd.index("--channel") + 1] == "unix,target.type=virtio,target.name=org.qemu.guest_agent.0"
|
assert cmd[cmd.index("--channel") + 1] == "unix,target.type=virtio,target.name=org.qemu.guest_agent.0"
|
||||||
assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400
|
assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400
|
||||||
|
|||||||
@@ -1,5 +1,79 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.12.5",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Удаление машины в любом состоянии",
|
||||||
|
"summary": "Пункт «Удалить машину» теперь есть всегда, а не только у выключенной машины. Работающую или приостановленную машину панель сначала выключит, а подтверждение честно об этом предупреждает. Вместе с машиной удаляются её пробросы портов.",
|
||||||
|
"features": [
|
||||||
|
"«Удалить машину» доступно в меню машины и в списке машин при любом состоянии",
|
||||||
|
"Работающая или приостановленная машина перед удалением выключается",
|
||||||
|
"Пока с машиной идёт копия, клон или другая задача, удалить её нельзя: панель просит дождаться завершения",
|
||||||
|
"Вместе с машиной удаляются её пробросы портов, чтобы они не вели на адрес, который достанется другой машине",
|
||||||
|
"Версия обновлена до 0.12.5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.12.4",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Машина с установочного образа после установки перезагружается сама",
|
||||||
|
"summary": "Раньше машина, созданная с ISO, после установки системы выключалась вместо перезагрузки, а при следующем запуске снова загружалась с установочного образа. Теперь она создаётся сразу в рабочем виде, диск стоит первым в порядке загрузки, и после установки машина перезагружается уже в установленную систему.",
|
||||||
|
"features": [
|
||||||
|
"После установки с ISO машина перезагружается сама, а не выключается",
|
||||||
|
"Для машин с ISO диск стоит первым в порядке загрузки: пока он пустой, машина загружается с образа, а после установки с диска и повторно в установщик не попадает",
|
||||||
|
"Версия обновлена до 0.12.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.12.3",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Экран машины снова подключается",
|
||||||
|
"summary": "На Ubuntu 26.04 экран машины в браузере не подключался: новая версия noVNC обращалась не по тому адресу. Теперь экран работает и с новой, и со старой версией noVNC.",
|
||||||
|
"features": [
|
||||||
|
"Экран машины в браузере подключается на Ubuntu 26.04 и Debian 13 (noVNC 1.6), как и на Ubuntu 24.04 (noVNC 1.3)",
|
||||||
|
"Версия обновлена до 0.12.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.12.2",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Понятный итог, если машину создать не удалось",
|
||||||
|
"summary": "Если создание машины сорвалось, панель больше не предлагает открыть экран несуществующей машины. Она пишет, что на сервере от неё ничего не осталось, и даёт кнопку «Создать заново» с тем же образом. Недосозданный диск удаляется сам, поэтому то же название можно использовать сразу.",
|
||||||
|
"features": [
|
||||||
|
"Кнопки «Открыть экран машины» и «Перейти к машине» появляются только после успешного создания",
|
||||||
|
"После неудачного создания: кнопка «Создать заново» с тем же установочным или готовым образом",
|
||||||
|
"Недосозданный диск удаляется сам, повторное создание под тем же названием больше не упирается в «Диск уже существует»",
|
||||||
|
"Версия обновлена до 0.12.2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.12.1",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Понятное предупреждение, когда диск машины больше свободного места",
|
||||||
|
"summary": "Если диск новой машины больше, чем свободно на сервере, панель больше не запускает установку, которая падала с ошибкой на английском. Она объясняет, в чём риск, и создаёт такую машину только после подтверждения: диск занимает место постепенно, по мере заполнения.",
|
||||||
|
"features": [
|
||||||
|
"В шаге «Мощность» видно, сколько места свободно на сервере",
|
||||||
|
"Диск больше свободного места создаётся только с подтверждением, раньше установка падала с ошибкой virt-install",
|
||||||
|
"Версия обновлена до 0.12.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.12.0",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Резервные копии по расписанию",
|
||||||
|
"summary": "Сервер сам копирует машины каждый день или раз в неделю в выбранное время и хранит только последние копии, чтобы они не заняли весь диск. Копии делает отдельная служба сервера, поэтому ночное обновление панели их не прерывает. Исправлена загрузка ISO и образов дисков с компьютера.",
|
||||||
|
"features": [
|
||||||
|
"Загрузка ISO и образов дисков с компьютера снова работает: браузер отправлял пустой запрос, и сервер отвечал ошибкой 422",
|
||||||
|
"Расписание копий: каждый день или в выбранный день недели, время, все машины или только отмеченные",
|
||||||
|
"Сервер хранит последние N копий по расписанию для каждой машины и удаляет старые только после удачной новой",
|
||||||
|
"Копии, сделанные вручную, расписание не удаляет",
|
||||||
|
"Если сервер был выключен в назначенное время, копия делается в течение двух часов после включения, иначе в следующий раз",
|
||||||
|
"На странице «Резервные копии» видно следующую копию и итог последнего запуска по каждой машине",
|
||||||
|
"Перезапуск панели больше не объявляет прерванными задачи, которые ведёт служба копий",
|
||||||
|
"Установочный образ не собирается с именем администратора, которое запрещает установщик Ubuntu (admin, root, kvm)",
|
||||||
|
"Версия обновлена до 0.12.0"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.11.1",
|
"version": "0.11.1",
|
||||||
"date": "2026-09-27",
|
"date": "2026-09-27",
|
||||||
|
|||||||
+112
-46
@@ -18,6 +18,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote, urlsplit
|
from urllib.parse import quote, urlsplit
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
@@ -45,7 +46,22 @@ OPERATIONS_DIR = Path("/var/log/virtuality/operations")
|
|||||||
DEFAULT_BRIDGE = "br0"
|
DEFAULT_BRIDGE = "br0"
|
||||||
NOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)
|
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"))
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
static_dir = BASE_DIR / "static"
|
static_dir = BASE_DIR / "static"
|
||||||
static_dir.mkdir(exist_ok=True)
|
static_dir.mkdir(exist_ok=True)
|
||||||
@@ -278,6 +294,17 @@ def progress_from_line(current: int, line: str) -> int:
|
|||||||
return current
|
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:
|
def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
|
||||||
operation = read_operation(operation_id)
|
operation = read_operation(operation_id)
|
||||||
if not operation:
|
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}")
|
append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}")
|
||||||
if fresh.get("vm_name") and fresh.get("network_mode") == "nat":
|
if fresh.get("vm_name") and fresh.get("network_mode") == "nat":
|
||||||
network_core.release_nat_address(fresh["vm_name"])
|
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:
|
except Exception as exc:
|
||||||
fresh = read_operation(operation_id) or operation
|
fresh = read_operation(operation_id) or operation
|
||||||
append_operation_log(operation_id, f"Ошибка запуска операции: {exc}")
|
append_operation_log(operation_id, f"Ошибка запуска операции: {exc}")
|
||||||
@@ -906,7 +939,9 @@ def console_info(name: str) -> dict[str, Any]:
|
|||||||
url = None
|
url = None
|
||||||
if port and has_novnc:
|
if port and has_novnc:
|
||||||
token = console_serializer.dumps({"vm": name, "port": port})
|
token = console_serializer.dumps({"vm": name, "port": port})
|
||||||
url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}"
|
# "../": noVNC 1.6 (Ubuntu 26.04) строит адрес от своей страницы /novnc/vnc.html, а 1.3 (Ubuntu 24.04)
|
||||||
|
# приклеивает path к корню сайта; в обоих случаях получается /console/ws/<токен>.
|
||||||
|
url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=../console/ws/{token}"
|
||||||
return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}
|
return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}
|
||||||
|
|
||||||
|
|
||||||
@@ -1237,12 +1272,12 @@ def vm_boot_order_options() -> list[dict[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_boot_order(value: str, source_type: str) -> str:
|
def normalize_boot_order(value: str, source_type: str) -> str:
|
||||||
|
"""auto для ISO — сначала диск, потом CD, как в Proxmox: пустой диск не загрузочный, и машина стартует
|
||||||
|
с установщика, а после установки — уже с диска, и повторно в установщик не попадает."""
|
||||||
value = (value or "auto").strip()
|
value = (value or "auto").strip()
|
||||||
if value == "auto":
|
|
||||||
return "cdrom_disk" if source_type == "iso" else "disk"
|
|
||||||
if value in ("disk", "cdrom_disk", "disk_cdrom"):
|
if value in ("disk", "cdrom_disk", "disk_cdrom"):
|
||||||
return value
|
return value
|
||||||
return "cdrom_disk" if source_type == "iso" else "disk"
|
return "disk_cdrom" if source_type == "iso" else "disk"
|
||||||
|
|
||||||
|
|
||||||
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
|
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
|
||||||
@@ -1257,10 +1292,20 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
|
|||||||
return value
|
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):
|
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()
|
profile = host_profile.load_host_profile()
|
||||||
default_mode = profile.get("recommended_network", "nat")
|
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
|
# Virtuality noVNC console patch
|
||||||
@@ -1296,20 +1341,20 @@ async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWri
|
|||||||
def login_page(request: Request):
|
def login_page(request: Request):
|
||||||
if get_current_user(request):
|
if get_current_user(request):
|
||||||
return RedirectResponse(url="/", status_code=303)
|
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)
|
@app.post("/login", response_class=HTMLResponse)
|
||||||
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
|
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||||
if not is_configured():
|
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)
|
key = client_key(request)
|
||||||
retry_after = login_throttle.retry_after(key)
|
retry_after = login_throttle.retry_after(key)
|
||||||
if retry_after:
|
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):
|
if not verify_linux_password(username, password):
|
||||||
login_throttle.record_failure(key)
|
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)
|
login_throttle.record_success(key)
|
||||||
token = serializer.dumps({"user": AUTH_USER})
|
token = serializer.dumps({"user": AUTH_USER})
|
||||||
response = RedirectResponse(url="/", status_code=303)
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
@@ -1333,7 +1378,7 @@ def dashboard(request: Request):
|
|||||||
return RedirectResponse(url="/setup", status_code=303)
|
return RedirectResponse(url="/setup", status_code=303)
|
||||||
services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")}
|
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()]
|
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)
|
@app.get("/help", response_class=HTMLResponse)
|
||||||
@@ -1341,7 +1386,7 @@ def help_page(request: Request):
|
|||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return 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)
|
@app.get("/host", response_class=HTMLResponse)
|
||||||
@@ -1350,7 +1395,7 @@ def host_page(request: Request):
|
|||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
profile = host_profile.load_host_profile()
|
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")
|
@app.post("/host/refresh")
|
||||||
@@ -1368,7 +1413,7 @@ def iso_page(request: Request, error: str | None = None):
|
|||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return 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)
|
@app.post("/iso/upload", response_class=HTMLResponse)
|
||||||
@@ -1378,11 +1423,11 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)):
|
|||||||
return auth_redirect
|
return auth_redirect
|
||||||
safe_name = safe_iso_filename(iso_file.filename or "")
|
safe_name = safe_iso_filename(iso_file.filename or "")
|
||||||
if not safe_name:
|
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)
|
ISO_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
target = ISO_DIR / safe_name
|
target = ISO_DIR / safe_name
|
||||||
if target.exists():
|
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"
|
tmp_target = ISO_DIR / f".{safe_name}.uploading"
|
||||||
try:
|
try:
|
||||||
with tmp_target.open("wb") as out:
|
with tmp_target.open("wb") as out:
|
||||||
@@ -1391,7 +1436,7 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)):
|
|||||||
refresh_iso_pool()
|
refresh_iso_pool()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
tmp_target.unlink(missing_ok=True)
|
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)
|
return RedirectResponse(url="/iso", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -1424,7 +1469,7 @@ def disk_images_page(request: Request, error: str | None = None):
|
|||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return 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)
|
@app.post("/disk-images/upload", response_class=HTMLResponse)
|
||||||
@@ -1434,7 +1479,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
|||||||
return auth_redirect
|
return auth_redirect
|
||||||
safe_name = safe_disk_upload_filename(image_file.filename or "")
|
safe_name = safe_disk_upload_filename(image_file.filename or "")
|
||||||
if not safe_name:
|
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)
|
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
|
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
|
||||||
saved_paths: list[Path] = []
|
saved_paths: list[Path] = []
|
||||||
@@ -1455,7 +1500,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
|||||||
tmp_target.unlink(missing_ok=True)
|
tmp_target.unlink(missing_ok=True)
|
||||||
for path in saved_paths:
|
for path in saved_paths:
|
||||||
path.unlink(missing_ok=True)
|
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 = []
|
operations = []
|
||||||
for path in saved_paths:
|
for path in saved_paths:
|
||||||
@@ -1507,8 +1552,7 @@ def safe_network_template(request: Request, error: str | None = None, status_cod
|
|||||||
"nft_rules": "",
|
"nft_rules": "",
|
||||||
}
|
}
|
||||||
error = error or f"Ошибка чтения сетевого состояния: {exc}"
|
error = error or f"Ошибка чтения сетевого состояния: {exc}"
|
||||||
return templates.TemplateResponse("network.html", {
|
return templates.TemplateResponse(request, "network.html", {
|
||||||
"request": request,
|
|
||||||
"app_name": APP_NAME,
|
"app_name": APP_NAME,
|
||||||
"user": AUTH_USER,
|
"user": AUTH_USER,
|
||||||
"vms": parse_virsh_list(),
|
"vms": parse_virsh_list(),
|
||||||
@@ -1535,7 +1579,7 @@ def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: st
|
|||||||
return RedirectResponse(url="/network", status_code=303)
|
return RedirectResponse(url="/network", status_code=303)
|
||||||
manual_ip = (manual_ip or "").strip()
|
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):
|
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 = Path("/var/lib/virtuality/network/vm_ips.json")
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
@@ -1616,8 +1660,7 @@ def network_diagnose(request: Request, vm_name: str = Form(...), external_port:
|
|||||||
diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol)
|
diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol)
|
||||||
except NetworkError as exc:
|
except NetworkError as exc:
|
||||||
error = str(exc)
|
error = str(exc)
|
||||||
return templates.TemplateResponse("network.html", {
|
return templates.TemplateResponse(request, "network.html", {
|
||||||
"request": request,
|
|
||||||
"app_name": APP_NAME,
|
"app_name": APP_NAME,
|
||||||
"user": AUTH_USER,
|
"user": AUTH_USER,
|
||||||
"vms": parse_virsh_list(),
|
"vms": parse_virsh_list(),
|
||||||
@@ -1657,7 +1700,7 @@ def update_page(request: Request):
|
|||||||
}
|
}
|
||||||
current = str(info.get("current_version", ""))
|
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)
|
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")
|
@app.get("/update/status")
|
||||||
@@ -1702,7 +1745,7 @@ def operations_page(request: Request):
|
|||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return 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)
|
@app.get("/operations/{operation_id}", response_class=HTMLResponse)
|
||||||
@@ -1713,7 +1756,18 @@ def operation_detail_page(request: Request, operation_id: str):
|
|||||||
operation = read_operation(operation_id)
|
operation = read_operation(operation_id)
|
||||||
if not operation:
|
if not operation:
|
||||||
return RedirectResponse(url="/operations", status_code=303)
|
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")
|
@app.get("/api/operations")
|
||||||
@@ -1739,8 +1793,7 @@ def logs_page(request: Request, source: str = "web", lines: int = 220):
|
|||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
selected = read_log_source(source, lines)
|
selected = read_log_source(source, lines)
|
||||||
return templates.TemplateResponse("logs.html", {
|
return templates.TemplateResponse(request, "logs.html", {
|
||||||
"request": request,
|
|
||||||
"app_name": APP_NAME,
|
"app_name": APP_NAME,
|
||||||
"user": AUTH_USER,
|
"user": AUTH_USER,
|
||||||
"sources": LOG_SOURCES,
|
"sources": LOG_SOURCES,
|
||||||
@@ -1772,12 +1825,16 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/create", response_class=HTMLResponse)
|
@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)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
ci_user = ci_user.strip()
|
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
|
error = None
|
||||||
use_cloud_init = source_type == "disk_image" and cloud_init == "1"
|
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 []
|
ssh_keys = cloudinit.parse_ssh_keys(ci_ssh_key) if use_cloud_init else []
|
||||||
@@ -1791,6 +1848,8 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
|
|||||||
error = "CPU должен быть от 1 до 128 vCPU."
|
error = "CPU должен быть от 1 до 128 vCPU."
|
||||||
elif disk_size < 4 or disk_size > 4096:
|
elif disk_size < 4 or disk_size > 4096:
|
||||||
error = "Диск должен быть от 4 GB до 4096 GB."
|
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"):
|
elif network_mode not in ("nat", "bridge"):
|
||||||
error = "Некорректный режим сети."
|
error = "Некорректный режим сети."
|
||||||
elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
|
elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
|
||||||
@@ -1889,11 +1948,16 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
|
|||||||
steps.append(" ".join(shlex.quote(part) for part in virt))
|
steps.append(" ".join(shlex.quote(part) for part in virt))
|
||||||
cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)]
|
cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)]
|
||||||
else:
|
else:
|
||||||
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
|
# no_install: машина сразу создаётся в рабочем виде, с образом в приводе. Без этого virt-install ставит
|
||||||
|
# на время установки «перезагрузка = выключение» и сам должен перезапустить машину, а он не ждёт
|
||||||
|
# (--noautoconsole) — после установки машина просто выключалась.
|
||||||
|
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--install", "no_install=yes", "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
|
||||||
|
if too_big:
|
||||||
|
cmd += ["--check", "disk_size=off"] # подтверждено пользователем: virt-install иначе откажется
|
||||||
|
|
||||||
success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "")
|
success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "")
|
||||||
operation_id = str(uuid.uuid4())
|
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)
|
start_background_operation(operation, cmd)
|
||||||
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
||||||
|
|
||||||
@@ -1905,7 +1969,7 @@ def vm_console_page(request: Request, name: str):
|
|||||||
return auth_redirect
|
return auth_redirect
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
if not valid_vm_name(name) or not vm_exists(name):
|
||||||
return RedirectResponse(url="/", status_code=303)
|
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}")
|
@app.websocket("/console/ws/{token}")
|
||||||
@@ -1955,7 +2019,7 @@ def vm_detail_page(request: Request, name: str):
|
|||||||
return auth_redirect
|
return auth_redirect
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
if not valid_vm_name(name) or not vm_exists(name):
|
||||||
return RedirectResponse(url="/", status_code=303)
|
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")
|
@app.post("/vm/{name}/resources")
|
||||||
@@ -2030,14 +2094,21 @@ def vm_action(request: Request, name: str, action: str):
|
|||||||
if action in ("shutdown", "destroy", "delete"):
|
if action in ("shutdown", "destroy", "delete"):
|
||||||
backups.cancel_late_restart(name)
|
backups.cancel_late_restart(name)
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
if vm_state(name) == "running":
|
import core
|
||||||
|
|
||||||
|
busy = core.active_operations_for(name)
|
||||||
|
if busy:
|
||||||
|
return redirect_with_message(f"/vm/{name}", "error", f"С машиной сейчас идёт задача «{busy[0].get('title') or 'операция'}». Дождитесь её завершения и удалите машину снова.")
|
||||||
|
if core.vm_is_running(name): # и на паузе тоже: undefine активной машины оставил бы её работать без настроек
|
||||||
run_cmd(["virsh", "destroy", name], timeout=20)
|
run_cmd(["virsh", "destroy", name], timeout=20)
|
||||||
# --nvram: UEFI machines keep firmware variables that block a plain undefine.
|
# --nvram: UEFI machines keep firmware variables that block a plain undefine.
|
||||||
result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
|
result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
|
||||||
if not result["ok"]:
|
if not result["ok"]:
|
||||||
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}")
|
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}")
|
||||||
network_core.release_nat_address(name)
|
network_core.release_nat_address(name)
|
||||||
return redirect_with_message("/", "message", f"Машина {name} удалена")
|
removed = network_core.remove_vm_forwards(name)
|
||||||
|
message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {removed}" if removed else "")
|
||||||
|
return redirect_with_message("/", "message", message)
|
||||||
if action not in allowed:
|
if action not in allowed:
|
||||||
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
|
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
|
||||||
result = run_cmd(allowed[action], timeout=30)
|
result = run_cmd(allowed[action], timeout=30)
|
||||||
@@ -2083,18 +2154,13 @@ def api_health(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
# Feature modules register template globals into app.templates, so they are imported once it exists.
|
# Feature modules register template globals into app.templates, so they are imported once it exists.
|
||||||
from features import backups, downloads, settings, setup, snapshots, vmops # noqa: E402
|
from features import backup_schedule, backups, downloads, settings, setup, snapshots, vmops # noqa: E402
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
def close_orphaned_operations() -> None:
|
|
||||||
import core
|
|
||||||
|
|
||||||
core.interrupt_orphaned_operations()
|
|
||||||
|
|
||||||
|
|
||||||
app.include_router(setup.router)
|
app.include_router(setup.router)
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
app.include_router(snapshots.router)
|
app.include_router(snapshots.router)
|
||||||
|
app.include_router(backup_schedule.router)
|
||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
app.include_router(downloads.router)
|
app.include_router(downloads.router)
|
||||||
app.include_router(vmops.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 = {"request": request, "app_name": APP_NAME, "user": AUTH_USER}
|
||||||
data.update(context or {})
|
data.update(context or {})
|
||||||
data.update(extra)
|
data.update(extra)
|
||||||
return templates.TemplateResponse(template, data, status_code=status_code)
|
return templates.TemplateResponse(request, template, data, status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- auth
|
# ---------------------------------------------------------------- auth
|
||||||
@@ -278,9 +278,27 @@ def list_operations(limit: int = 25) -> list[dict[str, Any]]:
|
|||||||
return operations
|
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:
|
def interrupt_orphaned_operations() -> int:
|
||||||
"""Operations run in threads of the panel process: after a restart the ones still marked
|
"""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():
|
if not OPERATIONS_DIR.exists():
|
||||||
return 0
|
return 0
|
||||||
count = 0
|
count = 0
|
||||||
@@ -291,6 +309,9 @@ def interrupt_orphaned_operations() -> int:
|
|||||||
continue
|
continue
|
||||||
if data.get("status") not in ("queued", "running"):
|
if data.get("status") not in ("queued", "running"):
|
||||||
continue
|
continue
|
||||||
|
owner = data.get("owner")
|
||||||
|
if owner_alive(owner) and owner["pid"] != os.getpid():
|
||||||
|
continue
|
||||||
data.pop("log_tail", None)
|
data.pop("log_tail", None)
|
||||||
message = "Прервано: панель перезапускалась во время операции. Проверьте состояние машины и запустите действие снова."
|
message = "Прервано: панель перезапускалась во время операции. Проверьте состояние машины и запустите действие снова."
|
||||||
update_operation(data, status="error", progress=100, message=message, finished_at=utc_now(), interrupted=True)
|
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]:
|
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)
|
operation.update(meta)
|
||||||
write_operation(operation)
|
write_operation(operation)
|
||||||
append_operation_log(operation["id"], "Операция поставлена в очередь.")
|
append_operation_log(operation["id"], "Операция поставлена в очередь.")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -227,6 +227,7 @@ def describe_backup(vm: str, backup_id: str, path: Path, meta: dict[str, Any]) -
|
|||||||
"size_bytes": size,
|
"size_bytes": size,
|
||||||
"size": format_bytes(size),
|
"size": format_bytes(size),
|
||||||
"version": str(meta.get("version") or ""),
|
"version": str(meta.get("version") or ""),
|
||||||
|
"auto": meta.get("auto") is True,
|
||||||
"complete": complete,
|
"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 копия делается
|
поднимают BackupError с понятным текстом. С wait=True копия делается
|
||||||
в текущем потоке — удобно для расписания."""
|
в текущем потоке — так её делает расписание (auto=True: такие копии
|
||||||
|
расписание само удаляет, оставляя последние N)."""
|
||||||
cancel_late_restart(vm_name)
|
cancel_late_restart(vm_name)
|
||||||
if not core.valid_vm_name(vm_name):
|
if not core.valid_vm_name(vm_name):
|
||||||
raise BackupError("Некорректное имя машины")
|
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")
|
raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space")
|
||||||
backup_id = reserve_backup_dir(vm_name)
|
backup_id = reserve_backup_dir(vm_name)
|
||||||
try:
|
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:
|
except Exception:
|
||||||
remove_backup_dir(backup_root() / vm_name / backup_id)
|
remove_backup_dir(backup_root() / vm_name / backup_id)
|
||||||
raise
|
raise
|
||||||
@@ -555,7 +558,7 @@ def backup_worker(operation: dict[str, Any]) -> None:
|
|||||||
log(f"qemu-img завершился с кодом {code}.")
|
log(f"qemu-img завершился с кодом {code}.")
|
||||||
raise BackupError(f"Не удалось скопировать диск {disk['target']} — подробности в журнале задачи")
|
raise BackupError(f"Не удалось скопировать диск {disk['target']} — подробности в журнале задачи")
|
||||||
size = sum(item.stat().st_size for item in dest.iterdir() if item.is_file())
|
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")
|
(dest / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
log(f"Копия готова: {format_bytes(size)} (сжатие включено).")
|
log(f"Копия готова: {format_bytes(size)} (сжатие включено).")
|
||||||
except Exception as exc: # noqa: BLE001 — любая ошибка = операция с понятным текстом
|
except Exception as exc: # noqa: BLE001 — любая ошибка = операция с понятным текстом
|
||||||
@@ -777,8 +780,11 @@ def backups_page(request: Request):
|
|||||||
auth = core.require_auth(request)
|
auth = core.require_auth(request)
|
||||||
if auth:
|
if auth:
|
||||||
return auth
|
return auth
|
||||||
|
from features import backup_schedule # расписание само импортирует этот модуль
|
||||||
|
|
||||||
items = list_backups()
|
items = list_backups()
|
||||||
context = {
|
context = {
|
||||||
|
"schedule": backup_schedule.overview(),
|
||||||
"groups": group_by_vm(items, existing_vm_names()),
|
"groups": group_by_vm(items, existing_vm_names()),
|
||||||
"storage": storage_summary(items),
|
"storage": storage_summary(items),
|
||||||
"running": [op for op in core.running_operations() if op.get("type") in ("backup", "restore")],
|
"running": [op for op in core.running_operations() if op.get("type") in ("backup", "restore")],
|
||||||
@@ -841,8 +847,11 @@ def vm_backups_page(request: Request, name: str):
|
|||||||
return auth
|
return auth
|
||||||
if not core.valid_vm_name(name) or not core.vm_exists(name):
|
if not core.valid_vm_name(name) or not core.vm_exists(name):
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
from features import backup_schedule
|
||||||
|
|
||||||
items = list_backups(name)
|
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)
|
return core.render(request, "vm_backups.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -783,9 +783,9 @@ def api_cancel_operation(request: Request, operation_id: str):
|
|||||||
return {"ok": True, "operation": read_operation(operation_id) or operation}
|
return {"ok": True, "operation": read_operation(operation_id) or operation}
|
||||||
|
|
||||||
|
|
||||||
@router.on_event("startup")
|
|
||||||
def close_stale_downloads() -> None:
|
def close_stale_downloads() -> None:
|
||||||
"""После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part."""
|
"""После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part.
|
||||||
|
Вызывается из app.run_startup_tasks() при старте панели."""
|
||||||
reap_stale_downloads()
|
reap_stale_downloads()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -436,6 +436,17 @@ def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port
|
|||||||
return normalize_forward(forward)
|
return normalize_forward(forward)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_vm_forwards(vm_name: str) -> int:
|
||||||
|
"""Машина удалена: её пробросы вели бы на адрес, который потом может достаться другой машине."""
|
||||||
|
items = load_port_forwards()
|
||||||
|
kept = [item for item in items if item.get('vm_name') != vm_name]
|
||||||
|
if len(kept) == len(items):
|
||||||
|
return 0
|
||||||
|
save_port_forwards(kept)
|
||||||
|
apply_port_forwards()
|
||||||
|
return len(items) - len(kept)
|
||||||
|
|
||||||
|
|
||||||
def delete_port_forward(forward_id: str) -> None:
|
def delete_port_forward(forward_id: str) -> None:
|
||||||
items = [item for item in load_port_forwards() if item.get('id') != forward_id]
|
items = [item for item in load_port_forwards() if item.get('id') != forward_id]
|
||||||
save_port_forwards(items)
|
save_port_forwards(items)
|
||||||
|
|||||||
@@ -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 input:checked + .option-body .option-check { border: 5px solid var(--brand); }
|
||||||
.option-title { font-weight: 650; font-size: 14px; }
|
.option-title { font-weight: 650; font-size: 14px; }
|
||||||
.option-text { color: var(--text-2); font-size: 12.5px; }
|
.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; }
|
.option-specs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||||
|
|
||||||
/* segmented */
|
/* segmented */
|
||||||
|
|||||||
@@ -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-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-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-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>
|
</svg>
|
||||||
|
|||||||
|
До Ширина: | Высота: | Размер: 12 KiB После Ширина: | Высота: | Размер: 12 KiB |
@@ -300,6 +300,8 @@
|
|||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
if (!valid(file)) { setError('Выберите файл'); return; }
|
if (!valid(file)) { setError('Выберите файл'); return; }
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
// FormData собираем до setActive: заблокированное поле файла браузер в отправку не включает.
|
||||||
|
const data = new FormData(form);
|
||||||
xhr = new XMLHttpRequest();
|
xhr = new XMLHttpRequest();
|
||||||
setActive(true); progress.hidden = false; progress.classList.remove('success', 'danger');
|
setActive(true); progress.hidden = false; progress.classList.remove('success', 'danger');
|
||||||
statusEl.textContent = 'Загрузка на сервер…';
|
statusEl.textContent = 'Загрузка на сервер…';
|
||||||
@@ -337,7 +339,7 @@
|
|||||||
xhr.open('POST', form.action);
|
xhr.open('POST', form.action);
|
||||||
xhr.setRequestHeader('Accept', 'application/json');
|
xhr.setRequestHeader('Accept', 'application/json');
|
||||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
xhr.send(new FormData(form));
|
xhr.send(data);
|
||||||
});
|
});
|
||||||
showFile();
|
showFile();
|
||||||
}
|
}
|
||||||
@@ -381,7 +383,7 @@
|
|||||||
messageEl.textContent = op.message || '';
|
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; }
|
if (logEl) { const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; logEl.textContent = op.log_tail || ''; if (atBottom) logEl.scrollTop = logEl.scrollHeight; }
|
||||||
render();
|
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 (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
const timer = setInterval(poll, 1500);
|
const timer = setInterval(poll, 1500);
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -39,6 +39,53 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</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 %}
|
{% if groups %}
|
||||||
<div class="stack mt-3">
|
<div class="stack mt-3">
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
@@ -75,7 +122,7 @@
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-body stack-sm">
|
<div class="card-body stack-sm">
|
||||||
<div class="row">{{ icon('info') }}<h3>Как это работает</h3></div>
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -101,9 +101,9 @@
|
|||||||
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить {{ vm.name }} принудительно?" data-confirm-button="Выключить" data-toast="Машина {{ vm.name }} выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}Выключить принудительно</button></form>
|
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить {{ vm.name }} принудительно?" data-confirm-button="Выключить" data-toast="Машина {{ vm.name }} выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}Выключить принудительно</button></form>
|
||||||
{% elif vm.state != 'paused' %}
|
{% elif vm.state != 'paused' %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form>
|
<form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form>
|
||||||
<div class="menu-sep"></div>
|
|
||||||
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.</li>
|
<li>Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.</li>
|
||||||
<li>Если курсор «застрял» внутри экрана, нажмите <code>Ctrl + Alt</code>.</li>
|
<li>Если курсор «застрял» внутри экрана, нажмите <code>Ctrl + Alt</code>.</li>
|
||||||
<li>После установки системы извлеките установочный диск на странице машины → «Диски и загрузка».</li>
|
<li>После установки машина перезагрузится уже с диска: он стоит первым в порядке загрузки. Установочный образ можно извлечь на странице машины → «Диски и загрузка».</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div></article>
|
</div></article>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<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>
|
<p class="muted" data-op="message">{{ operation.message }}</p>
|
||||||
{% if operation.vm_name %}
|
{% 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 btn-primary" href="/vm/{{ operation.vm_name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран машины</a>
|
||||||
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
|
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -70,6 +70,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</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">
|
<article class="card">
|
||||||
<div class="card-body stack-sm">
|
<div class="card-body stack-sm">
|
||||||
<div class="row">{{ icon('clock') }}<h3>Сколько это займёт</h3></div>
|
<div class="row">{{ icon('clock') }}<h3>Сколько это займёт</h3></div>
|
||||||
|
|||||||
@@ -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="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 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>
|
</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 %}
|
{% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -33,9 +33,8 @@
|
|||||||
{% if s.running %}
|
{% if s.running %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}<span>Перезагрузить<small>Мягкая перезагрузка системы</small></span></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}<span>Перезагрузить<small>Мягкая перезагрузка системы</small></span></button></form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить принудительно?" data-confirm-button="Выключить" data-toast="Машина выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}<span>Выключить принудительно<small>Если система зависла</small></span></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить принудительно?" data-confirm-button="Выключить" data-toast="Машина выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}<span>Выключить принудительно<small>Если система зависла</small></span></button></form>
|
||||||
{% else %}
|
|
||||||
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина {{ vm.name }} и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить машину?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}<span>Удалить машину<small>Вместе с дисками</small></span></button></form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." data-confirm-title="Удалить машину {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}<span>Удалить машину<small>Вместе с дисками</small></span></button></form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user