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

509 lines
19 KiB
Markdown
Raw Permalink 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.
# 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` 模式
- 步骤级重试机制