317 строки
14 KiB
Python
317 строки
14 KiB
Python
#!/usr/bin/env python3
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
if not app_path.exists():
|
|
raise SystemExit(f'app.py not found: {app_path}')
|
|
|
|
text = app_path.read_text()
|
|
changed = []
|
|
|
|
if 'import zipfile' not in text:
|
|
text = text.replace('import uuid\n', 'import uuid\nimport lzma\nimport tarfile\nimport zipfile\n', 1)
|
|
changed.append('archive imports added')
|
|
elif 'import lzma' not in text:
|
|
text = text.replace('import tarfile\n', 'import lzma\nimport tarfile\n', 1) if 'import tarfile\n' in text else text.replace('import zipfile\n', 'import lzma\nimport zipfile\n', 1)
|
|
changed.append('lzma import added')
|
|
else:
|
|
changed.append('archive imports already present')
|
|
|
|
helpers = r'''
|
|
|
|
def safe_disk_upload_filename(filename: str) -> str | None:
|
|
original = Path(filename or "").name.strip()
|
|
lower = original.lower()
|
|
if lower.endswith(".tar.gz"):
|
|
suffix = ".tar.gz"
|
|
stem = original[:-7]
|
|
elif lower.endswith(".img.xz"):
|
|
suffix = ".img.xz"
|
|
stem = original[:-7]
|
|
elif lower.endswith(".tgz"):
|
|
suffix = ".tgz"
|
|
stem = original[:-4]
|
|
else:
|
|
suffix = Path(original).suffix.lower()
|
|
stem = Path(original).stem
|
|
if suffix not in (".img", ".raw", ".qcow2", ".img.xz", ".zip", ".tar.gz", ".tgz"):
|
|
return None
|
|
stem = re.sub(r"\s+", "-", stem.strip())
|
|
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
|
|
stem = stem.strip("._-")[:120]
|
|
if not stem:
|
|
stem = "virtuality-disk"
|
|
return f"{stem}{suffix}"
|
|
|
|
|
|
def disk_upload_is_archive(name: str) -> bool:
|
|
lower = str(name or "").lower()
|
|
return lower.endswith((".zip", ".tar.gz", ".tgz"))
|
|
|
|
|
|
def disk_upload_is_xz_image(name: str) -> bool:
|
|
return str(name or "").lower().endswith(".img.xz")
|
|
|
|
|
|
def disk_upload_is_image(name: str) -> bool:
|
|
lower = str(name or "").lower()
|
|
return lower.endswith((".img", ".raw", ".qcow2", ".img.xz"))
|
|
|
|
|
|
def archive_member_is_safe(name: str) -> bool:
|
|
if not name:
|
|
return False
|
|
p = Path(name)
|
|
if p.is_absolute():
|
|
return False
|
|
return ".." not in p.parts
|
|
|
|
|
|
def archive_member_basename(name: str) -> str | None:
|
|
return safe_disk_image_filename(Path(name).name)
|
|
|
|
|
|
def unique_disk_image_path(name: str) -> Path:
|
|
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
safe = safe_disk_image_filename(name)
|
|
if not safe:
|
|
raise ValueError("Некорректное имя образа диска")
|
|
target = DISK_IMAGES_DIR / safe
|
|
if not target.exists():
|
|
return target
|
|
stem = target.stem
|
|
suffix = target.suffix
|
|
for index in range(1, 1000):
|
|
candidate = DISK_IMAGES_DIR / f"{stem}-{index}{suffix}"
|
|
if not candidate.exists():
|
|
return candidate
|
|
raise ValueError("Не удалось подобрать свободное имя файла")
|
|
|
|
|
|
def find_archive_disk_members(archive_path: Path) -> list[dict[str, Any]]:
|
|
members: list[dict[str, Any]] = []
|
|
lower = archive_path.name.lower()
|
|
if lower.endswith(".zip"):
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|
for info in archive.infolist():
|
|
if info.is_dir() or not archive_member_is_safe(info.filename):
|
|
continue
|
|
safe_name = archive_member_basename(info.filename)
|
|
if safe_name:
|
|
members.append({"kind": "zip", "name": info.filename, "safe_name": safe_name, "size": int(info.file_size)})
|
|
elif lower.endswith((".tar.gz", ".tgz")):
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
for info in archive.getmembers():
|
|
if not info.isfile() or not archive_member_is_safe(info.name):
|
|
continue
|
|
safe_name = archive_member_basename(info.name)
|
|
if safe_name:
|
|
members.append({"kind": "tar", "name": info.name, "safe_name": safe_name, "size": int(info.size)})
|
|
else:
|
|
raise ValueError("Поддерживаются только .zip, .tar.gz и .tgz")
|
|
return sorted(members, key=lambda item: item.get("size", 0), reverse=True)
|
|
|
|
|
|
def extract_disk_archive(archive_path: Path) -> list[Path]:
|
|
members = find_archive_disk_members(archive_path)
|
|
if not members:
|
|
raise ValueError("В архиве не найдено .img, .raw или .qcow2 файлов")
|
|
extracted: list[Path] = []
|
|
lower = archive_path.name.lower()
|
|
if lower.endswith(".zip"):
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|
for member in members:
|
|
target = unique_disk_image_path(member["safe_name"])
|
|
with archive.open(member["name"], "r") as src, target.open("wb") as dst:
|
|
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
|
extracted.append(target)
|
|
else:
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
for member in members:
|
|
file_obj = archive.extractfile(member["name"])
|
|
if file_obj is None:
|
|
continue
|
|
target = unique_disk_image_path(member["safe_name"])
|
|
with file_obj as src, target.open("wb") as dst:
|
|
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
|
extracted.append(target)
|
|
return extracted
|
|
|
|
|
|
def extract_xz_disk_image(compressed_path: Path, safe_name: str | None = None) -> Path:
|
|
source_name = safe_name or compressed_path.name
|
|
if not source_name.lower().endswith(".img.xz"):
|
|
raise ValueError("Поддерживаются только сжатые образы .img.xz")
|
|
raw_name = source_name[:-3]
|
|
target = unique_disk_image_path(raw_name)
|
|
try:
|
|
with lzma.open(compressed_path, "rb") as src, target.open("wb") as dst:
|
|
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
|
except Exception:
|
|
target.unlink(missing_ok=True)
|
|
raise
|
|
return target
|
|
|
|
|
|
def disk_convert_target_path(source_path: Path) -> Path:
|
|
if source_path.suffix.lower() == ".qcow2":
|
|
return source_path
|
|
target = source_path.with_suffix(".qcow2")
|
|
if not target.exists():
|
|
return target
|
|
for index in range(1, 1000):
|
|
candidate = source_path.with_name(f"{source_path.stem}-{index}.qcow2")
|
|
if not candidate.exists():
|
|
return candidate
|
|
raise ValueError("Не удалось подобрать имя qcow2 для конвертации")
|
|
|
|
|
|
def disk_convert_progress(line: str, current: int) -> int:
|
|
match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "")
|
|
if match:
|
|
return max(current, int(float(match.group(1))))
|
|
match = re.search(r"(\d+(?:\.\d+)?)%", line or "")
|
|
if match:
|
|
return max(current, int(float(match.group(1))))
|
|
return current
|
|
|
|
|
|
def run_disk_convert_worker(operation_id: str, source_path: str, target_path: str) -> None:
|
|
operation = read_operation(operation_id)
|
|
if not operation:
|
|
return
|
|
source = Path(source_path)
|
|
target = Path(target_path)
|
|
update_operation(operation, status="running", progress=1, message=f"Конвертация {source.name} в qcow2", started_at=utc_now())
|
|
cmd = ["qemu-img", "convert", "-p", "-f", disk_image_format(source), "-O", "qcow2", str(source), str(target)]
|
|
append_operation_log(operation_id, "Запуск конвертации:")
|
|
append_operation_log(operation_id, " ".join(cmd))
|
|
try:
|
|
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
|
progress = 1
|
|
if process.stdout:
|
|
for line in process.stdout:
|
|
append_operation_log(operation_id, line)
|
|
progress = disk_convert_progress(line, progress)
|
|
fresh = read_operation(operation_id) or operation
|
|
update_operation(fresh, progress=progress, message=f"Конвертация {source.name}: {progress}%")
|
|
exit_code = process.wait()
|
|
fresh = read_operation(operation_id) or operation
|
|
if exit_code == 0:
|
|
append_operation_log(operation_id, f"Конвертация завершена: {target}")
|
|
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=f"Готово: {target.name}", finished_at=utc_now(), target_path=str(target))
|
|
else:
|
|
append_operation_log(operation_id, f"qemu-img завершился с ошибкой. Exit code: {exit_code}")
|
|
target.unlink(missing_ok=True)
|
|
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"qemu-img завершился с ошибкой: {exit_code}", finished_at=utc_now())
|
|
except Exception as exc:
|
|
fresh = read_operation(operation_id) or operation
|
|
append_operation_log(operation_id, f"Ошибка конвертации: {exc}")
|
|
target.unlink(missing_ok=True)
|
|
update_operation(fresh, status="error", progress=100, exit_code=-1, message=str(exc), finished_at=utc_now())
|
|
|
|
|
|
def start_disk_convert_operation(source_path: Path) -> dict[str, Any] | None:
|
|
if source_path.suffix.lower() == ".qcow2":
|
|
return None
|
|
target_path = disk_convert_target_path(source_path)
|
|
operation_id = str(uuid.uuid4())
|
|
operation = {
|
|
"id": operation_id,
|
|
"type": "disk_convert",
|
|
"title": f"Конвертация {source_path.name}",
|
|
"status": "queued",
|
|
"progress": 0,
|
|
"message": "Конвертация поставлена в очередь",
|
|
"created_at": utc_now(),
|
|
"updated_at": utc_now(),
|
|
"created_by": AUTH_USER,
|
|
"source_path": str(source_path),
|
|
"target_path": str(target_path),
|
|
}
|
|
write_operation(operation)
|
|
append_operation_log(operation_id, "Операция поставлена в очередь.")
|
|
threading.Thread(target=run_disk_convert_worker, args=(operation_id, str(source_path), str(target_path)), daemon=True).start()
|
|
return operation
|
|
|
|
|
|
def disk_upload_response(request: Request, payload: dict[str, Any]):
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest" or "application/json" in request.headers.get("accept", ""):
|
|
return JSONResponse(payload)
|
|
return RedirectResponse(url="/disk-images", status_code=303)
|
|
'''
|
|
|
|
if 'def safe_disk_upload_filename(' not in text:
|
|
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
|
|
if marker not in text:
|
|
marker = '\n\ndef bridge_exists(name: str) -> bool:'
|
|
if marker not in text:
|
|
raise SystemExit('helper insert marker not found')
|
|
text = text.replace(marker, helpers + marker, 1)
|
|
changed.append('disk archive helpers added')
|
|
else:
|
|
pattern = r"\n\ndef safe_disk_upload_filename\(filename: str\).*?\n\ndef disk_upload_response\(request: Request, payload: dict\[str, Any\]\):.*?\n return RedirectResponse\(url=\"/disk-images\", status_code=303\)\n"
|
|
text, count = re.subn(pattern, helpers, text, count=1, flags=re.S)
|
|
changed.append('disk archive helpers replaced with img.xz support' if count else 'disk archive helpers already present')
|
|
|
|
old_route_start = text.find('@app.post("/disk-images/upload"')
|
|
if old_route_start == -1:
|
|
raise SystemExit('disk image upload route not found')
|
|
next_route = text.find('\n\n@app.post("/disk-images/{name}/delete")', old_route_start)
|
|
if next_route == -1:
|
|
raise SystemExit('disk image delete route marker not found')
|
|
new_route = r'''@app.post("/disk-images/upload", response_class=HTMLResponse)
|
|
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
safe_name = safe_disk_upload_filename(image_file.filename or "")
|
|
if not safe_name:
|
|
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
|
|
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
|
|
saved_paths: list[Path] = []
|
|
try:
|
|
with tmp_target.open("wb") as out:
|
|
shutil.copyfileobj(image_file.file, out, length=1024 * 1024)
|
|
if disk_upload_is_archive(safe_name):
|
|
saved_paths = extract_disk_archive(tmp_target)
|
|
tmp_target.unlink(missing_ok=True)
|
|
elif disk_upload_is_xz_image(safe_name):
|
|
saved_paths = [extract_xz_disk_image(tmp_target, safe_name)]
|
|
tmp_target.unlink(missing_ok=True)
|
|
else:
|
|
target = unique_disk_image_path(safe_name)
|
|
tmp_target.rename(target)
|
|
saved_paths = [target]
|
|
except Exception as exc:
|
|
tmp_target.unlink(missing_ok=True)
|
|
for path in saved_paths:
|
|
path.unlink(missing_ok=True)
|
|
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
|
|
|
|
operations = []
|
|
for path in saved_paths:
|
|
operation = start_disk_convert_operation(path)
|
|
if operation:
|
|
operations.append(operation)
|
|
|
|
payload = {
|
|
"ok": True,
|
|
"mode": "converting" if operations else "ready",
|
|
"operation_id": operations[0]["id"] if operations else None,
|
|
"operation_ids": [op["id"] for op in operations],
|
|
"files": [path.name for path in saved_paths],
|
|
"message": f"Загружено файлов: {len(saved_paths)}. Конвертаций запущено: {len(operations)}.",
|
|
}
|
|
return disk_upload_response(request, payload)
|
|
'''
|
|
text = text[:old_route_start] + new_route + text[next_route:]
|
|
changed.append('disk image upload route supports img.xz and archives')
|
|
|
|
app_path.write_text(text)
|
|
print('disk archive import patch applied:')
|
|
for item in changed:
|
|
print(f'- {item}')
|