release: nexus btpanel session fix and app-v2
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user