Быстрое создание машин: cloud-init, тип системы для установщика, постоянный адрес в сети NAT
- готовые образы: пользователь, пароль (хеш SHA-512) и SSH-ключ через virt-install --cloud-init, диск увеличивается до выбранного размера, osinfo из каталога - установка с ISO: выбор Linux / Windows 11 (UEFI + TPM) / Windows 10 / другая; Windows получает SATA-диск и сетевую карту e1000e - каждая машина в NAT получает фиксированный MAC и резервацию DHCP — адрес не меняется, пробросы портов не ломаются; при удалении резервация снимается - xorriso в пакетах ноды (для seed-образа cloud-init) - tests/test_quickvm.py; тест каталога скачиваний сделан детерминированным Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
@@ -178,7 +178,7 @@ case "$(uname -m)" in
|
|||||||
esac
|
esac
|
||||||
install_available "Пакеты виртуализации установлены" \
|
install_available "Пакеты виртуализации установлены" \
|
||||||
"${QEMU_SYSTEM_PACKAGES[@]}" qemu-utils libvirt-daemon-system libvirt-clients virtinst \
|
"${QEMU_SYSTEM_PACKAGES[@]}" qemu-utils libvirt-daemon-system libvirt-clients virtinst \
|
||||||
bridge-utils dnsmasq-base swtpm cloud-image-utils
|
bridge-utils dnsmasq-base swtpm cloud-image-utils xorriso
|
||||||
|
|
||||||
step "Устанавливаем зависимости панели управления"
|
step "Устанавливаем зависимости панели управления"
|
||||||
install_available "Пакеты панели установлены" \
|
install_available "Пакеты панели установлены" \
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch):
|
|||||||
cmd = captured["cmd"]
|
cmd = captured["cmd"]
|
||||||
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 "network=virtuality-nat,model=virtio" 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"] == "cdrom_disk"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -317,12 +317,16 @@ def test_catalog_route(logged_in, data_dirs, www, monkeypatch):
|
|||||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None)
|
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None)
|
||||||
|
|
||||||
assert unquote(logged_in.post("/downloads/catalog/nope", follow_redirects=False).headers["location"]).startswith("/disk-images?download_error=Такого образа нет")
|
assert unquote(logged_in.post("/downloads/catalog/nope", follow_redirects=False).headers["location"]).startswith("/disk-images?download_error=Такого образа нет")
|
||||||
|
# The worker is started by hand below: a 6 KB file would otherwise finish before the page is rendered.
|
||||||
|
pending = {}
|
||||||
|
monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker))
|
||||||
response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False)
|
response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False)
|
||||||
assert response.status_code == 303 and "download_message=" in response.headers["location"]
|
assert response.status_code == 303 and "download_message=" in response.headers["location"]
|
||||||
page = logged_in.get("/disk-images").text
|
page = logged_in.get("/disk-images").text
|
||||||
assert "Скачивается" in page
|
assert "Скачивается" in page
|
||||||
running = downloads.active_downloads("disk")
|
running = downloads.active_downloads("disk")
|
||||||
assert running[0]["catalog_id"] == "debian-13"
|
assert running[0]["catalog_id"] == "debian-13"
|
||||||
|
core.run_operation(pending["operation"], pending["worker"])
|
||||||
assert wait_for(running[0]["id"])["status"] == "success"
|
assert wait_for(running[0]["id"])["status"] == "success"
|
||||||
assert (data_dirs["disk_images"] / "debian-13-genericcloud-amd64.qcow2").read_bytes() == payload
|
assert (data_dirs["disk_images"] / "debian-13-genericcloud-amd64.qcow2").read_bytes() == payload
|
||||||
page = logged_in.get("/disk-images").text
|
page = logged_in.get("/disk-images").text
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses."""
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
import app
|
||||||
|
import cloudinit
|
||||||
|
import conftest
|
||||||
|
import network_core
|
||||||
|
from test_audit_fixes import find, recording
|
||||||
|
|
||||||
|
|
||||||
|
def create(logged_in, monkeypatch, form):
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(operation=operation, cmd=cmd))
|
||||||
|
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
|
||||||
|
response = logged_in.post("/vm/create", data=form, follow_redirects=False)
|
||||||
|
return response, captured
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_form(image, **extra):
|
||||||
|
form = {"name": "cloud-vm", "memory": 2048, "vcpus": 2, "disk_size": 20, "disk_image_path": str(image), "source_type": "disk_image", "network_mode": "nat", "cloud_init": "1", "ci_user": "viktor", "ci_password": "secret123"}
|
||||||
|
form.update(extra)
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- cloud-init seed
|
||||||
|
def test_user_data_has_user_password_and_keys():
|
||||||
|
text = cloudinit.build_user_data("web", "viktor", "$6$abc", ["ssh-ed25519 AAAAC3 me@laptop"])
|
||||||
|
assert "hostname: web" in text and "- name: viktor" in text and "passwd: '$6$abc'" in text
|
||||||
|
assert "- ssh-ed25519 AAAAC3 me@laptop" in text and "ssh_pwauth: true" in text and "growpart" in text
|
||||||
|
assert "ssh_pwauth: false" in cloudinit.build_user_data("web", "viktor", "", ["ssh-ed25519 AAAAC3 me"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssh_keys_and_users_are_validated():
|
||||||
|
assert cloudinit.parse_ssh_keys("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me\n\nssh-rsa AAAAB3 x\n") == ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me", "ssh-rsa AAAAB3 x"]
|
||||||
|
assert cloudinit.parse_ssh_keys("-----BEGIN OPENSSH PRIVATE KEY-----") is None
|
||||||
|
assert cloudinit.parse_ssh_keys("") == []
|
||||||
|
assert cloudinit.valid_user("viktor") and cloudinit.valid_user("web_admin-2")
|
||||||
|
assert not cloudinit.valid_user("root") and not cloudinit.valid_user("Viktor") and not cloudinit.valid_user("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_hash_uses_sha512_crypt():
|
||||||
|
digest = cloudinit.hash_password("secret123")
|
||||||
|
assert digest.startswith("$6$") and len(digest) > 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_login_comes_from_catalog():
|
||||||
|
assert cloudinit.default_user("resolute-server-cloudimg-amd64.img") == "ubuntu"
|
||||||
|
assert cloudinit.default_user("debian-13-genericcloud-arm64.qcow2") == "debian"
|
||||||
|
assert cloudinit.default_user("my-own-image.qcow2") == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- creating from a cloud image
|
||||||
|
def test_cloud_image_vm_gets_seed_resize_and_fixed_address(logged_in, data_dirs, monkeypatch):
|
||||||
|
image = data_dirs["disk_images"] / "resolute-server-cloudimg-amd64.img"
|
||||||
|
image.write_bytes(b"QFI\xfb")
|
||||||
|
monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash")
|
||||||
|
calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
|
||||||
|
response, captured = create(logged_in, monkeypatch, cloud_form(image, disk_size=60))
|
||||||
|
assert response.status_code == 303, response.text[:400]
|
||||||
|
script = captured["cmd"][-1]
|
||||||
|
assert "qemu-img convert -p -f qcow2 -O qcow2" in script
|
||||||
|
assert f"qemu-img resize {shlex.quote(str(data_dirs['images'] / 'cloud-vm.qcow2'))} 60G" in script
|
||||||
|
assert "--import" in script and "--osinfo ubuntu24.04" in script and "--cloud-init user-data=" in script and "bus=virtio" in script
|
||||||
|
operation = captured["operation"]
|
||||||
|
assert operation["cloud_init_user"] == "viktor" and operation["nat_ip"] == "192.168.100.50"
|
||||||
|
assert "Вход: viktor по паролю" in operation["success_message"] and "192.168.100.50" in operation["success_message"]
|
||||||
|
mac = network_core.stable_mac("cloud-vm")
|
||||||
|
assert f"mac={mac}" in operation["network"] and mac.startswith("52:54:00:")
|
||||||
|
reservation = find(calls, "virsh", "net-update", "virtuality-nat", "add-last", "ip-dhcp-host")[0]
|
||||||
|
assert f"mac='{mac}' name='cloud-vm' ip='192.168.100.50'" in reservation[5] and "--live" in reservation and "--config" in reservation
|
||||||
|
user_data = open(operation["seed_file"]).read()
|
||||||
|
assert "- name: viktor" in user_data and "$6$fake$hash" in user_data and "hostname: cloud-vm" in user_data
|
||||||
|
cloudinit.remove_seed(operation["seed_file"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_image_smaller_disk_is_not_resized(logged_in, data_dirs, monkeypatch):
|
||||||
|
image = data_dirs["disk_images"] / "cloud.qcow2"
|
||||||
|
image.write_bytes(b"qcow")
|
||||||
|
monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash")
|
||||||
|
response, captured = create(logged_in, monkeypatch, cloud_form(image, disk_size=20))
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert "qemu-img resize" not in captured["cmd"][-1] and "--osinfo detect=on,require=off" in captured["cmd"][-1]
|
||||||
|
cloudinit.remove_seed(captured["operation"]["seed_file"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_init_can_be_skipped(logged_in, data_dirs, monkeypatch):
|
||||||
|
image = data_dirs["disk_images"] / "cloud.qcow2"
|
||||||
|
image.write_bytes(b"qcow")
|
||||||
|
response, captured = create(logged_in, monkeypatch, cloud_form(image, cloud_init="0", ci_user="", ci_password=""))
|
||||||
|
assert response.status_code == 303 and "--cloud-init" not in captured["cmd"][-1] and captured["operation"]["seed_file"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_init_validation(logged_in, data_dirs, monkeypatch):
|
||||||
|
image = data_dirs["disk_images"] / "cloud.qcow2"
|
||||||
|
image.write_bytes(b"qcow")
|
||||||
|
assert create(logged_in, monkeypatch, cloud_form(image, ci_password="123"))[0].status_code == 400
|
||||||
|
assert create(logged_in, monkeypatch, cloud_form(image, ci_user="Root User"))[0].status_code == 400
|
||||||
|
assert create(logged_in, monkeypatch, cloud_form(image, ci_user="root"))[0].status_code == 400
|
||||||
|
assert create(logged_in, monkeypatch, cloud_form(image, ci_ssh_key="not a key"))[0].status_code == 400
|
||||||
|
monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash")
|
||||||
|
ok, captured = create(logged_in, monkeypatch, cloud_form(image, ci_password="", ci_ssh_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA me@pc"))
|
||||||
|
assert ok.status_code == 303 and "по SSH-ключу" in captured["operation"]["success_message"]
|
||||||
|
cloudinit.remove_seed(captured["operation"]["seed_file"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- installers by OS type
|
||||||
|
def iso_form(iso, **extra):
|
||||||
|
form = {"name": "new-vm", "memory": 4096, "vcpus": 2, "disk_size": 64, "iso_path": str(iso), "source_type": "iso", "network_mode": "nat"}
|
||||||
|
form.update(extra)
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows11_gets_uefi_tpm_sata_and_e1000e(logged_in, data_dirs, monkeypatch):
|
||||||
|
iso = data_dirs["iso"] / "win11.iso"
|
||||||
|
iso.write_bytes(b"iso")
|
||||||
|
response, captured = create(logged_in, monkeypatch, iso_form(iso, os_type="windows11"))
|
||||||
|
assert response.status_code == 303
|
||||||
|
cmd = captured["cmd"]
|
||||||
|
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
|
||||||
|
assert cmd[cmd.index("--boot") + 1] == "uefi,cdrom,hd"
|
||||||
|
assert "--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]
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_installer_detects_os_and_uses_virtio(logged_in, data_dirs, monkeypatch):
|
||||||
|
iso = data_dirs["iso"] / "debian.iso"
|
||||||
|
iso.write_bytes(b"iso")
|
||||||
|
response, captured = create(logged_in, monkeypatch, iso_form(iso))
|
||||||
|
cmd = captured["cmd"]
|
||||||
|
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "cdrom,hd"
|
||||||
|
assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd
|
||||||
|
assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- fixed addresses
|
||||||
|
def test_reservation_is_reused_and_released(data_dirs, monkeypatch):
|
||||||
|
calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
|
||||||
|
assert network_core.reserve_nat_address("web01", "52:54:00:aa:bb:cc") == "192.168.100.51"
|
||||||
|
assert not find(calls, "virsh", "net-update")
|
||||||
|
network_core.release_nat_address("web01")
|
||||||
|
deleted = find(calls, "virsh", "net-update", "virtuality-nat", "delete")[0]
|
||||||
|
assert "name='web01' ip='192.168.100.51'" in deleted[5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch):
|
||||||
|
calls = recording(monkeypatch, app, [])
|
||||||
|
net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
|
||||||
|
assert logged_in.post("/vm/web01/delete", follow_redirects=False).status_code == 303
|
||||||
|
assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete")
|
||||||
+77
-13
@@ -4,6 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -26,6 +27,8 @@ from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer
|
|||||||
|
|
||||||
import auth
|
import auth
|
||||||
import host_profile
|
import host_profile
|
||||||
|
import catalog
|
||||||
|
import cloudinit
|
||||||
import network_core
|
import network_core
|
||||||
import presenters
|
import presenters
|
||||||
import update_core
|
import update_core
|
||||||
@@ -293,12 +296,16 @@ def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
|
|||||||
update_operation(fresh, progress=new_progress, message=line.strip()[:240] or fresh.get("message"))
|
update_operation(fresh, progress=new_progress, message=line.strip()[:240] or fresh.get("message"))
|
||||||
exit_code = process.wait()
|
exit_code = process.wait()
|
||||||
fresh = read_operation(operation_id) or operation
|
fresh = read_operation(operation_id) or operation
|
||||||
|
if fresh.get("seed_file"):
|
||||||
|
cloudinit.remove_seed(fresh["seed_file"])
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
append_operation_log(operation_id, "virt-install завершился успешно.")
|
append_operation_log(operation_id, "virt-install завершился успешно.")
|
||||||
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
|
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
|
||||||
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message="VM создана успешно", finished_at=utc_now())
|
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=fresh.get("success_message") or "Машина создана", finished_at=utc_now())
|
||||||
else:
|
else:
|
||||||
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":
|
||||||
|
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())
|
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"virt-install завершился с ошибкой: {exit_code}", 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
|
||||||
@@ -577,7 +584,8 @@ def list_disk_image_files() -> list[dict[str, str]]:
|
|||||||
except OSError:
|
except OSError:
|
||||||
size_gb = 0
|
size_gb = 0
|
||||||
updated = "unknown"
|
updated = "unknown"
|
||||||
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated})
|
entry = catalog.entry_for_filename(item.name)
|
||||||
|
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else ""})
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
@@ -614,6 +622,14 @@ def disk_image_format(path: Path) -> str:
|
|||||||
return 'qcow2' if path.suffix.lower() == '.qcow2' else 'raw'
|
return 'qcow2' if path.suffix.lower() == '.qcow2' else 'raw'
|
||||||
|
|
||||||
|
|
||||||
|
def disk_image_virtual_size(path: Path) -> int:
|
||||||
|
info = run_cmd(["qemu-img", "info", "--output=json", str(path)], timeout=30)
|
||||||
|
try:
|
||||||
|
return int(json.loads(info.get("stdout") or "{}").get("virtual-size", 0)) if info.get("ok") else 0
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def bridge_exists(name: str) -> bool:
|
def bridge_exists(name: str) -> bool:
|
||||||
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
|
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
|
||||||
return False
|
return False
|
||||||
@@ -1239,7 +1255,7 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
|
|||||||
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(), "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("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)
|
||||||
|
|
||||||
|
|
||||||
# Virtuality noVNC console patch
|
# Virtuality noVNC console patch
|
||||||
@@ -1748,12 +1764,15 @@ 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)):
|
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("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
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}
|
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}
|
||||||
error = None
|
error = None
|
||||||
|
use_cloud_init = source_type == "disk_image" and cloud_init == "1"
|
||||||
|
ssh_keys = cloudinit.parse_ssh_keys(ci_ssh_key) if use_cloud_init else []
|
||||||
if not valid_vm_name(name):
|
if not valid_vm_name(name):
|
||||||
error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа."
|
error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа."
|
||||||
elif vm_exists(name):
|
elif vm_exists(name):
|
||||||
@@ -1772,6 +1791,14 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
|
|||||||
error = "Некорректный источник VM."
|
error = "Некорректный источник VM."
|
||||||
elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom"):
|
elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom"):
|
||||||
error = "Некорректный порядок загрузки VM."
|
error = "Некорректный порядок загрузки VM."
|
||||||
|
elif os_type not in {item["id"] for item in catalog.OS_TYPES}:
|
||||||
|
error = "Некорректный тип системы."
|
||||||
|
elif use_cloud_init and not cloudinit.valid_user(ci_user):
|
||||||
|
error = "Имя пользователя: строчные латинские буквы, цифры, дефис и подчёркивание, до 32 символов."
|
||||||
|
elif use_cloud_init and ssh_keys is None:
|
||||||
|
error = "SSH-ключ не похож на публичный ключ. Нужна строка вида «ssh-ed25519 AAAA… комментарий» — из файла ~/.ssh/id_ed25519.pub."
|
||||||
|
elif use_cloud_init and not ssh_keys and len(ci_password) < 6:
|
||||||
|
error = "Задайте пароль не короче 6 символов или вставьте публичный SSH-ключ — иначе в машину нельзя будет войти."
|
||||||
elif network_mode == "bridge" and not bridge_exists(bridge):
|
elif network_mode == "bridge" and not bridge_exists(bridge):
|
||||||
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
|
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
|
||||||
else:
|
else:
|
||||||
@@ -1802,24 +1829,60 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
|
|||||||
selected_boot_order = normalize_boot_order(boot_order, source_type)
|
selected_boot_order = normalize_boot_order(boot_order, source_type)
|
||||||
is_arm = selected_arch == "aarch64"
|
is_arm = selected_arch == "aarch64"
|
||||||
virt_type = "kvm" if profile.get("kvm_device") else "qemu"
|
virt_type = "kvm" if profile.get("kvm_device") else "qemu"
|
||||||
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
|
os_info = catalog.os_type(os_type) if source_type == "iso" else None
|
||||||
|
windows = os_type.startswith("windows") if source_type == "iso" else False
|
||||||
|
# Windows installers have no virtio drivers: SATA disk and an Intel network card work out of the box.
|
||||||
|
disk_bus = "sata" if windows or os_type == "other" and source_type == "iso" else "virtio"
|
||||||
|
nic_model = "e1000e" if disk_bus == "sata" else "virtio"
|
||||||
|
nat_ip = None
|
||||||
|
if network_mode == "nat":
|
||||||
|
mac = network_core.stable_mac(name)
|
||||||
|
nat_ip = network_core.reserve_nat_address(name, mac)
|
||||||
|
network_arg = f"network={network_core.NETWORK_NAME},model={nic_model},mac={mac}"
|
||||||
|
else:
|
||||||
|
network_arg = f"bridge={bridge},model={nic_model}"
|
||||||
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type]
|
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type]
|
||||||
if is_arm:
|
if is_arm:
|
||||||
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57"]
|
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57"]
|
||||||
|
uefi = is_arm or os_type == "windows11" and source_type == "iso"
|
||||||
|
catalog_entry = catalog.entry_for_filename(Path(disk_image_path).name) if source_type == "disk_image" else None
|
||||||
|
if catalog_entry:
|
||||||
|
uefi = uefi or bool(catalog_entry["arches"].get(selected_arch, {}).get("uefi"))
|
||||||
|
boot_arg = virt_boot_arg(selected_boot_order, uefi)
|
||||||
|
cmd += ["--boot", boot_arg]
|
||||||
|
if os_type == "windows11" and source_type == "iso":
|
||||||
|
# Windows 11 refuses to install without a TPM 2.0.
|
||||||
|
cmd += ["--tpm", "model=tpm-crb,backend.type=emulator,backend.version=2.0"]
|
||||||
|
graphics = ["--graphics", "vnc,listen=127.0.0.1", "--noautoconsole"]
|
||||||
|
|
||||||
cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]
|
seed_file = ""
|
||||||
|
login_hint = ""
|
||||||
if source_type == "disk_image":
|
if source_type == "disk_image":
|
||||||
source_disk = Path(disk_image_path).resolve()
|
source_disk = Path(disk_image_path).resolve()
|
||||||
source_format = disk_image_format(source_disk)
|
source_format = disk_image_format(source_disk)
|
||||||
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
|
osinfo = catalog_entry["osinfo"] if catalog_entry else "detect=on,require=off"
|
||||||
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=127.0.0.1", "--noautoconsole"])
|
steps = [f"qemu-img convert -p -f {source_format} -O qcow2 {shlex.quote(str(source_disk))} {shlex.quote(str(disk_path))}"]
|
||||||
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
|
if disk_size * 1024 ** 3 > disk_image_virtual_size(source_disk):
|
||||||
|
# cloud-init grows the root file system to the new size on first boot.
|
||||||
|
steps.append(f"qemu-img resize {shlex.quote(str(disk_path))} {disk_size}G")
|
||||||
|
virt = cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus={disk_bus}", "--osinfo", osinfo, "--network", network_arg, *graphics]
|
||||||
|
if use_cloud_init:
|
||||||
|
try:
|
||||||
|
password_hash = cloudinit.hash_password(ci_password) if ci_password else ""
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return vm_form_context(request, error=f"Не удалось подготовить пароль: {exc}", form=form, status_code=500)
|
||||||
|
user_file, meta_file = cloudinit.write_seed(name, cloudinit.build_user_data(name, ci_user, password_hash, ssh_keys or []), cloudinit.build_meta_data(name))
|
||||||
|
seed_file = str(user_file)
|
||||||
|
virt += ["--cloud-init", f"user-data={user_file},meta-data={meta_file}"]
|
||||||
|
login_hint = f"Вход: {ci_user}" + (" по паролю" if password_hash else "") + (" и по SSH-ключу" if ssh_keys else "")
|
||||||
|
steps.append(" ".join(shlex.quote(part) for part in virt))
|
||||||
|
cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)]
|
||||||
else:
|
else:
|
||||||
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=127.0.0.1", "--noautoconsole"]
|
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]
|
||||||
|
|
||||||
|
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, "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, "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)
|
||||||
|
|
||||||
@@ -1959,6 +2022,7 @@ def vm_action(request: Request, name: str, action: str):
|
|||||||
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)
|
||||||
return redirect_with_message("/", "message", f"Машина {name} удалена")
|
return redirect_with_message("/", "message", f"Машина {name} удалена")
|
||||||
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)
|
||||||
|
|||||||
@@ -80,3 +80,12 @@ def os_type(type_id: str) -> dict[str, Any]:
|
|||||||
def image_filename(entry: dict[str, Any], arch: str) -> str:
|
def image_filename(entry: dict[str, Any], arch: str) -> str:
|
||||||
url = entry["arches"][arch]["url"]
|
url = entry["arches"][arch]["url"]
|
||||||
return url.rsplit("/", 1)[-1]
|
return url.rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def entry_for_filename(name: str) -> dict[str, Any] | None:
|
||||||
|
"""Catalog entry whose download has this file name (any architecture)."""
|
||||||
|
for entry in CLOUD_IMAGES:
|
||||||
|
for arch in entry["arches"].values():
|
||||||
|
if arch["url"].rsplit("/", 1)[-1] == name:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""cloud-init seed for machines created from ready-made (cloud) images.
|
||||||
|
|
||||||
|
Cloud images ship without a password: the first login is configured here —
|
||||||
|
user, password and/or SSH key — and virt-install attaches the seed as a small
|
||||||
|
CD-ROM (`--cloud-init user-data=…,meta-data=…`)."""
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import catalog
|
||||||
|
from core import STORAGE_DIR
|
||||||
|
|
||||||
|
USER_RE = re.compile(r"[a-z_][a-z0-9_-]{0,31}")
|
||||||
|
SSH_KEY_RE = re.compile(r"^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com) [A-Za-z0-9+/=]+( .*)?$")
|
||||||
|
RESERVED_USERS = {"root", "daemon", "bin", "sys", "nobody", "systemd-network", "syslog", "messagebus"}
|
||||||
|
|
||||||
|
|
||||||
|
def default_user(image_name: str) -> str:
|
||||||
|
entry = catalog.entry_for_filename(image_name)
|
||||||
|
return str(entry.get("login") or "admin") if entry else "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def valid_user(name: str) -> bool:
|
||||||
|
return bool(USER_RE.fullmatch(name or "")) and name not in RESERVED_USERS
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ssh_keys(text: str) -> list[str] | None:
|
||||||
|
"""Public keys, one per line. None when a line is not a public key."""
|
||||||
|
keys = []
|
||||||
|
for line in (text or "").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if not SSH_KEY_RE.match(line) or len(line) > 4096:
|
||||||
|
return None
|
||||||
|
keys.append(line)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""SHA-512 crypt hash: the seed CD-ROM stays readable on the server, so the password is never stored as text."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["openssl", "passwd", "-6", "-stdin"], input=password + "\n", capture_output=True, text=True, timeout=20, check=False)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
raise RuntimeError(f"openssl недоступен: {exc}") from exc
|
||||||
|
digest = result.stdout.strip()
|
||||||
|
if result.returncode != 0 or not digest.startswith("$6$"):
|
||||||
|
raise RuntimeError(result.stderr.strip() or "openssl passwd завершился с ошибкой")
|
||||||
|
return digest
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_data(hostname: str, user: str, password_hash: str, ssh_keys: list[str]) -> str:
|
||||||
|
lines = [
|
||||||
|
"#cloud-config",
|
||||||
|
f"hostname: {hostname}",
|
||||||
|
"manage_etc_hosts: true",
|
||||||
|
"users:",
|
||||||
|
f" - name: {user}",
|
||||||
|
" sudo: ALL=(ALL) NOPASSWD:ALL",
|
||||||
|
" groups: [sudo, wheel, adm]",
|
||||||
|
" shell: /bin/bash",
|
||||||
|
" lock_passwd: false",
|
||||||
|
]
|
||||||
|
if password_hash:
|
||||||
|
lines.append(f" passwd: '{password_hash}'")
|
||||||
|
if ssh_keys:
|
||||||
|
lines.append(" ssh_authorized_keys:")
|
||||||
|
lines += [f" - {key}" for key in ssh_keys]
|
||||||
|
lines += [
|
||||||
|
f"ssh_pwauth: {'true' if password_hash else 'false'}",
|
||||||
|
"chpasswd:",
|
||||||
|
" expire: false",
|
||||||
|
"disable_root: true",
|
||||||
|
"growpart:",
|
||||||
|
" mode: auto",
|
||||||
|
" devices: ['/']",
|
||||||
|
"resize_rootfs: true",
|
||||||
|
"package_update: false",
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_meta_data(name: str) -> str:
|
||||||
|
return f"instance-id: virtuality-{name}\nlocal-hostname: {name}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def write_seed(name: str, user_data: str, meta_data: str) -> tuple[Path, Path]:
|
||||||
|
"""Files for virt-install --cloud-init; the folder is removed once the machine is created."""
|
||||||
|
root = STORAGE_DIR / "tmp"
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
folder = Path(tempfile.mkdtemp(prefix=f"cloudinit-{name}-", dir=str(root)))
|
||||||
|
user_file, meta_file = folder / "user-data", folder / "meta-data"
|
||||||
|
user_file.write_text(user_data)
|
||||||
|
meta_file.write_text(meta_data)
|
||||||
|
user_file.chmod(0o600)
|
||||||
|
return user_file, meta_file
|
||||||
|
|
||||||
|
|
||||||
|
def remove_seed(user_file: Path | str) -> None:
|
||||||
|
folder = Path(user_file).parent
|
||||||
|
if folder.name.startswith("cloudinit-"):
|
||||||
|
shutil.rmtree(folder, ignore_errors=True)
|
||||||
@@ -177,6 +177,60 @@ def nat_network_xml() -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def stable_mac(vm_name: str) -> str:
|
||||||
|
"""Locally administered QEMU MAC derived from the machine name (same name → same address)."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
digest = hashlib.sha1(vm_name.encode()).digest()
|
||||||
|
return '52:54:00:' + ':'.join(f'{b:02x}' for b in digest[:3])
|
||||||
|
|
||||||
|
|
||||||
|
def nat_reservations() -> list[dict[str, str]]:
|
||||||
|
"""DHCP reservations of the NAT network: [{'name', 'mac', 'ip'}]."""
|
||||||
|
result = run_cmd(['virsh', 'net-dumpxml', NETWORK_NAME], timeout=8)
|
||||||
|
if not result['ok']:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{'mac': m.group(1).lower(), 'name': m.group(2), 'ip': m.group(3)}
|
||||||
|
for m in re.finditer(r"<host\s+mac='([^']+)'\s+name='([^']+)'\s+ip='([^']+)'\s*/>", result['stdout'])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _net_update(action: str, host_xml: str) -> dict[str, Any]:
|
||||||
|
base = ['virsh', 'net-update', NETWORK_NAME, action, 'ip-dhcp-host', host_xml]
|
||||||
|
result = run_cmd(base + ['--live', '--config'], timeout=15)
|
||||||
|
if not result['ok'] and 'not active' in (result['stderr'] + result['stdout']).lower():
|
||||||
|
result = run_cmd(base + ['--config'], timeout=15)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def reserve_nat_address(vm_name: str, mac: str) -> str | None:
|
||||||
|
"""Pin the machine to one NAT address for its whole life; None when libvirt refused."""
|
||||||
|
if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name or ''):
|
||||||
|
return None
|
||||||
|
reservations = nat_reservations()
|
||||||
|
for item in reservations:
|
||||||
|
if item['name'] == vm_name:
|
||||||
|
if item['mac'] != mac.lower():
|
||||||
|
_net_update('modify', f"<host mac='{mac}' name='{vm_name}' ip='{item['ip']}'/>")
|
||||||
|
return item['ip']
|
||||||
|
taken = {item['ip'] for item in reservations}
|
||||||
|
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)
|
||||||
|
taken.update(re.findall(r'\b(192\.168\.100\.\d+)/', leases['stdout'] if leases['ok'] else ''))
|
||||||
|
start, end = int(DHCP_START.rsplit('.', 1)[1]), int(DHCP_END.rsplit('.', 1)[1])
|
||||||
|
free = next((f'192.168.100.{last}' for last in range(start, end + 1) if f'192.168.100.{last}' not in taken), None)
|
||||||
|
if not free:
|
||||||
|
return None
|
||||||
|
result = _net_update('add-last', f"<host mac='{mac}' name='{vm_name}' ip='{free}'/>")
|
||||||
|
return free if result['ok'] else None
|
||||||
|
|
||||||
|
|
||||||
|
def release_nat_address(vm_name: str) -> None:
|
||||||
|
for item in nat_reservations():
|
||||||
|
if item['name'] == vm_name:
|
||||||
|
_net_update('delete', f"<host mac='{item['mac']}' name='{item['name']}' ip='{item['ip']}'/>")
|
||||||
|
|
||||||
|
|
||||||
def libvirt_network_info() -> dict[str, Any]:
|
def libvirt_network_info() -> dict[str, Any]:
|
||||||
info = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8)
|
info = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8)
|
||||||
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)
|
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)
|
||||||
|
|||||||
@@ -445,8 +445,17 @@
|
|||||||
});
|
});
|
||||||
$$('[data-visible-when]', form).forEach((el) => {
|
$$('[data-visible-when]', form).forEach((el) => {
|
||||||
const [key, expected] = el.dataset.visibleWhen.split('=');
|
const [key, expected] = el.dataset.visibleWhen.split('=');
|
||||||
el.hidden = value(key) !== expected;
|
const target = field(key);
|
||||||
|
const current = target instanceof HTMLInputElement && target.type === 'checkbox' ? (target.checked ? target.value : '0') : value(key);
|
||||||
|
el.hidden = current !== expected;
|
||||||
});
|
});
|
||||||
|
// Default login of the chosen cloud image (ubuntu, debian, …) until the user types a name.
|
||||||
|
const login = field('ci_user');
|
||||||
|
if (login && login.dataset.autoLogin === '1') {
|
||||||
|
const image = field('disk_image_path');
|
||||||
|
const option = image && image.selectedIndex >= 0 ? image.options[image.selectedIndex] : null;
|
||||||
|
login.value = option?.dataset.login || 'admin';
|
||||||
|
}
|
||||||
const preset = value('preset');
|
const preset = value('preset');
|
||||||
const custom = preset === 'custom';
|
const custom = preset === 'custom';
|
||||||
const presetInput = $(`input[name=preset][value="${preset}"]`, form);
|
const presetInput = $(`input[name=preset][value="${preset}"]`, form);
|
||||||
@@ -461,13 +470,13 @@
|
|||||||
setSummary('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path')));
|
setSummary('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path')));
|
||||||
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
|
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
|
||||||
setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`);
|
setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`);
|
||||||
setSummary('disk', source === 'disk_image' ? 'из образа' : `${value('disk_size')} ГБ`);
|
setSummary('disk', `${value('disk_size')} ГБ`);
|
||||||
setSummary('network', value('network_mode') === 'bridge' ? 'Локальная сеть' : 'Автоматически (NAT)');
|
setSummary('network', value('network_mode') === 'bridge' ? 'Локальная сеть' : 'Автоматически (NAT)');
|
||||||
const hasSource = source === 'disk_image' ? form.dataset.hasDisks === '1' : form.dataset.hasIsos === '1';
|
const hasSource = source === 'disk_image' ? form.dataset.hasDisks === '1' : form.dataset.hasIsos === '1';
|
||||||
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
|
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
|
||||||
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
|
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
|
||||||
}
|
}
|
||||||
form.addEventListener('input', sync);
|
form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); });
|
||||||
form.addEventListener('change', sync);
|
form.addEventListener('change', sync);
|
||||||
sync();
|
sync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,15 +55,40 @@
|
|||||||
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
|
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field" data-show-when="source_type=iso">
|
||||||
|
<label>Что будете устанавливать</label>
|
||||||
|
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(170px,1fr))">
|
||||||
|
{% for os in os_types %}
|
||||||
|
<label class="option">
|
||||||
|
<input type="radio" name="os_type" value="{{ os.id }}" {% if (form.os_type or 'linux') == os.id %}checked{% endif %}>
|
||||||
|
<span class="option-body"><span class="option-top"><span class="option-title">{{ os.title }}</span><span class="option-check"></span></span><span class="option-text">{{ os.hint }}</span></span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span>
|
||||||
|
</div>
|
||||||
<div class="field" data-show-when="source_type=disk_image">
|
<div class="field" data-show-when="source_type=disk_image">
|
||||||
<label for="disk_image_path">Образ системы</label>
|
<label for="disk_image_path">Образ системы</label>
|
||||||
{% if disk_images %}
|
{% if disk_images %}
|
||||||
<select class="select" id="disk_image_path" name="disk_image_path">
|
<select class="select" id="disk_image_path" name="disk_image_path">
|
||||||
{% for image in disk_images %}<option value="{{ image.path }}" data-label="{{ image.name }}" {% if form.disk_image_path == image.path %}selected{% endif %}>{{ image.name }} · {{ image.size }}</option>{% endfor %}
|
{% for image in disk_images %}<option value="{{ image.path }}" data-label="{{ image.title or image.name }}" data-login="{{ image.login }}" {% if form.disk_image_path == image.path %}selected{% endif %}>{{ image.title or image.name }} · {{ image.size }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="hint">Образ будет скопирован — оригинал останется для других машин.</span>
|
<span class="hint">Образ будет скопирован — оригинал останется для других машин.</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stack-sm" data-show-when="source_type=disk_image" style="padding:14px 16px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-2)">
|
||||||
|
<label class="switch-row" style="cursor:pointer">
|
||||||
|
<span class="switch-text"><strong>Настроить первый вход</strong><span>У готовых образов нет пароля — без этого шага войти в машину не получится.</span></span>
|
||||||
|
<input type="checkbox" name="cloud_init" value="1" {% if form.cloud_init|default('1') == '1' %}checked{% endif %} style="width:20px;height:20px;accent-color:var(--brand)">
|
||||||
|
</label>
|
||||||
|
<div class="form-grid" data-visible-when="cloud_init=1">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field"><label for="ci_user">Пользователь</label><input class="input" id="ci_user" name="ci_user" value="{{ form.ci_user or '' }}" pattern="[a-z_][a-z0-9_\-]{0,31}" autocomplete="off" data-auto-login="{{ '0' if form.ci_user else '1' }}"><span class="hint">Получит права администратора (sudo)</span></div>
|
||||||
|
<div class="field"><label for="ci_password">Пароль</label><input class="input" id="ci_password" type="password" name="ci_password" minlength="6" autocomplete="new-password"><span class="hint">Не короче 6 символов</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label for="ci_ssh_key">SSH-ключ <span class="subtle">(необязательно)</span></label><textarea class="input" id="ci_ssh_key" name="ci_ssh_key" rows="2" placeholder="ssh-ed25519 AAAA… — содержимое файла ~/.ssh/id_ed25519.pub">{{ form.ci_ssh_key or '' }}</textarea><span class="hint">С ключом можно обойтись без пароля: вход по SSH будет только по ключу.</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="alert warning" data-missing-source="iso" hidden>{{ icon('triangle-alert') }}<div><strong>Нет установочных образов</strong>Загрузите .iso с системой, например Ubuntu или Debian.</div><a class="btn btn-sm alert-action" href="/iso">Загрузить</a></div>
|
<div class="alert warning" data-missing-source="iso" hidden>{{ icon('triangle-alert') }}<div><strong>Нет установочных образов</strong>Загрузите .iso с системой, например Ubuntu или Debian.</div><a class="btn btn-sm alert-action" href="/iso">Загрузить</a></div>
|
||||||
<div class="alert warning" data-missing-source="disk_image" hidden>{{ icon('triangle-alert') }}<div><strong>Нет готовых образов</strong>Загрузите образ системы в формате .qcow2, .img или архив.</div><a class="btn btn-sm alert-action" href="/disk-images">Загрузить</a></div>
|
<div class="alert warning" data-missing-source="disk_image" hidden>{{ icon('triangle-alert') }}<div><strong>Нет готовых образов</strong>Загрузите образ системы в формате .qcow2, .img или архив.</div><a class="btn btn-sm alert-action" href="/disk-images">Загрузить</a></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +120,7 @@
|
|||||||
<div class="form-row-3" data-custom-resources data-visible-when="preset=custom">
|
<div class="form-row-3" data-custom-resources data-visible-when="preset=custom">
|
||||||
<div class="field"><label for="vcpus">Ядра</label><input class="input" id="vcpus" type="number" name="vcpus" min="1" max="128" value="{{ form.vcpus }}" required></div>
|
<div class="field"><label for="vcpus">Ядра</label><input class="input" id="vcpus" type="number" name="vcpus" min="1" max="128" value="{{ form.vcpus }}" 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="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" data-show-when="source_type=iso"><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>
|
||||||
{% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %}
|
{% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user