# deploy.py nginx 站点启用超时修复 — 实施计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 修复 `python deploy.py` 中 nginx 站点启用命令 30s 超时问题，通过将链式 `ln -snf && rm -f` 拆为 5 步独立 SSH 调用 + 前置诊断清理。

**Architecture:** 在 `deploy.py` 中新增 `enable_nginx_site(remote_conf, domain)` 函数（5 步骤：诊断→清理→建链→清默认→验证），替换 `deploy_nginx` 第 250-253 行的链式命令。每步独立 `ssh_command(timeout=30)` 调用，失败立即停止；变量入口校验防御 shell 注入。

**Tech Stack:** Python 3.10+, subprocess (ssh/scp), bash test/case/ln 内置命令

---

## 文件结构

**修改**：`G:\IdeaProjects\emotion-museun\deploy.py`
- 新增函数 `enable_nginx_site`（在 `deploy_nginx` 之前，约 70 行）
- 修改 `deploy_nginx`（第 250-256 行，替换链式命令为函数调用）

**不修改**：
- `ssh_command()` / `run_ssh_args()` / `scp_file()` 底层
- `conf/emotion-museum.conf` nginx 配置文件
- 其他 deploy.py 子脚本

---

## Task 1: 添加 `enable_nginx_site` 函数骨架 + 变量安全校验

**Files:**
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:230-266`（在 `deploy_nginx` 前插入新函数）

- [ ] **Step 1: 在 `deploy_nginx` 之前插入新函数骨架**

**Step 1a**：先在文件顶部 imports 区（第 23-28 行附近）追加 `import re`：

将 `deploy.py` 第 23-28 行：
```python
import io
import os
import sys
import time
import subprocess
from pathlib import Path
```

改为：
```python
import io
import os
import re
import sys
import time
import subprocess
from pathlib import Path
```

**Step 1b**：在 `deploy.py` 第 230 行（`# Nginx 配置` 注释之前）插入以下代码：

```python
# ============================================================================
# Nginx 站点启用（防御性诊断 + 清理）
# ============================================================================

# 变量安全校验正则
_SAFE_DOMAIN = re.compile(r'^[a-zA-Z0-9.-]+$')
_SAFE_REMOTE_CONF = re.compile(r'^[a-zA-Z0-9./_-]+$')


def _validate_nginx_inputs(remote_conf: str, domain: str) -> bool:
    """校验 domain 和 remote_conf 防止 shell 注入和路径遍历。

    Returns: True 合法；False 非法（已 log_error）
    """
    if not domain or not _SAFE_DOMAIN.match(domain):
        log_error(f"非法 domain 名称: {domain!r}")
        return False
    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
    return True


def enable_nginx_site(remote_conf: str, domain: str) -> bool:
    """幂等地启用 nginx 站点（sites-enabled symlink）。

    流程：
    1. 诊断目标路径当前状态（symlink/dir/file/missing）
    2. 清理异常状态（目录/文件/断链）；失败短路
    3. 二次检查 + 创建 symlink
    4. 移除 default 站点（失败仅 warning）
    5. 验证 symlink + default 状态

    Returns: True 成功；False 失败（stderr 已打印）
    """
    if not _validate_nginx_inputs(remote_conf, domain):
        return False

    target = f"/etc/nginx/sites-enabled/{domain}.conf"
    log_info(f"启用 nginx 站点: {target} -> {remote_conf}")
    return True  # 后续 Task 替换为完整 5 步实现
```

