diff --git a/docs/superpowers/plans/2026-06-02-deploy-nginx-site-enable-fix.md b/docs/superpowers/plans/2026-06-02-deploy-nginx-site-enable-fix.md new file mode 100644 index 0000000..810d34c --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-deploy-nginx-site-enable-fix.md @@ -0,0 +1,508 @@ +# 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] 诊断结果: +[INFO] 清理步骤: 处理异常状态... +[INFO] 清理步骤完成 +[INFO] 建链步骤: 创建 symlink... +[INFO] 建链步骤完成 +[INFO] 清默认步骤: 移除 default 站点... +[INFO] 清默认步骤完成 +[INFO] 验证步骤: 检查 symlink 和 default 状态... +[INFO] 验证结果: +[INFO] +[INFO] default:OK +[INFO] +[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 ..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` 模式 +- 步骤级重试机制