--- author: 部署脚本修复会话 created_at: 2026-06-02 purpose: 修复 deploy.py 的 nginx 站点启用命令 30s 超时问题(防御性诊断 + 清理) --- # 修复 deploy.py nginx 站点启用超时 — 设计文档 ## 问题陈述 执行 `python deploy.py` 部署时,nginx 站点启用步骤失败: ``` [INFO] 启用站点配置... [ERROR] 启用站点失败: 命令执行超时 (30s): ssh ... root@101.200.208.45 ln -snf /etc/nginx/sites-available/lifescript.happylifeos.com.conf /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf && rm -f /etc/nginx/sites-enabled/default ``` **根因分析**(用户已确认):之前能成功部署,本次突然失败。`ln -snf` 目标在远程服务器上已存在为**目录**或**断链**(非空 symlink 指向已被删除/修改的源文件),`ln` 在某些情况下会递归进入等待而 hang。`&&` 链式让 `rm -f default` 也永不执行。30s 总超时被耗尽。 ## 方案选择 经过 brainstorming 对话,用户选择 **方案 A:防御性诊断 + 清理**(修改脚本使其对异常状态自愈,不依赖手动干预)。 **未选方案**: - 方案 B(仅手动清理):不解决根本问题,未来重现概率高 - 方案 C(手动 + 脚本加固):用户已先用方案 A 的脚本修复,验证后再决定是否需要手动清理 - 方案 D(仅增超时):绕过问题,不修复根因 ## 设计 ### 核心思路 将 `ln -snf ... && rm -f ...` 的**链式单命令**拆为**多步独立 SSH 调用**,每步: - 独立超时(30s 对单步足够) - 独立返回 `ok/err/stderr` - 失败立即停止并打印详细诊断 - 前置诊断 + 主动清理 hang 源(目录/断链) ### 新增函数 `enable_nginx_site(remote_conf, domain)` 签名: ```python def enable_nginx_site(remote_conf: str, domain: str) -> bool: """幂等地启用 nginx 站点(sites-enabled symlink)。 流程: 1. 诊断目标路径当前状态 2. 清理异常状态(目录 / 文件 / 断链) 3. 创建 symlink 4. 移除 default 站点 5. 验证结果 Returns: True 成功;False 失败(stderr 已打印) """ ``` ### 5 步骤流程 | 步骤 | SSH 命令 | 失败处理 | 失败时是否继续 | |---|---|---|---| | 1. 诊断 | `test -e && (test -L && echo "symlink" \|\| (test -d && echo "dir" \|\| echo "file")) \|\| echo "missing"` | 仅诊断,不失败(stdout 捕获到 `status` 变量) | — | | 2. 清理 | `case "$status" in symlink) unlink ;; dir) rm -rf ;; file) rm -f ;; missing) ;; esac`(`$status` 是步骤 1 输出,通过 Python f-string 注入 SSH 命令) | 失败时打印 stderr + 提示"权限可能不足" | **否**(清理失败则终止,不重建链) | | 3. 建链 | 两段式:先 `if [ -e ]; then echo "二次检查失败:target 仍存在" >&2; exit 1; fi`,再 `ln -snf {remote_conf} ` | 失败时区分:二次检查失败 → 提示"并发进程干扰";ln 自身失败 → 提示"scp_file 是否成功"或"权限不足" | — | | 4. 清默认 | `rm -f /etc/nginx/sites-enabled/default` | 失败时**仅 warning**(不阻塞):打印"default 站点可能仍存在,请在步骤 5 验证时确认" | — | | 5. 验证 | `readlink /etc/nginx/sites-enabled/{domain}.conf && (test ! -e /etc/nginx/sites-enabled/default && echo "default:OK" \|\| echo "default:STILL_EXISTS") && ls -la /etc/nginx/sites-enabled/` | 失败时打印实际值与期望值(含 default 状态) | — | **步骤间值传递**(Python 端实现): ```python # 步骤 1:执行诊断,stdout 捕获为 status ok, status, err = ssh_command(diagnose_cmd, capture=True) status = status.strip() # 期望值: "symlink" | "dir" | "file" | "missing" # 步骤 2:用 f-string 注入 status 到 case 语句 cleanup_cmd = f'case "{status}" in symlink) unlink {target} ;; ...) ;; esac' ok, _, err = ssh_command(cleanup_cmd, capture=True) if not ok: log_error("清理步骤失败") return False # 短路 ``` **关键决策**: - **步骤 2 用 `test -e/-L/-d/-f` 替代 `ls -ld` 解析**:shell 内置 test 比解析 `ls` 输出更可靠,跨 Unix 工具版本差异小 - **步骤 2 失败则短路终止**:清理失败时建链毫无意义(脏状态仍在),立即返回 False 让用户介入 - **有效 symlink 走 `unlink` 而非 `rm -f`**:与目录/文件区分对待,避免误删有效链接 - **缺失(missing)跳过清理**:直接进建链步骤,是最常见的健康场景 - **步骤 3 加 `test ! -e ` 二次防护**:极端并发场景(步骤 2 清理后、步骤 3 执行前有其他进程瞬间创建同名项)下,`ln -snf` 仍会 hang;前置 test 检查 + 失败时明确提示"并发进程干扰",避免静默 hang - **步骤 3 用两段式而非 `&& ... ||` 链**:避免 `ln` 自身失败(如权限不足)被误报为"二次检查失败",错误信息更精准 - **步骤 4 失败仅 warning 不短路**:default 站点清理是非关键操作(即使存在也不阻塞 symlink 启用),失败时打印 warning 告知"default 站点可能仍存在",由用户在步骤 5 验证时确认 - **步骤 5 验证同时检查 default**:除 `readlink` 验证 symlink 外,追加 `test ! -e /etc/nginx/sites-enabled/default` 确认 default 站点已清理;失败时同时打印 symlink 状态和 default 状态 **超时设置**:每步独立调用 `ssh_command(cmd, timeout=30)`,与现有 `ssh_command()` 默认值一致。**不修改** `ssh_command()` / `run_ssh_args()` 底层,也不需要新增超时参数。30s 对单步 SSH 命令(不涉及大文件传输)足够;如未来出现网络慢问题,可在调用处单独覆盖(如 `ssh_command(cmd, timeout=60)`)。 ### 集成位置 修改 `G:\IdeaProjects\emotion-museun\deploy.py`: - **第 250-256 行**:替换 `ln -snf ... && rm -f ...` 块为 `enable_nginx_site(remote_conf, DOMAIN)` 调用 - **第 232-266 行**(`deploy_nginx` 函数)保持其他逻辑不变(scp_file 上传 conf、nginx -t、reload) - **新增函数** `enable_nginx_site` 放在 `deploy_nginx` 之前 - **实施方式**:5 步都是直接调用现有 `ssh_command()`(不修改底层),只是用 Python `if/elif/else` 在函数内串起来 ### 变量安全校验 `enable_nginx_site(remote_conf, domain)` 函数入口处对 `domain` 和 `remote_conf` 都做基本校验,防止变量污染导致误操作: ```python import re _SAFE_DOMAIN = re.compile(r'^[a-zA-Z0-9.-]+$') # domain 字符集 _SAFE_REMOTE_CONF = re.compile(r'^[a-zA-Z0-9./_-]+$') # 路径字符集(允许下划线) if not domain or not _SAFE_DOMAIN.match(domain): log_error(f"非法 domain 名称: {domain!r}") return False # domain 额外结构校验:禁止首尾连字符/点、禁止连续点(非法 TLD) if domain.startswith('-') or domain.endswith('-') \ or domain.startswith('.') or domain.endswith('.') \ or '..' in domain: log_error(f"非法 domain 结构: {domain!r} (首尾连字符/点 或 连续点非法)") return False if not remote_conf or not _SAFE_REMOTE_CONF.match(remote_conf): log_error(f"非法 remote_conf 路径: {remote_conf!r}") return False # 路径额外结构校验:禁止连续斜杠(路径注入)、禁止 .. 路径遍历 if '//' in remote_conf or '..' in remote_conf: log_error(f"remote_conf 包含可疑路径片段: {remote_conf!r}") return False ``` 校验规则: - `domain` 非空,仅含字母、数字、点、连字符;**首尾不能是 `-` 或 `.`;不能含 `..` 连续点** - `remote_conf` 非空,仅含字母、数字、`/`、点、连字符、下划线;**不能含 `//` 连续斜杠或 `..` 路径遍历** - 不含 `;`、`&`、`|`、`$`、反引号、空格等 shell 特殊字符 虽然 `remote_conf` 当前来源固定(f-string 拼接),但**未来重构时**(如改为从配置文件读取)这个校验能防止意外引入危险字符。 ### 错误信息改进 `enable_nginx_site` 内部用统一的 `log_error` 输出结构化信息,**使用描述性名称而非"步骤 N"**: ``` [ERROR] 启用站点失败 - 建链步骤: Permission denied [ERROR] 上下文: 目标 = /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf [ERROR] 提示: 确认 scp_file 是否成功上传了 sites-available 下的 conf 文件 ``` 各步骤使用的描述性名称: - 诊断步骤 - 清理步骤 - 建链步骤 - 清默认步骤 - 验证步骤 替代当前的"命令执行超时 (30s)",便于快速定位卡在哪一步。 ## 范围 ### 包含 - 修改 `deploy.py` 的 `deploy_nginx()` 函数调用方式 - 新增 `enable_nginx_site()` 函数(约 50-70 行) - 错误信息结构化改进 ### 不包含(明确排除) - 不修改其他 deploy.py 子脚本(server/web/web-admin/life-script) - 不改 `ssh_command()` / `run_ssh_args()` 底层 - 不增加 `--dry-run` 模式 - 不改 nginx 配置文件本身 - 不改默认 30s 超时(单步足够,问题在链式 hang,不在网络慢) ## 验证计划 ### 单元级(脚本内) 部署后通过以下方式验证: 1. **干净服务器场景**:远程 `/etc/nginx/sites-enabled/{domain}.conf` 不存在 → 期望成功 2. **断链场景**:手动 `ln -s /nonexistent /etc/nginx/sites-enabled/{domain}.conf` → 期望步骤 2 清理 + 步骤 3 重建 3. **目录场景**:手动 `mkdir /etc/nginx/sites-enabled/{domain}.conf` → 期望步骤 2 `rm -rf` 清理 + 步骤 3 成功 4. **文件场景**:手动 `touch /etc/nginx/sites-enabled/{domain}.conf` → 期望步骤 2 `rm -f` 清理 + 步骤 3 成功 5. **正常 symlink 场景**:手动重建有效 symlink → 期望步骤 2 unlink + 步骤 3 重建 6. **权限不足场景**:以非 root 用户运行 deploy.py(或临时 `chmod 000 /etc/nginx/sites-enabled/{domain}.conf` 父目录) → 期望步骤 2 正确报错 + 短路终止,不进入步骤 3 7. **并发干扰场景**(极难手动复现,可选):在步骤 2 清理和步骤 3 之间用 `watch` 命令持续创建同名目录 → 期望步骤 3 二次防护触发,提示"并发进程干扰" ### 集成级 跑 `python deploy.py nginx` 完整流程,期望: - 不再出现"启用站点失败" - `/etc/nginx/sites-enabled/{domain}.conf` 是有效 symlink - `/etc/nginx/sites-enabled/default` 不存在 - `nginx -t` 通过 - `systemctl reload nginx` 成功 ### 验证前置 修复后**必须**执行 `python deploy.py nginx`(或 `python deploy.py all`)进行端到端验证,**不能仅靠代码 review**。这是用户在前次 PATH 修复中明确要求的规则(CLAUDE.md "验证规则(强制)")。 ## 风险与回滚 ### 风险 1. **远程 rm -rf 误删**:仅针对 `/etc/nginx/sites-enabled/{domain}.conf` 单个路径(已通过 `{domain}` 限定 + 变量安全校验),不会影响其他站点 2. **步骤 2 误判**:如果 `test -e/-L/-d/-f` 在远程 shell 版本异常或文件系统不支持(如 NFS 边缘情况)时输出不符合预期 → 步骤 3 二次防护 `test ! -e` 仍能检测到残留,整体仍自愈 3. **SSH 连接问题掩盖**:如果 SSH 本身不通,新代码会立即在步骤 1 报告 stderr,不会比原代码更差 4. **并发进程干扰**:步骤 2 清理和步骤 3 之间有其他进程创建同名目录 → 步骤 3 二次防护触发,提示"并发进程干扰"而非静默 hang 5. **变量污染**:通过变量安全校验(domain + remote_conf)防御,即使未来重构时从配置读取也不会引入 shell 注入 ### 回滚 - 改动局限在 `deploy.py` 单文件、新增 1 个函数 - 如新代码有问题,`git checkout deploy.py` 即可恢复 ## 后续可选增强(不在本次范围) - `enable_nginx_site` 通用化到 `disable_nginx_site()`,支持 `deploy.py nginx disable ` - 把 `enable_nginx_site` 提取到独立模块(`deploy_nginx_helpers.py`),便于其他 deploy.py 复用 - 增加 `--dry-run` 模式,仅打印将执行命令不实际执行 - 步骤级重试机制(如步骤 3 失败时自动重试 1 次) ## 相关文件 - `G:\IdeaProjects\emotion-museun\deploy.py` — 主修改目标(第 232-266 行 `deploy_nginx`,新增 `enable_nginx_site`) - `G:\IdeaProjects\emotion-museun\conf\emotion-museum.conf` — nginx 配置文件(不修改) - `G:\IdeaProjects\emotion-museun\docs\superpowers\specs\2026-06-02-deploy-nginx-site-enable-fix-design.md` — 本设计文档