- [ ] **Step 2: 验证 Python 语法正确**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "import ast; ast.parse(open('deploy.py', encoding='utf-8').read())"`
Expected: 退出码 0，无输出（语法正确）

- [ ] **Step 3: 验证变量校验逻辑**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import _validate_nginx_inputs; print(_validate_nginx_inputs('/etc/nginx/sites-available/lifescript.happylifeos.com.conf', 'lifescript.happylifeos.com'))"`
Expected: 输出 `True`

Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import _validate_nginx_inputs; print(_validate_nginx_inputs('/etc/nginx/sites-available/test.conf', '-evil.com'))"`
Expected: 输出 `False`（同时 stderr 出现 `[ERROR] 非法 domain 结构`）

Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import _validate_nginx_inputs; print(_validate_nginx_inputs('/etc/nginx/../etc/passwd', 'example.com'))"`
Expected: 输出 `False`（同时 stderr 出现 `[ERROR] remote_conf 包含可疑路径片段`）

- [ ] **Step 4: Commit**

```bash
cd G:\IdeaProjects\emotion-museun
git add deploy.py
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "feat(deploy): 添加 enable_nginx_site 函数骨架 + 变量安全校验"
```

---

## Task 2: 实现步骤 1（诊断）+ 步骤 2（清理，case 注入 status）

**Files:**
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:enable_nginx_site` 函数体

- [ ] **Step 1: 替换 `enable_nginx_site` 末尾的 `return True` 为 5 步骤实现（部分）**

将 `enable_nginx_site` 函数末尾的：
```python
    log_info(f"启用 nginx 站点: {target} -> {remote_conf}")
    return True  # 后续 Task 替换为完整 5 步实现
```

替换为：
```python
    log_info(f"启用 nginx 站点: {target} -> {remote_conf}")

    # ===== 步骤 1: 诊断目标路径状态 =====
    log_info("诊断步骤: 检查目标路径...")
    diagnose_cmd = (
        f'test -e {target} && '
        f'(test -L {target} && echo "symlink" || '
        f'(test -d {target} && echo "dir" || echo "file")) || '
        f'echo "missing"'
    )
    ok, status, err = ssh_command(diagnose_cmd, timeout=30)
    if not ok:
        log_error(f"诊断步骤失败: {err}")
        return False
    status = status.strip()
    log_info(f"诊断结果: {status}")

    # ===== 步骤 2: 清理异常状态 =====
    log_info("清理步骤: 处理异常状态...")
    cleanup_cmd = (
        f'case "{status}" in '
        f'symlink) unlink {target} ;; '
        f'dir) rm -rf {target} ;; '
        f'file) rm -f {target} ;; '
        f'missing) ;; '
        f'esac'
    )
    ok, _, err = ssh_command(cleanup_cmd, timeout=30)
    if not ok:
        log_error(f"清理步骤失败: {err}")
        log_error("上下文: 目标 = " + target)
        log_error("提示: 权限可能不足（检查 /etc/nginx/sites-enabled 写权限）")
        return False
    log_info("清理步骤完成")
    return True  # Task 3 替换为完整建链+清默认
```

- [ ] **Step 2: 验证 Python 语法**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "import ast; ast.parse(open('deploy.py', encoding='utf-8').read())"`
Expected: 退出码 0

- [ ] **Step 3: 验证 SSH 命令构造正确（不执行 SSH）**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "
from deploy import _validate_nginx_inputs
target = '/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf'
status = 'dir'
diagnose_cmd = f'test -e {target} && (test -L {target} && echo symlink || (test -d {target} && echo dir || echo file)) || echo missing'
cleanup_cmd = f'case \"{status}\" in symlink) unlink {target} ;; dir) rm -rf {target} ;; file) rm -f {target} ;; missing) ;; esac'
print('DIAGNOSE:', diagnose_cmd)
print('CLEANUP:', cleanup_cmd)
"`
Expected:
```
DIAGNOSE: test -e /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf && (test -L /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf && echo symlink || (test -d /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf && echo dir || echo file)) || echo missing
CLEANUP: case "dir" in symlink) unlink /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf ;; dir) rm -rf /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf ;; file) rm -f /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf ;; missing) ;; esac
```

- [ ] **Step 4: Commit**

