Files
knowledge-governance/scripts/verify_cloud_release_target.py
姚章浩 e6472b0ec6 feat(library): 云测试发布目标接入的角色分离、快照搬运与图谱门禁
为什么改:发布执行链是"与目标同机"假设写死的(本机 docker.sock、本机 drain.json、
本机 Redis/PG),把目标换成云测试不能只改展示 URL;直接搬整套 worker 又会抢走
本机封存/QA/周报队列并按本机配置写错目标。所以先把"谁消费哪段队列"变成显式角色,
再补齐跨主机搬运与目标侧门禁。

角色(LIBRARY_WORKER_ROLE=full/release-executor/capture)不只影响 handler 列表,
还决定是否拥有 DDL(release-executor 只读校验迁移版本,落后即拒绝启动)、
收拾哪些 library_jobs.kind、是否跑 release 接管扫描、是否起 scheduler/QA/周报/探测。
capture 保留本机原有全部业务;release-executor 只挂 sync/restore/recover/baseline。

快照跨主机:新增内容寻址的 bundle(版本清单先落、blob 按 sha256 命名、清单最后
Link 作提交点),pack/unpack 幂等且拒绝覆盖与同名不同内容;执行器使用前仍逐 blob
重算 sha256,因此传输不可变有两道证据。

目标侧门禁:目标知识库 graph_enabled=true 时拒绝发布(发布只写 PG/ES/OSS,不写
Neo4j;未知也拒绝),只读检测、不改目标配置、不做图谱迁移。

云端入口:新增 drain 感知的 ingress 闸门(建连超时与读超时分离,半关闭单向传播,
SIGTERM 排空后写 drain.json 并 exit 0/2)、compose 叠加片段(app 不再发布端口、
frontend 与 adapter 的入口收敛到闸门、指向真实网络 weknora-test-v071-network)、
执行器启动脚本与治理 PG 反向隧道脚本(默认 unix socket,不占 TCP)。

验证:deploy/cloud-test/test_gate.py 13 项全 PASS(含 12 秒静默流、半关闭、2MiB 完整、
排空 exit 0/active_at_close=1、排空超时 exit 2、上游不可达不挂住);go test 相关包全绿;
scripts/ci_checks.py 16 项全部 success(EXIT=0,1241 用例通过/0 失败/191 已声明可选集成跳过);
云测试只读预检 17 项 / 6 FAIL,FAIL 项即当前部署缺口(无闸门、app 仍发布端口、
adapter 与 frontend 仍直连 app、云端归档根未建),未部署、未改云端任何状态。
2026-10-30 18:38:38 +08:00

