Files
Nexus/server/api/schemas.py
T
Nexus Deploy 4ba45abf38 feat(servers): 凭据轮询登录与连接失败列表
- 密码/SSH密钥预设增加用户名;快速添加(IP)轮询全部预设尝试 SSH 登录
- 成功入 servers 列表;失败写入 pending_servers 并支持重试/删除
- 新增 credential_poller、try_ssh_login 与 add-by-ip/pending API
2026-06-06 20:08:48 +08:00

662 lines
23 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.
"""Nexus — Pydantic Request/Response Models
Shared schemas for API validation, replacing raw `dict` parameters.
"""
from typing import Optional, List
from pydantic import BaseModel, Field, field_validator, model_validator
from server.api.schema_path_validators import (
coerce_nexus_host_abs_path,
coerce_optional_remote_abs_path,
coerce_remote_abs_path,
coerce_remote_abs_path_list,
coerce_remote_relative_path,
)
# ── Setting ──
class SettingUpdatePayload(BaseModel):
value: str = Field(..., min_length=0)
class ApiKeyRevealRequest(BaseModel):
"""Shared re-auth payload for revealing sensitive values (API key, install cmd, etc.)."""
current_password: str = Field(..., min_length=1, max_length=255)
# ── Agent ──
class AgentHeartbeat(BaseModel):
server_id: Optional[int] = None # None = unregistered agent, silently discard
is_online: bool = True
system_info: Optional[dict] = None
agent_version: Optional[str] = None
# ── Server ──
class ServerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
domain: str = Field(..., min_length=1, max_length=255)
port: int = Field(22, ge=1, le=65535)
username: str = Field("root", max_length=100)
auth_method: str = Field("key", pattern="^(key|password)$")
password: Optional[str] = None
preset_id: Optional[int] = None
ssh_key_path: Optional[str] = None
ssh_key_private: Optional[str] = None
ssh_key_public: Optional[str] = None
ssh_key_preset_id: Optional[int] = None
agent_port: int = Field(8601, ge=1, le=65535)
description: Optional[str] = None
target_path: Optional[str] = None
category: Optional[str] = None
platform_id: Optional[int] = None
node_id: Optional[int] = None
protocols: Optional[list] = None
extra_attrs: Optional[dict] = None
files_elevation: Optional[str] = Field(
default=None,
pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)",
)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class ServerUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
domain: Optional[str] = Field(None, min_length=1, max_length=255)
port: Optional[int] = Field(None, ge=1, le=65535)
username: Optional[str] = None
auth_method: Optional[str] = Field(None, pattern="^(key|password)$")
password: Optional[str] = None
preset_id: Optional[int] = None
ssh_key_path: Optional[str] = None
ssh_key_private: Optional[str] = None
ssh_key_public: Optional[str] = None
ssh_key_preset_id: Optional[int] = None
agent_port: Optional[int] = Field(None, ge=1, le=65535)
agent_api_key: Optional[str] = None
description: Optional[str] = None
target_path: Optional[str] = None
category: Optional[str] = None
platform_id: Optional[int] = None
node_id: Optional[int] = None
protocols: Optional[list] = None
extra_attrs: Optional[dict] = None
files_elevation: Optional[str] = Field(
default=None,
pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)",
)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
if v is None:
return None
return coerce_optional_remote_abs_path(str(v))
class ServerCheck(BaseModel):
server_ids: List[int] = Field(..., min_length=1)
class ServerImportResult(BaseModel):
"""Result of CSV batch import."""
total: int = 0
created: int = 0
skipped: int = 0
failed: int = 0
errors: List[dict] = [] # [{row, name, domain, error}]
created_ids: List[int] = []
class BatchAgentAction(BaseModel):
"""Batch install/upgrade Agent on multiple servers."""
server_ids: List[int] = Field(..., min_length=1, max_length=50)
class BatchAgentResultItem(BaseModel):
server_id: int
server_name: str
success: bool
stdout: str = ""
error: str = ""
class BatchAgentResult(BaseModel):
results: List[BatchAgentResultItem] = []
class FileUpload(BaseModel):
"""Upload file to remote server via SFTP."""
server_id: int = Field(..., ge=1)
remote_path: str = Field(..., min_length=1)
@field_validator("remote_path", mode="before")
@classmethod
def _normalize_remote_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
# ── Sync ──
class SyncFiles(BaseModel):
server_ids: List[int] = Field(..., min_length=1)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
batch_size: int = Field(50, ge=1, le=200)
concurrency: int = Field(10, ge=1, le=50)
batch_id: Optional[str] = Field(
None,
min_length=12,
max_length=12,
description="Client-supplied push batch id (12 hex); must connect /ws/sync before POST",
)
@field_validator("batch_id", mode="before")
@classmethod
def _normalize_batch_id(cls, v: object) -> object:
if v is None or v == "":
return None
s = str(v).strip().lower()
if len(s) != 12 or not all(c in "0123456789abcdef" for c in s):
raise ValueError("batch_id must be 12 hexadecimal characters")
return s
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class SyncPreview(BaseModel):
"""Dry-run: rsync --dry-run --stats against one representative server."""
server_id: int = Field(..., ge=1)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
verbose: bool = False # If True, include per-file list (truncated to 200 lines)
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class SyncBrowse(BaseModel):
server_id: int
path: str = Field("/", min_length=1)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class SyncBrowseLocal(BaseModel):
"""Browse a local directory on the Nexus server (for upload file manager)."""
path: str = Field(..., min_length=1)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
class LocalFileOperation(BaseModel):
"""Delete or rename a file/directory in the local upload staging area."""
operation: str = Field(..., pattern="^(delete|rename)$")
path: str = Field(..., min_length=1)
new_name: Optional[str] = None # required for rename
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
class LocalFilePreview(BaseModel):
"""Preview file content in the local upload staging area."""
path: str = Field(..., min_length=1)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
class ValidateSourcePath(BaseModel):
"""Validate a local directory path on the Nexus server as a push source."""
path: str = Field(..., min_length=1)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
class SyncDiagnose(BaseModel):
"""Diagnose why a push failed for a specific server."""
server_id: int = Field(..., ge=1)
target_path: Optional[str] = None
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class FileSyncDiff(BaseModel):
"""Compare a local file with its remote counterpart to show push diff."""
server_id: int = Field(..., ge=1)
source_path: str = Field(..., min_length=1)
relative_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
@field_validator("relative_path", mode="before")
@classmethod
def _normalize_relative_path(cls, v: object) -> str:
return coerce_remote_relative_path(str(v))
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class SyncCancel(BaseModel):
"""Cancel an in-progress push by batch_id."""
batch_id: str = Field(..., min_length=1, max_length=32)
class ReconcileStaleLogs(BaseModel):
"""Mark long-stuck sync_logs rows (status=running) as failed."""
max_age_minutes: int = Field(120, ge=5, le=10080)
class SyncVerify(BaseModel):
"""Post-push verification: compare local source with remote target via md5sum."""
server_ids: List[int] = Field(..., min_length=1)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
max_files: int = Field(200, ge=1, le=500)
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class FileOperation(BaseModel):
"""Remote file operation via SSH exec (delete / rename / mkdir)."""
server_id: int = Field(..., ge=1)
operation: str = Field(..., pattern="^(delete|rename|mkdir)$")
path: str = Field(..., min_length=1) # source path (or new dir path for mkdir)
new_path: Optional[str] = None # required for rename
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
@field_validator("new_path", mode="before")
@classmethod
def _normalize_new_path(cls, v: object) -> object:
if v is None:
return None
return coerce_remote_abs_path(str(v))
class FileClipboardApply(BaseModel):
"""Batch copy or move (cut+paste) remote paths into a destination directory."""
server_id: int = Field(..., ge=1)
mode: str = Field(..., pattern="^(copy|move)$")
sources: list[str] = Field(..., min_length=1, max_length=50)
dest_dir: str = Field(..., min_length=1, max_length=4096)
@field_validator("sources", mode="before")
@classmethod
def _normalize_sources(cls, v: object) -> list[str]:
if not isinstance(v, list):
raise ValueError("sources 必须为路径列表")
return coerce_remote_abs_path_list([str(x) for x in v])
@field_validator("dest_dir", mode="before")
@classmethod
def _normalize_dest_dir(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileDownload(BaseModel):
"""Download a file from a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileRead(BaseModel):
"""Read text file content from a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
max_size: int = Field(1_000_000, ge=1, le=5_000_000) # 1MB default, 5MB max
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileWrite(BaseModel):
"""Write text content to a file on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
content: str = Field(..., max_length=5_000_000)
etag: str | None = Field(None, max_length=64)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileChmod(BaseModel):
"""Change file permissions on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
mode: str = Field(..., min_length=3, max_length=4, pattern=r"^[0-7]{3,4}$")
owner: Optional[str] = Field(None, max_length=64, pattern=r"^[a-zA-Z0-9_.-]+$")
group: Optional[str] = Field(None, max_length=64, pattern=r"^[a-zA-Z0-9_.-]+$")
recursive: bool = False
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
@model_validator(mode="after")
def _group_requires_owner(self):
if self.group and not self.owner:
raise ValueError("指定属组时必须同时指定属主")
return self
class FileCompress(BaseModel):
"""Compress files into an archive on a remote server."""
server_id: int = Field(..., ge=1)
paths: list[str] = Field(..., min_length=1, max_length=100)
dest: str = Field(..., min_length=1, max_length=4096)
format: str = Field("tar.gz", pattern=r"^(tar\.gz|zip)$")
@field_validator("paths", mode="before")
@classmethod
def _normalize_paths(cls, v: object) -> list[str]:
if not isinstance(v, list):
raise ValueError("paths 必须为路径列表")
return coerce_remote_abs_path_list([str(x) for x in v])
@field_validator("dest", mode="before")
@classmethod
def _normalize_dest(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileDecompress(BaseModel):
"""Decompress an archive on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
dest: str = Field(..., min_length=1, max_length=4096)
@field_validator("path", "dest", mode="before")
@classmethod
def _normalize_paths(cls, v: object) -> str:
text = str(v).strip()
if text == ".":
raise ValueError("解压目标必须为绝对路径(不可使用 '.'")
return coerce_remote_abs_path(text)
# ── Script ──
class ScriptCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
category: str = Field("ops", pattern="^(ops|deploy|check|cleanup)$")
content: str = Field(..., min_length=1)
description: Optional[str] = None
created_by: Optional[str] = None
class ScriptUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = Field(None, pattern="^(ops|deploy|check|cleanup)$")
content: Optional[str] = None
description: Optional[str] = None
class ScriptExecute(BaseModel):
script_id: Optional[int] = None
command: str = Field(..., min_length=1)
server_ids: List[int] = Field(..., min_length=1)
credential_id: Optional[int] = None
timeout: int = Field(60, ge=1, le=600)
long_task: bool = False
class ScriptExecutionRetry(BaseModel):
server_ids: Optional[List[int]] = None
credential_id: Optional[int] = None
long_task: Optional[bool] = None
timeout: int = Field(120, ge=1, le=600)
class ScriptExecutionMarkStuck(BaseModel):
detail: str = Field("", max_length=500)
class AgentScriptCallback(BaseModel):
"""Child host reports long-running script completion (curl from nohup wrapper)."""
job_id: str = Field(..., min_length=8, max_length=64)
server_id: int = Field(..., ge=1)
secret: str = Field(..., min_length=16, max_length=128)
exit_code: int = 0
message: str = Field("", max_length=500)
log_tail: Optional[str] = Field(None, max_length=2000)
# ── DB Credential ──
class DbCredentialCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
db_type: str = Field("mysql", pattern="^(mysql|postgresql|mariadb|mongodb|redis)$")
host: str = Field(..., min_length=1)
port: int = Field(3306, ge=1, le=65535)
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
database: Optional[str] = None
class DbCredentialUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
db_type: Optional[str] = Field(None, pattern="^(mysql|postgresql|mariadb|mongodb|redis)$")
host: Optional[str] = Field(None, min_length=1)
port: Optional[int] = Field(None, ge=1, le=65535)
username: Optional[str] = Field(None, min_length=1)
password: Optional[str] = Field(None, min_length=1, description="留空则不修改密码")
database: Optional[str] = None
# ── Schedule ──
class ScheduleCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
server_ids: str = Field(..., min_length=3) # JSON array string
run_mode: str = Field("once", pattern="^(once|cron)$")
cron_expr: Optional[str] = None # required when run_mode=cron
fire_at: Optional[str] = None # ISO datetime, required when run_mode=once
enabled: bool = True
# Schedule type
schedule_type: str = Field("push", pattern="^(push|script)$")
# Push-specific (required when schedule_type == 'push')
source_path: Optional[str] = None
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
# Script-specific (required when schedule_type == 'script')
script_id: Optional[int] = None
script_content: Optional[str] = None
exec_timeout: int = Field(60, ge=10, le=600)
long_task: bool = False
@model_validator(mode="after")
def _validate_schedule_fields(self):
if self.run_mode == "cron" and not self.cron_expr:
raise ValueError("cron 模式必须提供 cron_expr")
if self.run_mode == "once" and not self.fire_at:
raise ValueError("一次性模式必须提供 fire_at 执行时间")
if self.schedule_type == "script" and not self.script_id and not self.script_content:
raise ValueError("script 类型调度必须提供 script_id 或 script_content")
if self.schedule_type == "push" and not self.source_path:
raise ValueError("push 类型调度必须提供 source_path")
return self
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> object:
if v is None:
return None
text = str(v).strip()
if not text:
return None
return coerce_nexus_host_abs_path(text)
class ScheduleUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
source_path: Optional[str] = None
server_ids: Optional[str] = None
run_mode: Optional[str] = Field(None, pattern="^(once|cron)$")
cron_expr: Optional[str] = None
fire_at: Optional[str] = None
sync_mode: Optional[str] = Field(None, pattern="^(incremental|full|overwrite|checksum)$")
enabled: Optional[bool] = None
schedule_type: Optional[str] = Field(None, pattern="^(push|script)$")
script_id: Optional[int] = None
script_content: Optional[str] = None
exec_timeout: Optional[int] = Field(None, ge=10, le=600)
long_task: Optional[bool] = None
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> object:
if v is None:
return None
text = str(v).strip()
if not text:
return None
return coerce_nexus_host_abs_path(text)
# ── Preset ──
class PresetCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
username: str = Field("root", min_length=1, max_length=100)
encrypted_pw: str = Field(..., min_length=1)
class PresetUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
username: Optional[str] = Field(None, min_length=1, max_length=100)
encrypted_pw: Optional[str] = Field(None, min_length=1, description="留空则不修改密码")
# ── SSH Key Preset ──
class SshKeyPresetCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
username: str = Field("root", min_length=1, max_length=100)
private_key: str = Field(..., min_length=1)
public_key: Optional[str] = None
class SshKeyPresetUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
username: Optional[str] = Field(None, min_length=1, max_length=100)
private_key: Optional[str] = Field(None, min_length=1, description="留空则不修改私钥")
public_key: Optional[str] = None
class AddByIpRequest(BaseModel):
domain: str = Field(..., min_length=1, max_length=255, description="IP 或域名")
port: int = Field(22, ge=1, le=65535)
name: Optional[str] = Field(None, min_length=1, max_length=100)
agent_port: int = Field(8601, ge=1, le=65535)
target_path: Optional[str] = Field("", description="推送目标路径,可后续编辑")
category: Optional[str] = None
platform_id: Optional[int] = None
node_id: Optional[int] = None
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path_add(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
# ── Platform / Node ──
class PlatformCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
category: str = Field(..., min_length=1, max_length=50)
type: str = Field(..., min_length=1, max_length=50)
default_protocols: Optional[list] = None
charset: str = "utf-8"
class PlatformUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = None
type: Optional[str] = None
default_protocols: Optional[list] = None
charset: Optional[str] = None
class NodeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
parent_id: Optional[int] = None
sort_order: int = 0
class NodeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
parent_id: Optional[int] = None
sort_order: Optional[int] = None