cb5b4c42de
- main.py: Redis主Worker选举,防止多Worker重复执行后台任务
- agent.py: 告警/恢复广播使用真实服务器名(查MySQL)替代"server-{id}"
- sync_v2.py: 修复browse目录解析死代码,正确取parts[8]
- install.html: 移除过时install.php引用,更新为"删除.env重装"
- nginx配置: 移除PHP-FPM路由(install.html纯静态无需PHP)
- schedule_runner: 清理未使用的get_redis_sync导入
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
4.8 KiB
Python
148 lines
4.8 KiB
Python
"""Nexus — Sync Engine v2 API Routes (Step 4)
|
|
|
|
S1: POST /api/sync/files — rsync file sync
|
|
S2: POST /api/sync/commands — SSH exec batch
|
|
S3: POST /api/sync/config — system config push
|
|
S4: POST /api/sync/sftp — SFTP transfer
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
|
|
from server.api.schemas import SyncFiles, SyncCommands, SyncConfig, SyncSftp, SyncBrowse
|
|
from server.api.dependencies import get_server_service
|
|
from server.application.services.sync_engine_v2 import SyncEngineV2
|
|
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.api.auth_jwt import get_current_admin
|
|
from server.domain.models import Admin
|
|
|
|
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
|
|
|
|
|
async def _get_sync_engine(request: Request) -> SyncEngineV2:
|
|
"""DI factory for SyncEngineV2"""
|
|
session = request.state.db
|
|
return SyncEngineV2(
|
|
server_repo=ServerRepositoryImpl(session),
|
|
sync_log_repo=SyncLogRepositoryImpl(session),
|
|
audit_repo=AuditLogRepositoryImpl(session),
|
|
retry_repo=PushRetryJobRepositoryImpl(session),
|
|
)
|
|
|
|
|
|
@router.post("/files")
|
|
async def sync_files(
|
|
payload: SyncFiles,
|
|
request: Request,
|
|
admin: Admin = Depends(get_current_admin),
|
|
):
|
|
"""S1: File sync via rsync over SSH (with parallel execution)"""
|
|
engine = await _get_sync_engine(request)
|
|
return await engine.sync_files(
|
|
server_ids=payload.server_ids,
|
|
source_path=payload.source_path,
|
|
target_path=payload.target_path,
|
|
sync_mode=payload.sync_mode,
|
|
operator=admin.username,
|
|
batch_size=payload.batch_size,
|
|
concurrency=payload.concurrency,
|
|
)
|
|
|
|
|
|
@router.post("/commands")
|
|
async def sync_commands(
|
|
payload: SyncCommands,
|
|
request: Request,
|
|
admin: Admin = Depends(get_current_admin),
|
|
):
|
|
"""S2: Execute commands on multiple servers via SSH in parallel"""
|
|
engine = await _get_sync_engine(request)
|
|
return await engine.sync_commands(
|
|
server_ids=payload.server_ids,
|
|
commands=payload.commands,
|
|
operator=admin.username,
|
|
timeout=payload.timeout,
|
|
concurrency=payload.concurrency,
|
|
)
|
|
|
|
|
|
@router.post("/config")
|
|
async def sync_config(
|
|
payload: SyncConfig,
|
|
request: Request,
|
|
admin: Admin = Depends(get_current_admin),
|
|
):
|
|
"""S3: Push system configuration changes to multiple servers"""
|
|
engine = await _get_sync_engine(request)
|
|
return await engine.sync_config(
|
|
server_ids=payload.server_ids,
|
|
config_updates=payload.config,
|
|
operator=admin.username,
|
|
concurrency=payload.concurrency,
|
|
)
|
|
|
|
|
|
@router.post("/sftp")
|
|
async def sftp_transfer(
|
|
payload: SyncSftp,
|
|
request: Request,
|
|
admin: Admin = Depends(get_current_admin),
|
|
):
|
|
"""S4: SFTP file transfer to/from a server"""
|
|
engine = await _get_sync_engine(request)
|
|
return await engine.sftp_transfer(
|
|
server_id=payload.server_id,
|
|
operation=payload.operation,
|
|
local_path=payload.local_path,
|
|
remote_path=payload.remote_path,
|
|
operator=admin.username,
|
|
)
|
|
|
|
|
|
@router.post("/browse")
|
|
async def browse_directory(
|
|
payload: SyncBrowse,
|
|
request: Request,
|
|
admin: Admin = Depends(get_current_admin),
|
|
):
|
|
"""Browse remote directory listing via SSH"""
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
|
|
session = request.state.db
|
|
server_id = payload.server_id
|
|
path = payload.path
|
|
|
|
if not server_id:
|
|
raise HTTPException(status_code=400, detail="server_id required")
|
|
|
|
repo = ServerRepositoryImpl(session)
|
|
server = await repo.get_by_id(server_id)
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
# Use ls -la for detailed listing, parse output
|
|
import shlex
|
|
result = await exec_ssh_command(server, f"ls -la {shlex.quote(path)}", timeout=10)
|
|
if result["exit_code"] != 0:
|
|
return {"path": path, "entries": [], "error": result["stderr"][:500]}
|
|
|
|
entries = []
|
|
for line in result["stdout"].split("\n")[1:]: # Skip "total" line
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split(None, 8)
|
|
if len(parts) >= 9:
|
|
is_dir = parts[0].startswith("d")
|
|
entries.append({
|
|
"name": parts[8], # Last field after split(None, 8) — handles spaces in names
|
|
"is_dir": is_dir,
|
|
"size": parts[4],
|
|
"perms": parts[0],
|
|
"owner": parts[2],
|
|
"modified": " ".join(parts[5:8]),
|
|
})
|
|
|
|
return {"path": path, "entries": entries} |