Merge branch 'feature/vmops' into claude/production-system-image-smcxyr

# Conflicts:
#	tests/conftest.py
#	tests/dev_server.py
#	web/app.py
#	web/static/app.css
#	web/static/icons.svg
Этот коммит содержится в:
Claude
2026-09-25 15:03:48 +00:00
родитель a43f96883e 34684634af
Коммит 2b7223f311
19 изменённых файлов: 1400 добавлений и 22 удалений
+21 -5
Просмотреть файл
@@ -43,6 +43,9 @@ QEMU_IMG_INFO = (FIXTURES / "qemu-img-info.json").read_text()
# Снимки web01: имя → (состояние, описание); список берётся из fixtures/snapshot-list.txt.
SNAPSHOTS = {"before-upd": ("shutoff", "Перед обновлением ядра"), "clean": ("running", "Чистая система после установки")}
# Заметки машин для `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": ""}
@@ -70,19 +73,31 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
if cmd[:2] in (["virsh", "snapshot-create-as"], ["virsh", "snapshot-revert"], ["virsh", "snapshot-delete"]):
return fake_result("Domain snapshot %s created" % option(cmd, "--name"))
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")
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")
if cmd[:2] == ["virsh", "dumpxml"]:
return fake_result(DUMPXML_MIGRATABLE if "--migratable" in cmd else DUMPXML)
if cmd[:2] == ["virsh", "domblklist"]:
return fake_result(DOMBLKLIST_DETAILS)
if cmd[:2] == ["qemu-img", "info"]:
return fake_result(QEMU_IMG_INFO)
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(QEMU_IMG_INFO)
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"]:
@@ -141,6 +156,7 @@ def data_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"])
monkeypatch.setattr(core, "BACKUPS_DIR", dirs["backups"])
monkeypatch.setattr(core, "CONFIG_DIR", dirs["config"])
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"])
+70 -2
Просмотреть файл
@@ -7,6 +7,7 @@ import json
import os
import sys
import tempfile
import time
import uuid
from pathlib import Path
@@ -19,6 +20,64 @@ 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_vm_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)
# Снимки для демо: имя, дата, состояние, описание.
DEMO_SNAPSHOTS = [
("snap-20260925-1012", "2026-09-25 10:12:03 +0000", "shutoff", "Перед обновлением ядра"),
@@ -43,7 +102,7 @@ def demo_run_cmd(cmd, timeout=12, **kwargs):
state, description = next(((s, d) for n, _, s, d in DEMO_SNAPSHOTS if n == snap), ("shutoff", ""))
memory = "internal" if state == "running" else "no"
return conftest.fake_result(f"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state><memory snapshot='{memory}'/></domainsnapshot>")
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
return demo_vm_run_cmd(cmd, timeout, **kwargs)
def seed_backups(backups_dir: Path) -> None:
@@ -71,9 +130,12 @@ def seed(root: Path) -> None:
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 = 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
core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"]
seed_backups(dirs["backups"])
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.UFW_STATE_FILE = dirs["network"] / "ufw_rules.json"
@@ -142,6 +204,12 @@ def seed(root: Path) -> None:
op = core.new_operation("download", title, download_kind=kind, url=url, filename=filename, target_path=str(target), page="/iso" if kind == "iso" else "/disk-images", catalog_id=catalog_id, total_bytes=total, downloaded_bytes=done, cancel_requested=False)
core.update_operation(op, status="running", progress=progress, message=message, started_at=core.utc_now())
core.append_operation_log(op["id"], f"Ссылка: {url}")
# Завершённое клонирование 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__":
+6
Просмотреть файл
@@ -0,0 +1,6 @@
Id Name State Title
--------------------------------------------
1 web01 running Сайт компании
- db01 shut off
- win11 shut off Бухгалтерия 1С
+2 -2
Просмотреть файл
@@ -98,7 +98,7 @@ def test_parse_qemu_img_info_fixture():
def test_vm_disks_skips_cdrom(data_dirs):
disks = backups.vm_disks("web01")
assert [(d["target"], d["source"]) for d in disks] == [("vda", "/var/lib/virtuality/images/web01.qcow2")]
assert [(d["target"], d["source"]) for d in disks] == [("vda", str(core.IMAGES_DIR / "web01.qcow2"))]
assert DOMBLKLIST_DETAILS.count("cdrom") == 1
@@ -193,7 +193,7 @@ def test_create_backup_shuts_down_copies_and_restarts(fast_worker, monkeypatch):
assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls
assert virsh.calls[-1] == ["virsh", "start", "web01"]
assert ["virsh", "destroy", "web01"] not in virsh.calls
assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", "/var/lib/virtuality/images/web01.qcow2", str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]]
assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", str(core.IMAGES_DIR / "web01.qcow2"), str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]]
path = fast_worker["backups"] / "web01" / operation["backup_id"]
meta = json.loads((path / "meta.json").read_text())
assert meta["vm"] == "web01" and meta["note"] == "ночная копия" and meta["disks"][0]["target"] == "vda"
+437
Просмотреть файл
@@ -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=Диск+увеличен&notes_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"