Files
Nexus/server/api/scripts.py
T

466 lines
17 KiB
Python
Raw Normal View History

"""Nexus — Scripts API Routes (Script library CRUD + command execution)
Presentation layer — receives HTTP requests, delegates to ScriptService.
All operations require JWT authentication.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from server.config import settings
from server.api.dependencies import get_script_service, get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import (
ScriptCreate, ScriptUpdate, ScriptExecute, ScriptExecutionRetry,
ScriptExecutionMarkStuck, DbCredentialCreate, DbCredentialUpdate,
)
from server.application.services.script_service import ScriptService
from server.domain.models import Script, DbCredential, Admin, AuditLog
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
logger = logging.getLogger("nexus.scripts")
# ── Script CRUD ──
@router.get("/", response_model=list)
async def list_scripts(
category: Optional[str] = Query(None, description="Filter by category"),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List all scripts, optionally filtered by category"""
scripts = await service.list_scripts(category)
return [_script_to_dict(s) for s in scripts]
@router.get("/summaries", response_model=list)
async def list_script_summaries(
category: Optional[str] = Query(None, description="Filter by category"),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List script metadata only; never expose script content to V2 read-only UI."""
scripts = await service.list_scripts(category)
return [_script_summary_to_dict(s) for s in scripts]
@router.get("/exec-config", response_model=dict)
async def script_exec_config(
admin: Admin = Depends(get_current_admin),
):
"""Limits for script execution UI (built-in batching)."""
return {
"batch_size": settings.SCRIPT_EXEC_BATCH_SIZE,
"concurrency": settings.SCRIPT_EXEC_CONCURRENCY,
"max_timeout": 600,
}
# ── DB Credentials (must register before /{id}) ──
@router.get("/credentials", response_model=list)
async def list_credentials(
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List all database credentials"""
credentials = await service.list_credentials()
return [_credential_to_dict(c) for c in credentials]
@router.post("/credentials", response_model=dict, status_code=201)
async def create_credential(
payload: DbCredentialCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Create a database credential (password will be encrypted)"""
data = payload.model_dump(exclude_none=True)
data["encrypted_password"] = data.pop("password", "")
credential = DbCredential(**data)
created = await service.create_credential(credential)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_credential",
target_type="credential", target_id=created.id,
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _credential_to_dict(created)
@router.put("/credentials/{id}", response_model=dict)
async def update_credential(
id: int,
payload: DbCredentialUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Update a database credential (password optional — omit to keep existing)"""
from server.infrastructure.database.db_credential_repo import DbCredentialRepositoryImpl
from server.infrastructure.database.crypto import encrypt_value
repo = DbCredentialRepositoryImpl(db)
credential = await repo.get_by_id(id)
if not credential:
raise HTTPException(status_code=404, detail="Credential not found")
updates = payload.model_dump(exclude_unset=True)
# Handle password separately — encrypt if provided, skip if omitted
new_password = updates.pop("password", None)
# Explicit allowlist to prevent mass-assignment to sensitive columns
ALLOWED_FIELDS = {"name", "db_type", "host", "port", "username", "database"}
for key, value in updates.items():
if key in ALLOWED_FIELDS and hasattr(credential, key):
setattr(credential, key, value)
if new_password:
credential.encrypted_password = encrypt_value(new_password)
updated = await repo.update(credential)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_credential",
target_type="credential", target_id=id,
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _credential_to_dict(updated)
@router.delete("/credentials/{id}", status_code=204)
async def delete_credential(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a database credential"""
result = await service.delete_credential(id)
if not result:
raise HTTPException(status_code=404, detail="Credential not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_credential",
target_type="credential", target_id=id,
ip_address=request.client.host if request.client else "",
))
@router.get("/executions", response_model=dict)
async def list_executions(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status: Optional[str] = Query(None, description="Filter: running/completed/failed/partial"),
script_id: Optional[int] = Query(None, ge=1, description="Filter by script library id"),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List script execution history (newest first)."""
return await service.list_executions(
limit=limit, offset=offset, status=status, script_id=script_id,
)
@router.post("/executions/{id}/stop", response_model=dict)
async def stop_execution(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Stop pending long-task processes on child hosts."""
execution = await service.stop_execution(id, operator=admin.username)
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
detail = await service.get_execution_detail(id)
return detail if detail else _execution_to_dict(execution)
@router.post("/executions/{id}/mark-stuck", response_model=dict)
async def mark_execution_stuck(
id: int,
payload: ScriptExecutionMarkStuck,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Mark execution as suspected stuck — event timeline + MySQL + audit."""
execution = await service.mark_execution_stuck(
id, operator=admin.username, detail=payload.detail,
)
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
detail = await service.get_execution_detail(id)
return detail or _execution_to_dict(execution)
@router.post("/executions/{id}/retry", response_model=dict, status_code=201)
async def retry_execution(
id: int,
payload: ScriptExecutionRetry,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Re-run failed / pending / cancelled servers from a previous execution."""
from server.api.dependencies import check_dangerous_command
prev = await service.get_execution(id)
if not prev:
raise HTTPException(status_code=404, detail="Execution not found")
command, _ = service._parse_execution_meta(prev.command or "")
warnings = check_dangerous_command(command)
if warnings:
raise HTTPException(
status_code=400,
detail={"message": "危险命令已拒绝执行", "warnings": warnings},
)
try:
execution = await service.retry_execution(
id,
server_ids=payload.server_ids,
credential_id=payload.credential_id,
timeout=payload.timeout,
long_task=payload.long_task,
operator=admin.username,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
detail = await service.get_execution_detail(execution.id)
if detail:
detail["parent_execution_id"] = id
return detail
out = _execution_to_dict(execution)
out["parent_execution_id"] = id
return out
@router.get("/executions/{id}", response_model=dict)
async def get_execution(
id: int,
fetch_logs: bool = Query(
False,
description="Tail /var/log/nexus-job/*.log on child hosts (long tasks)",
),
log_lines: int = Query(100, ge=10, le=500),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Get execution result; optional live log tail from managed servers."""
detail = await service.get_execution_detail(
id, fetch_logs=fetch_logs, log_tail_lines=log_lines,
)
if not detail:
raise HTTPException(status_code=404, detail="Execution not found")
return detail
@router.get("/{id}", response_model=dict)
async def get_script(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Get a single script by ID"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
return _script_to_dict(script)
@router.post("/", response_model=dict, status_code=201)
async def create_script(
payload: ScriptCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Create a new script"""
data = payload.model_dump(exclude_none=True)
data.setdefault("created_by", admin.username)
script = Script(**data)
created = await service.create_script(script)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_script",
target_type="script", target_id=created.id,
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _script_to_dict(created)
@router.put("/{id}", response_model=dict)
async def update_script(
id: int,
payload: ScriptUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Update an existing script"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
# Explicit allowlist to prevent mass-assignment to sensitive columns
ALLOWED_FIELDS = {"name", "category", "content", "description"}
for key, value in payload.model_dump(exclude_unset=True).items():
if key in ALLOWED_FIELDS:
setattr(script, key, value)
updated = await service.update_script(script)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_script",
target_type="script", target_id=id,
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _script_to_dict(updated)
@router.delete("/{id}", status_code=204)
async def delete_script(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a script"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
result = await service.delete_script(id)
if not result:
raise HTTPException(status_code=404, detail="Script not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_script",
target_type="script", target_id=id,
detail=f"名称={script.name}", ip_address=request.client.host if request.client else "",
))
# ── Command Execution ──
async def _resolve_exec_command(service: ScriptService, payload: ScriptExecute) -> str:
"""Use explicit command or load content from script library."""
if payload.command:
return payload.command
script = await service.get_script(payload.script_id) # type: ignore[arg-type]
if not script:
raise HTTPException(status_code=404, detail="脚本不存在")
content = (script.content or "").strip()
if not content:
raise HTTPException(status_code=400, detail="脚本内容为空,无法执行")
return content
@router.post("/exec", response_model=dict)
async def execute_command(
payload: ScriptExecute,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Execute a shell command on multiple servers via Agent /api/exec"""
# P2-18: Log dangerous commands for audit trail
from server.api.dependencies import check_dangerous_command
command = await _resolve_exec_command(service, payload)
warnings = check_dangerous_command(command)
if warnings:
raise HTTPException(
status_code=400,
detail={"message": "危险命令已拒绝执行", "warnings": warnings},
)
try:
execution = await service.execute_command(
command=command,
server_ids=payload.server_ids,
script_id=payload.script_id,
credential_id=payload.credential_id,
timeout=payload.timeout,
operator=admin.username,
long_task=payload.long_task,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
detail = await service.get_execution_detail(execution.id)
return detail if detail else _execution_to_dict(execution)
# ── Helper functions ──
def _script_summary_to_dict(script: Script) -> dict:
content = script.content or ""
return {
"id": script.id,
"name": script.name,
"category": script.category,
"description": script.description,
"created_by": script.created_by,
"created_at": str(script.created_at) if script.created_at else None,
"updated_at": str(script.updated_at) if script.updated_at else None,
"has_content": bool(content.strip()),
"content_size": len(content.encode("utf-8")),
"line_count": len(content.splitlines()) if content else 0,
}
def _script_to_dict(script: Script) -> dict:
return {
"id": script.id,
"name": script.name,
"category": script.category,
"content": script.content,
"description": script.description,
"created_by": script.created_by,
"created_at": str(script.created_at) if script.created_at else None,
"updated_at": str(script.updated_at) if script.updated_at else None,
}
def _execution_to_dict(execution) -> dict:
return {
"id": execution.id,
"script_id": execution.script_id,
"command": execution.command,
"server_ids": execution.server_ids,
"status": execution.status,
"results": execution.results,
"operator": execution.operator,
"credential_id": getattr(execution, "credential_id", None),
"started_at": str(execution.started_at) if execution.started_at else None,
"completed_at": str(execution.completed_at) if execution.completed_at else None,
}
def _credential_to_dict(credential: DbCredential) -> dict:
"""Convert credential to dict (never expose password in API response)"""
return {
"id": credential.id,
"name": credential.name,
"db_type": credential.db_type,
"host": credential.host,
"port": credential.port,
"username": credential.username,
"database": credential.database,
"created_at": str(credential.created_at) if credential.created_at else None,
}