Files
knowledge-governance/scripts/library-kill-takeover.sh
T
姚章浩 ed307f3c20 feat(scripts): 真中断演练脚本改成父可直接执行
旧 kill-takeover.sh 的判据自相矛盾:要求"作业已 running 且 ledger 齐"才启动,
可它又要先启动再等作业;维护窗口里要求业务 health 200(那时 app 是被有意停掉
的);没杀到任何进程也 exit 0。

这一版把"做成"定义清楚:真 SIGKILL 真容器 → 同一作业被接管 → 目标按期望内容
恢复 → 服务/入口恢复,四条全成立才 exit 0;missed/超时/接管失败各有非零退出码
并写成 not_run。流程:前置体检(目标拓扑、无在飞作业、无未释放维护窗口、镜像
里真的带屏障代码、只允许本机栈)→ 注入屏障 → ARMED 后由人在页面发起作业 →
看到 .hit 锁住这一个作业 → 读回写集合台账(文件名/自述摘要/作业绑定,可选独立
校验器)→ SIGKILL → 等同一作业接管 → 核对 prebackup:skipped、生效版本、写集合
摘要不变、目标计数、维护释放与容器恢复 → 撤掉注入。每一步有界,不做后台 sleep
轮询假装工作。
2026-10-30 03:09:33 +08:00

