Files
happy-life-star/docs/superpowers/specs/2026-06-02-deploy-nginx-site-enable-fix-design.md
T

230 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 <target> && (test -L <target> && echo "symlink" \|\| (test -d <target> && echo "dir" \|\| echo "file")) \|\| echo "missing"` | 仅诊断,不失败(stdout 捕获到 `status` 变量) | — |
| 2. 清理 | `case "$status" in symlink) unlink <target> ;; dir) rm -rf <target> ;; file) rm -f <target> ;; missing) ;; esac``$status` 是步骤 1 输出,通过 Python f-string 注入 SSH 命令) | 失败时打印 stderr + 提示"权限可能不足" | **否**(清理失败则终止,不重建链) |
| 3. 建链 | 两段式:先 `if [ -e <target> ]; then echo "二次检查失败:target 仍存在" >&2; exit 1; fi`,再 `ln -snf {remote_conf} <target>` | 失败时区分:二次检查失败 → 提示"并发进程干扰"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 <target>` 二次防护**:极端并发场景(步骤 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 <domain>`
-`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` — 本设计文档