#!/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