Files
knowledge-governance/scripts/deploy-api-web.sh
T
姚章浩 b7f7e4f121
build / build (push) Successful in 1m1s
fix(deploy): 上线前校验 .env 键值形状(两行粘成一行的坏值不再能上线)
事故复盘(2026-10-14,测试实例 p1 整体不可用约 6 分钟):
- 我用 `rstrip("\n") + 新行` 往 `.env` 追加 `VERSION_HISTORY_LINK_ORIGIN`,而 `.env` 最后一行
  当时没有结尾换行 ⇒ 两行**粘成一行**:
  `VERSION_HISTORY_GITEA_BASE_URL=http://192.168.50.223:3000VERSION_HISTORY_LINK_ORIGIN=https://gitea.zheromiku.top`
- 配置校验按设计拒绝这个非法 origin ⇒ `cmd/api`/`cmd/worker` 启动即退出 ⇒ 容器 crash-loop
  (fail-closed 是对的,但代价是平台起不来)。

修复:
- `scripts/deploy-api-web.sh` 新增 **1a 步护栏**:上线前解析 `.env`,只要某行的值里又出现 `KEY=`
  (两行粘连)或键名不合法 ⇒ 打印 `ENV_VALUE_LOOKS_MERGED` 并**拒绝部署**(不动线上);
- `internal/appconfig` 在校验失败时补线索:值里含 `=` ⇒ 提示"`.env` 里两行可能粘成了一行";
- 回归用例 `TestLoadRejectsMergedVersionHistoryEnvValue`(粘行必拒 + 线索文案 + 合法值仍放行)。

验证:护栏对"粘行样例/键名非法"退出码 1、对"正常样例/当前真实 .env"退出码 0;Go 用例通过;
make verify 15/15。线上已修 `.env` 并重启 api+worker(healthy),重新同步后页面链接已全部走公网 origin。
2026-10-14 11:17:35 +08:00

