diff --git a/docs/audit/2026-06-11-server-onboarding-automation.md b/docs/audit/2026-06-11-server-onboarding-automation.md new file mode 100644 index 00000000..cc2a06a9 --- /dev/null +++ b/docs/audit/2026-06-11-server-onboarding-automation.md @@ -0,0 +1,41 @@ +# 审计 — 新服务器接入自动化 + +## 范围(文件清单) + +| 文件 | 变更 | +|------|------| +| `server/application/services/server_onboarding_service.py` | 调度入口 | +| `server/application/services/server_batch_service.py` | `_run_onboard` | +| `server/application/server_batch_common.py` | `detect_and_save_target_path` | +| `server/api/servers.py` | 创建/导入/IP 添加钩子 | +| `server/api/schemas.py` | `onboarding_job_id` | +| `frontend/src/composables/servers/useServerFormDialog.ts` | 任务注册 | +| `tests/test_server_onboarding.py` | 单测 | + +## Step 3 规则扫描 + +| H | 规则 | 结论 | +|---|------|------| +| H1 | sudoers 仍走白名单模板 | PASS | +| H2 | 创建 API 不因 onboard 失败而 5xx | PASS — 后台 job | +| H3 | 探测失败不阻断推送 | PASS — wwwroot 回退 | + +## Closure + +| H | 判定 | 依据 | +|---|------|------| +| H1 | PASS | files_sudoers_service | +| H2 | PASS | schedule 独立 try/except | +| H3 | PASS | detect 失败 stdout 提示 | + +## DoD + +- [x] pytest test_server_onboarding +- [x] changelog 2026-06-11-server-onboarding-automation.md +- [x] 设计 docs/design/specs/2026-06-11-server-onboarding-automation-design.md + +## 验证 + +```bash +pytest tests/test_server_onboarding.py -q +``` diff --git a/docs/audit/2026-06-12-server-onboarding-automation.md b/docs/audit/2026-06-12-server-onboarding-automation.md new file mode 100644 index 00000000..ae7b83cb --- /dev/null +++ b/docs/audit/2026-06-12-server-onboarding-automation.md @@ -0,0 +1,46 @@ +# 审计 — 2026-06-12 新服务器接入自动化(生产部署) + +## 范围(文件清单) + +| 文件 | 说明 | +|------|------| +| `server/application/services/server_onboarding_service.py` | 调度入口 | +| `server/application/services/server_batch_service.py` | `_run_onboard` | +| `server/application/server_batch_common.py` | `detect_and_save_target_path` | +| `server/api/servers.py` | 创建/导入/IP 添加钩子 | +| `server/api/schemas.py` | `onboarding_job_id` | +| `frontend/src/composables/servers/useServerFormDialog.ts` | 任务注册 | +| `frontend/src/pages/ServersPage.vue` | IP 批量添加注册 | +| `frontend/src/types/api.ts` | 类型 | +| `frontend/src/utils/auditLabels.ts` | batch_server_onboard 标签 | +| `scripts/batch_detect_target_path.py` | 复用 detect_and_save_target_path | +| `tests/test_server_onboarding.py` | 单测 | + +## Step 3 规则扫描 + +| H | 规则 | 结论 | +|---|------|------| +| H1 | sudoers 白名单 | PASS | +| H2 | 创建 API 不 5xx | PASS | +| H3 | 探测失败可推 wwwroot | PASS | + +## Closure + +| H | 判定 | 依据 | +|---|------|------| +| H1 | PASS | files_sudoers_service | +| H2 | PASS | server_onboarding_service | +| H3 | PASS | _run_onboard | + +## DoD + +- [x] pytest test_server_onboarding +- [x] changelog 2026-06-12-server-onboarding-automation.md +- [x] 7/7 门控 + +## 验证 + +```bash +pytest tests/test_server_onboarding.py -q +bash deploy/pre_deploy_check.sh +``` diff --git a/docs/changelog/2026-06-11-server-onboarding-automation.md b/docs/changelog/2026-06-11-server-onboarding-automation.md new file mode 100644 index 00000000..415d53e4 --- /dev/null +++ b/docs/changelog/2026-06-11-server-onboarding-automation.md @@ -0,0 +1,40 @@ +# Changelog — 2026-06-11 新服务器接入自动化 + +## 摘要 + +创建/导入/凭据轮询添加服务器成功后,后台自动执行 **sudoers 配置(含 rsync)** + **目标路径探测**,无需再手动点「一键配置 sudo」和「检测目标路径」。 + +## 动机 + +- 非 root 新机推送失败多因未配 sudoers +- 运维希望加机后开箱即用 + +## 变更 + +| 文件 | 说明 | +|------|------| +| `server/application/services/server_onboarding_service.py` | 调度 `onboard` 批任务 | +| `server/application/services/server_batch_service.py` | op `onboard` | +| `server/application/server_batch_common.py` | `detect_and_save_target_path` 抽取 | +| `server/api/servers.py` | 创建/导入/IP 添加后触发 | +| `frontend/.../useServerFormDialog.ts` | 进度条 + toast | +| `tests/test_server_onboarding.py` | 单测 | + +## 行为 + +1. **非 root**:`install_nexus_files_sudoers`(失败则任务项失败) +2. **未设路径**:探测 workerman.bat;失败仍标记成功,提示可推 `/www/wwwroot` +3. **root**:跳过 sudo,仅探测(若未设路径) + +API 响应可选字段:`onboarding_job_id`、`onboarding_label`。 + +## 迁移 / 重启 + +需重启 API / 重建镜像。无 DB 迁移。 + +## 验证 + +```bash +pytest tests/test_server_onboarding.py -q +# 新建 ubuntu 服务器 → 底部任务栏「新服务器初始化」 +``` diff --git a/docs/changelog/2026-06-12-server-onboarding-automation.md b/docs/changelog/2026-06-12-server-onboarding-automation.md new file mode 100644 index 00000000..bc7c83a5 --- /dev/null +++ b/docs/changelog/2026-06-12-server-onboarding-automation.md @@ -0,0 +1,25 @@ +# Changelog — 2026-06-12 新服务器接入自动化(生产部署) + +## 摘要 + +部署 `7ba5e21`:创建/导入/IP 添加服务器后自动后台 **onboard**(非 root sudoers + 未设路径探测)。 + +## 动机 + +避免新机重复出现推送 Permission denied / 未配 sudoers。 + +## 变更 + +同 [2026-06-11-server-onboarding-automation.md](./2026-06-11-server-onboarding-automation.md)。 + +## 迁移 / 重启 + +重建 Docker 镜像并重启 API。无 DB 迁移。 + +## 验证 + +```bash +pytest tests/test_server_onboarding.py -q +# 生产:新建 ubuntu 子机 → 任务栏「新服务器初始化」 +curl -s https://api.synaglobal.vip/health +``` diff --git a/docs/design/plans/2026-06-11-server-onboarding-automation.md b/docs/design/plans/2026-06-11-server-onboarding-automation.md new file mode 100644 index 00000000..5912ea21 --- /dev/null +++ b/docs/design/plans/2026-06-11-server-onboarding-automation.md @@ -0,0 +1,19 @@ +# 实施 — 新服务器接入自动化 + +## 文件 + +| 文件 | 变更 | +|------|------| +| `server/application/server_batch_common.py` | `detect_and_save_target_path` | +| `server/application/services/server_onboarding_service.py` | 调度入口 | +| `server/application/services/server_batch_service.py` | op `onboard` | +| `server/api/servers.py` | 创建/导入后触发 | +| `server/api/schemas.py` | `onboarding_job_id` | +| `frontend/.../useServerFormDialog.ts` | 注册进度条 | +| `tests/test_server_onboarding.py` | 单测 | + +## 验证 + +```bash +pytest tests/test_server_onboarding.py -q +``` diff --git a/docs/design/specs/2026-06-11-server-onboarding-automation-design.md b/docs/design/specs/2026-06-11-server-onboarding-automation-design.md new file mode 100644 index 00000000..0a431ed8 --- /dev/null +++ b/docs/design/specs/2026-06-11-server-onboarding-automation-design.md @@ -0,0 +1,34 @@ +# 设计 — 新服务器接入自动化 + +## 背景 + +非 root 子机推送需目标机 `nexus-files` sudoers;未设 `target_path` 时推送回退 `/www/wwwroot`。此前需人工「一键配置 sudo」+「检测目标路径」。 + +## 目标 + +创建/导入/凭据轮询添加服务器成功后,**后台自动**: + +1. 非 root:安装/补全 `nexus-files` sudoers(含 rsync) +2. `target_path` 仍为「未设路径」:SSH 探测 workerman.bat 并写入站点目录 + +## 方案 + +新增批量操作 `onboard`,复用现有 `server_batch_service` 后台任务 + WebSocket 进度。 + +| 入口 | 触发 | +|------|------| +| POST `/servers/` | 单台 → job | +| CSV import | 批量 created_ids → 一个 job | +| 凭据轮询 / add-by-ip / pending retry | `_create_server_from_poll` 内触发 | + +root SSH:跳过 sudo 步骤;已有具体 `target_path`:跳过探测。 + +## 非目标 + +- 不自动安装 Agent +- 不因 onboarding 失败阻断创建 API(仅记录 batch job 失败项) + +## 验收 + +- 新建 ubuntu 服务器 → 返回 `onboarding_job_id`,任务含 sudo + detect +- pytest 覆盖 onboard 编排逻辑 diff --git a/frontend/src/composables/servers/useServerFormDialog.ts b/frontend/src/composables/servers/useServerFormDialog.ts index 96d18b50..e4b76685 100644 --- a/frontend/src/composables/servers/useServerFormDialog.ts +++ b/frontend/src/composables/servers/useServerFormDialog.ts @@ -1,6 +1,8 @@ import { ref, computed } from 'vue' import { http } from '@/api' import { useSnackbar } from '@/composables/useSnackbar' +import { registerServerBatchJob } from '@/composables/useScriptExecutionQueue' +import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast' import { formatApiError } from '@/utils/apiError' import type { FilesElevationMode, ServerApiItem } from '@/types/api' @@ -229,7 +231,21 @@ export function useServerFormDialog(onSaved: () => void) { if (isEditing.value && editingId.value != null) { await http.put(`/servers/${editingId.value}`, payload) } else { - await http.post('/servers/', payload) + const created = await http.post('/servers/', payload) + if (created.onboarding_job_id) { + registerServerBatchJob( + created.onboarding_job_id, + created.onboarding_label || '新服务器初始化', + 1, + 'onboard', + '0/1', + 'running', + ) + showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)') + } } close() onSaved() diff --git a/frontend/src/pages/ServersPage.vue b/frontend/src/pages/ServersPage.vue index 78362d4a..137f8485 100644 --- a/frontend/src/pages/ServersPage.vue +++ b/frontend/src/pages/ServersPage.vue @@ -727,6 +727,23 @@ async function submitServerBatchJob( return res } +function registerOnboardingJob( + jobId: number | undefined, + label: string | undefined, + total: number, +) { + if (!jobId) return + registerServerBatchJob( + jobId, + label || '新服务器初始化', + total, + 'onboard', + `0/${total}`, + 'running', + ) + showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)') +} + onMounted(() => { offBatchComplete = onScriptExecutionComplete((alert) => { if (alert.taskKind !== 'server_batch') return @@ -1173,6 +1190,7 @@ async function submitQuickAdd() { const res = await http.post('/servers/add-by-ip', body) if (res.success && res.server) { showQuickAdd.value = false + registerOnboardingJob(res.onboarding_job_id, res.onboarding_label, 1) snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`) loadServers() loadStats() @@ -1193,6 +1211,7 @@ async function retryPending(item: PendingServerItem) { try { const res = await http.post(`/servers/pending/${item.id}/retry`, {}) if (res.success && res.server) { + registerOnboardingJob(res.onboarding_job_id, res.onboarding_label, 1) snackbar(`重试成功:${res.matched_preset} (${res.matched_username})`) loadServers() loadStats() @@ -1705,6 +1724,7 @@ async function submitBatchAdd() { }) batchAddResult.value = res if (res.created > 0) { + registerOnboardingJob(res.onboarding_job_id, '新服务器初始化', res.created) loadServers() loadStats() } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index e142091b..845ad5a8 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -266,6 +266,8 @@ export interface AddByIpResponse { message?: string matched_preset?: string matched_username?: string + onboarding_job_id?: number + onboarding_label?: string } /** Item from POST /api/servers/add-by-ips-batch */ @@ -289,6 +291,7 @@ export interface AddByIpsBatchResult { input_lines?: number duplicates_removed?: number items: AddByIpsBatchItemResult[] + onboarding_job_id?: number } /** Alert history item from /api/alert-history/ */ diff --git a/frontend/src/utils/auditLabels.ts b/frontend/src/utils/auditLabels.ts index 4fe962d1..50c3ee7f 100644 --- a/frontend/src/utils/auditLabels.ts +++ b/frontend/src/utils/auditLabels.ts @@ -39,6 +39,7 @@ const ACTION_LABELS: Record = { batch_upgrade_agent: '批量升级 Agent', batch_uninstall_agent: '批量卸载 Agent', batch_detect_path: '批量检测路径', + batch_server_onboard: '新服务器初始化', batch_health_check: '批量健康检查', batch_update_category: '批量修改分类', diff --git a/scripts/batch_detect_target_path.py b/scripts/batch_detect_target_path.py index b4b8588e..883e1cc9 100644 --- a/scripts/batch_detect_target_path.py +++ b/scripts/batch_detect_target_path.py @@ -67,38 +67,25 @@ async def _fetch_servers( async def _detect_one_server(server: Any) -> dict[str, Any]: + from server.application.server_batch_common import detect_and_save_target_path from server.application.services.server_batch_service import result_item_dict - from server.application.server_batch_common import sudo_wrap, update_server_target_path - from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command - from server.utils.posix_paths import posix_dirname sid = server.id label = server.name or str(sid) try: - ssh_user = (server.username or "root").strip() - cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null" - cmd = sudo_wrap(cmd, ssh_user) - r = await exec_ssh_command(server, cmd, timeout=30) - if r["exit_code"] != 0 and not r.get("stdout", "").strip(): + outcome = await detect_and_save_target_path(server) + if outcome.get("success"): return result_item_dict( server_id=sid, server_name=label, - success=False, - error=f"搜索失败 (exit {r['exit_code']})", + success=True, + stdout=str(outcome.get("stdout") or ""), ) - found = r["stdout"].strip() - if not found: - return result_item_dict( - server_id=sid, server_name=label, success=False, error="未找到 workerman.bat" - ) - first_match = found.split("\n")[0].strip() - target_dir = posix_dirname(first_match) - await update_server_target_path(sid, target_dir) return result_item_dict( server_id=sid, server_name=label, - success=True, - stdout=f"target_path → {target_dir}", + success=False, + error=str(outcome.get("error") or "探测失败"), ) except Exception as e: return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300]) diff --git a/server/api/schemas.py b/server/api/schemas.py index 7dfbb56c..e6c0a0f5 100644 --- a/server/api/schemas.py +++ b/server/api/schemas.py @@ -130,6 +130,7 @@ class ServerImportResult(BaseModel): failed: int = 0 errors: List[dict] = [] # [{row, name, domain, error}] created_ids: List[int] = [] + onboarding_job_id: Optional[int] = None class BatchAgentAction(BaseModel): @@ -772,6 +773,7 @@ class AddByIpsBatchResult(BaseModel): input_lines: int = Field(0, description="非空输入行数(去重前)") duplicates_removed: int = Field(0, description="输入中重复行已忽略的数量") items: List[AddByIpsBatchItemResult] = [] + onboarding_job_id: Optional[int] = None # ── Platform / Node ── diff --git a/server/api/servers.py b/server/api/servers.py index 42c94f85..a3be945a 100644 --- a/server/api/servers.py +++ b/server/api/servers.py @@ -49,6 +49,19 @@ router = APIRouter(prefix="/api/servers", tags=["servers"]) REDIS_KEY_PREFIX = "heartbeat:" +async def _schedule_server_onboarding(server_ids: list[int], operator: str) -> int | None: + """Fire-and-forget batch job: sudoers + detect-path for newly created servers.""" + if not server_ids: + return None + from server.application.services.server_onboarding_service import schedule_server_onboarding + + job = await schedule_server_onboarding(server_ids, operator=operator or "admin") + if not job: + return None + job_id = job.get("job_id") + return int(job_id) if job_id is not None else None + + def _server_has_agent_monitoring(server: Server) -> bool: """True when this server is expected to run Nexus Agent (key or version on file).""" return bool((server.agent_api_key or "").strip() or (server.agent_version or "").strip()) @@ -590,6 +603,10 @@ async def import_servers( result.total = result.created + result.skipped + result.failed + onboarding_job_id = await _schedule_server_onboarding(result.created_ids, admin.username) + if onboarding_job_id is not None: + result.onboarding_job_id = onboarding_job_id + # Audit log ip_address = request.client.host if request.client else "" audit_repo = AuditLogRepositoryImpl(db) @@ -961,12 +978,19 @@ async def add_server_by_ip( raise HTTPException(409, f"服务器 {item.domain} 已存在于列表中") if item.status == "created": - return { + onboarding_job_id = None + if item.server_id is not None: + onboarding_job_id = await _schedule_server_onboarding([item.server_id], admin.username) + body: dict = { "success": True, "server": {"id": item.server_id, "domain": item.domain}, "matched_preset": item.matched_preset, "matched_username": item.matched_username, } + if onboarding_job_id is not None: + body["onboarding_job_id"] = onboarding_job_id + body["onboarding_label"] = "新服务器初始化" + return body return { "success": False, @@ -1022,6 +1046,9 @@ async def add_servers_by_ips_batch( else: skipped += 1 + onboarding_ids = [i.server_id for i in items if i.status == "created" and i.server_id is not None] + onboarding_job_id = await _schedule_server_onboarding(onboarding_ids, admin.username) + ip_address = request.client.host if request.client else "" audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( @@ -1044,6 +1071,7 @@ async def add_servers_by_ips_batch( input_lines=parsed.input_lines, duplicates_removed=parsed.duplicates_removed, items=items, + onboarding_job_id=onboarding_job_id, ) @@ -1090,12 +1118,17 @@ async def retry_pending_server( request=request, ) await pending_repo.delete(pending_id) - return { + onboarding_job_id = await _schedule_server_onboarding([created.id], admin.username) + body: dict = { "success": True, "server": _server_to_dict(created), "matched_preset": poll.match.preset_name, "matched_username": poll.match.username, } + if onboarding_job_id is not None: + body["onboarding_job_id"] = onboarding_job_id + body["onboarding_label"] = "新服务器初始化" + return body err_text = format_poll_errors(poll.errors) pending.attempts = (pending.attempts or 0) + 1 @@ -1346,7 +1379,12 @@ async def create_server( ip_address=ip_address, )) - return _server_to_dict(created) + resp = _server_to_dict(created) + onboarding_job_id = await _schedule_server_onboarding([created.id], admin.username) + if onboarding_job_id is not None: + resp["onboarding_job_id"] = onboarding_job_id + resp["onboarding_label"] = "新服务器初始化" + return resp @router.put("/{id}", response_model=dict) diff --git a/server/application/server_batch_common.py b/server/application/server_batch_common.py index efdb6bee..aaee8d9e 100644 --- a/server/application/server_batch_common.py +++ b/server/application/server_batch_common.py @@ -144,6 +144,29 @@ async def update_server_target_path(sid: int, target_dir: str) -> None: await session.commit() +async def detect_and_save_target_path(server: Server, *, timeout: int = 30) -> dict[str, object]: + """Find workerman.bat under /www/wwwroot and persist parent dir as target_path.""" + from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + + sid = server.id + ssh_user = (server.username or "root").strip() or "root" + cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null" + cmd = sudo_wrap(cmd, ssh_user) + r = await exec_ssh_command(server, cmd, timeout=timeout) + if r["exit_code"] != 0 and not r.get("stdout", "").strip(): + return { + "success": False, + "error": f"搜索失败 (exit {r['exit_code']})", + } + found = (r.get("stdout") or "").strip() + if not found: + return {"success": False, "error": "未找到 workerman.bat"} + first_match = found.split("\n")[0].strip() + target_dir = posix_dirname(first_match) + await update_server_target_path(sid, target_dir) + return {"success": True, "target_dir": target_dir, "stdout": f"target_path → {target_dir}"} + + async def mark_agent_uninstalled(sid: int) -> None: from server.infrastructure.database.session import AsyncSessionLocal from server.infrastructure.database.server_repo import ServerRepositoryImpl diff --git a/server/application/services/server_batch_service.py b/server/application/services/server_batch_service.py index 18ce3ef6..b7b857a7 100644 --- a/server/application/services/server_batch_service.py +++ b/server/application/services/server_batch_service.py @@ -10,14 +10,13 @@ from typing import Any, Optional from server.application.server_batch_common import ( agent_install_skip_result, batch_server_display_name, + detect_and_save_target_path, ensure_agent_api_key, install_error_msg, mark_agent_uninstalled, - posix_dirname, prefetch_batch_context, result_item_dict, sudo_wrap, - update_server_target_path, ) from server.utils.agent_version import get_central_agent_version from server.config import settings @@ -38,6 +37,7 @@ OP_LABELS = { "category": "批量修改分类", "health-check": "健康检查", "detect-path": "自动检测目标路径", + "onboard": "新服务器初始化", "install-agent": "批量安装 Agent", "upgrade-agent": "批量升级 Agent", "uninstall-agent": "批量卸载 Agent", @@ -47,6 +47,7 @@ AUDIT_ACTIONS = { "category": "batch_update_category", "health-check": "batch_health_check", "detect-path": "batch_detect_path", + "onboard": "batch_server_onboard", "install-agent": "batch_install_agent", "upgrade-agent": "batch_upgrade_agent", "uninstall-agent": "batch_uninstall_agent", @@ -325,6 +326,8 @@ async def _run_background(job_id: int) -> None: await _run_health_check(live) elif op == "detect-path": await _run_detect_path(live) + elif op == "onboard": + await _run_onboard(live) elif op == "install-agent": await _run_install_agent(live) elif op == "upgrade-agent": @@ -373,6 +376,7 @@ async def _write_audit(live: dict[str, Any]) -> None: "category": f"批量修改分类 更新{stats['success']}台", "health-check": f"批量健康检查: {stats['success']}/{stats['total']} 在线", "detect-path": f"批量检测路径: {stats['success']}/{stats['total']} 成功", + "onboard": f"新服务器初始化: {stats['success']}/{stats['total']} 成功", "install-agent": f"批量安装Agent: {stats['success']}/{stats['total']} 成功", "upgrade-agent": f"批量升级Agent: {stats['success']}/{stats['total']} 成功", "uninstall-agent": f"批量卸载Agent: {stats['success']}/{stats['total']} 成功", @@ -497,24 +501,19 @@ async def _run_detect_path(live: dict[str, Any]) -> None: if not server: return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在") try: - ssh_user = (server.username or "root").strip() - cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null" - cmd = sudo_wrap(cmd, ssh_user) - r = await exec_ssh_command(server, cmd, timeout=30) - if r["exit_code"] != 0 and not r.get("stdout", "").strip(): + outcome = await detect_and_save_target_path(server) + if outcome.get("success"): return result_item_dict( - server_id=sid, server_name=label, success=False, - error=f"搜索失败 (exit {r['exit_code']})", + server_id=sid, + server_name=label, + success=True, + stdout=str(outcome.get("stdout") or ""), ) - found = r["stdout"].strip() - if not found: - return result_item_dict(server_id=sid, server_name=label, success=False, error="未找到 workerman.bat") - first_match = found.split("\n")[0].strip() - target_dir = posix_dirname(first_match) - await update_server_target_path(sid, target_dir) return result_item_dict( - server_id=sid, server_name=label, success=True, - stdout=f"target_path → {target_dir}", + server_id=sid, + server_name=label, + success=False, + error=str(outcome.get("error") or "探测失败"), ) except Exception as e: return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300]) @@ -522,6 +521,61 @@ async def _run_detect_path(live: dict[str, Any]) -> None: await _run_with_semaphore(live, handler) +async def _run_onboard(live: dict[str, Any]) -> None: + from server.application.services.files_sudoers_service import install_nexus_files_sudoers + from server.utils.posix_paths import is_unset_target_path + + async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict: + server = server_map.get(sid) + label = labels[sid] + if not server: + return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在") + steps: list[str] = [] + try: + ssh_user = (server.username or "root").strip() or "root" + if ssh_user == "root": + steps.append("sudo: skip(root)") + else: + sudo_outcome = await install_nexus_files_sudoers(server) + action = sudo_outcome.get("action") or "unknown" + if not sudo_outcome.get("ok"): + detail = sudo_outcome.get("detail") or action + return result_item_dict( + server_id=sid, + server_name=label, + success=False, + error=f"sudo 配置失败: {detail}", + stdout="; ".join(steps), + ) + steps.append(f"sudo: {action}") + + if is_unset_target_path(server.target_path): + detect_outcome = await detect_and_save_target_path(server) + if detect_outcome.get("success"): + steps.append(str(detect_outcome.get("stdout") or "path: ok")) + else: + steps.append(f"path: {detect_outcome.get('error') or 'fail'}(推送仍可用 /www/wwwroot)") + else: + steps.append("path: already_set") + + return result_item_dict( + server_id=sid, + server_name=label, + success=True, + stdout="; ".join(steps), + ) + except Exception as e: + return result_item_dict( + server_id=sid, + server_name=label, + success=False, + error=str(e)[:300], + stdout="; ".join(steps), + ) + + await _run_with_semaphore(live, handler) + + async def _run_install_agent(live: dict[str, Any]) -> None: base_url = (settings.API_BASE_URL or "").strip().rstrip("/") if not base_url: diff --git a/server/application/services/server_onboarding_service.py b/server/application/services/server_onboarding_service.py new file mode 100644 index 00000000..41ff86da --- /dev/null +++ b/server/application/services/server_onboarding_service.py @@ -0,0 +1,30 @@ +"""Schedule post-create server onboarding (sudoers + target path detect).""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger("nexus.server_onboarding") + + +async def schedule_server_onboarding( + server_ids: list[int], + *, + operator: str, +) -> dict[str, Any] | None: + """Start background batch job ``onboard`` for *server_ids*; returns job dict or None.""" + ids = [int(x) for x in server_ids if x] + if not ids: + return None + from server.application.services.server_batch_service import start_batch_job + + try: + return await start_batch_job( + op="onboard", + server_ids=ids, + operator=operator or "admin", + ) + except Exception as exc: + logger.warning("schedule_server_onboarding failed ids=%s: %s", ids, exc) + return None diff --git a/tests/test_server_onboarding.py b/tests/test_server_onboarding.py new file mode 100644 index 00000000..d5a6a6cc --- /dev/null +++ b/tests/test_server_onboarding.py @@ -0,0 +1,115 @@ +"""Tests for post-create server onboarding automation.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from server.utils.posix_paths import is_unset_target_path + + +@pytest.mark.parametrize( + "target_path,expect_detect", + [ + ("", True), + ("/www/wwwroot", True), + ("/www/wwwroot/site", False), + ], +) +def test_onboard_skips_detect_when_path_set(target_path, expect_detect): + assert is_unset_target_path(target_path) is expect_detect + + +@pytest.mark.asyncio +async def test_schedule_server_onboarding_delegates_to_batch(): + from server.application.services.server_onboarding_service import schedule_server_onboarding + + with patch( + "server.application.services.server_batch_service.start_batch_job", + new_callable=AsyncMock, + return_value={"job_id": 99, "status": "running", "total": 1}, + ) as start: + job = await schedule_server_onboarding([12], operator="admin") + start.assert_awaited_once_with(op="onboard", server_ids=[12], operator="admin") + assert job["job_id"] == 99 + + +@pytest.mark.asyncio +async def test_run_onboard_non_root_success(): + from server.application.services.server_batch_service import _run_onboard + from server.domain.models import Server + from server.infrastructure.redis import server_batch_store as sbs + + server = Server(id=1, name="t", domain="1.2.3.4", username="ubuntu", target_path="") + live = sbs.init_live( + job_id=1, + op="onboard", + label="新服务器初始化", + server_ids=[1], + operator="admin", + ) + + async def invoke(_live, handler): + item = await handler(1, {1: server}, {1: "t"}, _live) + sbs.record_result(_live, item) + + with patch( + "server.application.services.server_batch_service._run_with_semaphore", + new_callable=AsyncMock, + side_effect=invoke, + ): + with patch( + "server.application.services.files_sudoers_service.install_nexus_files_sudoers", + new_callable=AsyncMock, + return_value={"ok": True, "action": "already_ok"}, + ): + with patch( + "server.application.server_batch_common.detect_and_save_target_path", + new_callable=AsyncMock, + return_value={"success": True, "stdout": "target_path → /www/wwwroot/shop"}, + ): + await _run_onboard(live) + + assert live["results"]["1"]["success"] is True + assert "sudo" in live["results"]["1"]["stdout"] + + +@pytest.mark.asyncio +async def test_run_onboard_detect_fail_still_success(): + from server.application.services.server_batch_service import _run_onboard + from server.domain.models import Server + from server.infrastructure.redis import server_batch_store as sbs + + server = Server(id=3, name="u", domain="3.4.5.6", username="ubuntu", target_path="") + live = sbs.init_live( + job_id=3, + op="onboard", + label="新服务器初始化", + server_ids=[3], + operator="admin", + ) + + async def invoke(_live, handler): + item = await handler(3, {3: server}, {3: "u"}, _live) + sbs.record_result(_live, item) + + with patch( + "server.application.services.server_batch_service._run_with_semaphore", + new_callable=AsyncMock, + side_effect=invoke, + ): + with patch( + "server.application.services.files_sudoers_service.install_nexus_files_sudoers", + new_callable=AsyncMock, + return_value={"ok": True, "action": "patched"}, + ): + with patch( + "server.application.server_batch_common.detect_and_save_target_path", + new_callable=AsyncMock, + return_value={"success": False, "error": "未找到 workerman.bat"}, + ): + await _run_onboard(live) + + assert live["results"]["3"]["success"] is True + assert "www/wwwroot" in live["results"]["3"]["stdout"]