feat(clone): real disk-copy progress, owner assignment and DB registration

- _do_clone_sync now tails the PVE clone task log and maps the disk
  transfer percentage to deploy progress 50→95% instead of a static 70%.
- Add owner_id to CloneRequest; admins may assign the clone to another
  user (mirrors LXC deploy). Register the cloned instance via
  save_vm_instance so it has an owner and is visible to limited users.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 00:38:26 +05:00
parent 8ebef3d10e
commit 1cc4d86b18
2 changed files with 91 additions and 7 deletions

View File

@@ -7,6 +7,7 @@ progress through DeployTask records (with `kind` field) + WebSocket broadcast.
from __future__ import annotations
import re
import time
from typing import Optional
@@ -16,7 +17,7 @@ from ...db import SessionLocal
from ...models import ProxmoxServer, VMInstance, OSTemplate, DeployTask, User
from ...proxmox import ProxmoxClient
from ...logging_service import LoggingService
from ._helpers import _get_proxmox_client, get_next_vmid
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance, save_vm_instance
# Re-use deploy executor and update helper
@@ -386,10 +387,56 @@ def _do_reinstall_sync(task_id: int, server_id: int, vmid: int, node: str,
# ==================== Clone ====================
_PCT_RE = re.compile(r"\((\d{1,3}(?:\.\d+)?)\s*%\)")
def _wait_clone_with_progress(client, task_id: int, node: str, upid: str,
timeout: int = 600) -> bool:
"""Wait for a PVE clone task, mapping its disk-copy % to deploy progress 50→95.
PVE reports copy progress only in the task log (e.g. "transferred 12.0 GiB of
20.0 GiB (59.94%)"), so we tail the log and parse the last percentage seen.
"""
start = time.time()
log_pos = 0
last_reported = -1
while time.time() - start < timeout:
# Tail new log lines and extract the most recent percentage.
try:
lines = client.get_task_log(node, upid, start=log_pos, limit=500) or []
except Exception:
lines = []
pct = None
for line in lines:
log_pos = max(log_pos, line.get('n', log_pos) or log_pos)
m = _PCT_RE.search(line.get('t', '') or '')
if m:
try:
pct = float(m.group(1))
except ValueError:
pass
if pct is not None:
progress = 50 + int(min(pct, 100) * 0.45) # 50..95
if progress != last_reported:
last_reported = progress
_update_deploy_task(task_id, 'running',
f'Копирование диска... {pct:.0f}%', progress)
status = client.get_task_status(node, upid)
if status and status.get('status') == 'stopped':
exit_status = status.get('exitstatus')
return exit_status == 'OK' or (
isinstance(exit_status, str) and exit_status.startswith('WARNINGS:'))
time.sleep(2)
logger.warning(f"Таймаут ожидания задачи клонирования {upid}")
return False
def _do_clone_sync(task_id: int, server_id: int, src_vmid: int, node: str, vm_type: str,
new_name: str, full: bool, target_node: Optional[str],
target_storage: Optional[str], description: Optional[str],
user_id: int, username: str):
user_id: int, username: str, owner_id: Optional[int] = None):
"""Clone existing VM/LXC into a new VMID."""
db = SessionLocal()
server = None
@@ -438,10 +485,21 @@ def _do_clone_sync(task_id: int, server_id: int, src_vmid: int, node: str, vm_ty
_update_deploy_task(task_id, 'running', 'Ожидание завершения клонирования...', 70,
vmid=new_vmid, node=target_node or node)
try:
client.wait_for_task(node, upid, timeout=600)
_wait_clone_with_progress(client, task_id, node, upid, timeout=600)
except Exception:
pass
# Register the cloned instance locally so it gets an owner and is visible
# to limited users (live list also picks it up from PVE, but without owner).
try:
save_vm_instance(
db=db, server_id=server_id, vmid=new_vmid,
node=target_node or node, vm_type=vm_type, name=new_name,
description=description, owner_id=owner_id or user_id,
)
except Exception as reg_err:
logger.warning(f"[CLONE #{task_id}] failed to register instance: {reg_err}")
LoggingService.log_proxmox_action(
db=db, action='clone',
resource_type='lxc' if is_lxc else 'vm', resource_id=src_vmid,

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from loguru import logger
from typing import List
@@ -135,7 +135,8 @@ def get_all_virtual_machines(
ipam_by_name[alloc.resource_name.lower()] = alloc
# Build base query for cached VMs (not deleted, not templates)
query = db.query(VMInstance).filter(
# joinedload(owner) avoids an N+1 query when rendering the owner column.
query = db.query(VMInstance).options(joinedload(VMInstance.owner)).filter(
VMInstance.deleted_at.is_(None),
VMInstance.is_template == False
)
@@ -252,6 +253,7 @@ def get_all_virtual_machines(
"hostname": ip_hostname or f"{'vps' if vm.vm_type == 'qemu' else 'lxc'}{vm.vmid}.{server.hostname if server else 'local'}",
"type": vm.vm_type,
"status": live.get('status') or vm.status or "unknown",
"lock": live.get('lock') or None,
"node": vm.node,
"cores": vm.cores or 0,
"memory": vm.memory or 0,
@@ -262,6 +264,14 @@ def get_all_virtual_machines(
"os": os_template,
"os_template": os_template,
"owner": owner,
"owner_user": (
{
"username": vm.owner.username,
"email": vm.owner.email,
"full_name": vm.owner.full_name,
}
if vm.owner else None
),
"owner_hostname": "",
"storage": "Storage1 (DIR)",
"tags": vm.tags or "",
@@ -358,6 +368,20 @@ class CloneRequest(BaseModel):
target_node: Optional[str] = None
target_storage: Optional[str] = None
description: Optional[str] = None
owner_id: Optional[int] = None
def _resolve_clone_owner(db: Session, current_user: User, owner_id: Optional[int]) -> int:
"""Resolve the owner for a cloned instance (admins may assign to others)."""
if owner_id and owner_id != current_user.id:
is_admin = current_user.has_permission("user:manage") or current_user.is_admin
if not is_admin:
raise HTTPException(status_code=403, detail="Only admins can assign instance to another user")
owner = db.query(User).filter(User.id == owner_id).first()
if not owner:
raise HTTPException(status_code=404, detail="Owner user not found")
return owner.id
return current_user.id
class ChangePasswordRequest(BaseModel):
@@ -397,6 +421,7 @@ def clone_vm_endpoint(
require_vm_access(db, current_user, server_id, vmid)
_resolve_server(db, server_id)
owner_id = _resolve_clone_owner(db, current_user, body.owner_id)
task = DeployTask(
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
@@ -407,7 +432,7 @@ def clone_vm_endpoint(
_deploy_executor.submit(
_do_clone_sync, task.id, server_id, vmid, node, 'qemu',
body.new_name, body.full, body.target_node, body.target_storage, body.description,
current_user.id, current_user.username,
current_user.id, current_user.username, owner_id,
)
return DeployTaskStartResponse(task_id=task.id, status='pending', name=body.new_name)
@@ -428,6 +453,7 @@ def clone_container_endpoint(
require_vm_access(db, current_user, server_id, vmid)
_resolve_server(db, server_id)
owner_id = _resolve_clone_owner(db, current_user, body.owner_id)
task = DeployTask(
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
@@ -438,7 +464,7 @@ def clone_container_endpoint(
_deploy_executor.submit(
_do_clone_sync, task.id, server_id, vmid, node, 'lxc',
body.new_name, body.full, body.target_node, body.target_storage, body.description,
current_user.id, current_user.username,
current_user.id, current_user.username, owner_id,
)
return DeployTaskStartResponse(task_id=task.id, status='pending', name=body.new_name)