Files

243 lines
13 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
# 织忆 MemoryWeave 统一部署脚本 — build two + install two + restart two + verify
# 2026-10-08 · t_f57b8bbf · 根治「重启 zhiyid 不重启 sidecar → Rust 改部署后 sidecar 跑旧二进制静默故障」事故
#
# 用法:
# ./scripts/deploy.sh 完整部署: 构建两+装两+停两+启两+校验
# ./scripts/deploy.sh --verify-only 只跑校验(不构建/不安装/不重启)—— 反例测试/人工体检用
# ./scripts/deploy.sh --mark-approved 评审通过后把当前 HEAD 追加进已批准部署清单(独立动作,不构建)
# ./scripts/deploy.sh --allow-unreviewed 显式承担「构建范围含未评审提交」的风险(默认拒绝)
# make deploy = ./scripts/deploy.sh
#
# 🔴 部署前必须核对夹带(2026-10-17 t_3f83df64):构建基准 HEAD 相对 scripts/deploy-approved-commits.txt
# 批准清单之间的每个提交都须在清单内;不在 ⇒ 未评审 ⇒ 默认拒绝,需显式 --allow-unreviewed 或先 --mark-approved。
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN_DIR="${BIN_DIR:-/home/muc/bin}"
# --allow-dirty:显式承担「把在途改动编进生产二进制」的风险(默认拒绝)
ALLOW_DIRTY=0
# --allow-unreviewed:显式承担「把未经评审的提交编进生产」的风险(默认拒绝)
ALLOW_UNREVIEWED=0
# --mark-approved:把当前 HEAD 记录为「已批准部署基线」(评审通过后手动打点,独立动作,不构建)
MARK_APPROVED=0
for _a in "$@"; do
[ "$_a" = "--allow-dirty" ] && ALLOW_DIRTY=1
[ "$_a" = "--allow-unreviewed" ] && ALLOW_UNREVIEWED=1
[ "$_a" = "--mark-approved" ] && MARK_APPROVED=1
done
# 构建产物(持久路径,均已 gitignore)
BUILD_GO="$REPO_DIR/go/cmd/zhiyid/zhiyid"
BUILD_RUST="$REPO_DIR/rust/target/release/zhiyi-consolidate"
# 安装落点(与 ~/.config/systemd/user/*.service 的 ExecStart 一致)
INSTALL_GO="$BIN_DIR/zhiyid-new"
INSTALL_RUST="$BIN_DIR/zhiyi-consolidate"
# 服务名(用户级 systemd)
SVC_GO="zhiyid.service"
SVC_RUST="zhiyi-consolidate.service"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:7821/health}"
# 已批准部署清单文件(**tracked**,审批历史进 git,落盘可审):
# 每行一个「评审通过」的 commit SHA,追加式。见 preflight_reviewed_commits —— 部署夹带闸门的比对基准。
APPROVED_FILE="$REPO_DIR/scripts/deploy-approved-commits.txt"
log() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[deploy:FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
# 校验单个服务的运行态是否=本次构建的二进制
# 判据优先级(纪律): 副作用(PID/inode/校验和) > 返回值 > 自报
verify_service() {
local name="$1" build_path="$2" pid exe run_sha build_sha
# ① is-active
[ "$(systemctl --user is-active "$name")" = "active" ] || die "$name 非 active"
# ② 取 MainPID
pid="$(systemctl --user show -p MainPID --value "$name")"
[ -n "$pid" ] && [ "$pid" != "0" ] || die "$name: MainPID 为空/0"
# ③ 进程 exe 不得指向已删除 inode(旧二进制被 install 覆盖后、进程仍跑旧 inode → readlink 含 "(deleted)")
exe="$(readlink "/proc/$pid/exe" 2>/dev/null || true)"
case "$exe" in
"") die "$name: 无法 readlink /proc/$pid/exe" ;;
*" (deleted)"*) die "$name: 进程仍跑已删除 inode → 二进制被换过但进程没重启 ($exe)" ;;
esac
# ④ 运行中二进制校验和 == 本次构建产物(抓「磁盘装了新版但进程仍跑旧版」静默故障)
run_sha="$(sha256sum "/proc/$pid/exe" 2>/dev/null | awk '{print $1}')"
build_sha="$(sha256sum "$build_path" 2>/dev/null | awk '{print $1}')"
[ -n "$run_sha" ] || die "$name: 无法读取运行中二进制"
[ "$run_sha" = "$build_sha" ] || die "$name: 运行中二进制 ≠ 本次构建产物 (运行=$run_sha 构建=$build_sha) → sidecar 静默跑旧版"
log "✔ $name active · PID=$pid · exe=$exe · 校验和匹配本次构建"
}
# 深健康检查: /health 连 sidecar 可达性(P0 已做,故障返 503)→ 期望 200
verify_health() {
local code
code="$(curl -s -o /dev/null -w '%{http_code}' -m 5 "$HEALTH_URL")"
[ "$code" = "200" ] || die "/health 返回 $code (期望 200) → zhiyid 或 sidecar 故障"
log "✔ /health = $code"
}
# 🔴 构建前洁净检查(2026-10-14 加)。起因(当晚三次同族事故):
# ① c345ed5 把另一任务在途的 server.go 布线一起提交 ⇒ HEAD 直接编译不过
# ② 本脚本原先**直接从工作树构建** ⇒ 任一 worker 的在途改动会被顺手编进**生产二进制**,
# 而且部署后「运行二进制校验和匹配」会通过 —— 校验和只能证明「跑的是本次构建」,
# **不能证明「本次构建来自已提交的代码」**。这是验证链上的一个洞。
# 判据:`git status --porcelain`(含未跟踪,不含已忽略)必须为空。
# 未跟踪文件也要拦:`internal/peer` 那类事故正是「已提交代码引用了未跟踪文件」——
# 反之未跟踪的新源文件被编进去,二进制就与 commit 不一致。
preflight_clean_tree() {
if [ "$ALLOW_DIRTY" = "1" ]; then
log "⚠️ --allow-dirty:跳过洁净检查(本次构建含未提交改动,风险自负)"
return 0
fi
local dirty
dirty="$(git -C "$REPO_DIR" status --porcelain 2>/dev/null || true)"
if [ -n "$dirty" ]; then
printf '%s\n' "$dirty" | sed 's/^/ /' >&2
die "工作树不洁净 → 拒绝构建(会把在途改动编进生产二进制,部署后校验和仍会通过,无法事后察觉)。
处置:先提交/收工;确需带脏部署则显式加 --allow-dirty 并在卡上记录原因。"
fi
log "✔ 洁净检查通过(含未跟踪文件:无残留)"
}
# 🔴 部署夹带闸门(2026-10-17 t_3f83df64 加)。起因:01:18 生产二进制被换成 e3dd0f6e487f,
# 而 t_fa6c9104(744ebb8) 到 03:03 才评审通过 —— 该提交在**未评审时**已进生产(搭了
# t_c661dd17「部署 fa0880d」的顺风车)。「按基准打包、夹带未批准提交」是反复发生的机制缺陷。
# 判据:本次构建基准 HEAD 相对已批准清单(scripts/deploy-approved-commits.txt,每行一个已批准 commit SHA)
# 之间的**每一个提交**都必须在批准清单内;任何不在清单内的提交进入构建范围 ⇒ 未评审 ⇒ 告警并
# **要求显式确认**,不得静默通过。显式确认 = --allow-unreviewed(承担风险)或先 --mark-approved
# 把 HEAD 追加进批准清单(评审通过后打点)。
preflight_reviewed_commits() {
if [ "$ALLOW_UNREVIEWED" = "1" ]; then
log "⚠️ --allow-unreviewed:跳过评审夹带检查(本次含未评审提交,风险自负)"
return 0
fi
local head approved unreviewed n
head="$(git -C "$REPO_DIR" rev-parse HEAD 2>/dev/null || true)"
[ -n "$head" ] || die "无法解析 HEAD($REPO_DIR 不是有效 git 仓库?)"
if [ ! -f "$APPROVED_FILE" ]; then
die "无已批准部署清单($APPROVED_FILE 不存在)→ 拒绝构建。\n 处置:① 先 `./scripts/deploy.sh --mark-approved` 把当前 HEAD 追加进批准清单(评审通过后打点);\n ② 确需跳过请显式加 --allow-unreviewed 并在卡上记录原因。"
fi
approved="$(sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' "$APPROVED_FILE")"
[ -n "$approved" ] || die "已批准部署清单为空:$APPROVED_FILE"
# 清单里的 SHA 必须都仍存在于仓库(被 reset/rebase 抹掉的批准提交不可复核)
local bad
bad="$(printf '%s\n' "$approved" | while read -r a; do
[ -n "$a" ] && git -C "$REPO_DIR" cat-file -e "$a^{commit}" 2>/dev/null || echo "$a"
done)"
[ -z "$bad" ] || die "批准清单含仓库中不存在的 commit:$(printf '%s\n' "$bad" | tr '\n' ' ')\n 可能被 reset/rebase 抹掉 → 拒绝构建,需人工复核批准清单。"
# 构建范围 = HEAD 相对清单中一个已批准**祖先**提交的推进量(取列表序首祖先,窗口偏保守=安全)
latest="$(printf '%s\n' "$approved" | while read -r a; do
git -C "$REPO_DIR" merge-base --is-ancestor "$a" "$head" 2>/dev/null && { echo "$a"; break; }
done)"
if [ -z "$latest" ]; then
# 没有任何批准提交是 HEAD 的祖先 ⇒ HEAD 之前全是未评审 ⇒ 除非 HEAD 恰在某批准提交上
if printf '%s\n' "$approved" | grep -qx "$head"; then
log "✔ 评审夹带检查通过(HEAD=$head 已在批准清单内)"
return 0
fi
unreviewed="$(printf '%s\n' "$head")"
else
unreviewed="$(git -C "$REPO_DIR" rev-list "$latest..$head" 2>/dev/null || true)"
[ -z "$unreviewed" ] && { log "✔ 评审夹带检查通过(HEAD=$head 已在批准清单内)"; return 0; }
fi
# 过滤:已在批准清单里的提交不算未评审
unreviewed="$(printf '%s\n' "$unreviewed" | while read -r c; do
printf '%s\n' "$approved" | grep -qx "$c" || echo "$c"
done | sed '/^$/d')"
if [ -z "$unreviewed" ]; then
log "✔ 评审夹带检查通过(HEAD 范围内提交均已在批准清单内)"
return 0
fi
n="$(printf '%s\n' "$unreviewed" | wc -l | tr -d ' ')"
echo "⚠️ 构建范围含 $n 个**未评审**提交(不在已批准部署清单 $APPROVED_FILE 内):" >&2
printf '%s\n' "$unreviewed" | sed 's/^/ /' >&2
die "拒绝构建:构建范围夹带未评审提交。\n 处置:① 先评审这批提交,再 `./scripts/deploy.sh --mark-approved` 把 HEAD 追加进批准清单;\n ② 确需带未评审提交部署则显式加 --allow-unreviewed 并在卡上记录原因。"
}
do_build() {
preflight_reviewed_commits
preflight_clean_tree
log "构建 Go → $BUILD_GO"
( cd "$REPO_DIR/go" && CGO_ENABLED=1 go build -o cmd/zhiyid/zhiyid ./cmd/zhiyid )
log "构建 Rust → $BUILD_RUST"
( cd "$REPO_DIR/rust" && cargo build --release )
}
do_install() {
log "停止 $SVC_GO + $SVC_RUST"
systemctl --user stop "$SVC_GO" || true
systemctl --user stop "$SVC_RUST" || true
# 用 install 而非 cp:install 先写临时再 rename,避免 Text file busy(进程占着旧 inode 时 cp 会 EBUSY)
log "install 二进制(不用 cp,避免 Text file busy)"
install -m 0755 "$BUILD_GO" "$INSTALL_GO"
install -m 0755 "$BUILD_RUST" "$INSTALL_RUST"
log "daemon-reload"
systemctl --user daemon-reload
log "启动 $SVC_GO + $SVC_RUST"
systemctl --user start "$SVC_GO"
systemctl --user start "$SVC_RUST"
sleep 2
}
# bi-temporal e2e 闸门(2026-10-14 加):
# 为什么:`bitemporal_e2e_test.go` 默认 `t.Skip`(需 sidecar),
# 若无人设 ZHIYI_BITEMPORAL_SOCK ⇒ **闸门永不开火** ⇒ 等于不存在。
# 机制铁律「写完无人调用 = 零效力」⇒ 接进部署流程:每次部署后自动跑一遍。
# 跑法:用**本次已安装的** sidecar 开一个临时 socket + 临时 data-dir(不碰生产库),
# 跑 Go e2e(真实 LanceDB 写→读回)。失败即 die —— 这正是 2026-10-14
# 「只改 Go 不改 Rust ⇒ 三列在 LanceDB 边界静默丢 ⇒ 生产 no-op」的守门人。
verify_e2e_bitemporal() {
local sock=/tmp/mw-deploy-bit.sock data=/tmp/mw-deploy-bitdata log=/tmp/mw-deploy-bit.log spid rc
rm -rf "$data" "$sock"; mkdir -p "$data"
"$INSTALL_RUST" --mode socket --socket "$sock" --data-dir "$data" >"$log" 2>&1 &
spid=$!
for _ in $(seq 1 30); do [ -S "$sock" ] && break; sleep 0.5; done
if [ ! -S "$sock" ]; then
kill "$spid" 2>/dev/null
die "bi-temporal 闸门:临时 sidecar 未能监听 $sock(见 $log)"
fi
( cd "$REPO_DIR/go" && CGO_ENABLED=1 ZHIYI_BITEMPORAL_SOCK="$sock" \
go test -run TestBitemporalE2E ./internal/storage/ >/tmp/mw-deploy-bit-gotest.log 2>&1 )
rc=$?
kill "$spid" 2>/dev/null
for _ in $(seq 1 5); do kill -0 "$spid" 2>/dev/null || break; sleep 1; done
kill -0 "$spid" 2>/dev/null && kill -9 "$spid" 2>/dev/null
if [ "$rc" -ne 0 ]; then
sed 's/^/ /' /tmp/mw-deploy-bit-gotest.log | tail -15 >&2
die "bi-temporal 端到端闸门 FAIL(exit=$rc)—— 三列在 LanceDB 真实写→读回环断了。
典型根因:只改 Go payload 未改 Rust schema / 只改 Rust 未改 payload。"
fi
log "✔ bi-temporal e2e 闸门通过(三列经真实 LanceDB 写→读回 完整往返)"
}
do_verify() {
log "校验 ..."
verify_service "$SVC_GO" "$BUILD_GO"
verify_service "$SVC_RUST" "$BUILD_RUST"
verify_health
verify_e2e_bitemporal
log "✅ 部署成功: 两服务 active · 进程无 (deleted) · 运行二进制=本次构建 · /health=200 · e2e 闸门通过"
}
if [ "${1:-}" = "--verify-only" ]; then
do_verify
exit 0
fi
# --mark-approved:独立动作(不构建/不安装/不重启)—— 评审通过后把 HEAD 追加进已批准部署清单
if [ "$MARK_APPROVED" = "1" ]; then
head="$(git -C "$REPO_DIR" rev-parse HEAD)"
touch "$APPROVED_FILE"
if grep -qx "$head" "$APPROVED_FILE"; then
log "✔ HEAD $head 已在批准清单中($APPROVED_FILE),无需追加"
else
printf '%s\n' "$head" >> "$APPROVED_FILE"
log "✔ 已追加批准部署 commit = $head(写入 $APPROVED_FILE)"
fi
log " 后续 deploy.sh 默认拒绝构建范围含未批准提交;越界部署需 --allow-unreviewed 或先 --mark-approved"
exit 0
fi
do_build
do_install
do_verify