"""Nexus — Pydantic Request/Response Models Shared schemas for API validation, replacing raw `dict` parameters. """ from datetime import datetime 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_iso_datetime, coerce_optional_remote_abs_path, coerce_remote_abs_path, coerce_remote_abs_path_list, coerce_remote_relative_path, ) # ── Setting ── # 与 SyncFiles / ScriptExecute 一致:批量选机上限 SERVER_IDS_BATCH_MAX = 2000 class SettingUpdatePayload(BaseModel): value: str = Field(..., min_length=0, description="设置值(字符串存储)") @field_validator("value", mode="before") @classmethod def _coerce_value_to_str(cls, v: object) -> object: if v is None: return "" if isinstance(v, bool): return "true" if v else "false" if isinstance(v, (int, float)): return str(int(v)) if isinstance(v, float) and v == int(v) else str(v) return v 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, max_length=SERVER_IDS_BATCH_MAX) 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=SERVER_IDS_BATCH_MAX) class BatchAgentResultItem(BaseModel): server_id: int server_name: str success: bool stdout: str = "" error: str = "" class BatchAgentResult(BaseModel): results: List[BatchAgentResultItem] = [] class BatchCategoryUpdate(BaseModel): """Batch update server category for selected IDs.""" server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX) category: str = Field("", max_length=100, description="分类名称;留空表示清除分类") class BatchCategoryResult(BaseModel): updated: int = 0 not_found: List[int] = [] class BatchJobStarted(BaseModel): """Immediate response when a server batch job is queued.""" job_id: int status: str = "running" label: str op: str total: int = 0 done: int = 0 remaining: int = 0 success: int = 0 failed: int = 0 progress: str = "0/0" class BatchJobStatus(BaseModel): job_id: int op: str label: str status: str total: int = 0 done: int = 0 remaining: int = 0 success: int = 0 failed: int = 0 progress: str = "0/0" operator: str = "" 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, max_length=2000) 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 必须为 12 位十六进制字符") 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, max_length=2000) 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] = Field(None, ge=1) command: Optional[str] = Field(None, min_length=1) server_ids: List[int] = Field(..., min_length=1, max_length=2000) credential_id: Optional[int] = None timeout: int = Field(60, ge=1, le=600) long_task: bool = False @model_validator(mode="after") def require_command_or_script(self) -> "ScriptExecute": cmd = (self.command or "").strip() if cmd: self.command = cmd elif not self.script_id: raise ValueError("须提供 command 或 script_id") return self 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[datetime] = None # 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 target_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("fire_at", mode="before") @classmethod def _parse_fire_at(cls, v: object) -> object: return coerce_optional_iso_datetime(v) @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) @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 ScheduleUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) source_path: Optional[str] = None target_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[datetime] = 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("fire_at", mode="before") @classmethod def _parse_fire_at(cls, v: object) -> object: return coerce_optional_iso_datetime(v) @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) @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)) # ── 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)) class AddByIpsBatchRequest(BaseModel): """批量按 IP 添加:每行一个地址(可选 ``名称IP``),凭据预设轮询 SSH,失败入 pending。""" text: str = Field(..., min_length=1, max_length=50_000, description="每行一个 IP/域名,或 名称IP") port: int = Field(22, ge=1, le=65535) 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_batch(cls, v: object) -> object: return coerce_optional_remote_abs_path(v if v is None else str(v)) class CredentialPollErrorOut(BaseModel): preset_type: str preset_name: str username: str error: str class AddByIpsBatchItemResult(BaseModel): domain: str status: str = Field(..., description="created | pending | skipped") server_id: Optional[int] = None pending_id: Optional[int] = None message: Optional[str] = None matched_preset: Optional[str] = None matched_username: Optional[str] = None errors: Optional[List[CredentialPollErrorOut]] = None class AddByIpsBatchResult(BaseModel): total: int = 0 created: int = 0 pending: int = 0 skipped: int = 0 input_lines: int = Field(0, description="非空输入行数(去重前)") duplicates_removed: int = Field(0, description="输入中重复行已忽略的数量") items: List[AddByIpsBatchItemResult] = [] # ── 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