diff --git a/scripts/svc.sh b/scripts/svc.sh index 9317ad93..3caba848 100755 --- a/scripts/svc.sh +++ b/scripts/svc.sh @@ -1355,6 +1355,8 @@ svc_identity_run() { svc_db_identity_ensure() { # 仅 fresh/pending 或显式 adopt 调用。established 缺行不得走这里自动补。 # 非 agent 或无能力:跳过。可选参数 BIN:部署候选。 + # SVC_IDENTITY_LAST_OUT:最近一次 identity CLI 输出,供候选门禁区分失败原因。 + SVC_IDENTITY_LAST_OUT="" local bin="${1:-${ENV_BIN:-}}" manifest_ok || return 0 local kind; kind="$(svc_get kind)" @@ -1365,6 +1367,7 @@ svc_db_identity_ensure() { IFS=$'\t' read -r iid dbref data <<<"$line" local out rc=0 out="$(svc_identity_run "$bin" ensure "$iid" "$dbref" "$data" 2>&1)" || rc=$? + SVC_IDENTITY_LAST_OUT="$out" if [ "$rc" -ne 0 ]; then c_err "[$ENV_NAME] DB 身份 ensure 失败:$out" return 1 @@ -1374,6 +1377,7 @@ svc_db_identity_ensure() { } svc_db_identity_verify() { + SVC_IDENTITY_LAST_OUT="" manifest_ok || return 0 local kind; kind="$(svc_get kind)" [ "$kind" = agent ] || return 0 @@ -1384,6 +1388,7 @@ svc_db_identity_verify() { IFS=$'\t' read -r iid dbref data <<<"$line" local out rc=0 out="$(svc_identity_run "$bin" verify "$iid" "$dbref" "$data" 2>&1)" || rc=$? + SVC_IDENTITY_LAST_OUT="$out" if [ "$rc" -ne 0 ]; then c_err "[$ENV_NAME] DB 身份校验失败,拒绝启动:$out" c_err " 先调查库文件与身份行;确认归属后 scripts/svc.sh $ENV_NAME adopt --confirm $iid" @@ -1395,6 +1400,22 @@ svc_db_identity_verify() { return 0 } +# svc_identity_schema_missing SRCBIN:最近一次 identity CLI 报身份表缺失(库 schema +# 早于 v27)时打印迁移指引并返回 0;否则返回 1,调用方沿用原失败提示。不自动迁移。 +svc_identity_schema_missing() { + case "${SVC_IDENTITY_LAST_OUT:-}" in + *"identity table missing"*) ;; + *) return 1 ;; + esac + local data_root + data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json" 2>/dev/null)" \ + || data_root="" + c_err "[$ENV_NAME] 数据库 schema 早于身份表(pre-v27,identity table missing):候选无法补齐/验证 DB 身份;旧服务/旧制品/登记保持不变" + c_err " 处理:先备份数据库 → scripts/svc.sh $ENV_NAME stop → 用候选制品执行 $1 migrate --data $data_root(PostgreSQL:DSN 只经环境变量 AGENT_SYSTEM_DSN 传入,不进 argv)→ 再次部署" + c_err " svc 不会自动迁移;本次暂存的候选在拒绝后清理,迁移请使用同一版本的制品" + return 0 +} + # svc_deploy_identity_gate SRCBIN:部署候选的身份门禁——在停旧服务、覆盖制品之前 # 执行(2026-10-12 rdm1/rdmcand 事故:停服→覆盖→启动即被身份门禁拒绝,只剩 # 「登记版本≠磁盘制品」的混合状态且无恢复路径)。规则: @@ -1425,7 +1446,8 @@ svc_deploy_identity_gate() { data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")" if [ -f "$data_root/agent-system.db" ] || { [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; }; then svc_db_identity_ensure "$srcbin" || { - c_err "[$ENV_NAME] 候选补齐 DB 身份失败(外宾行/不一致 fail-closed);旧服务/旧制品/登记保持不变" + svc_identity_schema_missing "$srcbin" \ + || c_err "[$ENV_NAME] 候选补齐 DB 身份失败(外宾行/不一致 fail-closed);旧服务/旧制品/登记保持不变" return 1 } fi @@ -1433,7 +1455,8 @@ svc_deploy_identity_gate() { *) # established:只验证,不补行。缺行或不匹配在停旧服务前拒绝。 svc_db_identity_verify "$srcbin" || { - c_err "[$ENV_NAME] 候选无法验证已建立的 DB 身份;旧服务/旧制品/登记保持不变" + svc_identity_schema_missing "$srcbin" \ + || c_err "[$ENV_NAME] 候选无法验证已建立的 DB 身份;旧服务/旧制品/登记保持不变" return 1 } ;; @@ -6507,7 +6530,13 @@ finally: PY svc_db_marker_set pending || exit 1 svc_db_identity_ensure || { c_err "[$ENV_NAME] adopt ensure 失败"; exit 1; } - c_ok "[$ENV_NAME] adopt 完成,db_marker=established" ;; + local adopt_marker; adopt_marker="$(svc_db_marker)" + if [ "$adopt_marker" = established ]; then + c_ok "[$ENV_NAME] adopt 完成,db_marker=established" + else + c_warn "[$ENV_NAME] adopt 未建立 DB 身份:db_marker=$adopt_marker(当前已装制品不具备 identity 能力,无法建立身份)" + c_warn " 部署具备 identity ensure/verify 的制品后,由候选身份门禁补齐" + fi ;; status) env_status_detail ;; logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;; esac diff --git a/tests/test_svc_identity.py b/tests/test_svc_identity.py index 7e16c039..d8466bb3 100644 --- a/tests/test_svc_identity.py +++ b/tests/test_svc_identity.py @@ -413,6 +413,51 @@ class IdentityTests(tsr.SvcAutoRecoverTests): hang.chmod(0o755) self.assertIn("rc=1", probe(hang), "探测挂死须有界返回无能力") + def test_U1_adopt_identityless_artifact_reports_pending(self): + """已装制品无 identity 能力:adopt 的 ensure 静默跳过,marker 留在 pending—— + 输出必须如实报 pending,不得宣称 db_marker=established。""" + self.seed_agent("adx", 9454) + self.start_ok("adx") + iid = self.manifest("adx")["instance_id"] + self.svc("adx", "stop") + self.install_noid_bin("adx") + result = self.svc("adx", "adopt", "--confirm", iid) + self.assertNotIn("db_marker=established", result.stdout) + self.assertIn("db_marker=pending", result.stdout) + self.assertEqual(self.manifest("adx")["identity"]["db_marker"], "pending") + + def test_U1_update_pre_v27_schema_refused_with_migrate_guidance(self): + """候选有 identity 能力但库 schema 早于身份表:停服前拒绝,给出 + 备份→stop→migrate→再部署的指引,而非「外宾行」误导;不自动迁移。""" + cand = self.root / "stub-agent-system-noschema" + cand.write_text( + '#!/usr/bin/env bash\ncase "${1:-}" in identity) ' + 'echo "identity ${2:-ensure}: identity table missing" >&2; exit 1 ;; ' + '*) exec "%s" "$@" ;; esac\n' % self.stub) + cand.chmod(0o755) + remote, old_sha, _ = self._make_track_repo(cand, "agent-system") + self.git_ok(self.root, "clone", str(remote), str(self.root / "srccache")) + self.seed_agent("scx", 9455) + self.start_ok("scx") + self.set_kv("scx", "source", "dev@%s" % old_sha) + mpath = self.runtime("scx") / "manifest.json" + m = json.loads(mpath.read_text(encoding="utf-8")) + m["identity"]["db_marker"] = "pending" + mpath.write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + pid = self.pid_of("scx") + bin_before = (self.runtime("scx") / "bin" / "agent-system").read_bytes() + + result = self.svc("update", "scx", check=False) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("migrate --data", result.stdout) + self.assertIn("AGENT_SYSTEM_DSN", result.stdout) + self.assertNotIn("外宾行", result.stdout) + self.assertTrue(self.running("scx"), "拒绝必须发生在停旧服务之前") + self.assertEqual(self.pid_of("scx"), pid) + self.assertEqual((self.runtime("scx") / "bin" / "agent-system").read_bytes(), + bin_before, "旧制品不得被覆盖") + self.svc("scx", "stop") + def test_I3_start_ensures_sqlite_identity(self): self.seed_agent("idb1", 9441) self.start_ok("idb1")