Files
pvemanager/backend/app/services/task_queue_service.py
markmorado 0efa5968ad
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 7s
fix(core): четыре критических бага — маршруты, очередь задач, логин, shutdown
1. vms.py: catch-all маршруты /vm/{vmid}/{action} и /container/{vmid}/{action}
   перенесены в конец файла — раньше они перехватывали POST /unlock,
   /cloud-init и /exec, и эти операции всегда падали с 400 "Invalid action".
2. task_queue_service: зависшие после рестарта running-задачи помечаются
   failed при старте — иначе очередь bulk-операций блокировалась навсегда.
3. api-client.ts: 401 от /api/auth/login больше не вызывает перезагрузку
   страницы — пользователь видит сообщение "неверный логин/пароль".
4. task_queue_service: bulk shutdown теперь вызывает shutdown_vm/
   shutdown_container (graceful) вместо жёсткого stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 00:58:40 +05:00

394 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Task Queue Service for bulk VM/Container operations
Processes tasks from the queue in background
"""
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional
from loguru import logger
from sqlalchemy.orm import Session
def utcnow() -> datetime:
"""Get current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
try:
from backend.app.db import SessionLocal
from backend.app.models import TaskQueue, ProxmoxServer, User
from backend.app.proxmox import ProxmoxClient
from backend.app.websocket_manager import broadcast_task_update
except ImportError:
from app.db import SessionLocal
from app.models import TaskQueue, ProxmoxServer, User
from app.proxmox import ProxmoxClient
from app.websocket_manager import broadcast_task_update
def _ws_broadcast(task: TaskQueue, event: str = "task_update") -> None:
"""Fire-and-forget WS broadcast for a TaskQueue update."""
try:
data = task.to_dict()
data["kind"] = "bulk"
broadcast_task_update(task.user_id, event, data)
except Exception as e:
logger.debug(f"[TASK QUEUE] WS broadcast skipped: {e}")
class TaskQueueService:
"""Service for managing and processing bulk operation tasks"""
# Valid task types
TASK_TYPES = ['bulk_start', 'bulk_stop', 'bulk_restart', 'bulk_delete', 'bulk_shutdown', 'bulk_migrate']
@staticmethod
def create_task(
db: Session,
task_type: str,
user_id: int,
items: List[Dict[str, Any]]
) -> TaskQueue:
"""
Create a new bulk operation task
Args:
db: Database session
task_type: Type of operation (bulk_start, bulk_stop, etc.)
user_id: ID of user who initiated
items: List of VMs/containers to process
Each item: {server_id, vmid, vm_type, name, node}
"""
if task_type not in TaskQueueService.TASK_TYPES:
raise ValueError(f"Invalid task type: {task_type}")
if not items:
raise ValueError("No items provided for bulk operation")
task = TaskQueue(
task_type=task_type,
status='pending',
user_id=user_id,
total_items=len(items),
completed_items=0,
failed_items=0,
task_data=items,
results=[]
)
db.add(task)
db.commit()
db.refresh(task)
logger.info(f"[TASK QUEUE] Created task #{task.id}: {task_type} with {len(items)} items")
return task
@staticmethod
def get_pending_tasks(db: Session) -> List[TaskQueue]:
"""Get all pending tasks ordered by creation time"""
return db.query(TaskQueue).filter(
TaskQueue.status == 'pending'
).order_by(TaskQueue.created_at.asc()).all()
@staticmethod
def get_running_tasks(db: Session) -> List[TaskQueue]:
"""Get all currently running tasks"""
return db.query(TaskQueue).filter(
TaskQueue.status == 'running'
).all()
@staticmethod
def get_user_tasks(db: Session, user_id: int, limit: int = 20) -> List[TaskQueue]:
"""Get recent tasks for a user"""
return db.query(TaskQueue).filter(
TaskQueue.user_id == user_id
).order_by(TaskQueue.created_at.desc()).limit(limit).all()
@staticmethod
def get_active_tasks(db: Session) -> List[TaskQueue]:
"""Get all active (pending or running) tasks"""
return db.query(TaskQueue).filter(
TaskQueue.status.in_(['pending', 'running'])
).order_by(TaskQueue.created_at.asc()).all()
@staticmethod
def cancel_task(db: Session, task_id: int, user_id: int) -> bool:
"""Cancel a pending task"""
task = db.query(TaskQueue).filter(
TaskQueue.id == task_id,
TaskQueue.user_id == user_id,
TaskQueue.status == 'pending'
).first()
if not task:
return False
task.status = 'cancelled'
task.completed_at = utcnow()
db.commit()
_ws_broadcast(task, "task_update")
logger.info(f"[TASK QUEUE] Task #{task_id} cancelled by user {user_id}")
return True
class TaskQueueProcessor:
"""Processor that executes tasks from the queue"""
def __init__(self):
self._proxmox_clients: Dict[int, ProxmoxClient] = {}
def _get_proxmox_client(self, db: Session, server_id: int) -> Optional[ProxmoxClient]:
"""Get or create Proxmox client for server"""
if server_id in self._proxmox_clients:
client = self._proxmox_clients[server_id]
if client.is_connected():
return client
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
return None
# Require valid credentials before building (from_server would otherwise
# construct a client with no usable auth).
has_password = server.use_password and server.password
has_token = server.api_token_name and server.api_token_value
if not (has_password or has_token):
return None
try:
client = ProxmoxClient.from_server(server)
self._proxmox_clients[server_id] = client
return client
except Exception as e:
logger.error(f"[TASK QUEUE] Failed to connect to server {server_id}: {e}")
return None
def _execute_vm_action(
self,
client: ProxmoxClient,
action: str,
vmid: int,
vm_type: str,
node: str,
target_node: Optional[str] = None,
online: bool = False,
) -> tuple[bool, str]:
"""Execute action on VM/container"""
try:
if vm_type == 'lxc':
if action == 'start':
client.start_container(node, vmid)
elif action == 'stop':
client.stop_container(node, vmid)
elif action == 'restart':
client.restart_container(node, vmid)
elif action == 'shutdown':
# Graceful shutdown через ACPI/init внутри контейнера
client.shutdown_container(node, vmid)
elif action == 'delete':
client.delete_container(node, vmid)
elif action == 'migrate':
if not target_node:
return False, "target_node is required"
client.migrate_container(node, vmid, target_node, online=online)
else:
return False, f"Unknown action: {action}"
else: # qemu
if action == 'start':
client.start_vm(node, vmid)
elif action == 'stop':
client.stop_vm(node, vmid)
elif action == 'restart':
client.restart_vm(node, vmid)
elif action == 'shutdown':
# Graceful shutdown через ACPI — гостевая ОС завершается штатно
client.shutdown_vm(node, vmid)
elif action == 'delete':
client.delete_vm(node, vmid)
elif action == 'migrate':
if not target_node:
return False, "target_node is required"
client.migrate_vm(node, vmid, target_node, online=online)
else:
return False, f"Unknown action: {action}"
return True, "OK"
except Exception as e:
return False, str(e)
def process_task(self, task: TaskQueue) -> None:
"""Process a single task"""
db = SessionLocal()
try:
# Mark as running
task = db.query(TaskQueue).filter(TaskQueue.id == task.id).first()
if not task or task.status != 'pending':
return
task.status = 'running'
task.started_at = utcnow()
db.commit()
_ws_broadcast(task, "task_update")
logger.info(f"[TASK QUEUE] Starting task #{task.id}: {task.task_type}")
# Determine action from task type
action_map = {
'bulk_start': 'start',
'bulk_stop': 'stop',
'bulk_restart': 'restart',
'bulk_shutdown': 'shutdown',
'bulk_delete': 'delete',
'bulk_migrate': 'migrate',
}
action = action_map.get(task.task_type)
if not action:
task.status = 'failed'
task.error_message = f"Unknown task type: {task.task_type}"
task.completed_at = utcnow()
db.commit()
return
results = []
items = task.task_data or []
for item in items:
server_id = item.get('server_id')
vmid = item.get('vmid')
vm_type = item.get('vm_type', 'qemu')
node = item.get('node', '')
name = item.get('name', f'{vm_type.upper()}-{vmid}')
target_node = item.get('target_node')
online = bool(item.get('online'))
client = self._get_proxmox_client(db, server_id)
if not client:
results.append({
'server_id': server_id,
'vmid': vmid,
'name': name,
'success': False,
'message': 'Failed to connect to Proxmox server'
})
task.failed_items += 1
else:
success, message = self._execute_vm_action(
client, action, vmid, vm_type, node, target_node, online)
results.append({
'server_id': server_id,
'vmid': vmid,
'name': name,
'success': success,
'message': message
})
if success:
task.completed_items += 1
else:
task.failed_items += 1
# Update progress
task.results = results
db.commit()
_ws_broadcast(task, "task_update")
# Mark as completed
task.status = 'completed'
task.completed_at = utcnow()
db.commit()
_ws_broadcast(task, "task_update")
logger.info(f"[TASK QUEUE] Completed task #{task.id}: {task.completed_items} success, {task.failed_items} failed")
except Exception as e:
logger.error(f"[TASK QUEUE] Task #{task.id} failed with error: {e}")
try:
task.status = 'failed'
task.error_message = str(e)
task.completed_at = utcnow()
db.commit()
_ws_broadcast(task, "task_update")
except Exception:
pass
finally:
db.close()
def process_pending_tasks(self) -> int:
"""Process all pending tasks. Returns number of tasks processed."""
db = SessionLocal()
try:
# Get pending tasks
pending_tasks = TaskQueueService.get_pending_tasks(db)
if not pending_tasks:
return 0
# Check if there's already a running task
running_tasks = TaskQueueService.get_running_tasks(db)
if running_tasks:
logger.debug(f"[TASK QUEUE] {len(running_tasks)} task(s) already running, skipping")
return 0
# Process first pending task
task = pending_tasks[0]
db.close() # Close before processing (will create new session)
self.process_task(task)
return 1
except Exception as e:
logger.error(f"[TASK QUEUE] Error processing pending tasks: {e}")
return 0
finally:
try:
db.close()
except Exception:
pass
def recover_interrupted_tasks() -> int:
"""Пометить задачи, зависшие в статусе 'running' после рестарта бэкенда.
process_pending_tasks() пропускает обработку, пока существует running-задача,
поэтому без этой очистки одна прерванная рестартом задача навсегда блокирует
всю очередь bulk-операций. Вызывается один раз при старте приложения —
в этот момент никакой задачи выполняться не может.
Returns:
Количество восстановленных (помеченных failed) задач.
"""
db = SessionLocal()
try:
stuck = db.query(TaskQueue).filter(TaskQueue.status == 'running').all()
for task in stuck:
task.status = 'failed'
task.error_message = 'Interrupted by backend restart'
task.completed_at = utcnow()
if stuck:
db.commit()
for task in stuck:
_ws_broadcast(task, "task_update")
logger.warning(
f"[TASK QUEUE] Task #{task.id} ({task.task_type}) was interrupted "
f"by restart — marked as failed"
)
return len(stuck)
except Exception as e:
logger.error(f"[TASK QUEUE] Failed to recover interrupted tasks: {e}")
try:
db.rollback()
except Exception:
pass
return 0
finally:
db.close()
# Global processor instance
task_processor = TaskQueueProcessor()
def process_task_queue():
"""Function to be called by scheduler to process queue"""
return task_processor.process_pending_tasks()