Повседневные операции с машиной были добавлены: диск, клон, пауза, заметки, нагрузка
Модуль web/features/vmops.py (router подключён двумя строками в app.py):
- Увеличение диска на вкладке «Диски и загрузка»: размер из qemu-img info,
только рост, проверка свободного места; выключенная машина — qemu-img resize,
работающая — virsh blockresize. Диски вне /var/lib/virtuality не трогаются.
- Клонирование: страница /vm/{name}/clone с проверками (машина выключена,
хватает места, имя свободно), фоновая операция virt-clone с явными --file
в хранилище образов, прогресс по росту файлов копии, pool-refresh после.
- Пауза, продолжение, спящий режим (managedsave как операция) и удаление
сохранённого состояния — /vm/{name}/ops/{action}; пункты меню на обзоре
и странице машины, кнопка «Продолжить» для приостановленной машины.
- Заметки: название и описание через virsh desc (--new-desc=ТЕКСТ одним
аргументом, чтобы текст вида «--edit» не стал опцией); название видно
под именем машины на обзоре (virsh list --all --title, парсер в presenters).
- Живая нагрузка: /live/stats по virsh domstats для всех активных машин
одним вызовом, CPU % по разнице cpu.time между опросами; шкалы в строках
обзора и карточка «Нагрузка сейчас» на странице машины (panel.js, 5 с).
Тесты для всех парсеров и маршрутов (tests/test_vmops.py), демо-данные
в dev_server.py (три машины в разных состояниях, живые шкалы), значки
notebook-pen и expand в спрайте.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
@@ -35,6 +35,12 @@ DUMPXML = """<domain type='kvm'>
|
||||
"""
|
||||
|
||||
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
# Заметки машин для `virsh desc`: имя → (название, описание).
|
||||
NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.")}
|
||||
|
||||
|
||||
def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
|
||||
return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""}
|
||||
|
||||
@@ -42,6 +48,10 @@ def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
|
||||
def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
||||
joined = " ".join(cmd)
|
||||
if cmd[:2] == ["virsh", "list"]:
|
||||
if "--title" in cmd:
|
||||
return fake_result((FIXTURES / "list-title.txt").read_text())
|
||||
if "--name" in cmd:
|
||||
return fake_result("web01\ndb01\n" if "--all" in cmd else "web01\n")
|
||||
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
|
||||
if cmd[:2] == ["virsh", "dominfo"]:
|
||||
return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain")
|
||||
@@ -49,6 +59,20 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
||||
return fake_result(DUMPXML)
|
||||
if cmd[:2] == ["virsh", "domstate"]:
|
||||
return fake_result("running")
|
||||
if cmd[:2] == ["virsh", "domblklist"]:
|
||||
import core # диск лежит в подменённом data_dirs хранилище — иначе панель откажется его увеличивать
|
||||
|
||||
return fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", str(core.IMAGES_DIR)))
|
||||
if cmd[:2] == ["virsh", "domstats"]:
|
||||
return fake_result((FIXTURES / "domstats.txt").read_text())
|
||||
if cmd[:1] == ["virsh"] and "desc" in cmd[:3]:
|
||||
if any(arg.startswith("--new-desc") for arg in cmd):
|
||||
return fake_result("Domain description updated successfully")
|
||||
vm = cmd[cmd.index("desc") + 1]
|
||||
title, description = NOTES.get(vm, ("", ""))
|
||||
return fake_result(title if "--title" in cmd else description)
|
||||
if cmd[:2] == ["qemu-img", "info"]:
|
||||
return fake_result((FIXTURES / "qemu-img-info.json").read_text())
|
||||
if cmd[:2] == ["virsh", "domifaddr"]:
|
||||
return fake_result(" Name MAC address Protocol Address\n vnet0 52:54:00:aa:bb:cc ipv4 192.168.100.51/24")
|
||||
if cmd[:2] == ["virsh", "domiflist"]:
|
||||
@@ -75,6 +99,7 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
||||
@pytest.fixture()
|
||||
def data_dirs(tmp_path, monkeypatch):
|
||||
import app
|
||||
import core
|
||||
import host_profile
|
||||
import network_core
|
||||
import update_core
|
||||
@@ -91,10 +116,12 @@ def data_dirs(tmp_path, monkeypatch):
|
||||
}
|
||||
for path in dirs.values():
|
||||
path.mkdir()
|
||||
monkeypatch.setattr(app, "ISO_DIR", dirs["iso"])
|
||||
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
|
||||
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
|
||||
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
|
||||
for module in (app, core):
|
||||
monkeypatch.setattr(module, "ISO_DIR", dirs["iso"])
|
||||
monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"])
|
||||
monkeypatch.setattr(module, "DISK_IMAGES_DIR", dirs["disk_images"])
|
||||
monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"])
|
||||
monkeypatch.setattr(core, "STORAGE_DIR", tmp_path)
|
||||
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
|
||||
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
|
||||
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
|
||||
@@ -108,7 +135,7 @@ def data_dirs(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
|
||||
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
||||
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
|
||||
for module in (app, network_core, host_profile):
|
||||
for module in (app, core, network_core, host_profile):
|
||||
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
@@ -13,16 +14,78 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import conftest # noqa: E402 (sets sys.path and env)
|
||||
|
||||
import app # noqa: E402
|
||||
import core # noqa: E402
|
||||
import host_profile # noqa: E402
|
||||
import network_core # noqa: E402
|
||||
import update_core # noqa: E402
|
||||
|
||||
# Машины демо-сервера: имя → состояние virsh (web01 работает, db01 выключена, win11 на паузе).
|
||||
DEMO_VMS = {"web01": "running", "db01": "shut off", "win11": "paused"}
|
||||
DEMO_NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.\nЗа сервер отвечает Иван."), "win11": ("Бухгалтерия 1С", "")}
|
||||
DEMO_DOMSTATS = """Domain: 'web01'
|
||||
cpu.time={cpu_web}
|
||||
balloon.current=2097152
|
||||
balloon.maximum=2097152
|
||||
balloon.rss=1534208
|
||||
vcpu.current=2
|
||||
vcpu.maximum=2
|
||||
|
||||
Domain: 'win11'
|
||||
cpu.time={cpu_win}
|
||||
balloon.current=4194304
|
||||
balloon.maximum=4194304
|
||||
balloon.rss=3102720
|
||||
vcpu.current=4
|
||||
vcpu.maximum=4
|
||||
|
||||
"""
|
||||
_cpu_clock = {"web": 48_123_456_789, "win": 90_000_000_000, "at": time.monotonic()}
|
||||
|
||||
|
||||
def demo_run_cmd(cmd, timeout=12, **kwargs):
|
||||
"""Как conftest.fake_run_cmd, но с тремя машинами в разных состояниях и живой нагрузкой."""
|
||||
if cmd[:2] == ["virsh", "list"]:
|
||||
if "--title" in cmd:
|
||||
rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state:<10} {DEMO_NOTES.get(name, ('', ''))[0]}" for name, state in DEMO_VMS.items())
|
||||
return conftest.fake_result(" Id Name State Title\n" + "-" * 44 + "\n" + rows + "\n")
|
||||
if "--name" in cmd:
|
||||
names = [name for name, state in DEMO_VMS.items() if "--all" in cmd or state != "shut off"]
|
||||
return conftest.fake_result("\n".join(names) + "\n")
|
||||
rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state}" for name, state in DEMO_VMS.items())
|
||||
return conftest.fake_result(" Id Name State\n" + "-" * 25 + "\n" + rows)
|
||||
if cmd[:2] in (["virsh", "dominfo"], ["virsh", "domstate"]):
|
||||
name = cmd[-1]
|
||||
if name not in DEMO_VMS:
|
||||
return conftest.fake_result(ok=False, stderr=f"error: failed to get domain '{name}'")
|
||||
if cmd[1] == "domstate":
|
||||
return conftest.fake_result(DEMO_VMS[name])
|
||||
info = conftest.DOMINFO.replace("web01", name).replace("State: running", f"State: {DEMO_VMS[name]}")
|
||||
if name == "db01":
|
||||
info += "Managed save: yes\n"
|
||||
return conftest.fake_result(info)
|
||||
if cmd[:2] == ["virsh", "domstats"]:
|
||||
# Процессорное время растёт вместе с реальным: web01 нагружена на ~37 % (2 ядра), win11 на ~8 % (4 ядра).
|
||||
now = time.monotonic()
|
||||
elapsed_ns = (now - _cpu_clock["at"]) * 1e9
|
||||
_cpu_clock.update(web=_cpu_clock["web"] + int(elapsed_ns * 2 * 0.37), win=_cpu_clock["win"] + int(elapsed_ns * 4 * 0.08), at=now)
|
||||
return conftest.fake_result(DEMO_DOMSTATS.format(cpu_web=_cpu_clock["web"], cpu_win=_cpu_clock["win"]))
|
||||
if cmd[:2] == ["virsh", "domblklist"]:
|
||||
return conftest.fake_result((conftest.FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images/web01", str(core.IMAGES_DIR / cmd[2])))
|
||||
if cmd[:1] == ["virsh"] and "desc" in cmd[:3] and not any(arg.startswith("--new-desc") for arg in cmd):
|
||||
title, description = DEMO_NOTES.get(cmd[cmd.index("desc") + 1], ("", ""))
|
||||
return conftest.fake_result(title if "--title" in cmd else description)
|
||||
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||
|
||||
|
||||
def seed(root: Path) -> None:
|
||||
dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")}
|
||||
for path in dirs.values():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
|
||||
core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR, core.STORAGE_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], root
|
||||
for name in DEMO_VMS: # диски машин лежат в хранилище, иначе форма увеличения не появится
|
||||
with (dirs["images"] / f"{name}.qcow2").open("wb") as handle:
|
||||
handle.truncate(4 * 1024 * 1024)
|
||||
network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"]
|
||||
network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
|
||||
network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
|
||||
@@ -32,8 +95,8 @@ def seed(root: Path) -> None:
|
||||
host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json"
|
||||
update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log"
|
||||
update_core.SOURCE_DIR = Path(__file__).resolve().parents[1]
|
||||
for module in (app, network_core, host_profile):
|
||||
module.run_cmd = conftest.fake_run_cmd
|
||||
for module in (app, core, network_core, host_profile):
|
||||
module.run_cmd = demo_run_cmd
|
||||
app.auth.verify_password = lambda user, password: True
|
||||
|
||||
for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)):
|
||||
@@ -49,6 +112,12 @@ def seed(root: Path) -> None:
|
||||
op_id = str(uuid.uuid4())
|
||||
app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"})
|
||||
app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...")
|
||||
# Завершённое клонирование web01 → web01-copy: видно на обзоре и на странице /operations/{id}.
|
||||
clone_op = core.new_operation("vm_clone", "Клонирование web01 → web01-copy", vm_name="web01-copy", source_vm="web01", targets=[str(dirs["images"] / "web01-copy.qcow2")], expected_bytes=6443499520)
|
||||
core.append_operation_log(clone_op["id"], "virt-clone --original web01 --name web01-copy --file /var/lib/virtuality/images/web01-copy.qcow2")
|
||||
core.append_operation_log(clone_op["id"], "Allocating 'web01-copy.qcow2' | 6.0 GB 00:01:12")
|
||||
core.update_operation(clone_op, cmd="virt-clone --original web01 --name web01-copy --file " + str(dirs["images"] / "web01-copy.qcow2"))
|
||||
core.finish_operation(clone_op, True, "Машина web01-copy создана — это копия web01. Запустите её и смените имя компьютера внутри системы.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Vendored
@@ -0,0 +1,6 @@
|
||||
Id Name State Title
|
||||
--------------------------------------------
|
||||
1 web01 running Сайт компании
|
||||
- db01 shut off
|
||||
- win11 shut off Бухгалтерия 1С
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
"""vmops: увеличение диска, клонирование, пауза и спящий режим, заметки, живая нагрузка."""
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
import conftest
|
||||
import core
|
||||
import presenters
|
||||
from features import vmops
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
DOMSTATS = (FIXTURES / "domstats.txt").read_text()
|
||||
GIB = 1024 ** 3
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Записывает команды; отвечает по overrides {(префикс,): результат|функция}, иначе как fake_run_cmd."""
|
||||
|
||||
def __init__(self, overrides=None):
|
||||
self.calls: list[list[str]] = []
|
||||
self.overrides = overrides or {}
|
||||
|
||||
def __call__(self, cmd, timeout=12, **kwargs):
|
||||
self.calls.append(list(cmd))
|
||||
for prefix, result in self.overrides.items():
|
||||
if tuple(cmd[: len(prefix)]) == prefix:
|
||||
return result(cmd) if callable(result) else result
|
||||
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||
|
||||
def find(self, *prefix):
|
||||
return [call for call in self.calls if tuple(call[: len(prefix)]) == prefix]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def record(data_dirs, monkeypatch):
|
||||
"""Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины)."""
|
||||
import app
|
||||
|
||||
recorder = Recorder()
|
||||
for module in (core, app):
|
||||
monkeypatch.setattr(module, "run_cmd", recorder)
|
||||
monkeypatch.setattr(vmops, "_samples", {})
|
||||
return recorder
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def inline_operations(monkeypatch):
|
||||
"""Фоновые операции выполняются сразу, в этом же потоке."""
|
||||
|
||||
def run_now(operation, worker):
|
||||
core.update_operation(operation, status="running")
|
||||
worker(operation)
|
||||
|
||||
monkeypatch.setattr(vmops, "run_operation", run_now)
|
||||
|
||||
|
||||
def shut_off(record):
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("shut off")
|
||||
|
||||
|
||||
def location(response) -> str:
|
||||
assert response.status_code == 303, response.text[:300]
|
||||
return unquote(response.headers["location"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsers
|
||||
def test_parse_domstats_fixture():
|
||||
stats = vmops.parse_domstats(DOMSTATS)
|
||||
assert list(stats) == ["web01"]
|
||||
assert stats["web01"]["cpu.time"] == "48123456789"
|
||||
assert stats["web01"]["balloon.rss"] == "1534208"
|
||||
assert stats["web01"]["vcpu.current"] == "2"
|
||||
|
||||
|
||||
def test_parse_domstats_several_domains():
|
||||
text = DOMSTATS + "Domain: 'db01'\n\nDomain: 'win 11'\n cpu.time=5\n vcpu.current=4\n"
|
||||
stats = vmops.parse_domstats(text)
|
||||
assert set(stats) == {"web01", "db01", "win 11"}
|
||||
assert stats["db01"] == {}
|
||||
assert stats["win 11"] == {"cpu.time": "5", "vcpu.current": "4"}
|
||||
|
||||
|
||||
def test_compute_stats_cpu_percent_from_two_samples():
|
||||
samples: dict = {}
|
||||
first = vmops.compute_stats(vmops.parse_domstats(DOMSTATS), 100.0, samples)
|
||||
assert first["web01"]["cpu_pct"] is None # первый замер — сравнивать не с чем
|
||||
assert first["web01"]["mem_used_mb"] == 1498 and first["web01"]["mem_total_mb"] == 2048
|
||||
later = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=49123456789") # +1 с процессорного времени
|
||||
second = vmops.compute_stats(vmops.parse_domstats(later), 101.0, samples)
|
||||
assert second["web01"]["cpu_pct"] == 50.0 # 1 с на 2 ядра за 1 с стены
|
||||
burst = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=99123456789")
|
||||
third = vmops.compute_stats(vmops.parse_domstats(burst), 101.5, samples)
|
||||
assert third["web01"]["cpu_pct"] == 100.0 # не больше 100 %
|
||||
|
||||
|
||||
def test_compute_stats_skips_shut_off_and_forgets_stale():
|
||||
samples = {"old": (1.0, 5)}
|
||||
stats = vmops.compute_stats({"db01": {}, "web01": {"cpu.time": "10", "balloon.current": "1024", "vcpu.current": "1"}}, 2.0, samples)
|
||||
assert set(stats) == {"web01"}
|
||||
assert stats["web01"]["mem_used_mb"] == 1 and stats["web01"]["mem_total_mb"] == 1
|
||||
assert set(samples) == {"web01"}
|
||||
|
||||
|
||||
def test_parse_qemu_img_info():
|
||||
info = vmops.parse_qemu_img_info((FIXTURES / "qemu-img-info.json").read_text())
|
||||
assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"}
|
||||
assert vmops.parse_qemu_img_info("not json") == {}
|
||||
assert vmops.parse_qemu_img_info("[1, 2]") == {}
|
||||
|
||||
|
||||
def test_parse_virsh_list_titles():
|
||||
titles = presenters.parse_virsh_list_titles((FIXTURES / "list-title.txt").read_text())
|
||||
assert titles == {"web01": "Сайт компании", "win11": "Бухгалтерия 1С"}
|
||||
assert presenters.parse_virsh_list_titles(" Id Name State\n---\n 1 web01 running") == {}
|
||||
assert presenters.parse_virsh_list_titles("") == {}
|
||||
|
||||
|
||||
def test_parse_desc_output():
|
||||
assert vmops.parse_desc_output(conftest.fake_result("Сайт компании\n")) == "Сайт компании"
|
||||
assert vmops.parse_desc_output(conftest.fake_result("No title for domain: web01")) == ""
|
||||
assert vmops.parse_desc_output(conftest.fake_result(ok=False, stderr="error: failed to get domain")) == ""
|
||||
|
||||
|
||||
def test_has_managed_save():
|
||||
assert vmops.has_managed_save(conftest.DOMINFO + "Managed save: yes\n")
|
||||
assert not vmops.has_managed_save(conftest.DOMINFO + "Managed save: no\n")
|
||||
assert not vmops.has_managed_save(conftest.DOMINFO)
|
||||
|
||||
|
||||
def test_clean_notes():
|
||||
assert vmops.clean_title(" Сайт \n компании ") == "Сайт компании"
|
||||
assert len(vmops.clean_title("x" * 200)) == vmops.TITLE_MAX
|
||||
assert vmops.clean_description("строка 1\r\nстрока 2 \r\n\r\n") == "строка 1\nстрока 2"
|
||||
|
||||
|
||||
def test_clone_names_and_targets(data_dirs):
|
||||
assert vmops.suggest_clone_name("web01", {"web01"}) == "web01-copy"
|
||||
assert vmops.suggest_clone_name("web01", {"web01", "web01-copy", "web01-copy2"}) == "web01-copy3"
|
||||
disks = [
|
||||
{"device": "disk", "source": "/var/lib/virtuality/images/web01.qcow2"},
|
||||
{"device": "cdrom", "source": ""},
|
||||
{"device": "disk", "source": "/var/lib/virtuality/images/data.img"},
|
||||
]
|
||||
targets = vmops.clone_targets("web02", disks)
|
||||
assert targets == [data_dirs["images"] / "web02.qcow2", data_dirs["images"] / "web02-disk2.img"]
|
||||
|
||||
|
||||
def test_explain_error():
|
||||
assert "снимки" in vmops.explain_error("qemu-img: Can't resize an image which has snapshots", "x")
|
||||
assert vmops.explain_error("", "запасной текст") == "запасной текст"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- live stats
|
||||
def test_live_stats_requires_auth(client):
|
||||
assert client.get("/live/stats").status_code == 401
|
||||
|
||||
|
||||
def test_live_stats_json(logged_in, record):
|
||||
first = logged_in.get("/live/stats").json()
|
||||
assert first["ok"] is True
|
||||
assert first["stats"]["web01"]["cpu_pct"] is None
|
||||
assert first["stats"]["web01"]["mem_used_mb"] == 1498 and first["stats"]["web01"]["mem_total_mb"] == 2048
|
||||
second = logged_in.get("/live/stats").json()
|
||||
assert second["stats"]["web01"]["cpu_pct"] == 0.0 # cpu.time в подделке не растёт
|
||||
assert record.find("virsh", "domstats") == [["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", "web01"]] * 2 # только активные машины, один вызов на запрос
|
||||
|
||||
|
||||
def test_live_stats_without_active_vms(logged_in, record):
|
||||
record.overrides[("virsh", "list")] = conftest.fake_result("")
|
||||
assert logged_in.get("/live/stats").json()["stats"] == {}
|
||||
assert record.find("virsh", "domstats") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pages
|
||||
def test_dashboard_shows_title_and_meters(logged_in, record):
|
||||
html = logged_in.get("/").text
|
||||
assert "Сайт компании" in html
|
||||
assert 'data-live-stats="web01"' in html and 'data-live-stats="db01"' in html
|
||||
assert "/vm/web01/ops/pause" in html and "/vm/web01/ops/hibernate" in html
|
||||
assert "/vm/web01/clone" in html and "/vm/db01/clone" in html
|
||||
assert record.find("virsh", "list", "--all", "--title")
|
||||
|
||||
|
||||
def test_dashboard_paused_vm_offers_resume(logged_in, record):
|
||||
record.overrides[("virsh", "list")] = conftest.fake_result(" Id Name State\n-----------------------\n 2 web01 paused\n - db01 shut off")
|
||||
html = logged_in.get("/").text
|
||||
assert 'action="/vm/web01/ops/resume"' in html
|
||||
assert 'action="/vm/web01/start"' not in html
|
||||
assert 'action="/vm/db01/start"' in html
|
||||
|
||||
|
||||
def test_vm_page_shows_disks_notes_and_ops(logged_in, record):
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert "40 ГБ" in html and "занято 6.0 ГБ" in html
|
||||
assert 'action="/vm/web01/disks/vda/resize"' in html and 'min="41"' in html
|
||||
assert 'action="/vm/web01/disks/sda/resize"' not in html # привод не увеличивают
|
||||
assert 'value="Сайт компании"' in html and "Nginx + база. Бэкап по пятницам." in html
|
||||
assert "Приостановить" in html and "Спящий режим" in html and "Клонировать" in html
|
||||
assert 'data-live-stats="web01"' in html # карточка «Нагрузка сейчас»
|
||||
assert record.find("qemu-img", "info") == [["qemu-img", "info", "--output=json", "-U", str(core.IMAGES_DIR / "web01.qcow2")]]
|
||||
|
||||
|
||||
def test_vm_page_paused_and_saved_states(logged_in, record):
|
||||
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: paused"))
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert 'action="/vm/web01/ops/resume"' in html and "Продолжить" in html
|
||||
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: shut off") + "Managed save: yes\n")
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert "Машина в спящем режиме" in html and 'action="/vm/web01/ops/forget-save"' in html
|
||||
assert 'data-live-stats="web01"' not in html # выключенная машина — нагрузки нет
|
||||
|
||||
|
||||
def test_vm_page_messages(logged_in, record):
|
||||
html = logged_in.get("/vm/web01?disk_message=Диск+увеличен¬es_error=Не+вышло").text
|
||||
assert "Диск увеличен" in html and "Не вышло" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- disk resize
|
||||
def test_disk_resize_shut_off_uses_qemu_img(logged_in, record):
|
||||
shut_off(record)
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
|
||||
assert location(response).startswith("/vm/web01?disk_message=Диск web01.qcow2 увеличен до 50 ГБ")
|
||||
assert location(response).endswith("#storage")
|
||||
assert record.find("qemu-img", "resize") == [["qemu-img", "resize", str(core.IMAGES_DIR / "web01.qcow2"), "50G"]]
|
||||
assert record.find("virsh", "blockresize") == []
|
||||
|
||||
|
||||
def test_disk_resize_running_uses_blockresize(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "41"}, follow_redirects=False)
|
||||
assert "disk_message" in location(response)
|
||||
assert record.find("virsh", "blockresize") == [["virsh", "blockresize", "web01", "vda", "41G"]]
|
||||
assert record.find("qemu-img", "resize") == []
|
||||
|
||||
|
||||
def test_disk_resize_only_grows(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "40"}, follow_redirects=False)
|
||||
assert "не меньше 41 ГБ" in location(response)
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "999999"}, follow_redirects=False)
|
||||
assert "Слишком большой" in location(response)
|
||||
assert record.find("virsh", "blockresize") == [] and record.find("qemu-img", "resize") == []
|
||||
|
||||
|
||||
def test_disk_resize_rejects_unknown_or_cdrom(logged_in, record):
|
||||
assert "Такого диска" in location(logged_in.post("/vm/web01/disks/vdz/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/sda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
assert logged_in.post("/vm/web01/disks/../etc/resize", data={"size_gb": "50"}, follow_redirects=False).status_code in (400, 404)
|
||||
assert logged_in.post("/vm/-bad/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
def test_disk_resize_outside_storage_refused(logged_in, record):
|
||||
record.overrides[("virsh", "domblklist")] = conftest.fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", "/root"))
|
||||
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
|
||||
|
||||
def test_disk_resize_checks_free_space(logged_in, record, monkeypatch):
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: 2 * GIB)
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
|
||||
assert "свободно только 2.0 ГБ" in location(response)
|
||||
assert record.find("virsh", "blockresize") == []
|
||||
|
||||
|
||||
def test_disk_resize_reports_tool_error(logged_in, record):
|
||||
record.overrides[("virsh", "blockresize")] = conftest.fake_result(ok=False, stderr="error: internal error: Can't resize an image which has snapshots")
|
||||
assert "удалите снимки" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pause / resume / hibernate
|
||||
def test_ops_pause_and_resume(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/ops/pause", data={"next": "/"}, follow_redirects=False)
|
||||
assert location(response).startswith("/?ops_message=Машина web01 приостановлена")
|
||||
assert record.find("virsh", "suspend") == [["virsh", "suspend", "web01"]]
|
||||
response = logged_in.post("/vm/web01/ops/resume", follow_redirects=False)
|
||||
assert "ops_error=Продолжить можно только приостановленную" in location(response)
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("paused")
|
||||
response = logged_in.post("/vm/web01/ops/resume", data={"next": "http://evil.example/"}, follow_redirects=False)
|
||||
assert location(response).startswith("/vm/web01?ops_message=") # чужой next не используется
|
||||
assert record.find("virsh", "resume") == [["virsh", "resume", "web01"]]
|
||||
|
||||
|
||||
def test_ops_validation(logged_in, record):
|
||||
assert logged_in.post("/vm/web01/ops/explode", follow_redirects=False).status_code == 400
|
||||
assert logged_in.post("/vm/-bad/ops/pause", follow_redirects=False).status_code == 400
|
||||
assert location(logged_in.post("/vm/ghost/ops/pause", follow_redirects=False)) == "/"
|
||||
shut_off(record)
|
||||
assert "ops_error=Приостановить можно только работающую" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
|
||||
record.overrides[("virsh", "suspend")] = conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||
assert "занята другой операцией" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
|
||||
|
||||
|
||||
def test_ops_requires_login(client):
|
||||
assert client.post("/vm/web01/ops/pause", follow_redirects=False).headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_hibernate_runs_operation(logged_in, record, inline_operations, data_dirs):
|
||||
response = logged_in.post("/vm/web01/ops/hibernate", data={"next": "/"}, follow_redirects=False)
|
||||
assert location(response).startswith("/?ops_message=Машина web01 засыпает")
|
||||
assert record.find("virsh", "managedsave") == [["virsh", "managedsave", "web01"]]
|
||||
operations = core.list_operations(5)
|
||||
assert operations[0]["type"] == "vm_hibernate" and operations[0]["status"] == "success" and operations[0]["vm_name"] == "web01"
|
||||
|
||||
|
||||
def test_hibernate_blocked(logged_in, record, monkeypatch):
|
||||
shut_off(record)
|
||||
assert "ops_error=В спящий режим можно" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: 0)
|
||||
assert "свободного места" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
|
||||
assert record.find("virsh", "managedsave") == []
|
||||
|
||||
|
||||
def test_hibernate_failure_is_reported(logged_in, record, inline_operations):
|
||||
record.overrides[("virsh", "managedsave")] = conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running")
|
||||
logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False)
|
||||
operation = core.list_operations(1)[0]
|
||||
assert operation["status"] == "error" and "выключена" in operation["message"]
|
||||
|
||||
|
||||
def test_forget_saved_state(logged_in, record):
|
||||
shut_off(record)
|
||||
response = logged_in.post("/vm/web01/ops/forget-save", follow_redirects=False)
|
||||
assert "ops_message=Сохранённое состояние удалено" in location(response)
|
||||
assert record.find("virsh", "managedsave-remove") == [["virsh", "managedsave-remove", "web01"]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- notes
|
||||
def test_notes_save_running(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/notes/save", data={"title": " Сайт компании ", "description": "строка 1\r\nстрока 2"}, follow_redirects=False)
|
||||
assert location(response) == "/vm/web01?notes_message=Заметки сохранены"
|
||||
assert record.find("virsh", "desc") == [
|
||||
["virsh", "desc", "web01", "--title", "--new-desc=Сайт компании", "--config", "--live"],
|
||||
["virsh", "desc", "web01", "--new-desc=строка 1\nстрока 2", "--config", "--live"],
|
||||
]
|
||||
|
||||
|
||||
def test_notes_save_shut_off_and_dashes(logged_in, record):
|
||||
shut_off(record)
|
||||
logged_in.post("/vm/web01/notes/save", data={"title": "--edit", "description": ""}, follow_redirects=False)
|
||||
calls = record.find("virsh", "desc")
|
||||
assert calls[0] == ["virsh", "desc", "web01", "--title", "--new-desc=--edit", "--config"] # текст одним аргументом, без --live
|
||||
assert calls[1] == ["virsh", "desc", "web01", "--new-desc=", "--config"] # пустое описание очищает
|
||||
|
||||
|
||||
def test_notes_save_error(logged_in, record):
|
||||
record.overrides[("virsh", "desc")] = conftest.fake_result(ok=False, stderr="error: failed to get domain 'web01'")
|
||||
assert "notes_error=" in location(logged_in.post("/vm/web01/notes/save", data={"title": "x"}, follow_redirects=False))
|
||||
assert logged_in.post("/vm/-bad/notes/save", data={"title": "x"}, follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- clone
|
||||
def test_clone_page_blocked_while_running(logged_in, record):
|
||||
response = logged_in.get("/vm/web01/clone")
|
||||
assert response.status_code == 200
|
||||
assert "Сначала выключите машину" in response.text
|
||||
assert 'action="/vm/web01/shutdown"' in response.text
|
||||
assert "disabled" in response.text
|
||||
|
||||
|
||||
def test_clone_page_ready(logged_in, record):
|
||||
shut_off(record)
|
||||
html = logged_in.get("/vm/web01/clone").text
|
||||
assert "Сначала выключите" not in html
|
||||
assert 'value="web01-copy"' in html
|
||||
assert "6.0 ГБ" in html # нужно места — реальный размер диска
|
||||
assert "web01.qcow2" in html
|
||||
|
||||
|
||||
def test_clone_validation(logged_in, record):
|
||||
shut_off(record)
|
||||
assert "Имя копии" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "bad name!"}, follow_redirects=False))
|
||||
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "db01"}, follow_redirects=False))
|
||||
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "WEB01"}, follow_redirects=False))
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||
assert "Сначала выключите" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
|
||||
assert record.find("virt-clone") == []
|
||||
assert logged_in.post("/vm/-bad/clone/start", data={"new_name": "web02"}, follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
def test_clone_checks_space_and_existing_file(logged_in, record, data_dirs, monkeypatch):
|
||||
shut_off(record)
|
||||
(data_dirs["images"] / "web02.qcow2").write_bytes(b"x")
|
||||
assert "уже есть в хранилище" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: GIB)
|
||||
assert "Не хватает места" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web03"}, follow_redirects=False))
|
||||
|
||||
|
||||
def test_clone_starts_operation(logged_in, record, inline_operations, data_dirs):
|
||||
shut_off(record)
|
||||
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
|
||||
assert location(response).startswith("/operations/")
|
||||
operation = core.read_operation(location(response).split("/")[-1])
|
||||
assert operation["status"] == "success" and operation["type"] == "vm_clone"
|
||||
assert operation["vm_name"] == "web02" and operation["source_vm"] == "web01"
|
||||
assert "web02 создана" in operation["message"]
|
||||
assert record.find("virt-clone") == [["virt-clone", "--original", "web01", "--name", "web02", "--file", str(data_dirs["images"] / "web02.qcow2")]]
|
||||
assert record.find("virsh", "pool-refresh") == [["virsh", "pool-refresh", "virtuality-images"]]
|
||||
assert "virt-clone --original web01" in operation["log_tail"]
|
||||
|
||||
|
||||
def test_clone_failure_is_reported(logged_in, record, inline_operations):
|
||||
shut_off(record)
|
||||
record.overrides[("virt-clone",)] = conftest.fake_result(ok=False, stderr="ERROR Storage volume already exists")
|
||||
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
|
||||
operation = core.read_operation(location(response).split("/")[-1])
|
||||
assert operation["status"] == "error" and "уже есть" in operation["message"]
|
||||
assert record.find("virsh", "pool-refresh") == []
|
||||
|
||||
|
||||
def test_clone_progress_watcher(data_dirs):
|
||||
target = data_dirs["images"] / "copy.qcow2"
|
||||
target.write_bytes(b"\0" * (512 * 1024))
|
||||
operation = core.new_operation("vm_clone", "test", vm_name="copy", source_vm="web01")
|
||||
|
||||
class OneTick:
|
||||
def __init__(self):
|
||||
self.ticks = 0
|
||||
|
||||
def wait(self, timeout):
|
||||
self.ticks += 1
|
||||
return self.ticks > 1
|
||||
|
||||
vmops._watch_clone_progress(operation, [target], 1024 * 1024, OneTick())
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert 3 <= fresh["progress"] <= 95 and "Копируем диски" in fresh["message"]
|
||||
assert vmops._allocated_bytes(data_dirs["images"] / "missing.qcow2") == 0
|
||||
|
||||
|
||||
def test_clone_worker_stops_watcher(data_dirs, monkeypatch):
|
||||
"""Даже если virt-clone упал, поток прогресса останавливается."""
|
||||
monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12: conftest.fake_result(ok=False, stderr="boom"))
|
||||
operation = core.new_operation("vm_clone", "test", vm_name="web02", source_vm="web01", targets=[str(data_dirs["images"] / "web02.qcow2")], expected_bytes=10)
|
||||
before = threading.active_count()
|
||||
vmops.clone_worker(operation)
|
||||
assert threading.active_count() <= before
|
||||
assert core.read_operation(operation["id"])["status"] == "error"
|
||||
Ссылка в новой задаче
Block a user