1fa35e9870
按生产级备份组合落地:
- internal/releasesnapshot:新增门禁包,要求 root 下存在覆盖目标库、未过期
(默认 24h)、含 wiki_pages.jsonl 与 sha256 的快照;本地文件再校验哈希。
7 条单测(过期/换库/篡改/缺页/未接线/半成品)。
- 发布器:写目标库之前(Publish/Delete/Rollback 三条路)先过门禁;映射目标
(源库→目标库,共享真实库)或 policy=always 时必须有快照;门禁未接线
同样拒绝(fail-closed)。新错误码 release_target_snapshot_missing(不可重试)
+ 页面中文提示。6 条发布器单测。
- worker 配置新增 target_snapshot{root,max_age_hours,policy},校验 policy/
max_age/绝对路径;compose.release-sim.yaml 只读挂载快照目录。
- scripts/target_kb_snapshot.sh:默认云内执行(与 DB 同 VPC,凭据取自 WeKnora
容器)+ 默认不导 chunks(44s 出 8 张表);修复 \copy 不能跨行/不能带 WITH、
ES 计数改用 knowledge_base_id.keyword。
- ES:注册 kb_backup 仓库(path.repo),首个快照 36s/4.7GB/0 分片失败,
SLM kb-hourly(3d)+kb-daily(7d) 已建并执行成功。
- OSS:目标桶开启版本控制(此前未开启)。
- 文档 P6 增补硬门禁与云内执行;真实快照目录上门禁实测:两个绑定库通过、
未覆盖库拒绝、未接线拒绝。
274 lines
14 KiB
Bash
Executable File
274 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 目标 WeKnora 知识库的只读行级快照(KB 维度)。
|
||
#
|
||
# 用途:发布 / 撤权 / 回滚前,把目标库**当前状态**留档,出事后有可回退的现场。
|
||
# 只做 SELECT + \copy,不写目标库任何一行。
|
||
#
|
||
# 用法:
|
||
# bash scripts/target_kb_snapshot.sh # 已绑定的目标库;云内执行(快)
|
||
# EXECUTION=tunnel bash scripts/target_kb_snapshot.sh # 走本机 SSH 隧道(慢,兜底)
|
||
# bash scripts/target_kb_snapshot.sh <kb_id> [<kb_id>] # 指定目标库
|
||
# INCLUDE_CHUNKS=1 bash scripts/target_kb_snapshot.sh # 连 chunks 一起导(大,按需)
|
||
# PULL_CHUNKS=1 bash scripts/target_kb_snapshot.sh # 云内执行时把 chunks 结果也拉回本机
|
||
#
|
||
# 环境变量:
|
||
# EXECUTION cloud(默认,与 DB 同 VPC 的云主机上跑)| tunnel(本机经 SSH 隧道)
|
||
# SNAPSHOT_ROOT 输出根目录(默认 backups/target-kb-snapshots)
|
||
# INCLUDE_CHUNKS 1=连 chunks 一起导(默认 0:秒级完成;向量在 ES 侧,行可从源端重发)
|
||
# PULL_CHUNKS 1=云内执行时把 chunks 结果也拉回本机(默认 0,留在云主机)
|
||
# TUNNEL_HOST/TUNNEL_PORT 隧道模式的目标 PG 地址(默认 172.17.0.1:25432)
|
||
# SSH_CONFIG SSH 配置路径(默认 ~/.ssh/config.weknora-cloud-test)
|
||
# SSH_HOST 云主机别名(默认 weknora-test)
|
||
# APP_CONTAINER 云上提供 DB 凭据的容器(默认 weknora-test-v071-app-1)
|
||
# REMOTE_ROOT 云上快照目录(默认 /opt/weknora-test/kb-snapshots)
|
||
# WITH_ES 1=同时记录目标 ES 的文档数(默认 1)
|
||
# NOTE 附加到清单的备注(例如发布单号)
|
||
#
|
||
# 覆盖范围(PG 行级):knowledge_bases / knowledges / knowledge_tags /
|
||
# knowledge_tag_relations / wiki_pages / wiki_folders / resources /
|
||
# resource_bindings(+ chunks,可选)。含软删行,不含表结构。
|
||
#
|
||
# 不覆盖:Elasticsearch 文档与向量(见 ES 仓库 kb_backup 与 SLM 策略 kb-hourly/kb-daily)、
|
||
# OSS 对象本体(只在 resources.jsonl 里记路径/大小/哈希做清单)、表结构与索引。
|
||
# 整库级恢复请用阿里云 RDS 快照/PITR。
|
||
set -euo pipefail
|
||
|
||
WORKDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
cd "$WORKDIR"
|
||
|
||
EXECUTION="${EXECUTION:-cloud}"
|
||
SNAPSHOT_ROOT="${SNAPSHOT_ROOT:-$WORKDIR/backups/target-kb-snapshots}"
|
||
TUNNEL_HOST="${TUNNEL_HOST:-172.17.0.1}"
|
||
TUNNEL_PORT="${TUNNEL_PORT:-25432}"
|
||
INCLUDE_CHUNKS="${INCLUDE_CHUNKS:-0}"
|
||
PULL_CHUNKS="${PULL_CHUNKS:-0}"
|
||
SSH_CONFIG="${SSH_CONFIG:-$HOME/.ssh/config.weknora-cloud-test}"
|
||
SSH_HOST="${SSH_HOST:-weknora-test}"
|
||
APP_CONTAINER="${APP_CONTAINER:-weknora-test-v071-app-1}"
|
||
REMOTE_ROOT="${REMOTE_ROOT:-/opt/weknora-test/kb-snapshots}"
|
||
WITH_ES="${WITH_ES:-1}"
|
||
NOTE="${NOTE:-}"
|
||
|
||
KB_IDS=("$@")
|
||
if [ "${#KB_IDS[@]}" -eq 0 ]; then
|
||
mapfile -t KB_IDS < <(docker exec knowledge-governance-p1-postgres-1 psql -U governance -d governance -tA -c \
|
||
"SELECT unnest(ARRAY[internal_kb_id, external_kb_id]) FROM production_kb_bindings WHERE internal_kb_id <> '' OR external_kb_id <> ''" \
|
||
| sed '/^$/d' | sort -u)
|
||
fi
|
||
if [ "${#KB_IDS[@]}" -eq 0 ]; then
|
||
echo "no target knowledge base id given and none bound"; exit 1
|
||
fi
|
||
|
||
for id in "${KB_IDS[@]}"; do
|
||
case "$id" in
|
||
[0-9a-f]*-[0-9a-f]*-[0-9a-f]*-[0-9a-f]*-[0-9a-f]*) ;;
|
||
*) echo "invalid knowledge base id: $id"; exit 1 ;;
|
||
esac
|
||
done
|
||
|
||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||
PREFIX="$(printf '%s' "${KB_IDS[0]}" | cut -c1-8)"
|
||
OUT="$SNAPSHOT_ROOT/${STAMP}-${PREFIX}"
|
||
mkdir -p "$OUT"
|
||
|
||
KB_SQL="'$(printf '%s' "${KB_IDS[*]}" | sed "s/ /','/g")'"
|
||
|
||
# ---------- 生成导出脚本(本身也是留档的一部分) ----------
|
||
cat >"$OUT/export.sql" <<SQL
|
||
-- 目标知识库只读行级快照:$STAMP
|
||
-- 生成者:scripts/target_kb_snapshot.sh(EXECUTION=$EXECUTION)
|
||
-- 目标库:${KB_IDS[*]}
|
||
-- 说明:全部为 SELECT + \copy,不修改目标库。
|
||
\pset pager off
|
||
\timing off
|
||
|
||
\copy (SELECT to_jsonb(t) FROM knowledge_bases t WHERE t.id IN ($KB_SQL)) TO 'out/knowledge_bases.jsonl'
|
||
\copy (SELECT to_jsonb(t) FROM knowledges t WHERE t.knowledge_base_id IN ($KB_SQL)) TO 'out/knowledges.jsonl'
|
||
\copy (SELECT to_jsonb(t) FROM knowledge_tags t WHERE t.knowledge_base_id IN ($KB_SQL)) TO 'out/knowledge_tags.jsonl'
|
||
\copy (SELECT to_jsonb(r) FROM knowledge_tag_relations r WHERE r.knowledge_id IN (SELECT id FROM knowledges WHERE knowledge_base_id IN ($KB_SQL))) TO 'out/knowledge_tag_relations.jsonl'
|
||
\copy (SELECT to_jsonb(t) FROM wiki_pages t WHERE t.knowledge_base_id IN ($KB_SQL)) TO 'out/wiki_pages.jsonl'
|
||
\copy (SELECT to_jsonb(t) FROM wiki_folders t WHERE t.knowledge_base_id IN ($KB_SQL)) TO 'out/wiki_folders.jsonl'
|
||
-- 归属集合用 UNION 子查询(云上实测 0.6~0.8s;OR+IN 会退化成慢查询)。
|
||
-- 注意:psql 的 \copy 是单行元命令,且不接受 WITH 开头,整条语句必须写成一行。
|
||
\copy (SELECT to_jsonb(b) FROM resource_bindings b WHERE b.owner_id IN (SELECT id FROM knowledges WHERE knowledge_base_id IN ($KB_SQL) UNION SELECT id FROM chunks WHERE knowledge_base_id IN ($KB_SQL))) TO 'out/resource_bindings.jsonl'
|
||
\copy (SELECT to_jsonb(r) FROM resources r WHERE r.id IN (SELECT b.resource_id FROM resource_bindings b WHERE b.owner_id IN (SELECT id FROM knowledges WHERE knowledge_base_id IN ($KB_SQL) UNION SELECT id FROM chunks WHERE knowledge_base_id IN ($KB_SQL)))) TO 'out/resources.jsonl'
|
||
SQL
|
||
if [ "$INCLUDE_CHUNKS" = "1" ]; then
|
||
cat >>"$OUT/export.sql" <<SQL
|
||
\copy (SELECT to_jsonb(t) FROM chunks t WHERE t.knowledge_base_id IN ($KB_SQL)) TO 'out/chunks.jsonl'
|
||
SQL
|
||
fi
|
||
|
||
REMOTE_DIR=""
|
||
if [ "$EXECUTION" = "cloud" ]; then
|
||
# ---------- 云内执行:与 DB 同 VPC,内网直连,不受家用带宽限制 ----------
|
||
REMOTE_DIR="$REMOTE_ROOT/$STAMP-${PREFIX}"
|
||
ssh -F "$SSH_CONFIG" "$SSH_HOST" "mkdir -p '$REMOTE_DIR'"
|
||
scp -q -F "$SSH_CONFIG" "$OUT/export.sql" "$SSH_HOST:$REMOTE_DIR/export.sql"
|
||
ssh -F "$SSH_CONFIG" "$SSH_HOST" \
|
||
APP_CONTAINER="$APP_CONTAINER" REMOTE_DIR="$REMOTE_DIR" INCLUDE_CHUNKS="$INCLUDE_CHUNKS" \
|
||
'bash -s' <<'REMOTE'
|
||
set -euo pipefail
|
||
env_value() { docker inspect "$APP_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -E "^$1=" | head -1 | cut -d= -f2-; }
|
||
DB_HOST_VALUE=$(env_value DB_HOST); DB_PORT_VALUE=$(env_value DB_PORT)
|
||
DB_NAME_VALUE=$(env_value DB_NAME); DB_USER_VALUE=$(env_value DB_USER)
|
||
DB_PASS_VALUE=$(env_value DB_PASSWORD)
|
||
PGURL=$(python3 - "$DB_USER_VALUE" "$DB_PASS_VALUE" "$DB_HOST_VALUE" "$DB_PORT_VALUE" "$DB_NAME_VALUE" <<'PY'
|
||
import sys, urllib.parse as u
|
||
user, password, host, port, name = sys.argv[1:6]
|
||
print(f"postgres://{u.quote(user)}:{u.quote(password)}@{host}:{port}/{name}")
|
||
PY
|
||
)
|
||
docker run --rm --network host -e PGURL="$PGURL" \
|
||
-v "$REMOTE_DIR:/out" -v "$REMOTE_DIR/export.sql:/scripts/export.sql:ro" postgres:16-alpine \
|
||
sh -c 'psql "$PGURL" -q -f /scripts/export.sql' >/dev/null
|
||
rm -f "$REMOTE_DIR/export.sql"
|
||
if [ "$INCLUDE_CHUNKS" = "1" ] && [ -f "$REMOTE_DIR/chunks.jsonl" ]; then
|
||
gzip -f "$REMOTE_DIR/chunks.jsonl"
|
||
fi
|
||
python3 - "$REMOTE_DIR" <<'PY'
|
||
import hashlib, json, pathlib, sys
|
||
out = pathlib.Path(sys.argv[1]); files = []
|
||
for path in sorted(out.glob("*.jsonl*")):
|
||
digest = hashlib.sha256(); size = 0
|
||
with path.open("rb") as handle:
|
||
for block in iter(lambda: handle.read(1 << 20), b""):
|
||
digest.update(block); size += len(block)
|
||
files.append({"file": path.name, "bytes": size, "sha256": digest.hexdigest()})
|
||
(out / "REMOTE_MANIFEST.json").write_text(json.dumps({"host_files": files}, indent=2) + "\n")
|
||
print("remote files:", " ".join(f"{f['file']}={f['bytes']}" for f in files))
|
||
PY
|
||
exit 0
|
||
REMOTE
|
||
# 小表拉回本机;chunks 默认留在云主机(PULL_CHUNKS=1 才拉)
|
||
scp -q -F "$SSH_CONFIG" "$SSH_HOST:$REMOTE_DIR/*.jsonl" "$OUT/" 2>/dev/null || true
|
||
scp -q -F "$SSH_CONFIG" "$SSH_HOST:$REMOTE_DIR/REMOTE_MANIFEST.json" "$OUT/" 2>/dev/null || true
|
||
if [ "$PULL_CHUNKS" = "1" ]; then
|
||
scp -q -F "$SSH_CONFIG" "$SSH_HOST:$REMOTE_DIR/chunks.jsonl.gz" "$OUT/" 2>/dev/null || true
|
||
fi
|
||
else
|
||
# ---------- 隧道模式(兜底):本机经 SSH 隧道直连 ----------
|
||
SECRET_FILE="secrets/release-sim/target_database_url"
|
||
[ -f "$SECRET_FILE" ] || { echo "missing $SECRET_FILE"; exit 1; }
|
||
PGURL="$(python3 - "$SECRET_FILE" "$TUNNEL_HOST" "$TUNNEL_PORT" <<'PY'
|
||
import re, sys, pathlib
|
||
url = pathlib.Path(sys.argv[1]).read_text().strip()
|
||
print(re.sub(r'@[^/@]+:\d+/', '@%s:%s/' % (sys.argv[2], sys.argv[3]), url))
|
||
PY
|
||
)"
|
||
docker run --rm --network host -e PGURL="$PGURL" \
|
||
-v "$OUT:/out" -v "$OUT/export.sql:/scripts/export.sql:ro" postgres:16 \
|
||
sh -c 'psql "$PGURL" -q -f /scripts/export.sql' >/dev/null
|
||
fi
|
||
|
||
# ---------- ES 文档数(只读,失败不阻塞) ----------
|
||
ES_SUMMARY=""
|
||
if [ "$WITH_ES" = "1" ]; then
|
||
for id in "${KB_IDS[@]}"; do
|
||
count="$(curl -s --max-time 15 -H 'Content-Type: application/json' \
|
||
-X POST "http://${TUNNEL_HOST}:29200/weknora_test_chunks/_count" \
|
||
-d "{\"query\":{\"term\":{\"knowledge_base_id.keyword\":\"$id\"}}}" \
|
||
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("count","?"))' 2>/dev/null || echo "?")"
|
||
ES_SUMMARY="${ES_SUMMARY}${id}=${count} "
|
||
done
|
||
fi
|
||
|
||
# ---------- 清单 + 说明 ----------
|
||
python3 - "$OUT" "$STAMP" "$EXECUTION" "$INCLUDE_CHUNKS" "$ES_SUMMARY" "$NOTE" "$REMOTE_DIR" "${KB_IDS[@]}" <<'PY'
|
||
import hashlib, json, pathlib, sys
|
||
out = pathlib.Path(sys.argv[1]); stamp, execution = sys.argv[2], sys.argv[3]
|
||
include_chunks = sys.argv[4] == "1"; es_summary = sys.argv[5].strip(); note = sys.argv[6]
|
||
remote_dir = sys.argv[7]; kb_ids = sys.argv[8:]
|
||
files = []
|
||
for path in sorted(out.glob("*.jsonl")):
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
for block in iter(lambda: handle.read(1 << 20), b""):
|
||
digest.update(block)
|
||
files.append({"file": path.name, "rows": sum(1 for _ in path.open("rb")),
|
||
"bytes": path.stat().st_size, "sha256": digest.hexdigest(), "location": "local"})
|
||
for path in sorted(out.glob("chunks.jsonl.gz")):
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
for block in iter(lambda: handle.read(1 << 20), b""):
|
||
digest.update(block)
|
||
files.append({"file": path.name, "bytes": path.stat().st_size,
|
||
"sha256": digest.hexdigest(), "location": "local"})
|
||
remote_manifest = out / "REMOTE_MANIFEST.json"
|
||
if remote_manifest.exists():
|
||
for item in json.loads(remote_manifest.read_text()).get("host_files", []):
|
||
if not any(existing["file"] == item["file"] for existing in files):
|
||
item["location"] = f"cloud:{remote_dir}"
|
||
files.append(item)
|
||
manifest = {
|
||
"kind": "weknora-target-kb-snapshot",
|
||
"created_at": stamp,
|
||
"execution": execution,
|
||
"knowledge_base_ids": kb_ids,
|
||
"profile": "kb-rows" + ("" if include_chunks else " (chunks excluded: 向量在 ES 侧,行可从源端重发)"),
|
||
"covers": ["knowledge_bases", "knowledges", "knowledge_tags", "knowledge_tag_relations",
|
||
"wiki_pages", "wiki_folders", "resource_bindings", "resources"]
|
||
+ (["chunks"] if include_chunks else []),
|
||
"does_not_cover": ["Elasticsearch documents and vectors (see ES repository kb_backup, SLM kb-hourly/kb-daily)",
|
||
"OSS object bodies (listed by reference in resources.jsonl)",
|
||
"table structure / indexes", "rows outside the listed knowledge bases",
|
||
"whole-database recovery (use Aliyun RDS snapshot / PITR)"],
|
||
"es_document_counts": es_summary or None,
|
||
"remote_directory": remote_dir or None,
|
||
"note": note or None,
|
||
"files": files,
|
||
}
|
||
(out / "MANIFEST.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||
print("snapshot:", out)
|
||
print("files:", " ".join(f"{item['file']}={item.get('rows', '-')}" for item in files))
|
||
print("local bytes:", sum(item["bytes"] for item in files if item.get("location") == "local"))
|
||
print("es docs:", es_summary or "-")
|
||
PY
|
||
|
||
cat >"$OUT/README.md" <<'MD'
|
||
# 目标知识库快照(只读行级)
|
||
|
||
## 它是什么
|
||
发布 / 撤权 / 回滚**之前**的目标库现场:KB 维度的 PG 行(含软删行),每张表一个
|
||
JSONL(`to_jsonb(row)` 一行一条),附 `MANIFEST.json`(行数 / 字节 / sha256 / 覆盖范围 /
|
||
位置)与 `export.sql`(导出用的原始查询)。生成过程只有 SELECT + `\copy`,不修改目标库。
|
||
|
||
## 它不是什么
|
||
- 不含 Elasticsearch 文档与向量(见 ES 仓库 `kb_backup` 与 SLM 策略 `kb-hourly`/`kb-daily`)、
|
||
不含 OSS 对象本体(`resources.jsonl` 里只有路径 / 大小 / 哈希清单)、不含表结构与索引。
|
||
- 不是整库级灾备。**整库级恢复请用阿里云 RDS 的快照 / 按时间点恢复(PITR)**:
|
||
恢复到临时实例后,再按 KB 导出需要的行,这样连索引和约束一起回到某个时间点。
|
||
|
||
## 怎么恢复(行级)
|
||
先确认目标库当前状态已被另一份快照覆盖(避免二次覆盖),然后按外键顺序灌回:
|
||
|
||
```bash
|
||
URL=<目标库连接串>
|
||
OUT=<本快照目录>
|
||
docker run --rm --network host -e PGURL="$URL" -v "$OUT:/snap:ro" postgres:16 \
|
||
psql "$PGURL" -v ON_ERROR_STOP=1 <<'SQL'
|
||
BEGIN;
|
||
DO $$
|
||
DECLARE t text;
|
||
BEGIN
|
||
FOREACH t IN ARRAY ARRAY[
|
||
'knowledge_bases','knowledges','chunks','knowledge_tags','knowledge_tag_relations',
|
||
'resources','resource_bindings','wiki_folders','wiki_pages'] LOOP
|
||
EXECUTE format('CREATE TEMP TABLE stage_%s (raw jsonb)', t);
|
||
EXECUTE format($f$\copy stage_%s(raw) FROM '/snap/%s.jsonl'$f$, t, t);
|
||
EXECUTE format('INSERT INTO %I SELECT (jsonb_populate_record(NULL::%I, raw)).* FROM stage_%s
|
||
ON CONFLICT (id) DO NOTHING', t, t, t);
|
||
EXECUTE format('DROP TABLE stage_%s', t);
|
||
END LOOP;
|
||
END $$;
|
||
COMMIT;
|
||
SQL
|
||
```
|
||
|
||
注意:
|
||
- 只恢复了**行**;缺行时不会删、也不会改(`ON CONFLICT DO NOTHING`)。
|
||
- 与目标库既有唯一索引冲突的行会被跳过,恢复后请核对 `MANIFEST.json` 的行数。
|
||
- 向量在 Elasticsearch 侧,行恢复后必须重建索引 / 重放 ES 文档,否则检索不到。
|
||
MD
|
||
|
||
echo "done: $OUT" |