```bash
cd G:\IdeaProjects\emotion-museun
git add deploy.py
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "feat(deploy): 实现 enable_nginx_site 步骤 1（诊断）+ 步骤 2（清理）"
```

---

## Task 3: 实现步骤 3（建链，两段式）+ 步骤 4（清 default）

**Files:**
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:enable_nginx_site` 函数体

- [ ] **Step 1: 在清理步骤后插入步骤 3 和 4**

将 `enable_nginx_site` 函数末尾的 `return True  # Task 3 替换为完整建链+清默认` 替换为：

```python
    log_info("清理步骤完成")

    # ===== 步骤 3: 建链（两段式：先二次检查，再 ln -snf） =====
    log_info("建链步骤: 创建 symlink...")
    # 第一段：二次检查 target 不存在（防并发进程干扰）
    guard_cmd = f'if [ -e {target} ]; then echo "二次检查失败：target 仍存在" >&2; exit 1; fi'
    ok, _, err = ssh_command(guard_cmd, timeout=30)
    if not ok:
        log_error(f"建链步骤失败 - 二次检查: {err}")
        log_error("提示: 并发进程干扰（其他进程在步骤 2 后、步骤 3 前创建了同名项）")
        log_error("上下文: 目标 = " + target)
        return False
    # 第二段：执行 ln -snf（独立 SSH 调用，错误信息不会被二次检查误报）
    link_cmd = f'ln -snf {remote_conf} {target}'
    ok, _, err = ssh_command(link_cmd, timeout=30)
    if not ok:
        log_error(f"建链步骤失败 - ln 执行: {err}")
        log_error("提示: 确认 scp_file 是否成功上传了 sites-available 下的 conf 文件")
        log_error("上下文: 目标 = " + target)
        return False
    log_info("建链步骤完成")

    # ===== 步骤 4: 清理 default 站点（失败仅 warning） =====
    log_info("清默认步骤: 移除 default 站点...")
    ok, _, err = ssh_command("rm -f /etc/nginx/sites-enabled/default", timeout=30)
    if not ok:
        log_warn(f"清默认步骤失败（不阻塞）: {err}")
        log_warn("提示: default 文件可能仍存在，nginx 可能有端口冲突；步骤 5 验证会确认")
    else:
        log_info("清默认步骤完成")
    return True  # Task 4 替换为完整 5 步（含验证）
```

- [ ] **Step 2: 验证 Python 语法**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "import ast; ast.parse(open('deploy.py', encoding='utf-8').read())"`
Expected: 退出码 0

- [ ] **Step 3: 验证 SSH 命令构造（两段式）**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "
target = '/etc/nginx/sites-enabled/test.com.conf'
remote_conf = '/etc/nginx/sites-available/test.com.conf'
guard_cmd = f'if [ -e {target} ]; then echo \"二次检查失败：target 仍存在\" >&2; exit 1; fi'
link_cmd = f'ln -snf {remote_conf} {target}'
print('GUARD:', guard_cmd)
print('LINK:', link_cmd)
"`
Expected:
```
GUARD: if [ -e /etc/nginx/sites-enabled/test.com.conf ]; then echo "二次检查失败：target 仍存在" >&2; exit 1; fi
LINK: ln -snf /etc/nginx/sites-available/test.com.conf /etc/nginx/sites-enabled/test.com.conf
```

- [ ] **Step 4: Commit**

```bash
cd G:\IdeaProjects\emotion-museun
git add deploy.py
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "feat(deploy): 实现 enable_nginx_site 步骤 3（两段式建链）+ 步骤 4（清 default）"
```

---

## Task 4: 实现步骤 5（验证 symlink + default）+ 集成到 deploy_nginx

**Files:**
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:enable_nginx_site` 函数体（末尾）
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:249-256`（deploy_nginx 内部）

- [ ] **Step 1: 在 `enable_nginx_site` 末尾插入步骤 5**

将 `enable_nginx_site` 函数末尾的 `return True  # Task 4 替换为完整 5 步（含验证）` 替换为：