126 lines
7.6 KiB
Bash
Raw 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.
#!/usr/bin/env bash
# 重建并上线 governance api + web(本机 docker build 需要宿主代理 + --network host)
set -u
cd /home/zheromiku/knowledge-governance || exit 1
TS=$(date +%Y%m%d-%H%M%S)
PROXY_ARGS=(--build-arg HTTP_PROXY=http://127.0.0.1:7890 --build-arg HTTPS_PROXY=http://127.0.0.1:7890 --build-arg NO_PROXY=localhost,127.0.0.1 --build-arg GOPROXY=https://goproxy.cn,direct)
echo "=== 1. 打回滚标签 ($TS)"
docker tag knowledge-governance-p1-api:latest "knowledge-governance-p1-api:rollback-$TS" || exit 1
docker tag knowledge-governance-p1-web:latest "knowledge-governance-p1-web:rollback-$TS" || exit 1
echo "=== 1a. 上线前校验 .env 的键值形状(一行一值)"
# 2026-10-14 实测事故:`.env` 最后一行没有结尾换行、又被追加新键 ⇒ 两行粘成一行
# ⇒ VERSION_HISTORY_GITEA_BASE_URL 变成 "http://host:3000VERSION_HISTORY_LINK_ORIGIN=…"
# ⇒ 配置校验按设计拒绝启动 ⇒ api/worker 崩溃重启(平台整体不可用)。
# 这里在**上线前**用形状判定拦住:值里再出现 `KEY=` 或键名不合法,直接拒绝部署。
if [ -f .env ]; then
python3 - <<'ENVCHECK' || exit 1
import pathlib
import re
import sys
problems = []
for number, raw in enumerate(pathlib.Path(".env").read_text(encoding="utf-8").splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key.strip()):
problems.append((number, line, "键名不合法"))
elif re.search(r"[A-Z][A-Z0-9_]{3,}=", value):
# 不能用 \b:粘行处是 "…:3000VERSION_HISTORY_…",数字紧邻字母,没有词边界。
problems.append((number, line, "值里又出现 KEY=(两行粘在一起)"))
if problems:
print("ENV_VALUE_LOOKS_MERGED(.env 有坏值,拒绝上线)")
for number, line, why in problems:
print(f" line {number}: {why}: {line[:120]}")
sys.exit(1)
print(" .env 键值形状 OK")
ENVCHECK
fi
echo "=== 1b. 构建身份(构建期注入:页面「当前运行」只认产物里的完整 SHA 精确匹配)"
# 唯一来源是 scripts/version.py --identity(与 Makefile、CI 同一份规则):不注入 ⇒ 页面只能显示"未知"。
IDENTITY_ARGS=()
while IFS= read -r line; do IDENTITY_ARGS+=("$line"); done < <(python3 scripts/version.py --identity 2>/dev/null | python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(0)
release = d.get("release_version") or ""
count = d.get("commits_since_release")
print("--build-arg=VERSION=" + (release or "dev"))
print("--build-arg=RELEASE_VERSION=" + release)
print("--build-arg=BUILD_KIND=" + (d.get("build_kind") or "development"))
print("--build-arg=COMMITS_SINCE_RELEASE=" + ("" if count is None else str(count)))
print("--build-arg=COMMIT_SHA=" + (d.get("commit_sha") or "unknown"))
print("--build-arg=BUILD_TIME=" + (d.get("built_at") or "unknown"))
')
if [ "${#IDENTITY_ARGS[@]}" -lt 6 ]; then
echo "IDENTITY_UNAVAILABLE"
echo " 取不到构建身份(scripts/version.py --identity 失败)⇒ 拒绝继续:"
echo " 否则构建出来的产物无法被「版本记录」页识别成「当前运行」,只会显示未知。"
exit 1
fi
printf ' %s\n' "${IDENTITY_ARGS[@]}"
echo "=== 1c. 保留上一版构建的静态资源(CDN/浏览器可能还持有旧 index.html)"
# 只保留**当前正在跑的那一版**:旧 index.html 的旧哈希引用仍能取到文件,
# 页面不会因为某个 JS/CSS 404 而空白或"裸奔"。目录只读挂载进 web 容器。
LEGACY_DIR="deploy/legacy-assets"
PREV_WEB_IMAGE=$(docker inspect -f '{{.Image}}' knowledge-governance-p1-web-1 2>/dev/null || true)
mkdir -p "$LEGACY_DIR/assets" && chmod 777 "$LEGACY_DIR" "$LEGACY_DIR/assets"
if [ -n "$PREV_WEB_IMAGE" ]; then
docker run --rm --user 0 --entrypoint sh -v "$PWD/$LEGACY_DIR:/out" "$PREV_WEB_IMAGE" \
-c 'cp -a /usr/share/nginx/html/assets/. /out/assets/ 2>/dev/null || true' >/dev/null 2>&1 || true
echo " LEGACY_ASSETS=$(ls -1 "$LEGACY_DIR/assets" 2>/dev/null | tr '\n' ' ')"
fi
echo "=== 2. 构建 api"
docker build --network host "${PROXY_ARGS[@]}" "${IDENTITY_ARGS[@]}" -f Dockerfile -t knowledge-governance-p1-api:latest . || { echo "API_BUILD_FAILED"; exit 1; }
echo "=== 3. 构建 web"
docker build --network host "${PROXY_ARGS[@]}" -f Dockerfile.web -t knowledge-governance-p1-web:latest . || { echo "WEB_BUILD_FAILED"; exit 1; }
echo "=== 4. 上线 api + web(不动依赖)"
# COMPOSE_FILES 让调用方叠加发布接线 / 门禁 override,默认只有基础 compose.yaml。
# 例:COMPOSE_FILES="-f compose.yaml -f compose.release-sim.yaml" bash scripts/deploy-api-web.sh
#
# 注意(2026-10-10 实测踩到):重建 api 时若不叠加 compose.release-gates.yaml,两道生产门禁会被
# **静默关掉**(PRODUCTION_RELEASE_REQUESTS_ENABLED/BACKUP_RESTORE_VERIFIED=false),页面只在点击
# 「授权发布」时回一句 "production release requests are disabled" —— 很容易误判成功能坏了。
# 因此下面做前置保护:正在运行的 api 若已开闸、而本次没叠加门禁文件,直接拒绝(确要关闸用 ALLOW_GATE_OFF=1)。
COMPOSE_FILES_EFFECTIVE="${COMPOSE_FILES:--f compose.yaml}"
GATES_PREV=$(docker exec knowledge-governance-p1-api-1 sh -lc 'printf %s "${PRODUCTION_RELEASE_REQUESTS_ENABLED:-}"' 2>/dev/null || true)
if [ "${GATES_PREV}" = "true" ] && [[ "${COMPOSE_FILES_EFFECTIVE}" != *compose.release-gates.yaml* ]] && [ "${ALLOW_GATE_OFF:-0}" != "1" ]; then
echo "REFUSED_WOULD_CLOSE_PRODUCTION_GATES"
echo " 当前 api 已开闸(PRODUCTION_RELEASE_REQUESTS_ENABLED=true),而本次 COMPOSE_FILES 不含 compose.release-gates.yaml,"
echo " 继续会用基础 compose 重建 api → 生产授权接口被静默关闭。"
echo " 保留门禁:COMPOSE_FILES=\"-f compose.yaml -f compose.release-sim.yaml -f compose.release-gates.yaml\" bash scripts/deploy-api-web.sh"
echo " 确实要关闸:ALLOW_GATE_OFF=1 bash scripts/deploy-api-web.sh"
exit 1
fi
docker compose ${COMPOSE_FILES_EFFECTIVE} up -d --no-build --no-deps api web || { echo "UP_FAILED"; exit 1; }
echo "=== 5. 状态"
docker ps --filter name=knowledge-governance-p1 --format '{{.Names}} {{.Status}} {{.Image}}'
echo "api image=$(docker inspect -f '{{.Image}}' knowledge-governance-p1-api-1)"
echo "web image=$(docker inspect -f '{{.Image}}' knowledge-governance-p1-web-1)"
echo "=== 6. 健康"
echo "health=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8088/health)"
echo "=== 7. 生产端点环境变量(只报名字与非空)"
docker exec knowledge-governance-p1-api-1 sh -lc 'for v in PRODUCTION_WEKNORA_BASE_URL PRODUCTION_WEKNORA_API_KEY_FILE PRODUCTION_WEKNORA_ALLOW_DOCKER_HTTP; do eval "val=\$$v"; if [ -n "$val" ]; then echo "$v=<set>"; else echo "$v=<empty>"; fi; done'
echo "=== 7b. 生产门禁状态(重建后必须与预期一致)"
docker exec knowledge-governance-p1-api-1 sh -lc 'for v in PRODUCTION_RELEASE_REQUESTS_ENABLED PRODUCTION_BACKUP_RESTORE_VERIFIED; do eval "val=\$$v"; echo "$v=${val:-<empty>}"; done'
echo "=== 8. 新包"
docker exec knowledge-governance-p1-web-1 sh -lc 'grep -o "index-[A-Za-z0-9_-]*\.js" /usr/share/nginx/html/index.html | head -1'
echo "=== 9. 构建身份是否真的进了产物(页面据此判「当前运行」)"
INJECTED=$(printf '%s\n' "${IDENTITY_ARGS[@]}" | sed -n 's/^--build-arg=COMMIT_SHA=//p')
if docker exec knowledge-governance-p1-api-1 sh -lc "strings /app/api | grep -q '$INJECTED'"; then
echo "INJECTED_COMMIT_SHA=$INJECTED(已进 api 产物)"
else
echo "INJECTION_MISSING(产物里没有 $INJECTED —— 页面会显示当前运行未知)"
fi
echo "DONE"