544 lines
28 KiB
Bash
Executable File
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
# library-kill-takeover.sh —— 真实 worker 中断演练(只在本机测试栈)
#
# 旧脚本(handoffs/release-reuse-18683ca/kill-takeover.sh)为什么不能用:
# 1. 前置要求"作业已 running 且 ledger 齐"——可它又要先启动观察,永远错过;
# 2. 维护窗口里要求业务 health 200——那时 app 被有意停掉,必然被拒;
# 3. 没杀到任何进程也 exit 0——"没做"和"做成"分不开。
#
# 这一版的判据只有一条:**真的 SIGKILL 了真实 worker 容器 → 同一个作业被接管 →
# 目标按期望内容恢复并校验 → 服务/入口恢复**,四条全成立才 exit 0。
# missed / notrun / timeout 一律非 0,并在 evidence 里写成 not_run 而不是"通过"。
#
# 命中手段是"固定同步屏障"(internal/testbarrier,默认关闭,仅本机注入):
# worker 在指定阶段写完后写 <barrier>/after-database.hit 并停住 → 本脚本看到 .hit
# → SIGKILL 容器 → 容器按 restart 策略重启 → 启动/周期扫描接管同一作业。
# 屏障目录在本机归档根下(<archive>/barrier),没人放行就 300s 自行继续。
#
# 用法:
# bash scripts/library-kill-takeover.sh --check # 前置体检(不是演练通过)
# bash scripts/library-kill-takeover.sh --run --scope internal --confirm-kill
# → 打印 ARMED 后,父在页面上发起同步作业;脚本自己命中、杀、验证、收尾
# bash scripts/library-kill-takeover.sh --restore # 撤掉屏障注入
# 可选:--job <业务作业 id>|--since <RFC3339|unix>|--expect "knowledges=4,chunks=38,..."
#
set -uo pipefail
SCRIPT_VERSION=2
MODE=""
SCOPE="internal"
EXPECT_JOB=""
SINCE=""
CONFIRM_KILL=0
EXPECT_COUNTS="${EXPECT_COUNTS:-knowledges=4,chunks=38,resources=22,wiki_pages=31}"
VERIFIER="${WRITESET_VERIFIER:-$HOME/.hermes/handoffs/release-current-version-takeover/verify-writeset}"
WORKER_CONTAINER="${WORKER_CONTAINER:-knowledge-governance-p1-worker-1}"
GOV_DB_CONTAINER="${GOV_DB_CONTAINER:-knowledge-governance-p1-postgres-1}"
GOV_DB_USER="${GOV_DB_USER:-governance}"
GOV_DB_NAME="${GOV_DB_NAME:-governance}"
COMPOSE_PROJECT="${COMPOSE_PROJECT:-knowledge-governance-p1}"
COMPOSE_SERVICE="${COMPOSE_SERVICE:-worker}"
BARRIER_POINT="${BARRIER_POINT:-after:database}"
BARRIER_TIMEOUT="${BARRIER_TIMEOUT:-300s}"
HIT_WAIT_S="${HIT_WAIT_S:-900}"
RESTART_WAIT_S="${RESTART_WAIT_S:-180}"
TAKEOVER_WAIT_S="${TAKEOVER_WAIT_S:-420}"
DRILL_HOME="${DRILL_HOME:-$HOME/.local/share/knowledge-governance/drills}"
while [ $# -gt 0 ]; do
case "$1" in
--check) MODE=check ;;
--run) MODE=run ;;
--restore) MODE=restore ;;
--scope) SCOPE="${2:-}"; shift ;;
--job) EXPECT_JOB="${2:-}"; shift ;;
--since) SINCE="${2:-}"; shift ;;
--expect) EXPECT_COUNTS="${2:-}"; shift ;;
--point) BARRIER_POINT="${2:-}"; shift ;;
--confirm-kill) CONFIRM_KILL=1 ;;
-h|--help) sed -n '1,30p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 64 ;;
esac
shift
done
if [ -z "$MODE" ]; then
echo "must pass one of --check | --run | --restore" >&2
exit 64
fi
# set -u:所有跨函数使用的变量先给默认值
WORKER_IMAGE_ID=""
WORKER_IMAGE=""
ARCHIVE_ROOT_IN_CONTAINER=""
ARCHIVE_HOST=""
BARRIER_HOST=""
TARGET_INSTANCE=""
TARGET_PROJECT=""
TARGET_INGRESS_SERVICE=""
TARGET_WRITERS=""
INGRESS_CONTAINER=""
TARGET_DB_CONTAINER=""
TARGET_DB_USER=""
TARGET_DB_NAME=""
HIT_SUBJECT=""
JOB_ID=""
JOB_STATE=""
JOB_SCOPE=""
JOB_KIND=""
JOB_SNAPSHOT=""
JOB_TARGET=""
JOB_CREATED=""
WS_DIGEST=""
WS_REUSED=""
WS_TARGET=""
WS_BACKUP=""
WS_FILE=""
KILLED_AT=""
STATUS_OVERRIDE=""
COMPOSE_ARGS=()
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
EVIDENCE="$DRILL_HOME/$STAMP"
mkdir -p "$EVIDENCE" || { echo "cannot create evidence dir $EVIDENCE" >&2; exit 70; }
CHECKS="$EVIDENCE/checks.tsv"
: >"$CHECKS"
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$EVIDENCE/phases.log"; }
# record <name> <pass|fail|not_run> <detail>
record() {
printf '%s\t%s\t%s\n' "$1" "$2" "${3:-}" >>"$CHECKS"
log "CHECK $1 = $2${3:+ ($3)}"
}
die() { # die <code> <name> <detail>
record "$2" fail "$3"
log "ABORT: $2 — $3"
finish "$1"
}
# ---------- 小工具 ----------
worker_env() { # 只读非敏感变量
docker inspect "$WORKER_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null |
sed -n "s/^$1=//p" | head -1
}
gov_sql() { docker exec "$GOV_DB_CONTAINER" psql -U "$GOV_DB_USER" -d "$GOV_DB_NAME" -Atc "$1" 2>>"$EVIDENCE/psql.log"; }
container_field() { docker inspect "$1" --format "$2" 2>/dev/null; }
archive_root_host() {
docker inspect "$WORKER_CONTAINER" --format '{{range .Mounts}}{{.Destination}}={{.Source}}{{println}}{{end}}' 2>/dev/null |
sed -n "s#^$1=##p" | head -1
}
container_of_service() { # <compose project> <service>
docker ps -a --filter "label=com.docker.compose.project=$1" \
--format '{{.Names}}|{{.Label "com.docker.compose.service"}}' | awk -F'|' -v svc="$2" '$2==svc{print $1; exit}'
}
health_of() {
local status
status="$(docker inspect "$1" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no_healthcheck{{end}}' 2>/dev/null)"
echo "${status:-unknown}"
}
url_part() { # <url> <user|host|db>
case "$2" in
user) sed -E 's#^[a-zA-Z0-9+]+://([^:/@]+).*#\1#' <<<"$1" ;;
host) sed -E 's#^[a-zA-Z0-9+]+://[^@]*@([^:/]+).*#\1#' <<<"$1" ;;
db) sed -E 's#^[a-zA-Z0-9+]+://[^/]+/([^?]+).*#\1#' <<<"$1" ;;
*) echo "" ;;
esac
}
now_epoch() { date -u +%s; }
utc_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
finish() { # finish <exit code>
local code="$1" status="PASS"
if grep -q $'\tfail\t' "$CHECKS" 2>/dev/null; then status="FAIL"; fi
if grep -q $'\tnot_run\t' "$CHECKS" 2>/dev/null && [ "$code" -eq 0 ]; then status="FAIL"; fi
[ -n "$STATUS_OVERRIDE" ] && status="$STATUS_OVERRIDE"
cp "$CHECKS" "$EVIDENCE/checks.snapshot.tsv" 2>/dev/null
cat >"$EVIDENCE/summary.json" <<JSON
{
"script_version": $SCRIPT_VERSION,
"mode": "$MODE",
"status": "$status",
"exit_code": $code,
"scope": "$SCOPE",
"worker_container": "$WORKER_CONTAINER",
"worker_image": "$WORKER_IMAGE",
"barrier_point": "$BARRIER_POINT",
"job": "$JOB_ID",
"killed_at": "$KILLED_AT",
"writeset_digest": "$WS_DIGEST",
"evidence_dir": "$EVIDENCE"
}
JSON
log "SUMMARY status=$status exit=$code evidence=$EVIDENCE"
if [ "$MODE" = "check" ] && [ "$status" = "READY_FOR_PARENT" ]; then
log "READY_FOR_PARENT(只是前置体检通过,不是演练通过)"
fi
exit "$code"
}
# ---------- 前置体检(--check 与 --run 共用) ----------
preflight() {
command -v docker >/dev/null 2>&1 || die 2 preflight_docker "docker 命令不可用"
docker info >/dev/null 2>&1 || die 2 preflight_docker "docker daemon 不可用"
docker compose version >/dev/null 2>&1 || die 2 preflight_docker "docker compose 不可用"
record preflight_docker pass "docker + compose 可用"
docker inspect "$WORKER_CONTAINER" >/dev/null 2>&1 || die 2 preflight_worker "worker 容器不存在:$WORKER_CONTAINER"
WORKER_IMAGE_ID="$(container_field "$WORKER_CONTAINER" '{{.Image}}')"
WORKER_IMAGE="$(container_field "$WORKER_CONTAINER" '{{.Config.Image}}')"
local running
running="$(container_field "$WORKER_CONTAINER" '{{.State.Running}}')"
[ "$running" = "true" ] || die 2 preflight_worker "worker 没在运行(先把它起回来)"
record preflight_worker pass "image=$WORKER_IMAGE id=$WORKER_IMAGE_ID running=$running"
# 仅本机:worker 连的治理库必须就是本机这个容器(拒绝把屏障注入到远程栈)。
local gov_host
gov_host="$(url_part "$(worker_env DATABASE_URL)" host)"
[ "$gov_host" = "$GOV_DB_CONTAINER" ] || die 2 preflight_local_only \
"worker 连的治理库是 ${gov_host:-?},不是本机 $GOV_DB_CONTAINER:拒绝在非本机栈注入屏障"
[ "$(gov_sql 'select 1')" = "1" ] || die 2 preflight_gov_db "治理库读不到($GOV_DB_CONTAINER/$GOV_DB_NAME)"
record preflight_local_only pass "governance db=$GOV_DB_CONTAINER"
ARCHIVE_ROOT_IN_CONTAINER="$(worker_env LIBRARY_ARCHIVE_ROOT)"
[ -n "$ARCHIVE_ROOT_IN_CONTAINER" ] || die 2 preflight_archive "worker 没有 LIBRARY_ARCHIVE_ROOT"
ARCHIVE_HOST="$(archive_root_host "$ARCHIVE_ROOT_IN_CONTAINER")"
{ [ -n "$ARCHIVE_HOST" ] && [ -d "$ARCHIVE_HOST" ]; } || die 2 preflight_archive "归档根在宿主机上找不到(容器内 $ARCHIVE_ROOT_IN_CONTAINER)"
[ -d "$ARCHIVE_HOST/writesets" ] || die 2 preflight_archive "归档根下没有 writesets/:台账不在预期位置"
[ -w "$ARCHIVE_HOST" ] || die 2 preflight_archive "归档根宿主机路径不可写:$ARCHIVE_HOST"
BARRIER_HOST="$ARCHIVE_HOST/barrier"
record preflight_archive pass "archive=$ARCHIVE_HOST barrier=$BARRIER_HOST"
TARGET_INSTANCE="$(worker_env LIBRARY_TARGET_INSTANCE)"
TARGET_PROJECT="$(worker_env LIBRARY_TARGET_PROJECT)"
TARGET_INGRESS_SERVICE="$(worker_env LIBRARY_TARGET_INGRESS_SERVICE)"
TARGET_WRITERS="$(worker_env LIBRARY_TARGET_WRITERS)"
[ -n "$TARGET_INSTANCE" ] || die 2 preflight_topology "worker 没有 LIBRARY_TARGET_INSTANCE(维护目标未接线)"
[ -n "$TARGET_PROJECT" ] || die 2 preflight_topology "worker 没有 LIBRARY_TARGET_PROJECT"
docker inspect "$TARGET_INSTANCE" >/dev/null 2>&1 || die 2 preflight_topology "目标写入者容器缺失:$TARGET_INSTANCE"
INGRESS_CONTAINER="$(container_of_service "$TARGET_PROJECT" "$TARGET_INGRESS_SERVICE")"
[ -n "$INGRESS_CONTAINER" ] || die 2 preflight_topology "目标项目 $TARGET_PROJECT 下找不到入口服务 $TARGET_INGRESS_SERVICE"
local instance_running
instance_running="$(container_field "$TARGET_INSTANCE" '{{.State.Running}}')"
[ "$instance_running" = "true" ] || die 2 preflight_topology \
"目标写入者 $TARGET_INSTANCE 没在运行:先按预备份恢复并收尾,再来演练"
record preflight_topology pass "instance=$TARGET_INSTANCE running=$instance_running project=$TARGET_PROJECT writers=$TARGET_WRITERS ingress=$INGRESS_CONTAINER"
local held held_instance
held="$(gov_sql "select count(*) from library_maintenance where released_at is null")"
held_instance="$(gov_sql "select count(*) from library_instance_maintenance where released_at is null")"
if [ "$held" != "0" ] || [ "$held_instance" != "0" ]; then
local owner
owner="$(gov_sql "select coalesce(owner_job_id,'?') from library_maintenance where released_at is null limit 1")"
die 2 preflight_no_open_window "已有未释放的维护窗口(owner=${owner:-?} scope=$held instance=$held_instance):先恢复/收尾"
fi
record preflight_no_open_window pass "no unreleased maintenance"
local in_flight queued
in_flight="$(gov_sql "select coalesce(string_agg(id||'/'||state, ','),'') from library_jobs where state in ('pending','running')")"
[ -z "$in_flight" ] || die 2 preflight_no_inflight "已有在飞的整库作业:$in_flight(一次只演练一个作业)"
queued="$(gov_sql "select count(*) from jobs where type like 'library.%' and state in ('pending','running','retry')")"
[ "$queued" = "0" ] || die 2 preflight_no_inflight "队列里还有 $queued 个 library.* 作业在飞/排队"
record preflight_no_inflight pass "no in-flight library jobs"
# 镜像必须真的带屏障代码:只拷脚本不换镜像,等待会永远落空(旧脚本的病)。
local supported
supported="$(docker run --rm --entrypoint grep "$WORKER_IMAGE_ID" -ac KG_TEST_BARRIER_DIR /app/worker 2>/dev/null | tr -dc '0-9')"
[ "${supported:-0}" -ge 1 ] || die 2 preflight_barrier_supported \
"镜像 $WORKER_IMAGE 里没有屏障代码(grep KG_TEST_BARRIER_DIR=${supported:-0}):先部署带屏障的 worker 镜像"
record preflight_barrier_supported pass "image $WORKER_IMAGE contains barrier"
local target_db_url
target_db_url="$(worker_env LIBRARY_PRODUCTION_DATABASE_URL)"
TARGET_DB_CONTAINER="$(url_part "$target_db_url" host)"
TARGET_DB_USER="$(url_part "$target_db_url" user)"
TARGET_DB_NAME="$(url_part "$target_db_url" db)"
[ -n "$TARGET_DB_CONTAINER" ] || die 2 preflight_target_db "worker 没有 LIBRARY_PRODUCTION_DATABASE_URL:无法独立核对目标内容"
docker inspect "$TARGET_DB_CONTAINER" >/dev/null 2>&1 || die 2 preflight_target_db "目标库容器不存在:$TARGET_DB_CONTAINER"
record preflight_target_db pass "target db=$TARGET_DB_CONTAINER/$TARGET_DB_NAME user=$TARGET_DB_USER"
[ -z "$(worker_env KG_TEST_BARRIER_DIR)" ] || die 2 preflight_not_armed \
"worker 已经带着屏障注入在跑(上一轮没收尾):先 --restore"
record preflight_not_armed pass "barrier not armed"
if [ -n "$EXPECT_JOB" ]; then
case "$EXPECT_JOB" in
libsync-*|librestore-*|libbase-*) ;;
*) die 2 preflight_job_arg "--job 不是整库作业 id:$EXPECT_JOB" ;;
esac
fi
record preflight_done pass "OK"
}
# ---------- 屏障注入 / 撤销 ----------
compose_argv() { # <override or "">
local override="$1" cfg project_dir file
cfg="$(container_field "$WORKER_CONTAINER" '{{index .Config.Labels "com.docker.compose.project.config_files"}}')"
[ -n "$cfg" ] || { echo "worker 不是 compose 起的:找不到 config_files 标签" >&2; return 1; }
project_dir="$(container_field "$WORKER_CONTAINER" '{{index .Config.Labels "com.docker.compose.project.working_dir"}}')"
COMPOSE_ARGS=(-p "$COMPOSE_PROJECT")
[ -n "$project_dir" ] && COMPOSE_ARGS+=(--project-directory "$project_dir")
while IFS= read -r file; do
[ -n "$file" ] && COMPOSE_ARGS+=(-f "$file")
done < <(tr ',' '\n' <<<"$cfg")
[ -n "$override" ] && COMPOSE_ARGS+=(-f "$override")
return 0
}
write_override() {
cat >"$EVIDENCE/barrier.override.yaml" <<YAML
# 仅本机演练:给 worker 注入固定同步屏障(默认关闭;--restore 撤掉)
services:
$COMPOSE_SERVICE:
environment:
KG_TEST_BARRIER_DIR: "$ARCHIVE_ROOT_IN_CONTAINER/barrier"
KG_TEST_BARRIER_POINTS: "$BARRIER_POINT"
KG_TEST_BARRIER_TIMEOUT: "$BARRIER_TIMEOUT"
YAML
}
wait_worker_ready() {
local deadline=$(( $(now_epoch) + RESTART_WAIT_S ))
while [ "$(now_epoch)" -lt "$deadline" ]; do
if [ "$(container_field "$WORKER_CONTAINER" '{{.State.Running}}')" = "true" ] &&
docker logs --since 180s "$WORKER_CONTAINER" 2>&1 | grep -q "worker-1 started"; then
return 0
fi
sleep 1
done
return 1
}
arm_barrier() {
rm -rf "$BARRIER_HOST"
mkdir -p "$BARRIER_HOST" || die 2 arm_barrier "屏障目录建不出来:$BARRIER_HOST"
write_override
compose_argv "$EVIDENCE/barrier.override.yaml" || die 2 arm_barrier "compose 配置取不到"
log "recreating $WORKER_CONTAINER with barrier point=$BARRIER_POINT"
docker compose "${COMPOSE_ARGS[@]}" up -d --no-deps --force-recreate "$COMPOSE_SERVICE" >>"$EVIDENCE/compose.log" 2>&1 ||
die 2 arm_barrier "compose up 失败(见 compose.log)"
wait_worker_ready || die 2 arm_barrier "worker 重启后没有就绪"
local armed
armed="$(worker_env KG_TEST_BARRIER_DIR)"
[ "$armed" = "$ARCHIVE_ROOT_IN_CONTAINER/barrier" ] || die 2 arm_barrier "屏障注入没生效(KG_TEST_BARRIER_DIR=${armed:-空})"
record arm_barrier pass "dir=$armed point=$BARRIER_POINT timeout=$BARRIER_TIMEOUT"
}
restore_barrier() {
rm -rf "$BARRIER_HOST"
compose_argv "" || die 7 restore "compose 配置取不到"
docker compose "${COMPOSE_ARGS[@]}" up -d --no-deps --force-recreate "$COMPOSE_SERVICE" >>"$EVIDENCE/compose.log" 2>&1 ||
die 7 restore "compose up(撤销注入)失败"
wait_worker_ready || die 7 restore "worker 撤销注入后没有就绪"
[ -z "$(worker_env KG_TEST_BARRIER_DIR)" ] || die 7 restore "屏障注入还在(KG_TEST_BARRIER_DIR 非空)"
[ "$(container_field "$WORKER_CONTAINER" '{{.Image}}')" = "$WORKER_IMAGE_ID" ] || die 7 restore \
"撤销注入后镜像变了:$WORKER_IMAGE_ID -> $(container_field "$WORKER_CONTAINER" '{{.Image}}')"
record restore pass "barrier env removed, image unchanged"
}
# ---------- 命中、锁作业、取证 ----------
wait_for_hit() {
local base deadline
base="$BARRIER_HOST/$(sed 's/[:/ ]/-/g' <<<"$BARRIER_POINT")"
deadline=$(( $(now_epoch) + HIT_WAIT_S ))
log "ARMED: 现在请在页面上发起同步作业(scope=$SCOPE)。等待屏障点位 $BARRIER_POINT 命中 …"
while [ "$(now_epoch)" -lt "$deadline" ]; do
if [ -f "$base.timeout" ]; then
cp "$base.timeout" "$EVIDENCE/" 2>/dev/null
die 3 wait_for_hit "屏障超时(worker 已自行继续):这次没有停在写入中间,判 not_run"
fi
if [ -f "$base.hit" ]; then
cp "$base.hit" "$EVIDENCE/"
HIT_SUBJECT="$(sed -n 's/.*"subject":"\([^"]*\)".*/\1/p' "$base.hit")"
record wait_for_hit pass "hit=$BARRIER_POINT subject=${HIT_SUBJECT:-?}"
return 0
fi
sleep 1
done
die 3 wait_for_hit "等待屏障命中超时(${HIT_WAIT_S}s):作业没有到达 $BARRIER_POINT,判 not_run"
}
lock_job() {
JOB_ID="${EXPECT_JOB:-$HIT_SUBJECT}"
[ -n "$JOB_ID" ] || die 3 lock_job "屏障现场没有作业身份,也没有 --job"
if [ -n "$EXPECT_JOB" ] && [ "$HIT_SUBJECT" != "$EXPECT_JOB" ]; then
die 3 lock_job "屏障命中的是别的作业(hit=$HIT_SUBJECT 期望=$EXPECT_JOB):拒绝继续"
fi
local row others
row="$(gov_sql "select state||'|'||scope||'|'||kind||'|'||coalesce(snapshot_id,'')||'|'||coalesce(target_snapshot_id,'')||'|'||created_at::text from library_jobs where id='$JOB_ID'")"
[ -n "$row" ] || die 3 lock_job "治理库里没有作业 $JOB_ID"
IFS='|' read -r JOB_STATE JOB_SCOPE JOB_KIND JOB_SNAPSHOT JOB_TARGET JOB_CREATED <<<"$row"
[ "$JOB_SCOPE" = "$SCOPE" ] || die 3 lock_job "作业 scope=$JOB_SCOPE 与请求 $SCOPE 不一致"
[ "$JOB_STATE" = "running" ] || die 3 lock_job "作业状态是 $JOB_STATE 不是 running:命中时它已经不在写入路径上了"
if [ -n "$SINCE" ]; then
local since_epoch created_epoch
if [[ "$SINCE" =~ ^[0-9]+$ ]]; then since_epoch="$SINCE"; else since_epoch="$(date -u -d "$SINCE" +%s 2>/dev/null || echo 0)"; fi
created_epoch="$(date -u -d "$JOB_CREATED" +%s 2>/dev/null || echo 0)"
{ [ "$since_epoch" -gt 0 ] && [ "$created_epoch" -ge "$since_epoch" ]; } ||
die 3 lock_job "作业创建时间 $JOB_CREATED 不满足 --since $SINCE(或 --since 解析不了)"
fi
others="$(gov_sql "select coalesce(string_agg(id, ','),'') from library_jobs where state in ('pending','running') and id<>'$JOB_ID'")"
[ -z "$others" ] || die 3 lock_job "还有别的作业在飞:$others(只允许杀这一个作业的执行者)"
record lock_job pass "job=$JOB_ID state=running scope=$JOB_SCOPE kind=$JOB_KIND target=$JOB_TARGET created=$JOB_CREATED"
}
read_writeset_evidence() {
local row canonical="not_run"
row="$(gov_sql "select coalesce(evidence->>'writeset_digest','')||'|'||coalesce(evidence->>'writeset_reused_rows','')||'|'||coalesce(evidence->>'writeset_target_snapshot','')||'|'||coalesce(evidence->>'backup_snapshot','') from library_versions where scope='$SCOPE' and kind='production_prebackup' and source_job_id='$JOB_ID'")"
[ -n "$row" ] || die 4 read_writeset "作业 $JOB_ID 还没有预备份版本记录:屏障点位早于台账登记,不能当「写集合已登记」的证据"
IFS='|' read -r WS_DIGEST WS_REUSED WS_TARGET WS_BACKUP <<<"$row"
[ -n "$WS_DIGEST" ] || die 4 read_writeset "预备份记录里没有 writeset_digest:接管会失败,这次不算可接管"
[ "$WS_TARGET" = "$JOB_TARGET" ] || die 4 read_writeset "写集合绑定目标 $WS_TARGET 与作业目标 $JOB_TARGET 不一致"
WS_FILE="$ARCHIVE_HOST/writesets/$WS_DIGEST.json"
[ -s "$WS_FILE" ] || die 4 read_writeset "写集合文件不存在或为空:$WS_FILE"
grep -q "\"digest\":\"$WS_DIGEST\"" "$WS_FILE" || die 4 read_writeset "写集合文件自述摘要与登记不一致($WS_DIGEST)"
grep -q "\"job_id\":\"$JOB_ID\"" "$WS_FILE" || die 4 read_writeset "写集合文件不属于作业 $JOB_ID"
if [ -e "$VERIFIER" ]; then
"$VERIFIER" -archive-root "$ARCHIVE_HOST" -digest "$WS_DIGEST" -job "$JOB_ID" -scope "$SCOPE" \
>>"$EVIDENCE/verifier.log" 2>&1 || die 4 read_writeset "独立校验器判定写集合摘要不符(见 verifier.log)"
canonical="verified"
fi
record read_writeset pass "digest=$WS_DIGEST reused=${WS_REUSED:-0} backup=$WS_BACKUP canonical=$canonical"
}
kill_worker() {
[ "$CONFIRM_KILL" -eq 1 ] || die 4 kill "缺少 --confirm-kill:杀容器必须是显式动作"
local before_restart job_state deadline exit_code=""
before_restart="$(container_field "$WORKER_CONTAINER" '{{.RestartCount}}')"
job_state="$(gov_sql "select state from library_jobs where id='$JOB_ID'")"
[ "$job_state" = "running" ] || die 4 kill "杀之前作业状态变成 $job_state:这次不构成写入中的中断"
KILLED_AT="$(utc_now)"
log "SIGKILL $WORKER_CONTAINER (job=$JOB_ID at $KILLED_AT)"
docker kill --signal=KILL "$WORKER_CONTAINER" >>"$EVIDENCE/docker.log" 2>&1 || die 4 kill "docker kill 失败"
deadline=$(( $(now_epoch) + 30 ))
while [ "$(now_epoch)" -lt "$deadline" ]; do
exit_code="$(container_field "$WORKER_CONTAINER" '{{.State.ExitCode}}')"
[ "$exit_code" = "137" ] && break
sleep 1
done
[ "$exit_code" = "137" ] || die 4 kill "容器退出码是 ${exit_code:-?},不是 137(SIGKILL)"
record kill pass "container=$WORKER_CONTAINER exit=137 killed_at=$KILLED_AT restart_before=$before_restart"
}
wait_takeover() {
local deadline state="" restart_after
deadline=$(( $(now_epoch) + TAKEOVER_WAIT_S ))
log "等待同一个作业被接管(lease 30s + 扫描 30s,上限 ${TAKEOVER_WAIT_S}s)…"
while [ "$(now_epoch)" -lt "$deadline" ]; do
state="$(gov_sql "select state from library_jobs where id='$JOB_ID'")"
case "$state" in succeeded|failed|cancelled) break ;; esac
sleep 2
done
case "$state" in
succeeded) ;;
failed|cancelled) die 5 wait_takeover "作业终态是 $state(接管失败):$(gov_sql "select coalesce(error,'') from library_jobs where id='$JOB_ID'")" ;;
*) die 5 wait_takeover "等待接管超时(最后状态 ${state:-?}):不算成功" ;;
esac
restart_after="$(container_field "$WORKER_CONTAINER" '{{.RestartCount}}')"
record wait_takeover pass "same job succeeded; RestartCount=$restart_after"
}
verify_takeover_semantics() {
local stages finalize_at eff after_digest
stages="$(gov_sql "select coalesce(string_agg(stage||':'||state, ',' order by updated_at),'') from library_job_stages where job_id='$JOB_ID'")"
grep -q "prebackup:skipped" <<<"$stages" || die 6 verify_takeover \
"阶段里没有 prebackup:skipped:无法证明走的是接管路径(stages=$stages)"
finalize_at="$(gov_sql "select coalesce(max(updated_at)::text,'') from library_job_stages where job_id='$JOB_ID' and stage='finalize' and state='succeeded'")"
[ -n "$finalize_at" ] || die 6 verify_takeover "finalize 没有成功(stages=$stages)"
record verify_takeover pass "stages=$stages finalize_at=$finalize_at"
eff="$(gov_sql "select coalesce(snapshot_id,'')||'|'||coalesce(source_job_id,'')||'|'||coalesce(superseded_at::text,'') from library_versions where scope='$SCOPE' and kind='production_effective' and superseded_at is null")"
local eff_snapshot eff_job eff_superseded
IFS='|' read -r eff_snapshot eff_job eff_superseded <<<"$eff"
[ "$eff_snapshot" = "$JOB_TARGET" ] || die 6 verify_effective "当前生效版本是 ${eff_snapshot:-无},不是本作业目标 $JOB_TARGET"
[ "$eff_job" = "$JOB_ID" ] || die 6 verify_effective "生效版本来源作业是 ${eff_job:-无},不是 $JOB_ID"
[ -z "$eff_superseded" ] || die 6 verify_effective "生效版本已被顶替($eff_superseded)"
after_digest="$(gov_sql "select coalesce(evidence->>'writeset_digest','') from library_versions where scope='$SCOPE' and kind='production_prebackup' and source_job_id='$JOB_ID'")"
[ "$after_digest" = "$WS_DIGEST" ] || die 6 verify_effective "接管后写集合摘要变了($WS_DIGEST -> ${after_digest:-空})"
record verify_effective pass "effective=$eff_snapshot job=$JOB_ID writeset=$after_digest"
}
verify_target_counts() {
local part table want got sql="" failed=0 detail=""
for part in ${EXPECT_COUNTS//,/ }; do
table="${part%%=*}"
sql+="select '$table='||count(*) from $table; "
done
got="$(docker exec "$TARGET_DB_CONTAINER" psql -U "$TARGET_DB_USER" -d "$TARGET_DB_NAME" -Atc "$sql" 2>>"$EVIDENCE/psql.log")"
[ -n "$got" ] || die 6 verify_counts "目标库读数失败($TARGET_DB_CONTAINER/$TARGET_DB_NAME)"
for part in ${EXPECT_COUNTS//,/ }; do
table="${part%%=*}"; want="${part##*=}"
if ! grep -qx "$table=$want" <<<"$got"; then
failed=$((failed + 1))
detail+="$table=$(sed -n "s/^$table=//p" <<<"$got")(want $want) "
fi
done
[ "$failed" -eq 0 ] || die 6 verify_counts "目标内容与期望不符:$detail(实际 $(tr '\n' ' ' <<<"$got"))"
record verify_counts pass "counts=$(tr '\n' ' ' <<<"$got")"
}
verify_service_restored() {
local instance_running ingress_running held held_instance released released_instance
instance_running="$(container_field "$TARGET_INSTANCE" '{{.State.Running}}')"
ingress_running="$(container_field "$INGRESS_CONTAINER" '{{.State.Running}}')"
[ "$instance_running" = "true" ] || die 6 verify_service "目标写入者 $TARGET_INSTANCE 没有恢复运行"
[ "$ingress_running" = "true" ] || die 6 verify_service "入口 $INGRESS_CONTAINER 没有恢复运行"
held="$(gov_sql "select count(*) from library_maintenance where released_at is null")"
held_instance="$(gov_sql "select count(*) from library_instance_maintenance where released_at is null")"
released="$(gov_sql "select count(*) from library_maintenance where owner_job_id='$JOB_ID' and released_at is not null")"
released_instance="$(gov_sql "select count(*) from library_instance_maintenance where owner_job_id='$JOB_ID' and released_at is not null")"
{ [ "$held" = "0" ] && [ "$held_instance" = "0" ]; } || die 6 verify_service \
"还有未释放的维护占用(scope=$held instance=$held_instance)"
{ [ "$released" -ge 1 ] && [ "$released_instance" -ge 1 ]; } || die 6 verify_service \
"本作业的维护记录没有释放证据(scope=$released instance=$released_instance)"
# 业务 health 只在服务已恢复时作为附加证据:维护期间 app 被停是预期,不作为拒绝理由。
record verify_service pass "instance_running=$instance_running ingress_running=$ingress_running health(instance)=$(health_of "$TARGET_INSTANCE") health(ingress)=$(health_of "$INGRESS_CONTAINER") released=$released/$released_instance"
}
save_evidence() {
{
echo "# 脱敏证据(job=$JOB_ID scope=$SCOPE killed_at=$KILLED_AT)"
echo "## library_jobs"
gov_sql "select id||E'\t'||scope||E'\t'||kind||E'\t'||state||E'\t'||stage||E'\t'||coalesce(snapshot_id,'')||E'\t'||coalesce(target_snapshot_id,'')||E'\t'||created_at::text||E'\t'||coalesce(finished_at::text,'') from library_jobs where id='$JOB_ID'"
echo "## library_job_stages"
gov_sql "select stage||E'\t'||state||E'\t'||coalesce(total::text,'')||E'\t'||coalesce(completed::text,'')||E'\t'||coalesce(detail,'')||E'\t'||updated_at::text from library_job_stages where job_id='$JOB_ID' order by updated_at"
echo "## library_versions(本作业)"
gov_sql "select kind||E'\t'||snapshot_id||E'\t'||source_job_id||E'\t'||created_at::text||E'\t'||coalesce(superseded_at::text,'')||E'\t'||evidence::text from library_versions where source_job_id='$JOB_ID' order by created_at"
echo "## 维护占用(本作业)"
gov_sql "select 'scope|'||scope||'|'||state||'|'||coalesce(released_at::text,'not_released') from library_maintenance where owner_job_id='$JOB_ID' union all select 'instance|'||scope||'|'||state||'|'||coalesce(released_at::text,'not_released') from library_instance_maintenance where owner_job_id='$JOB_ID'"
echo "## 目标内容计数"
docker exec "$TARGET_DB_CONTAINER" psql -U "$TARGET_DB_USER" -d "$TARGET_DB_NAME" -Atc "select 'knowledges='||count(*) from knowledges union all select 'chunks='||count(*) from chunks union all select 'resources='||count(*) from resources union all select 'wiki_pages='||count(*) from wiki_pages" 2>/dev/null
} >"$EVIDENCE/job-evidence.txt"
log "evidence written: $EVIDENCE/job-evidence.txt"
}
# ---------- 主流程 ----------
log "library-kill-takeover v$SCRIPT_VERSION mode=$MODE scope=$SCOPE evidence=$EVIDENCE"
preflight
case "$MODE" in
check)
STATUS_OVERRIDE="READY_FOR_PARENT"
finish 0
;;
restore)
restore_barrier
finish 0
;;
run)
[ "$CONFIRM_KILL" -eq 1 ] || die 4 kill "缺少 --confirm-kill"
arm_barrier
wait_for_hit
lock_job
read_writeset_evidence
kill_worker
wait_takeover
verify_takeover_semantics
verify_target_counts
verify_service_restored
save_evidence
restore_barrier
STATUS_OVERRIDE="DRILL_PASSED"
finish 0
;;
esac