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

12 KiB
Raw Blame History

author, created_at, purpose
author created_at purpose
部署脚本修复会话 2026-06-02 修复 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)

签名:

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 端实现):

# 步骤 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) 函数入口处对 domainremote_conf 都做基本校验,防止变量污染导致误操作:

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.pydeploy_nginx() 函数调用方式
  • 新增 enable_nginx_site() 函数(约 50-70 行)
  • 错误信息结构化改进

不包含(明确排除)

  • 不修改其他 deploy.py 子脚本(backend-single/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 — 本设计文档