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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user