---
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` — 本设计文档
