feat(ops): prod timezone probe, web/app container sync, and D-01 agent upgrade.

Unify upgrade-agent with install.sh via agent_deploy; auto-sync Git web/app after
Docker upgrade to prevent vite hash drift; extend prod_probe for Beijing TZ checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-09 07:12:11 +08:00
parent 56139a42fe
commit 863e49e518
16 changed files with 520 additions and 70 deletions
+15 -3
View File
@@ -19,21 +19,33 @@ echo "═══ Nexus Frontend Deploy ═══"
REMOTE_RUNTIME=$(ssh_cmd 'bash -s' <<'REMOTE'
set -euo pipefail
docker_cmd() {
if docker "$@" 2>/dev/null; then return 0; fi
sudo docker "$@" 2>/dev/null
}
compose_cmd() {
if docker compose "$@" 2>/dev/null; then return 0; fi
sudo docker compose "$@" 2>/dev/null
}
for d in /opt/nexus /www/wwwroot/api.synaglobal.vip; do
[[ -d "$d/server" ]] || continue
if [[ -f "$d/docker/.env.prod" ]] && docker compose version >/dev/null 2>&1; then
if [[ -f "$d/docker/.env.prod" ]] && compose_cmd version >/dev/null 2>&1; then
prof="$d/docker/.host-profile"
[[ -f "$prof" ]] || prof="$d/docker/profiles/2c8g.env"
if [[ -f "$prof" ]]; then
args=(-f "$d/docker/docker-compose.prod.yml")
docker network inspect 1panel-network >/dev/null 2>&1 && \
docker_cmd network inspect 1panel-network >/dev/null 2>&1 && \
[[ -f "$d/docker/docker-compose.1panel.yml" ]] && \
args+=(-f "$d/docker/docker-compose.1panel.yml")
q=$(cd "$d" && docker compose "${args[@]}" \
q=$(cd "$d" && compose_cmd "${args[@]}" \
--env-file "$d/docker/.env.prod" --env-file "$prof" ps -q nexus 2>/dev/null || true)
[[ -n "$q" ]] && echo "docker $d" && exit 0
fi
fi
if [[ -f "$d/deploy/nexus-1panel.sh" ]]; then
q=$(docker_cmd ps --format '{{.Names}}' 2>/dev/null | grep -E '^nexus-prod-nexus-' || true)
[[ -n "$q" ]] && echo "docker $d" && exit 0
fi
if command -v supervisorctl >/dev/null 2>&1 && supervisorctl status nexus >/dev/null 2>&1; then
echo "supervisor $d"
exit 0
+7
View File
@@ -117,6 +117,13 @@ else
exit 1
fi
if [[ "$RUNTIME" == "docker" ]]; then
echo "▶ Sync Git web/app into container..."
ssh_cmd "sudo env NEXUS_ROOT=${DEPLOY_PATH} NEXUS_PUBLISH_PORT=\$(grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod 2>/dev/null | cut -d= -f2 | tr -d '\"' || echo 8600) bash ${DEPLOY_PATH}/deploy/sync_webapp_to_container.sh" || {
echo "WARN: web/app sync failed — run sync_webapp_to_container.sh on server manually" >&2
}
fi
sleep 3
PORT="$(ssh_cmd "grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod 2>/dev/null | cut -d= -f2 | tr -d '\"'" 2>/dev/null || true)"
PORT="${PORT:-8600}"
+6
View File
@@ -1206,6 +1206,12 @@ cmd_upgrade() {
docker image prune -f >/dev/null 2>&1 || true
fi
NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/install-nx-cli.sh" 2>/dev/null || true
if [[ -x "$NEXUS_ROOT/deploy/sync_webapp_to_container.sh" ]]; then
step "同步 Git web/app 进容器(消除镜像 vite 缓存 hash 漂移)..."
NEXUS_PUBLISH_PORT="$port" NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/sync_webapp_to_container.sh" || {
warn "web/app 同步失败,继续健康检查(请手动执行 sync_webapp_to_container.sh"
}
fi
if ! verify_health "$NEXUS_ROOT" "升级"; then
rollback_compose_upgrade "$NEXUS_ROOT" || true
exit 1
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Sync Git-tracked web/app into the running Nexus Docker container.
#
# Root cause: Dockerfile.prod runs vite build inside the image; Docker layer cache
# can produce asset hashes that differ from committed web/app on the host.
#
# Usage (on production host after git pull / upgrade):
# NEXUS_ROOT=/opt/nexus bash deploy/sync_webapp_to_container.sh
#
# Called automatically from deploy/nexus-1panel.sh cmd_upgrade and deploy-production.sh.
set -euo pipefail
NEXUS_ROOT="${NEXUS_ROOT:-/opt/nexus}"
CONTAINER="${NEXUS_CONTAINER:-}"
NEXUS_PUBLISH_PORT="${NEXUS_PUBLISH_PORT:-8600}"
info() { echo -e "\033[0;32m[INFO]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
docker_cmd() {
if docker "$@" 2>/dev/null; then return 0; fi
sudo docker "$@" 2>/dev/null
}
resolve_container() {
if [[ -n "$CONTAINER" ]]; then
return 0
fi
CONTAINER="$(docker_cmd ps --format '{{.Names}}' | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true)"
if [[ -z "$CONTAINER" ]]; then
error "未找到运行中的 Nexus 容器(可设 NEXUS_CONTAINER=名称)"
exit 1
fi
info "容器: $CONTAINER"
}
verify_entry_bundle() {
local html bundle_path code
html="$(curl -sf "http://127.0.0.1:${NEXUS_PUBLISH_PORT}/app/" 2>/dev/null || true)"
bundle_path="$(printf '%s' "$html" | sed -n 's/.*src="\(\/app\/assets\/index-[^"]*\.js\)".*/\1/p' | head -1)"
if [[ -z "$bundle_path" ]]; then
error "/app/ index.html 未解析到主 bundle"
exit 1
fi
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${NEXUS_PUBLISH_PORT}${bundle_path}" 2>/dev/null || echo 000)"
if [[ "$code" != "200" ]]; then
error "主 bundle ${bundle_path} → HTTP ${code}"
exit 1
fi
info "入口校验 OK: ${bundle_path} → HTTP 200"
}
main() {
local app="${NEXUS_ROOT}/web/app"
if [[ ! -f "${app}/index.html" ]]; then
error "缺少 ${app}/index.html,请先 git pull"
exit 1
fi
if [[ ! -d "${app}/assets" ]]; then
error "缺少 ${app}/assets"
exit 1
fi
resolve_container
info "同步 web/app → 容器 /app/web/app ..."
docker_cmd exec "$CONTAINER" rm -rf /app/web/app/assets
docker_cmd exec "$CONTAINER" mkdir -p /app/web/app
docker_cmd cp "${app}/index.html" "${CONTAINER}:/app/web/app/index.html"
docker_cmd cp "${app}/assets" "${CONTAINER}:/app/web/app/assets"
if [[ -f "${app}/favicon.ico" ]]; then
docker_cmd cp "${app}/favicon.ico" "${CONTAINER}:/app/web/app/favicon.ico" 2>/dev/null || true
fi
if [[ -x "${NEXUS_ROOT}/deploy/prune_frontend_assets.py" ]]; then
info "容器内 prune 未引用 assets(可选)..."
docker_cmd exec "$CONTAINER" python3 /app/deploy/prune_frontend_assets.py --dry-run 2>/dev/null || \
warn "容器内 prune 跳过(脚本或路径不可用)"
fi
verify_entry_bundle
info "web/app 同步完成"
}
main "$@"
@@ -0,0 +1,42 @@
# 审计 — 接续 Wave0 + D-012026-06-09
**Changelog**: `docs/changelog/2026-06-09-continuation-wave0-d01.md`
## 变更文件清单
| 文件 | 说明 |
|------|------|
| `scripts/prod_probe.py` | 生产时区验收:422、cron next_run、bundle |
| `deploy/sync_webapp_to_container.sh` | upgrade 后同步 Git web/app 进容器 |
| `deploy/nexus-1panel.sh` | upgrade 末尾调用 sync |
| `deploy/deploy-production.sh` | Docker 部署后 sync |
| `deploy/deploy-frontend.sh` | sudo docker 运行时探测 |
| `server/utils/agent_deploy.py` | D-01 统一 upgrade 命令 |
| `server/api/servers.py` | 单台 upgrade 用 agent_deploy |
| `server/application/services/server_batch_service.py` | 批量 upgrade + agent_port |
| `tests/test_agent_deploy.py` | 单测 |
## Step 3 规则扫描
| 规则 | 结论 |
|------|------|
| 密钥 | PASS — 无凭据入仓 |
| 静默吞错 | PASS — sync 失败 warnprobe 显式断言 |
| Shell 注入 | PASS — docker cp 路径来自 NEXUS_ROOT |
| 分层 | PASS — agent_deploy 在 utilsAPI 薄编排 |
## Closure
| 项 | 结果 |
|----|------|
| `test_agent_deploy.py` | passed |
| `pre_deploy_check.sh` | 7/7 |
| `prod_health_check.sh` origin | passed |
| 生产 `sync_webapp_to_container.sh` | index-mgwiCK5j.js OK |
## DoD
- [x] changelog + audit + Handoff 2026-06-09
- [x] 门控 7/7
- [x] 生产探针全绿
- [x] D-01 alignment-plan ☑
@@ -0,0 +1,36 @@
# 2026-06-09 — 接续方案:生产终验 + 部署 sync + Agent upgrade 对齐
## 变更摘要
1. **生产探针**`prod_probe.py` 增加时区验收(日期 422、cron next_run、SPA bundle 可加载)。
2. **部署加固**:新增 `sync_webapp_to_container.sh`,挂接 `nexus-1panel.sh upgrade``deploy-production.sh``deploy-frontend.sh` 修复 `sudo docker` 探测。
3. **D-01**`server/utils/agent_deploy.py` 统一单台/批量 upgrade 与 `web/agent/install.sh`(路径、pip 版本、agent_port)。
4. **文档**`AI-HANDOFF-2026-06-09.md`;更新时区 verification 报告 §生产。
## 动机
接续方案 Path A~D:闭环时区生产验收、消除 Docker vite 缓存导致的前端 hash 漂移、消除 upgrade 命令三处重复与批量硬编码 8601 端口。
## 涉及文件
- `scripts/prod_probe.py`
- `deploy/sync_webapp_to_container.sh`(新)
- `deploy/nexus-1panel.sh` · `deploy/deploy-production.sh` · `deploy/deploy-frontend.sh`
- `server/utils/agent_deploy.py`(新)
- `server/api/servers.py` · `server/application/services/server_batch_service.py`
- `tests/test_agent_deploy.py`
- `docs/project/AI-HANDOFF-2026-06-09.md`
- `docs/reports/2026-06-09-operator-timezone-beijing-verification.md`
## 迁移 / 重启
- 无 DB 迁移。
- `sync_webapp_to_container.sh` 在下次 `upgrade` / `deploy-production` 时自动执行;可手动:`NEXUS_ROOT=/opt/nexus bash deploy/sync_webapp_to_container.sh`
## 验证
```bash
NEXUS_PROBE_MODE=origin bash scripts/prod_health_check.sh
pytest tests/test_agent_deploy.py tests/test_operator_tz.py -q
bash deploy/pre_deploy_check.sh
```
@@ -75,7 +75,7 @@
| ID | 功能 | 优先级 | 状态 | 说明 | 验收标准 |
|----|------|--------|------|------|----------|
| D-01 | **Agent upgrade 与 install.sh venv 对齐** | P1 | | `upgrade-agent` API 已有;需与 `web/agent/install.sh` 路径/venv 一致 | 升级后 agent 版本与中心一致;install 脚本 idempotent |
| D-01 | **Agent upgrade 与 install.sh venv 对齐** | P1 | | `server/utils/agent_deploy.py` 统一 pip/路径/port;单台与批量共用 | 升级后 agent 版本与中心一致;install 脚本 idempotent |
| D-02 | **宝塔 API 监控集成** | 可选 | ☐ | 可选增强 | 设计文档 + 配置项 + 只读指标 |
| D-03 | **Agent mTLS** | 可选 | ☐ | 安全增强 | ADR + 双端证书轮换方案 |
| D-04 | **全局搜索**增强 | P3 | ☐ | `App.vue` 已有 `/search/` | 结果分组、键盘导航、schedules 新字段展示 |
+2 -3
View File
@@ -1,8 +1,7 @@
# Nexus — AI 接续(2026-06-08 · Linux
> **新会话请复制下方「复制提示词」整段。**
> SSOT`docs/project/nexus-functional-development-guide.md` · 门控 `bash deploy/pre_deploy_check.sh`
> 完整待办 132 条见本文 §「Master Backlog 索引」。
> **已由 [AI-HANDOFF-2026-06-09.md](AI-HANDOFF-2026-06-09.md) 取代**(生产 @ `56139a4` 时区交付)。下文保留 Master Backlog 索引。
> SSOT`docs/project/nexus-functional-development-guide.md` · 门控 `bash deploy/pre_deploy_check.sh`
---
+94
View File
@@ -0,0 +1,94 @@
# Nexus — AI 接续(2026-06-09 · Linux
> **新会话请复制下方「复制提示词」整段。**
> 取代 [`AI-HANDOFF-2026-06-08.md`](AI-HANDOFF-2026-06-08.md)(版本号已过时)。
> SSOT`docs/project/nexus-functional-development-guide.md` · 门控 `bash deploy/pre_deploy_check.sh`
---
## 复制提示词(整段给新会话)
```
【Nexus 6.0 接续 · Linux · 2026-06-09】
工作区:$NEXUS_ROOT(本机 ~/桌面/Nexus
仓库:http://66.154.115.8:3000/admin/Nexus.git · 分支 main
生产:https://api.synaglobal.vip · Docker @ /opt/nexus · 端口 8600 · 已部署 56139a4+
SSHNEXUS_SSH=azureuser@20.24.218.235 · NEXUS_SSH_KEY="/home/r/下载/Telegram Desktop/nz-admin.pem"
pushbash scripts/git-push.sh
部署:NEXUS_DEPLOY_PATH=/opt/nexus bash deploy/pre_deploy_check.sh && NEXUS_DEPLOY_PATH=/opt/nexus bash deploy/deploy-production.sh
(本机开发路径含中文时,部署必须显式 NEXUS_DEPLOY_PATH=/opt/nexus
【栈】FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA · Vite → web/app/
【铁律】单 Agent · perfect-implementation.mdc · 改代码必 changelog + audit · 测试 Agent 做、用户终验
【生产已部署】运维时区全局北京 + 后续修复(e3efbd4 / 56139a4
- cron/审计/告警日期按 Asia/ShanghaiDB 仍 UTC naive
- WS 告警 at 字段;安装向导固定 Asia/Shanghai;非法日期 422
- 验证:docs/reports/2026-06-09-operator-timezone-beijing-verification.md
【本会话新增(待 push/deploy)】
- prod_probe 时区验收项(422 / cron next_run / bundle
- deploy/sync_webapp_to_container.shupgrade 后对齐 Git web/app
- deploy-frontend.sh sudo docker 探测修复
- D-01server/utils/agent_deploy.py 统一 upgrade 与 install.sh
【用户终验待确认】
- /app/#/schedules:北京时间提示;cron 02:00 → next_run 列正确
- WS 告警条时间与告警页一致
- 审计/告警非法日期 422
【优先待办 TOP 10】
1. Wave 0 部署 sync 已入仓 — 下次 deploy 自动执行
2. ~75 条 API detail 英文改中文
3. Agent 401 子机 rollout
4. Dashboard 24h 趋势(B-02
5. 资产管理 / Sync 配置 UIC-01/C-02
6. L4 Playwright 全绿 + L5 清单
7. 无心跳自动离线 + 告警降噪
8. 用户浏览器终验(批量 101 台、WebSSH、Telegram 等)
9. 国内 CDN / 双入口(电信 HTTPS RST
10. POSIX + 文件管理 FM-L* 生产验收
【完整 132 条】AI-HANDOFF-2026-06-08.md §Master Backlog(编号仍有效)
【对齐计划 31 项】docs/design/plans/2026-06-07-missing-features-alignment-plan.md
【勿提交】deploy/gate_log.jsonl · .cursor/settings.json
【Handoff】docs/project/AI-HANDOFF-2026-06-09.md
```
---
## 生产状态
| 项 | 值 |
|----|-----|
| 分支 | `main` @ **`56139a4`**(时区)+ 本会话增量 |
| 生产 URL | `https://api.synaglobal.vip` |
| 运行时 | Docker @ `/opt/nexus` |
| 探针 | `NEXUS_PROBE_MODE=origin bash scripts/prod_health_check.sh` |
| 外网 | 部分线路 HTTPS RST;运维可用 SSH 隧道 `-L 18600:127.0.0.1:8600` |
---
## 2026-06-09 主要交付
| 主题 | 提交/文档 |
|------|-----------|
| 运维时区北京 | `e3efbd4` · design/changelog/audit/E2E |
| 生产探针扩展 | `scripts/prod_probe.py` 时区 + bundle 校验 |
| 部署 web/app 同步 | `deploy/sync_webapp_to_container.sh` |
| Agent upgrade 对齐 D-01 | `server/utils/agent_deploy.py` |
---
## 关键路径
| 用途 | 路径 |
|------|------|
| 时区工具 | `server/utils/display_time.py` |
| 调度 runner | `server/background/schedule_runner.py` |
| 生产探针 | `scripts/prod_probe.py` |
| 前端同步 | `deploy/sync_webapp_to_container.sh` |
| Agent 升级 | `server/utils/agent_deploy.py` · `web/agent/install.sh` |
| Handoff | 本文 |
@@ -12,8 +12,15 @@
- [x] 推送页预览按钮(未正式推送)
- [x] 生产 API:认证、CRUD、搜索、告警、资产、同步 browse、webssh-token、health/detail
## Agent 已验证(2026-06-09 时区 + 部署)
- [x] 调度时区:生产 API cron `0 2 * * *``next_run` UTC 18:00(北京 02:00);`SchedulesPage` 含北京时间提示
- [x] 告警/审计非法 `date_from` → HTTP 422`prod_probe` origin
- [x] `prod_health_check.sh` origin 全绿(含 per_page=50、once 调度 201
## 建议你终验(可选,有副作用或 Agent 未做)
- [ ] `/app/#/schedules`:在白名单 IP 浏览器核对 Picker 提示与 `next_run`
- [ ] WebSSH:选一台服务器实际连上并输入 `pwd`Agent 只验页面与 token API
- [ ] 推送:若需确认 rsync,用**预览**或测试机,勿对生产误点「推送」
- [ ] Telegram「测试发送」是否收到(设置页)
@@ -33,7 +33,28 @@ cd frontend && npx playwright test e2e/pages/schedules-cycle.spec.mjs e2e/pages/
新增:`frontend/e2e/pages/schedules-timezone.spec.mjs`
## 生产部署
## 生产部署2026-06-09 · `56139a4`
- **未执行** `deploy/deploy-production.sh`(需 SSH `nexus` 与明确批准)。
- 门控已放行;部署后请人工核对调度列表 `next_run`
| 检查 | 结果 |
|------|------|
| `NEXUS_PROBE_MODE=origin bash scripts/prod_health_check.sh` | **PASS**(含时区扩展项) |
| `/health` | ok |
| `/app/` | HTTP 200 |
| 告警 `date_from=not-a-date` | HTTP **422** |
| 审计 `/api/audit/?date_from=bad` | HTTP **422** |
| cron `0 2 * * *` 创建 | `next_run=2026-06-09 18:00:00`UTC naive → 北京次日 02:00 |
| `/app/assets/SchedulesPage-*.js` | 含「时间为北京时间」文案(源站静态资源 grep) |
部署命令:`NEXUS_DEPLOY_PATH=/opt/nexus bash deploy/deploy-production.sh`
### 浏览器终验说明
- 生产登录受 IP 白名单约束;本机 Playwright 经隧道登录返回 **403**(预期行为)。
- UI 时区提示与 `next_run` 列展示:本地 E2E(上表)+ 生产 API/静态资源探针已覆盖。
- **用户终验**(可选):在已白名单 IP 打开 `/app/#/schedules` 核对 Picker 提示与 `next_run` 列。
### 前端 hash 对齐(Wave 0
- 升级后曾漂移:容器 `index-FhWfGFoM.js` ≠ Git `index-mgwiCK5j.js`
- **已修复**`NEXUS_ROOT=/opt/nexus bash deploy/sync_webapp_to_container.sh` → 入口 `index-mgwiCK5j.js` HTTP 200。
- 后续 `nexus-1panel.sh upgrade` / `deploy-production.sh` 将自动执行 sync。
+53
View File
@@ -359,6 +359,59 @@ def run_auth_checks(cfg: Config, token: str, client_ip: str | None) -> list[str]
raise RuntimeError(f"schedule delete HTTP {d_code}")
lines.append(f"✓ probe schedule deleted id={rid}")
# Operator timezone: invalid date filters must 422 (no silent ignore)
for bad_path in (
"/api/alert-history/?date_from=not-a-date",
"/api/audit/?date_from=bad",
):
code, body = origin_curl(cfg, bad_path, token=token, client_ip=ip)
if code != 422:
raise RuntimeError(f"{bad_path} expected HTTP 422, got {code}: {body[:120]}")
lines.append("✓ alert/audit invalid date_from → HTTP 422")
# Cron Beijing semantics: 0 2 * * * → next_run UTC naive 18:00 (Beijing 02:00 next day)
cron_body = {
"name": "probe-cron-beijing-0200",
"schedule_type": "push",
"run_mode": "cron",
"cron_expr": "0 2 * * *",
"fire_at": None,
"source_path": "/tmp/nexus_probe",
"server_ids": f"[{sid}]",
"enabled": True,
}
code, body = origin_curl(
cfg, "/api/schedules/", method="POST", token=token, body=cron_body, client_ip=ip
)
if code != 201:
raise RuntimeError(f"cron schedule create HTTP {code}: {body[:200]}")
cron_sched = json.loads(body)
cron_id = cron_sched.get("id")
next_run = cron_sched.get("next_run") or ""
if not cron_id or "18:00:00" not in next_run:
raise RuntimeError(f"cron next_run Beijing mismatch: {next_run!r}")
lines.append(f"✓ cron 0 2 * * * next_run={next_run} (UTC naive → Beijing 02:00)")
d_code, _ = origin_curl(
cfg, f"/api/schedules/{cron_id}", method="DELETE", token=token, client_ip=ip
)
if d_code not in (200, 204):
raise RuntimeError(f"cron schedule delete HTTP {d_code}")
# SPA entry: index.html must reference a loadable main bundle
code, html = origin_curl(cfg, "/app/", client_ip=ip)
if code != 200:
raise RuntimeError(f"/app/ HTTP {code}")
import re
m = re.search(r'src="(/app/assets/index-[^"]+\.js)"', html)
if not m:
raise RuntimeError("/app/ index.html missing main bundle script")
bundle_path = m.group(1)
b_code, _ = origin_curl(cfg, bundle_path, client_ip=ip)
if b_code != 200:
raise RuntimeError(f"{bundle_path} HTTP {b_code}")
lines.append(f"✓ /app/ main bundle OK ({bundle_path})")
return lines
+16 -41
View File
@@ -1724,20 +1724,20 @@ async def upgrade_agent(
detail="NEXUS_API_BASE_URL 未配置,无法构建下载地址",
)
from server.application.server_batch_common import agent_files_curl_cmds
from server.utils.agent_deploy import (
agent_python_version_check_cmd,
agent_venv_python,
build_agent_upgrade_cmd,
build_agent_upgrade_rollback_cmd,
)
install_dir = "/opt/nexus-agent"
venv_python = f"{install_dir}/.venv/bin/python"
venv_python = agent_venv_python(install_dir)
ssh_user = (server.username or "root").strip()
agent_port = server.agent_port or 8601
# Step 1: Check Python version in venv
pyver_cmd = (
f"{venv_python} -c '"
"import sys; v=sys.version_info; "
"print(f\"py_ok_{v.major}.{v.minor}.{v.micro}\") "
"if v.major==3 and v.minor>=10 else sys.exit(1)'"
)
pyver_cmd = agent_python_version_check_cmd(venv_python)
try:
pyver = await exec_ssh_command(server, pyver_cmd, timeout=15)
except Exception as e:
@@ -1754,35 +1754,11 @@ async def upgrade_agent(
),
)
# Step 2: Ensure rsync (required for push/sync) — install if missing
rsync_check = "command -v rsync >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq rsync) || (yum install -y -q rsync) || (dnf install -y -q rsync)"
try:
await exec_ssh_command(server, rsync_check, timeout=60)
except Exception as rsync_err:
logger.debug(f"rsync check/install skipped (non-blocking): {rsync_err}")
# Step 3: Auto-update pip dependencies (locked versions)
pip_cmd = (
f"{venv_python} -m pip install -q "
f"fastapi==0.115.6 uvicorn==0.34.0 httpx==0.28.1 psutil python-multipart==0.0.19"
)
# Step 4: Backup current agent.py
backup_cmd = f"cp {install_dir}/agent.py {install_dir}/agent.py.bak"
# Step 4: pip install + backup + download new agent.py → restart service
# For non-root users, systemctl needs sudo (even after _sudo_wrap sets up NOPASSWD)
systemctl_prefix = "sudo " if ssh_user != "root" else ""
kill_port = f"fuser -k {agent_port}/tcp 2>/dev/null || true; "
upgrade_cmd = _sudo_wrap(
f"{pip_cmd} "
f"&& {backup_cmd} "
f"&& {agent_files_curl_cmds(base_url, install_dir)} "
f"&& {kill_port}{systemctl_prefix}systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& {systemctl_prefix}systemctl is-active nexus-agent "
f"&& echo 'upgrade_ok'",
ssh_user,
upgrade_cmd = build_agent_upgrade_cmd(
base_url=base_url,
ssh_user=ssh_user,
install_dir=install_dir,
agent_port=agent_port,
)
try:
@@ -1794,10 +1770,9 @@ async def upgrade_agent(
if not success:
# Rollback: restore backup and restart
rollback_cmd = _sudo_wrap(
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py "
f"&& {systemctl_prefix}systemctl restart nexus-agent",
ssh_user,
rollback_cmd = build_agent_upgrade_rollback_cmd(
ssh_user=ssh_user,
install_dir=install_dir,
)
try:
await exec_ssh_command(server, rollback_cmd, timeout=30)
@@ -8,7 +8,6 @@ import shlex
from typing import Any, Optional
from server.application.server_batch_common import (
agent_files_curl_cmds,
agent_install_skip_result,
batch_server_display_name,
ensure_agent_api_key,
@@ -589,30 +588,28 @@ async def _run_upgrade_agent(live: dict[str, Any]) -> None:
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
install_dir = "/opt/nexus-agent"
venv_python = f"{install_dir}/.venv/bin/python"
from server.utils.agent_deploy import (
AGENT_INSTALL_DIR,
build_agent_upgrade_cmd,
build_agent_upgrade_rollback_cmd,
)
install_dir = AGENT_INSTALL_DIR
ssh_user = (server.username or "root").strip()
batch_systemctl_prefix = "sudo " if ssh_user != "root" else ""
batch_kill_port = "fuser -k 8601/tcp 2>/dev/null || true; "
upgrade_cmd = sudo_wrap(
f"(command -v rsync >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq rsync) || (yum install -y -q rsync) || (dnf install -y -q rsync)) "
f"&& {venv_python} -c 'import sys; v=sys.version_info; sys.exit(0 if v.major==3 and v.minor>=10 else 1)' "
f"&& {venv_python} -m pip install -q fastapi==0.115.6 uvicorn==0.34.0 httpx==0.28.1 psutil python-multipart==0.0.19 "
f"&& cp {install_dir}/agent.py {install_dir}/agent.py.bak "
f"&& {agent_files_curl_cmds(base_url, install_dir)} "
f"&& {batch_kill_port}{batch_systemctl_prefix}systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& {batch_systemctl_prefix}systemctl is-active nexus-agent "
f"&& echo 'upgrade_ok'",
ssh_user,
agent_port = server.agent_port or 8601
upgrade_cmd = build_agent_upgrade_cmd(
base_url=base_url,
ssh_user=ssh_user,
install_dir=install_dir,
agent_port=agent_port,
)
r = await exec_ssh_command(server, upgrade_cmd, timeout=120)
ok = r["exit_code"] == 0 and "upgrade_ok" in r["stdout"]
if not ok:
try:
rollback = sudo_wrap(
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py && {batch_systemctl_prefix}systemctl restart nexus-agent",
ssh_user,
rollback = build_agent_upgrade_rollback_cmd(
ssh_user=ssh_user,
install_dir=install_dir,
)
await exec_ssh_command(server, rollback, timeout=30)
except Exception as rb_err:
+84
View File
@@ -0,0 +1,84 @@
"""Shared Agent install/upgrade paths and shell fragments (aligned with web/agent/install.sh)."""
from __future__ import annotations
from server.application.server_batch_common import agent_files_curl_cmds, sudo_wrap
# Must match web/agent/install.sh
AGENT_INSTALL_DIR = "/opt/nexus-agent"
AGENT_PIP_PACKAGES = (
"fastapi==0.115.6",
"uvicorn==0.34.0",
"httpx==0.28.1",
"psutil",
"python-multipart==0.0.19",
)
def agent_venv_python(install_dir: str = AGENT_INSTALL_DIR) -> str:
return f"{install_dir}/.venv/bin/python"
def agent_pip_install_cmd(venv_python: str | None = None) -> str:
py = venv_python or agent_venv_python()
pkgs = " ".join(AGENT_PIP_PACKAGES)
return f"{py} -m pip install -q {pkgs}"
def agent_python_version_check_cmd(venv_python: str | None = None) -> str:
py = venv_python or agent_venv_python()
return (
f"{py} -c '"
"import sys; v=sys.version_info; "
"print(f\"py_ok_{v.major}.{v.minor}.{v.micro}\") "
"if v.major==3 and v.minor>=10 else sys.exit(1)'"
)
def agent_rsync_install_cmd() -> str:
return (
"(command -v rsync >/dev/null 2>&1 || "
"(apt-get update -qq && apt-get install -y -qq rsync) || "
"(yum install -y -q rsync) || (dnf install -y -q rsync))"
)
def agent_kill_port_cmd(agent_port: int) -> str:
return f"fuser -k {int(agent_port)}/tcp 2>/dev/null || true; "
def build_agent_upgrade_cmd(
*,
base_url: str,
ssh_user: str,
install_dir: str = AGENT_INSTALL_DIR,
agent_port: int = 8601,
) -> str:
"""Shell pipeline: rsync → py check → pip → backup → download → restart → verify."""
venv_python = agent_venv_python(install_dir)
systemctl_prefix = "sudo " if ssh_user != "root" else ""
return sudo_wrap(
f"{agent_rsync_install_cmd()} "
f"&& {agent_python_version_check_cmd(venv_python)} "
f"&& {agent_pip_install_cmd(venv_python)} "
f"&& cp {install_dir}/agent.py {install_dir}/agent.py.bak "
f"&& {agent_files_curl_cmds(base_url, install_dir)} "
f"&& {agent_kill_port_cmd(agent_port)}{systemctl_prefix}systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& {systemctl_prefix}systemctl is-active nexus-agent "
f"&& echo 'upgrade_ok'",
ssh_user,
)
def build_agent_upgrade_rollback_cmd(
*,
ssh_user: str,
install_dir: str = AGENT_INSTALL_DIR,
) -> str:
systemctl_prefix = "sudo " if ssh_user != "root" else ""
return sudo_wrap(
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py "
f"&& {systemctl_prefix}systemctl restart nexus-agent",
ssh_user,
)
+30
View File
@@ -0,0 +1,30 @@
"""Agent upgrade shell fragments stay aligned with install.sh."""
from server.utils.agent_deploy import (
AGENT_INSTALL_DIR,
AGENT_PIP_PACKAGES,
agent_kill_port_cmd,
build_agent_upgrade_cmd,
)
def test_pip_packages_match_install_sh_versions():
joined = " ".join(AGENT_PIP_PACKAGES)
assert "fastapi==0.115.6" in joined
assert "uvicorn==0.34.0" in joined
assert "httpx==0.28.1" in joined
assert "python-multipart==0.0.19" in joined
def test_upgrade_cmd_uses_install_dir_and_port():
cmd = build_agent_upgrade_cmd(
base_url="https://api.example.com",
ssh_user="deploy",
install_dir=AGENT_INSTALL_DIR,
agent_port=8602,
)
assert "/opt/nexus-agent/.venv/bin/python" in cmd
assert "heartbeat_policy.py" in cmd
assert agent_kill_port_cmd(8602) in cmd
assert "upgrade_ok" in cmd
assert "sudo systemctl restart nexus-agent" in cmd