```python
    else:
        log_info("清默认步骤完成")

    # ===== 步骤 5: 验证结果 =====
    log_info("验证步骤: 检查 symlink 和 default 状态...")
    verify_cmd = (
        f'readlink {target} && '
        f'(test ! -e /etc/nginx/sites-enabled/default && echo "default:OK" || echo "default:STILL_EXISTS") && '
        f'ls -la /etc/nginx/sites-enabled/'
    )
    ok, stdout, err = ssh_command(verify_cmd, timeout=30)
    if not ok:
        log_error(f"验证步骤失败: {err}")
        log_error(f"实际输出: {stdout}")
        return False
    log_info("验证结果:")
    for line in stdout.split('\n'):
        if line.strip():
            log_info(f"  {line}")
    log_info("启用站点配置完成")
    return True
```

- [ ] **Step 2: 修改 `deploy_nginx`，替换链式命令为函数调用**

将 `deploy.py` 第 249-256 行的：

```python
    log_info("启用站点配置...")
    ok, _, err = ssh_command(
        f"ln -snf {remote_conf} /etc/nginx/sites-enabled/{DOMAIN}.conf "
        f"&& rm -f /etc/nginx/sites-enabled/default"
    )
    if not ok:
        log_error(f"启用站点失败: {err}")
        return False
```

替换为：

```python
    log_info("启用站点配置...")
    if not enable_nginx_site(remote_conf, DOMAIN):
        log_error("启用站点失败（详见 enable_nginx_site 内部日志）")
        return False
```

- [ ] **Step 3: 验证 Python 语法**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "import ast; ast.parse(open('deploy.py', encoding='utf-8').read())"`
Expected: 退出码 0

- [ ] **Step 4: 验证函数可正常导入**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import enable_nginx_site, _validate_nginx_inputs; print('import OK')"`
Expected: 输出 `import OK`

- [ ] **Step 5: 验证函数签名和文档**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "
from deploy import enable_nginx_site
import inspect
sig = inspect.signature(enable_nginx_site)
print('Signature:', sig)
print('Docstring (first line):', enable_nginx_site.__doc__.split(chr(10))[0])
"`
Expected:
```
Signature: (remote_conf: str, domain: str) -> bool
Docstring (first line): 幂等地启用 nginx 站点（sites-enabled symlink）。
```

- [ ] **Step 6: Commit**

```bash
cd G:\IdeaProjects\emotion-museun
git add deploy.py
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "feat(deploy): 实现 enable_nginx_site 步骤 5（验证）+ 集成到 deploy_nginx"
```

---

## Task 5: 端到端验证（CLAUDE.md 强制规则）

**Files:**
- 无文件修改

- [ ] **Step 1: SSH 连接预检**

Run: `cd G:\IdeaProjects\emotion-museun && python deploy.py verify 2>&1 | head -30`
Expected: SSH 连接成功（看到 `[INFO] SSH 连接正常`）

若失败：
- 检查 `~/.ssh/config` 是否有 `101.200.208.45` 免密配置
- 手动 `ssh root@101.200.208.45 echo ok` 测试

- [ ] **Step 2: 执行 nginx 部署**

Run: `cd G:\IdeaProjects\emotion-museun && python deploy.py nginx 2>&1 | tee C:\Users\peanu\AppData\Local\Temp\opencode\nginx-deploy-validation.txt`
Expected: 完整流程成功，无 30s 超时；看到：
```
[INFO] 部署 Nginx 配置
[INFO] 上传 Nginx 配置文件...
[INFO] 启用站点配置...
[INFO] 启用 nginx 站点: /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf -> /etc/nginx/sites-available/lifescript.happylifeos.com.conf
[INFO] 诊断步骤: 检查目标路径...
[INFO] 诊断结果: <symlink|dir|file|missing>
[INFO] 清理步骤: 处理异常状态...
[INFO] 清理步骤完成
[INFO] 建链步骤: 创建 symlink...
[INFO] 建链步骤完成
[INFO] 清默认步骤: 移除 default 站点...
[INFO] 清默认步骤完成
[INFO] 验证步骤: 检查 symlink 和 default 状态...
[INFO] 验证结果:
[INFO]   <readlink 输出>
[INFO]   default:OK
[INFO]   <ls -la 输出>
[INFO] 启用站点配置完成
[INFO] 验证 Nginx 配置...
[INFO] Nginx 配置重载完成
```

