feat(servers): 新服务器接入自动 sudo 配置与路径探测
创建/导入/IP 添加成功后后台 onboard 任务;前端注册进度条与 toast。
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 服务器 → 底部任务栏「新服务器初始化」
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 编排逻辑
|
||||
@@ -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<ServerApiItem & {
|
||||
onboarding_job_id?: number
|
||||
onboarding_label?: string
|
||||
}>('/servers/', payload)
|
||||
if (created.onboarding_job_id) {
|
||||
registerServerBatchJob(
|
||||
created.onboarding_job_id,
|
||||
created.onboarding_label || '新服务器初始化',
|
||||
1,
|
||||
'onboard',
|
||||
'0/1',
|
||||
'running',
|
||||
)
|
||||
showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)')
|
||||
}
|
||||
}
|
||||
close()
|
||||
onSaved()
|
||||
|
||||
@@ -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<AddByIpResponse>('/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<AddByIpResponse>(`/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()
|
||||
}
|
||||
|
||||
@@ -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/ */
|
||||
|
||||
@@ -39,6 +39,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
batch_upgrade_agent: '批量升级 Agent',
|
||||
batch_uninstall_agent: '批量卸载 Agent',
|
||||
batch_detect_path: '批量检测路径',
|
||||
batch_server_onboard: '新服务器初始化',
|
||||
batch_health_check: '批量健康检查',
|
||||
batch_update_category: '批量修改分类',
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
+41
-3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user