cdd0be328a
Critical runtime bugs:
- terminal.html WebSSH完全不可用(URL前缀/JSON解析/Content-Type三处错误)
- servers.py路由遮蔽:/logs被/{id}拦截,3个前端页面同步日志查询失败
- scripts.html startExecPoll()→startExecPolling(),长任务快速执行崩溃
- agent.py {value!r!s:.50}格式串非法,agent发非数值时ValueError
- alerts.html d.daily.reduce()无null检查,API返回空数据时TypeError
Resource leak / stability:
- websocket.py僵尸连接未关闭TCP,文件描述符泄漏
- websocket.py _last_alert_time字典无限增长(加1小时过期清理)
- asyncssh_pool.py全忙时超过MAX_CONNECTIONS无限增长
- self_monitor.py Telegram告警无冷却,宕机时每30秒刷屏
- schedule_runner.py一次性调度执行超60秒会重复触发
- 限速脚本EXPIRE每次重置窗口可绕过(改用Lua原子脚本)
Security:
- JWT access token加token_version声明,改密码后旧token立失效(零宽限)
- INSTALL_MODE导入时常量→动态函数,安装后JWT认证不再残留禁用
- install.py /lock端点加管理员存在性验证,防止阻断安装
- ServerUpdate schema移除connectivity只读字段,防止伪造连接状态
Frontend fixes:
- doExec()缺r.ok检查、commands.html null检查
- _server_to_dict()补last_checked_at+ssh_key_public
- _field_match()逗号cron表达式修复
- alerts类型显示、SSH会话名称、搜索高亮定位
- 一次性/循环定时任务(run_mode+fire_at+自动禁用)
Dead code removed (400+ lines):
- SyncService batch_push/_push_single等5个方法(零调用者)
- 5个未使用schema(SyncCommands/SyncConfig/SyncSftp/FileDeploy/PaginatedResponse)
- 6个零调用service方法、3个无前端API端点
- 4个未使用import
Schema migrations:
- push_schedules: run_mode + fire_at列,cron_expr改NULL
- servers: 7个新列 + ssh_key_private/public VARCHAR(500)→TEXT
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
264 lines
9.1 KiB
Python
264 lines
9.1 KiB
Python
"""Nexus — Sync Engine v2 (Step 4 Upgrade)
|
|
|
|
S1: Existing rsync push retained as Sync file mode
|
|
S5: Parallel execution + result aggregation with Semaphore
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import List, Optional
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from server.domain.models import Server, SyncLog, AuditLog
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
|
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
|
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
|
from server.infrastructure.redis.client import get_redis
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command, ssh_pool
|
|
|
|
logger = logging.getLogger("nexus.sync_v2")
|
|
|
|
# S5: Semaphore for concurrency control
|
|
MAX_CONCURRENT = 10
|
|
|
|
|
|
class SyncEngineV2:
|
|
"""Unified sync engine: file sync + preview, with parallel execution (S5)"""
|
|
|
|
def __init__(
|
|
self,
|
|
server_repo: ServerRepositoryImpl,
|
|
sync_log_repo: SyncLogRepositoryImpl,
|
|
audit_repo: AuditLogRepositoryImpl,
|
|
retry_repo: PushRetryJobRepositoryImpl,
|
|
):
|
|
self.server_repo = server_repo
|
|
self.sync_log_repo = sync_log_repo
|
|
self.audit_repo = audit_repo
|
|
self.retry_repo = retry_repo
|
|
|
|
# ── S1: Sync File Mode (rsync-based) ──
|
|
|
|
async def sync_files(
|
|
self,
|
|
server_ids: List[int],
|
|
source_path: str,
|
|
target_path: Optional[str] = None,
|
|
sync_mode: str = "incremental",
|
|
operator: str = "admin",
|
|
batch_size: int = 50,
|
|
concurrency: int = 10,
|
|
trigger_type: str = "manual",
|
|
) -> dict:
|
|
"""S1: File sync using rsync over SSH"""
|
|
concurrency = min(concurrency, MAX_CONCURRENT)
|
|
sem = asyncio.Semaphore(concurrency)
|
|
|
|
results = {}
|
|
# Build server list
|
|
servers = []
|
|
for sid in server_ids:
|
|
server = await self.server_repo.get_by_id(sid)
|
|
if server:
|
|
servers.append(server)
|
|
|
|
total = len(servers)
|
|
completed = 0
|
|
failed = 0
|
|
_counter_lock = asyncio.Lock()
|
|
|
|
async def _sync_one(server: Server):
|
|
nonlocal completed, failed
|
|
async with sem:
|
|
# Create sync log
|
|
sync_log = SyncLog(
|
|
server_id=server.id,
|
|
source_path=source_path,
|
|
target_path=target_path or server.target_path or "/tmp/sync",
|
|
trigger_type=trigger_type,
|
|
operator=operator,
|
|
status="running",
|
|
sync_mode=sync_mode,
|
|
started_at=datetime.now(timezone.utc),
|
|
)
|
|
sync_log = await self.sync_log_repo.create(sync_log)
|
|
|
|
# Execute rsync
|
|
import shlex
|
|
rsync_cmd = f"rsync -az --delete {shlex.quote(source_path)}/ {shlex.quote(target_path or server.target_path or '/tmp/sync')}/"
|
|
result = await exec_ssh_command(server, rsync_cmd, timeout=300)
|
|
|
|
# Update sync log
|
|
sync_log.status = "success" if result["exit_code"] == 0 else "failed"
|
|
sync_log.finished_at = datetime.now(timezone.utc)
|
|
sync_log.duration_seconds = int(
|
|
(sync_log.finished_at - sync_log.started_at).total_seconds()
|
|
) if sync_log.started_at else 0
|
|
sync_log.error_message = result["stderr"][:1000] if result["exit_code"] != 0 else None
|
|
sync_log = await self.sync_log_repo.update(sync_log)
|
|
|
|
async with _counter_lock:
|
|
if sync_log.status == "success":
|
|
completed += 1
|
|
else:
|
|
failed += 1
|
|
|
|
results[server.id] = sync_log
|
|
return sync_log
|
|
|
|
# S5: Parallel execution with concurrency control
|
|
tasks = [asyncio.create_task(_sync_one(s)) for s in servers]
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
# Audit
|
|
await self._audit(
|
|
"sync_files", "sync", 0,
|
|
f"File sync: {source_path} → {total} servers ({completed} ok, {failed} failed)",
|
|
operator,
|
|
)
|
|
|
|
return {
|
|
"total": total,
|
|
"completed": completed,
|
|
"failed": failed,
|
|
"results": {sid: _log_to_dict(log) for sid, log in results.items()},
|
|
}
|
|
|
|
async def _audit(self, action: str, target_type: str, target_id: int, detail: str, operator: str = "admin"):
|
|
"""Create audit log entry"""
|
|
try:
|
|
audit_log = AuditLog(
|
|
admin_username=operator,
|
|
action=action,
|
|
target_type=target_type,
|
|
target_id=target_id,
|
|
detail=detail,
|
|
)
|
|
await self.audit_repo.create(audit_log)
|
|
except Exception as e:
|
|
logger.error(f"Audit log failed: {e}")
|
|
|
|
|
|
# ── Preview (dry-run) ──
|
|
|
|
async def preview_sync(
|
|
self,
|
|
server_id: int,
|
|
source_path: str,
|
|
target_path: Optional[str] = None,
|
|
sync_mode: str = "incremental",
|
|
verbose: bool = False,
|
|
) -> dict:
|
|
"""Run rsync --dry-run --stats against one server; no data is transferred.
|
|
|
|
Returns parsed stats and optional file list (verbose, capped at 200 lines).
|
|
"""
|
|
import re
|
|
server = await self.server_repo.get_by_id(server_id)
|
|
if not server:
|
|
return {"error": "Server not found", "exit_code": -1}
|
|
|
|
target = target_path or server.target_path or "/tmp/sync"
|
|
|
|
# Build flags matching the actual sync mode
|
|
mode_flags = {
|
|
"incremental": "-az",
|
|
"full": "-az --delete",
|
|
"overwrite": "-az --inplace",
|
|
"checksum": "-az --checksum",
|
|
}
|
|
flags = mode_flags.get(sync_mode, "-az")
|
|
dry_flags = f"{flags} --dry-run --stats"
|
|
if verbose:
|
|
dry_flags += " -v"
|
|
|
|
cmd = (
|
|
f"rsync {dry_flags} "
|
|
f"{shlex.quote(source_path)}/ "
|
|
f"{shlex.quote(target)}/"
|
|
)
|
|
|
|
result = await exec_ssh_command(server, cmd, timeout=60)
|
|
stdout = result.get("stdout", "")
|
|
stderr = result.get("stderr", "")
|
|
exit_code = result.get("exit_code", -1)
|
|
|
|
# exit_code 23 = partial transfer (some files not transferred) is OK for dry-run
|
|
if exit_code not in (0, 23):
|
|
return {
|
|
"server_id": server_id,
|
|
"server_name": server.name,
|
|
"error": stderr[:500] or f"rsync exited with code {exit_code}",
|
|
"exit_code": exit_code,
|
|
}
|
|
|
|
stats = _parse_rsync_stats(stdout)
|
|
|
|
files: list[str] = []
|
|
files_truncated = False
|
|
if verbose:
|
|
all_lines = [
|
|
l for l in stdout.split("\n")
|
|
if l.strip()
|
|
and not l.startswith("Number of")
|
|
and not l.startswith("Total")
|
|
and not l.startswith("Literal")
|
|
and not l.startswith("Matched")
|
|
and not l.startswith("File list")
|
|
and not l.startswith("sent ")
|
|
and not l.startswith("speedup")
|
|
and not l.startswith("sending")
|
|
]
|
|
files = all_lines[:200]
|
|
files_truncated = len(all_lines) > 200
|
|
|
|
return {
|
|
"server_id": server_id,
|
|
"server_name": server.name,
|
|
"source_path": source_path,
|
|
"target_path": target,
|
|
"sync_mode": sync_mode,
|
|
"dry_run": True,
|
|
"stats": stats,
|
|
"files": files,
|
|
"files_truncated": files_truncated,
|
|
"exit_code": exit_code,
|
|
}
|
|
|
|
|
|
def _parse_rsync_stats(output: str) -> dict:
|
|
"""Extract key numbers from rsync --stats output."""
|
|
import re
|
|
stats: dict = {}
|
|
patterns = {
|
|
"files_total": r"Number of files:\s+([\d,]+)",
|
|
"files_created": r"Number of created files:\s+([\d,]+)",
|
|
"files_deleted": r"Number of deleted files:\s+([\d,]+)",
|
|
"files_transferred": r"Number of regular files transferred:\s+([\d,]+)",
|
|
"total_size_bytes": r"Total file size:\s+([\d,]+)\s+bytes",
|
|
"transfer_size_bytes": r"Total transferred file size:\s+([\d,]+)\s+bytes",
|
|
}
|
|
for key, pattern in patterns.items():
|
|
m = re.search(pattern, output)
|
|
if m:
|
|
try:
|
|
stats[key] = int(m.group(1).replace(",", ""))
|
|
except ValueError:
|
|
stats[key] = m.group(1)
|
|
return stats
|
|
|
|
|
|
def _log_to_dict(log: SyncLog) -> dict:
|
|
return {
|
|
"id": log.id, "server_id": log.server_id,
|
|
"status": log.status, "sync_mode": log.sync_mode,
|
|
"error_message": log.error_message,
|
|
"started_at": str(log.started_at) if log.started_at else None,
|
|
"finished_at": str(log.finished_at) if log.finished_at else None,
|
|
"duration_seconds": log.duration_seconds,
|
|
}
|