- [ ] **Step 3: 验证远程 nginx 状态**

Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import ssh_command; print(ssh_command('ls -la /etc/nginx/sites-enabled/', timeout=15))"`
Expected: 输出 `/etc/nginx/sites-enabled/` 目录列表，包含有效 symlink（如 `lifescript.happylifeos.com.conf -> /etc/nginx/sites-available/lifescript.happylifeos.com.conf`），**不包含** `default`

- [ ] **Step 4: 验证 nginx 服务可访问**

Run: `curl -I https://lifescript.happylifeos.com/ 2>&1 | head -5`
Expected: HTTP 200 或 301/302（站点可达）

- [ ] **Step 5: Commit 验证记录**

```bash
cd G:\IdeaProjects\emotion-museun
git add -A
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "test: 端到端验证 nginx 部署成功（见 opencode temp 验证日志）" --allow-empty
```

---

## 验证场景（设计文档要求）

以下场景可在 Task 5 验证后**可选**执行（手动复现异常状态）：

### 场景 A: 断链场景
```bash
ssh root@101.200.208.45 "ln -s /nonexistent /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf"
cd G:\IdeaProjects\emotion-museun && python deploy.py nginx
```
Expected: 诊断结果 = `symlink`，步骤 2 用 `unlink` 清理，步骤 3 重建成功

### 场景 B: 目录场景
```bash
ssh root@101.200.208.45 "rm -f /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf && mkdir /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf"
cd G:\IdeaProjects\emotion-museun && python deploy.py nginx
```
Expected: 诊断结果 = `dir`，步骤 2 用 `rm -rf` 清理，步骤 3 重建成功

### 场景 C: 干净场景
```bash
ssh root@101.200.208.45 "rm -f /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf"
cd G:\IdeaProjects\emotion-museun && python deploy.py nginx
```
Expected: 诊断结果 = `missing`，跳过清理，步骤 3 直接建链

### 场景 D: 正常 symlink 场景（幂等性）
```bash
ssh root@101.200.208.45 "ln -snf /etc/nginx/sites-available/lifescript.happylifeos.com.conf /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf"
cd G:\IdeaProjects\emotion-museun && python deploy.py nginx
```
Expected: 诊断结果 = `symlink`，步骤 2 用 `unlink`，步骤 3 重建（**幂等**：重复执行结果相同）

---

## 回滚

如新代码有问题：
```bash
cd G:\IdeaProjects\emotion-museun
git log --oneline deploy.py  # 找到本次 4 个 commit
git revert <commit-hash>..HEAD  # 批量回滚
# 或：
git checkout HEAD~4 -- deploy.py  # 恢复到本次修改前
```

---

## 风险提醒

1. **首次部署若远程 nginx 配置异常**：清理步骤可能误删用户手动创建的目录 → 建议首次部署前先 SSH 检查 `/etc/nginx/sites-enabled/` 状态
2. **SSH 免密登录失效**：所有 ssh_command 调用都会失败，Task 5 Step 1 会捕获
3. **网络慢导致 30s 超时**：单步 30s 对常规操作足够；如遇网络问题可临时改为 `timeout=60`（在调用处覆盖）

---

## 后续可选增强（不在本次范围）

- `disable_nginx_site()` 反向操作
- 提取到独立模块 `deploy_nginx_helpers.py`
- 增加 `--dry-run` 模式
- 步骤级重试机制
