Сравнить коммиты
8
Коммитов
v0.12.0
...
f31b4ade1d
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
f31b4ade1d | ||
|
|
2474ad3dc2 | ||
|
|
daeec06ec6 | ||
|
|
209e83044a | ||
|
|
5d4525f78d | ||
|
|
984eb85a5a | ||
|
|
48ac535c3a | ||
|
|
cc3abe3f4a |
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
||||||
|
|
||||||
Текущая версия: **0.12.0** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
Текущая версия: **0.12.4** (файл `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.12.0-ubuntu-26.04-amd64.iso
|
make iso # dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
|
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.12.0
|
0.12.4
|
||||||
|
|||||||
@@ -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** (классическая загрузка) или **Другая система**.
|
||||||
|
|
||||||
|
|||||||
@@ -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.12.0-ubuntu-26.04-amd64.iso
|
dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso
|
||||||
dist/virtuality-0.12.0-ubuntu-26.04-amd64.iso.sha256
|
dist/virtuality-0.12.4-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.12.0** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
|
Меню загрузки: **Установить Virtuality 0.12.4** (выбирается сам через 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.12.0-ubuntu-26.04-amd64.iso.sha256
|
sha256sum -c virtuality-0.12.4-ubuntu-26.04-amd64.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xorriso -indev virtuality-0.12.0-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
xorriso -indev virtuality-0.12.4-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
||||||
xorriso -osirrox on -indev virtuality-0.12.0-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
xorriso -osirrox on -indev virtuality-0.12.4-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -182,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.12.0-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.12.0-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
sudo dd if=dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
||||||
```
|
```
|
||||||
|
|
||||||
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
@@ -141,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):
|
||||||
@@ -196,3 +200,77 @@ def test_uploader_collects_file_before_disabling_input():
|
|||||||
assert "input.disabled = active" in uploader
|
assert "input.disabled = active" in uploader
|
||||||
assert uploader.index("new FormData(form)") < uploader.index("setActive(true)")
|
assert uploader.index("new FormData(form)") < uploader.index("setActive(true)")
|
||||||
assert "xhr.send(data)" in uploader
|
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
|
||||||
|
|||||||
@@ -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,49 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.12.4",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Машина с установочного образа после установки перезагружается сама",
|
||||||
|
"summary": "Раньше машина, созданная с ISO, после установки системы выключалась вместо перезагрузки, а при следующем запуске снова загружалась с установочного образа. Теперь она создаётся сразу в рабочем виде, диск стоит первым в порядке загрузки, и после установки машина перезагружается уже в установленную систему.",
|
||||||
|
"features": [
|
||||||
|
"После установки с ISO машина перезагружается сама, а не выключается",
|
||||||
|
"Для машин с ISO диск стоит первым в порядке загрузки: пока он пустой, машина загружается с образа, а после установки с диска и повторно в установщик не попадает",
|
||||||
|
"Версия обновлена до 0.12.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.12.3",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Экран машины снова подключается",
|
||||||
|
"summary": "На Ubuntu 26.04 экран машины в браузере не подключался: новая версия noVNC обращалась не по тому адресу. Теперь экран работает и с новой, и со старой версией noVNC.",
|
||||||
|
"features": [
|
||||||
|
"Экран машины в браузере подключается на Ubuntu 26.04 и Debian 13 (noVNC 1.6), как и на Ubuntu 24.04 (noVNC 1.3)",
|
||||||
|
"Версия обновлена до 0.12.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.12.2",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Понятный итог, если машину создать не удалось",
|
||||||
|
"summary": "Если создание машины сорвалось, панель больше не предлагает открыть экран несуществующей машины. Она пишет, что на сервере от неё ничего не осталось, и даёт кнопку «Создать заново» с тем же образом. Недосозданный диск удаляется сам, поэтому то же название можно использовать сразу.",
|
||||||
|
"features": [
|
||||||
|
"Кнопки «Открыть экран машины» и «Перейти к машине» появляются только после успешного создания",
|
||||||
|
"После неудачного создания: кнопка «Создать заново» с тем же установочным или готовым образом",
|
||||||
|
"Недосозданный диск удаляется сам, повторное создание под тем же названием больше не упирается в «Диск уже существует»",
|
||||||
|
"Версия обновлена до 0.12.2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.12.1",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Понятное предупреждение, когда диск машины больше свободного места",
|
||||||
|
"summary": "Если диск новой машины больше, чем свободно на сервере, панель больше не запускает установку, которая падала с ошибкой на английском. Она объясняет, в чём риск, и создаёт такую машину только после подтверждения: диск занимает место постепенно, по мере заполнения.",
|
||||||
|
"features": [
|
||||||
|
"В шаге «Мощность» видно, сколько места свободно на сервере",
|
||||||
|
"Диск больше свободного места создаётся только с подтверждением, раньше установка падала с ошибкой virt-install",
|
||||||
|
"Версия обновлена до 0.12.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"date": "2026-09-27",
|
"date": "2026-09-27",
|
||||||
|
|||||||
+62
-11
@@ -294,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:
|
||||||
@@ -322,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}")
|
||||||
@@ -922,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}
|
||||||
|
|
||||||
|
|
||||||
@@ -1253,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:
|
||||||
@@ -1273,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(request, "vm_create.html", {"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
|
||||||
@@ -1727,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(request, "operation_detail.html", {"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")
|
||||||
@@ -1785,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 []
|
||||||
@@ -1804,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)):
|
||||||
@@ -1902,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)
|
||||||
|
|
||||||
|
|||||||
@@ -383,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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user