252 lines
13 KiB
Python
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 python3
"""云测试发布目标:只读预检。
只做 ssh 上的只读命令(docker inspect/ps/exec 的只读查询、curl GET、test、stat),
不启停容器、不写文件、不改配置、不连治理库。每行一个判据:PASS / FAIL / WARN / MANUAL。
用法:
python3 scripts/verify_cloud_release_target.py --host weknora-test
python3 scripts/verify_cloud_release_target.py --host weknora-test --snapshot <64位ID>
退出码:有 FAIL 即 1,否则 0(WARN/MANUAL 不影响退出码,但必须人读)。
"""
import argparse
import json
import shlex
import subprocess
import sys
PROJECT_DEFAULT = "weknora-test-v071"
APP_DEFAULT = "app"
GATE_SERVICE_DEFAULT = "ingress"
DRAIN_DIR_DEFAULT = "/srv/kgtest-gate/state"
ARCHIVE_DEFAULT = "/var/lib/knowledge-governance/library"
GATE_URL_DEFAULT = "http://127.0.0.1:8080/health"
EXECUTOR_DEFAULT = "kgtest-release-executor"
RDS_HOST_DEFAULT = "pgm-wz9ohr9gk919pr1q.pg.rds.aliyuncs.com"
results = []
def record(level, name, detail):
results.append((level, name, detail))
print(f"{level:<6} {name}: {detail}", flush=True)
def ssh(host, command, timeout=25):
argv = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", host, command]
try:
completed = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return 124, "", "ssh timeout"
return completed.returncode, completed.stdout.strip(), completed.stderr.strip()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="weknora-test")
parser.add_argument("--project", default=PROJECT_DEFAULT)
parser.add_argument("--app-service", default=APP_DEFAULT)
parser.add_argument("--gate-service", default=GATE_SERVICE_DEFAULT)
parser.add_argument("--drain-dir", default=DRAIN_DIR_DEFAULT)
parser.add_argument("--archive", default=ARCHIVE_DEFAULT)
parser.add_argument("--gate-url", default=GATE_URL_DEFAULT)
parser.add_argument("--executor", default=EXECUTOR_DEFAULT)
parser.add_argument("--rds-host", default=RDS_HOST_DEFAULT)
parser.add_argument("--snapshot", default="")
args = parser.parse_args()
host = args.host
code, _, err = ssh(host, "true")
if code != 0:
record("FAIL", "ssh", f"{host} 不可达({err or code})")
return finish()
record("PASS", "ssh", f"{host} 可达")
# 1) 项目容器清单:每个服务都必须是写入者或"声明为非写入者",否则窗口拒绝开。
code, out, err = ssh(
host,
"docker ps -a --filter label=com.docker.compose.project=%s "
"--format '{{.Label \"com.docker.compose.service\"}}'" % shlex.quote(args.project),
)
services = sorted({line.strip() for line in out.splitlines() if line.strip()})
if code != 0:
record("FAIL", "project-containers", f"读不到项目容器({err or code})")
services = []
else:
record("PASS" if services else "FAIL", "project-containers",
f"project={args.project} services={','.join(services) or '<empty>'}")
# 2) 写入者不得发布宿主机端口(否则存在绕开入口闸门的直连通道)。
containers = {service: f"{args.project}-{service}-1" for service in services}
app_container = containers.get(args.app_service, "")
if app_container:
code, out, err = ssh(host, "docker inspect %s --format '{{json .HostConfig.PortBindings}}'"
% shlex.quote(app_container))
if code != 0:
record("FAIL", "writer-no-publish", f"inspect {app_container} 失败({err or code})")
else:
bindings = json.loads(out) if out else None
if bindings in (None, {}, "null"):
record("PASS", "writer-no-publish", f"{app_container} 未发布宿主机端口")
else:
record("FAIL", "writer-no-publish",
f"{app_container} 仍发布端口 {out}:拓扑校验会拒绝开维护窗口")
else:
record("FAIL", "writer-no-publish", f"项目里找不到写入者服务 {args.app_service}")
# 3) 入口闸门存在、健康、发布端口与入口地址端口一致。
code, out, err = ssh(host, "docker inspect %s --format '{{json .State.Health.Status}} {{json .HostConfig.PortBindings}} {{json .State.Running}}'"
% shlex.quote(f"{args.project}-{args.gate_service}-1"))
if code != 0:
record("FAIL", "gate", f"找不到闸门容器 {args.project}-{args.gate_service}-1({err or code})")
else:
parts = out.split(" ", 2)
health = parts[0] if parts else ""
bindings = parts[1] if len(parts) > 1 else ""
running = parts[2] if len(parts) > 2 else ""
if running.strip() == "true" and health.strip('"') == "healthy" and ":8080->" in bindings:
record("PASS", "gate", f"healthy,发布端口 {bindings}")
else:
record("FAIL", "gate", f"running={running} health={health} bindings={bindings}")
# 4) 经闸门的健康检查:这是"入口在服务"的实际证据,而不是容器状态。
code, out, err = ssh(host, "curl -s -o /dev/null -w '%%{http_code}' -m 8 %s" % shlex.quote(args.gate_url))
if code == 0 and out.strip() == "200":
record("PASS", "gate-health", f"{args.gate_url} -> 200")
else:
record("FAIL", "gate-health", f"{args.gate_url} -> {out or err or code}")
# 5) 闸门写排空证据的目录必须存在且可写(协调器要在同一真实文件上删/读)。
code, out, err = ssh(host, "test -d %s && test -w %s && echo ok" % (shlex.quote(args.drain_dir), shlex.quote(args.drain_dir)))
if code == 0 and out.strip() == "ok":
record("PASS", "drain-dir", f"{args.drain_dir} 存在且可写")
else:
record("FAIL", "drain-dir", f"{args.drain_dir} 不存在或不可写({err or code})")
code, out, _ = ssh(host, "stat -c '%%y %%s' %s 2>/dev/null" % shlex.quote(args.drain_dir + "/drain.json"))
if code == 0 and out:
record("WARN", "drain-file", f"已存在 drain.json({out}):协调器开窗口前会先删除并验证不存在")
else:
record("PASS", "drain-file", "无残留 drain.json")
# 6) 队列:redis 在本项目网络内可达(只读 PING;有口令时 NOAUTH 也算可达)。
redis_container = containers.get("redis", "")
if redis_container:
code, out, err = ssh(host, "docker exec %s redis-cli ping 2>&1" % shlex.quote(redis_container))
text = (out or err).strip()
if "PONG" in text or "NOAUTH" in text:
record("PASS", "redis", f"{redis_container}: {text}")
else:
record("FAIL", "redis", f"{redis_container}: {text or code}")
else:
record("FAIL", "redis", "项目里没有 redis 服务")
# 7) 索引:ES 可达且目标索引存在(只读 HEAD/GET)。
es_container = containers.get("elasticsearch", "")
if es_container:
code, out, err = ssh(host, "docker exec %s curl -s -o /dev/null -w '%%{http_code}' -m 8 http://localhost:9200/_cluster/health"
% shlex.quote(es_container))
record("PASS" if out.strip() == "200" else "FAIL", "elasticsearch",
f"{es_container} cluster/health -> {out.strip() or err or code}")
else:
record("FAIL", "elasticsearch", "项目里没有 elasticsearch 服务")
# 8) RDS:宿主机到 5432 的 TCP 可达(不登录、不带凭据)。
code, out, err = ssh(host, "timeout 5 bash -c 'cat < /dev/null > /dev/tcp/%s/5432' && echo open"
% shlex.quote(args.rds_host))
record("PASS" if out.strip() == "open" else "FAIL", "rds-reachable",
f"{args.rds_host}:5432 {'可达' if out.strip() == 'open' else '不可达'}")
# 9) 归档根:执行器要在这里读快照、写预备份与写集合台账。
code, out, err = ssh(host, "test -d %s && stat -c '%%a' %s" % (shlex.quote(args.archive), shlex.quote(args.archive)))
if code == 0:
record("PASS" if out.strip() == "700" else "WARN", "archive-root",
f"{args.archive} 权限 {out.strip()}(要求 0700)")
else:
record("FAIL", "archive-root", f"{args.archive} 不存在")
# 10) 可选的快照包:给出 ID 时只核对版本清单是否已落位(不读 blob)。
if args.snapshot:
version = f"{args.archive}/versions/{args.snapshot}.json"
code, out, err = ssh(host, "test -f %s && sha256sum %s | cut -d' ' -f1"
% (shlex.quote(version), shlex.quote(version)))
if code == 0 and out.strip() == args.snapshot:
record("PASS", "snapshot", f"{args.snapshot} 已导入且清单摘要自证一致")
else:
record("FAIL", "snapshot", f"{args.snapshot} 未导入或清单摘要不符({out.strip() or err or code})")
# 11) 其它入口:adapter / DobotAI / frontend 都能到 app。只改 frontend 不够,
# "唯一入口"必须逐条核对:谁能绕过闸门直连 app,就等于闸门关不干净。
code, out, _ = ssh(host, "docker network ls --format '{{.Name}}'")
networks = set(out.split())
record("PASS" if "weknora-test-v071-network" in networks else "FAIL", "target-network",
f"weknora-test-v071-network {'存在' if 'weknora-test-v071-network' in networks else '不存在'}")
def inspect_env(container):
code, env_raw, _ = ssh(host, "docker inspect %s --format '{{json .Config.Env}}'" % shlex.quote(container))
if code != 0 or not env_raw:
return None
values = {}
for entry in json.loads(env_raw):
key, _, value = entry.partition("=")
values[key] = value
return values
code, out, _ = ssh(host, "docker ps --format '{{.Names}}'")
running = set(out.split())
adapter = next((name for name in running if name.startswith("knowledge-adapter-adapter")), "")
if adapter:
env = inspect_env(adapter) or {}
base = env.get("WEKNORA_BASE_URL", "")
ok = base.startswith("http://ingress:") or base.startswith("http://kgtest-ingress:")
record("PASS" if ok else "FAIL", "entry/adapter",
f"{adapter} WEKNORA_BASE_URL={base or '<unset>'}(要求指向 ingress,否则 adapter 绕过闸门直连 app)")
else:
record("WARN", "entry/adapter", "未找到 adapter 容器(若它就是知识问答入口,必须改成指向 ingress)")
dobotai = next((name for name in running if name.startswith("dobotai-dingtalk")), "")
if dobotai:
env = inspect_env(dobotai) or {}
base = env.get("WEKNORA_BASE_URL", "")
code, mode, _ = ssh(host, "docker inspect %s --format '{{.HostConfig.NetworkMode}}'" % shlex.quote(dobotai))
host_network = mode.strip() == "host"
ok = host_network and base in ("http://127.0.0.1:8080", "http://localhost:8080")
record("PASS" if ok else "FAIL", "entry/dobotai",
f"{dobotai} net={mode.strip()} WEKNORA_BASE_URL={base or '<unset>'}(host 网络 + 127.0.0.1:8080 才恰好走闸门)")
else:
record("WARN", "entry/dobotai", "未找到 dobotai-dingtalk 容器")
if "frontend" in containers:
env = inspect_env(containers["frontend"]) or {}
app_host = env.get("APP_HOST", "")
record("PASS" if app_host == "ingress" else "FAIL", "entry/frontend",
f"frontend APP_HOST={app_host or '<unset>'}(要求 ingress)")
# 11) 发布执行器:在不在跑、角色对不对(只读这一个变量,不打印 env)。
code, out, err = ssh(host, "docker ps --filter name=%s --format '{{.Status}}'" % shlex.quote(args.executor))
if code == 0 and out.strip():
record("PASS", "executor", f"{args.executor} {out.strip()}")
code, role, _ = ssh(host, "docker exec %s printenv LIBRARY_WORKER_ROLE" % shlex.quote(args.executor))
if code == 0 and role.strip() == "release-executor":
record("PASS", "executor-role", "LIBRARY_WORKER_ROLE=release-executor")
else:
record("FAIL", "executor-role", f"LIBRARY_WORKER_ROLE={role.strip() or '<unset>'}(要求 release-executor)")
else:
record("WARN", "executor", f"{args.executor} 未运行(父部署后才会出现)")
# 12) 图谱:目标 KB 若开了 graph_enabled,发布只写 PG/ES/OSS,图谱会静默变旧。
record("MANUAL", "target-graph",
"需人工确认目标 KB 的 indexing_strategy.graph_enabled=false(发布路径不写 Neo4j)")
return finish()
def finish():
failures = [row for row in results if row[0] == "FAIL"]
print()
print(f"summary: {len(results)} checks, {len(failures)} FAIL")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())