Gate svc lifecycles on real Dev and RDM artifacts in CI

Why: stub suites proved the script logic but not real artifact
behavior (the 2026-10-12 incidents passed all stub tests); the report's
U3 requires real-combination gates, and realcombo dry-runs exposed two
real gaps: fresh deploys closed identity governance one lifecycle late,
and pg-bound identity calls put the DSN on the command line.
Evidence: make test exit 0; local realcombo dry-run 16 OK (rows in the
task card); CI adds svc-realcombo (SQLite, both real binaries, full RDM
track) and a real-PG identity step on test-pg with env-delivered DSN.
Impact: tests/test_svc_realcombo.py (SVC_TEST_REAL gate, explicit
NOT_RUN otherwise); ci.yaml new job + test-pg step (RDM clone via
trusted-base URL-scoped token header, allowlist addition reviewed in
this PR); svc.sh delivers identity DSN via AGENT_SYSTEM_DSN and runs
manifest adoption in the same deploy that first registers svc.env;
modules/backend pinned to main (env fallback, backend #182).
Refs: .task/feature-svc-u3-realcombo.md; user goal directive 2026-10-12
(U3); backend #182; RDM #285.
This commit is contained in:
2026-10-12 14:27:59 +00:00
parent 74623437f2
commit 99bbbc089e
5 changed files with 564 additions and 12 deletions
+243
View File
@@ -377,3 +377,246 @@ jobs:
env:
AGENT_PG_TEST_DSN: postgres://ci_agent:ci_job_local_only@127.0.0.1:5432/agent_test?sslmode=disable
run: make test-multi-instance
- name: Clone Agent-System-RDM with nested pins (reviewed allowlist addition)
env:
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# 凭据纪律与 Checkout 块同源:经 GIT_CONFIG_* 环境注入 URL 级 token 头,
# 不落文件/命令行;allowlist 扩项 = Agent-System-RDM 及其 pins(同一
# 组件集合),由本 PR 评审背书。SSH pins URL 以 insteadOf 改写到受信
# https 基址后再取。
SERVER="$(printf '%s' "$GITHUB_SERVER_URL" | sed 's:/*$::')"
scheme="${GITHUB_SERVER_URL%%://*}"
export GIT_CONFIG_COUNT=4
export GIT_CONFIG_KEY_0="protocol.$scheme.allow"
export GIT_CONFIG_VALUE_0="always"
export GIT_CONFIG_KEY_1="http.$SERVER/.extraheader"
export GIT_CONFIG_VALUE_1="AUTHORIZATION: token $SUBMODULE_TOKEN"
export GIT_CONFIG_KEY_2="url.$SERVER/weichen/.insteadOf"
export GIT_CONFIG_VALUE_2="ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
export GIT_CONFIG_KEY_3="core.hooksPath"
export GIT_CONFIG_VALUE_3="/dev/null"
git clone --depth=1 "$SERVER/weichen/Agent-System-RDM.git" rdm
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 \
GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1 GIT_CONFIG_KEY_2 \
GIT_CONFIG_VALUE_2 GIT_CONFIG_KEY_3 GIT_CONFIG_VALUE_3
unset SUBMODULE_TOKEN
git -C rdm submodule update --init --recursive
echo "RDM commit: $(git -C rdm rev-parse HEAD)"
make -C rdm build
- name: Real-combination identity gates over real PostgreSQL (U3, required)
env:
SVC_TEST_REAL: "1"
SVC_TEST_PG: "1"
SVC_REAL_RDM_BIN: $PWD/rdm/bin/agent-system-rdm
AGENT_PG_TEST_DSN: postgres://ci_agent:ci_job_local_only@127.0.0.1:5432/agent_test?sslmode=disable
run: |
set -euo pipefail
python3 -m unittest tests.test_svc_realcombo -v
# U3(统一部署方案):真实 Dev/RDM 制品的 svc.sh 生命周期门禁——桩套件
# (test_svc*.py)只证明脚本逻辑;本作业证明「声明≠能力」的最后一道:真实
# identity 领养/verify、release 切换、伴生工作台、RDM 全轨部署。缺件即失败,
# 不静默跳过(SVC_TEST_REAL=1 硬开关)。
svc-realcombo:
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# Credential handling stays inline: no file from the candidate is executed.
# Workflow provenance and runner isolation still require platform controls.
python3 - <<'PY_CHECKOUT'
import os
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import urlsplit
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
# 2026-10-05 修复(run 948/949 实测):注入值为内网直连形态
# 'http://172.17.0.1:3000'(docker 网关 → 宿主 Gitea),与公网拼写不同且为
# http——此前硬编码常量严格比对会拒绝合法检出。校验收宽为「http(s) + 非空
# netloc」;凭据仍只附着于「该基址 + 下方仓库白名单」构造的 URL。
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
# Reviewed allowlist, independent of candidate .gitmodules. Add repositories
# here only with an explicit review of the checkout credential's scope.
REPOSITORIES = {
"modules/core": "Agent-System-Core",
"modules/backend": "Agent-System-Backend",
"modules/frontend": "Agent-System-Frontend",
"modules/ui": "Agent-System-UI",
"modules/documents": "Agent-System-Documents",
"deps/agent-system-core": "Agent-System-Core",
"deps/development-guidelines": "Development-Guidelines",
"deps/agent-native": "Agent-Native",
"deps/agent-squad": "Agent-Squad",
"deps/agent-intent": "Agent-Intent",
"deps/agent-command": "Agent-Command",
"deps/error-code-system": "Error-Code-System",
"deps/logging-system": "Logging-System",
}
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
if not root_token or not component_token:
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-Dev":
sys.exit("Checkout repository is outside the reviewed allowlist")
sha = os.environ.get("GITHUB_SHA", "")
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
sys.exit("Checkout requires a fixed commit SHA")
# Discard inherited Git tracing/config/SSH hooks. Auth lives only in one
# Git process tree, in an exact URL-scoped header, never in a URL or file.
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
config = {
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
"init.templateDir": "", "credential.helper": "",
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never",
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
}
def git(repo, *args, extra=None):
values = config | (extra or {})
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return subprocess.run(["git", "-C", str(repo), *args], env=env,
check=True, stdout=subprocess.PIPE).stdout
def materialize(repo, depth=0):
if depth > 16:
raise ValueError("Submodule depth exceeds the reviewed checkout limit")
links = {}
for entry in git(repo, "ls-tree", "-rz", "HEAD").split(b"\0"):
if not entry:
continue
metadata, path = entry.split(b"\t", 1)
mode, kind, commit = metadata.decode().split()
if mode == "160000":
links[path.decode()] = commit
if not links:
return
raw = git(repo, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
entries = {}
for item in raw.split(b"\0"):
if not item:
continue
key, value = item.decode().split("\n", 1)
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
if not match:
raise ValueError("Unsupported candidate .gitmodules setting")
name, field = match.groups()
if field in entries.setdefault(name, {}):
raise ValueError("Duplicate candidate .gitmodules setting")
entries[name][field] = value
if set(entries) != set(links):
raise ValueError("Submodule names must match the fixed gitlink paths")
# Validate the entire level before any authenticated component fetch.
for name, fields in entries.items():
repository = REPOSITORIES.get(name)
if not repository or fields.get("path") != name:
raise ValueError("Submodule path is outside the reviewed allowlist")
trusted_url = f"{SERVER}/weichen/{repository}.git"
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
raise ValueError("Submodule URL is outside the reviewed allowlist")
for name in entries:
url = f"{SERVER}/weichen/{REPOSITORIES[name]}.git"
# Persist only the reviewed public URL so Git recognizes the
# initialized submodule after process-scoped config is gone.
git(repo, "config", f"submodule.{name}.url", url)
git(repo, "submodule", "update", "--init", "--checkout", "--", name,
extra={f"submodule.{name}.url": url,
f"submodule.{name}.update": "checkout",
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
child = repo / name
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
raise ValueError("Materialized submodule does not match the fixed SHA")
materialize(child, depth + 1)
try:
root = Path.cwd()
# An ephemeral clean checkout is required; do not reuse prior Git state.
if (root / ".git").exists():
raise ValueError("Checkout requires a fresh workspace")
git(root, "init", "-q")
url = SERVER + "/weichen/Agent-System-Dev.git"
git(root, "remote", "add", "origin", url)
git(root, "fetch", "--depth=1", "origin", sha,
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
materialize(root)
except (ValueError, OSError, subprocess.CalledProcessError) as error:
sys.exit(f"Checkout refused: {error}")
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Install Node 22
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
- name: Build the real agent-system binary
run: make build
- name: Clone Agent-System-RDM with nested pins (reviewed allowlist addition)
env:
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# 凭据纪律与 Checkout 块同源:经 GIT_CONFIG_* 环境注入 URL 级 token 头,
# 不落文件/命令行;allowlist 扩项 = Agent-System-RDM 及其 pins(同一
# 组件集合),由本 PR 评审背书。SSH pins URL 以 insteadOf 改写到受信
# https 基址后再取。
SERVER="$(printf '%s' "$GITHUB_SERVER_URL" | sed 's:/*$::')"
scheme="${GITHUB_SERVER_URL%%://*}"
export GIT_CONFIG_COUNT=4
export GIT_CONFIG_KEY_0="protocol.$scheme.allow"
export GIT_CONFIG_VALUE_0="always"
export GIT_CONFIG_KEY_1="http.$SERVER/.extraheader"
export GIT_CONFIG_VALUE_1="AUTHORIZATION: token $SUBMODULE_TOKEN"
export GIT_CONFIG_KEY_2="url.$SERVER/weichen/.insteadOf"
export GIT_CONFIG_VALUE_2="ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
export GIT_CONFIG_KEY_3="core.hooksPath"
export GIT_CONFIG_VALUE_3="/dev/null"
git clone --depth=1 "$SERVER/weichen/Agent-System-RDM.git" rdm
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 \
GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1 GIT_CONFIG_KEY_2 \
GIT_CONFIG_VALUE_2 GIT_CONFIG_KEY_3 GIT_CONFIG_VALUE_3
unset SUBMODULE_TOKEN
git -C rdm submodule update --init --recursive
echo "RDM commit: $(git -C rdm rev-parse HEAD)"
make -C rdm build
- name: Run real-combination lifecycle gates (SQLite, U3, required)
env:
SVC_TEST_REAL: "1"
SVC_REAL_BIN: $PWD/bin/agent-system
SVC_REAL_RDM_BIN: $PWD/rdm/bin/agent-system-rdm
SVC_RDM_ORIGIN: $PWD/rdm
run: |
set -euo pipefail
python3 -m unittest tests.test_svc_realcombo -v
File diff suppressed because one or more lines are too long
+17 -4
View File
@@ -1143,11 +1143,14 @@ svc_db_identity_ensure() {
line="$(svc_db_identity_args)" || return 1
IFS=$'\t' read -r iid dbref data <<<"$line"
local args=(identity ensure --instance-id "$iid" --dbref "$dbref" --data "$data")
# G1-7(U3):pg 档 DSN 经 AGENT_SYSTEM_DSN 环境交付,不进命令行——命令行对
# 同机任何进程可见;产物侧 identity CLI 须支持环境回退(backend #182)。
local dsn_env=()
if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then
args+=(--dsn "$DB_DSN")
dsn_env=("AGENT_SYSTEM_DSN=$DB_DSN")
fi
local out rc=0
out="$("$bin" "${args[@]}" 2>&1)" || rc=$?
out="$(env ${dsn_env[@]+"${dsn_env[@]}"} "$bin" "${args[@]}" 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then
c_err "[$ENV_NAME] DB 身份 ensure 失败:$out"
return 1
@@ -1164,11 +1167,13 @@ svc_db_identity_verify() {
line="$(svc_db_identity_args)" || return 1
IFS=$'\t' read -r iid dbref data <<<"$line"
local args=(identity verify --instance-id "$iid" --dbref "$dbref" --data "$data")
# G1-7(U3):DSN 环境交付同 ensure。
local dsn_env=()
if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then
args+=(--dsn "$DB_DSN")
dsn_env=("AGENT_SYSTEM_DSN=$DB_DSN")
fi
local out rc=0
out="$("$ENV_BIN" "${args[@]}" 2>&1)" || rc=$?
out="$(env ${dsn_env[@]+"${dsn_env[@]}"} "${ENV_BIN:-}" "${args[@]}" 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then
c_err "[$ENV_NAME] DB 身份校验失败,拒绝启动:$out"
c_err " 先 scripts/svc.sh $ENV_NAME init(或补跑 ensure);错库/错实例须人工处置(docs/recovery/u1-identity-manifest.md §5)"
@@ -2243,6 +2248,14 @@ agent_do_deploy() {
hist_event deploy fail "$(read_pid)" "ui-missing(已回退)"
return 1
fi
# 首次部署:agent_do_start 的 record_state 建立 svc.env 后,同轮补走 manifest
# 领养(ensure 身份行)——否则身份治理要到下一次生命周期动作才生效
#(U3 全轨实测:全新 rdm 部署当轮无 manifest)。
if ! svc_manifest_migrate; then
svc_rollback_deploy "manifest 领养失败($SRC_LABEL)" || true
hist_event deploy fail "$(read_pid)" "manifest 领养失败(已回退)"
return 1
fi
svc_release_commit_schema "$relid"
svc_journal_clear
svc_release_gc
+203
View File
@@ -0,0 +1,203 @@
"""svc.sh 真实组合门禁(U3,2026-10-12 统一部署方案)。
用**真实产物**(agent-system 组合二进制 + agent-system-rdm 组合二进制)而非 bash
桩验证部署生命周期:identity 领养/ensure/verify(真 SQLite schema_migrations +
agent_system_identity)、裸重启身份稳定、能力探测契约(capabilities v1)、
RDM 全轨部署(物化→门禁→release 切换→伴生工作台)、真实 PostgreSQL 身份
(AGENT_SYSTEM_DSN 环境交付,凭据不进命令行)。
运行条件(缺一即显式 NOT_RUN,不静默跳过):
SVC_TEST_REAL=1 —— 总开关
SVC_REAL_BIN=<agent-system> —— 真实基座/组合二进制(make build 产物)
SVC_REAL_RDM_BIN=<agent-system-rdm> —— 真实 RDM 组合二进制(make build 产物)
SVC_RDM_ORIGIN=<RDM 仓远端> —— (可选)全轨部署用;缺省仅做制品级生命周期
SVC_TEST_PG=1 + AGENT_PG_TEST_DSN —— (可选)真实 PG 身份专项
桩套件(test_svc*.py)不覆盖真实产物行为——本文件是「声明≠能力」的最后一道门。
"""
import importlib.util
import json
import os
import shutil
import sqlite3
import subprocess
import tempfile
import unittest
import urllib.request
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
REAL = os.environ.get("SVC_TEST_REAL") == "1"
REAL_BIN = os.environ.get("SVC_REAL_BIN", "")
REAL_RDM_BIN = os.environ.get("SVC_REAL_RDM_BIN", "")
RDM_ORIGIN = os.environ.get("SVC_RDM_ORIGIN", "")
PG = os.environ.get("SVC_TEST_PG") == "1" and bool(os.environ.get("AGENT_PG_TEST_DSN"))
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
tsr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tsr)
@unittest.skipUnless(REAL, "真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)")
class RealComboTests(tsr.SvcAutoRecoverTests):
def setUp(self):
super().setUp()
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
self.root / "scripts" / "svc_manifest.py")
self.real_bin = Path(REAL_BIN).resolve() if REAL_BIN else None
if self.real_bin is not None:
self.assertTrue(self.real_bin.is_file() and os.access(self.real_bin, os.X_OK),
"SVC_REAL_BIN 不可执行:%s" % self.real_bin)
def manifest(self, name):
p = self.runtime(name) / "manifest.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
def identity_row(self, name):
db = self.runtime(name) / "data" / "agent-system.db"
con = sqlite3.connect(str(db))
try:
return list(con.execute(
"SELECT instance_id, dbref FROM agent_system_identity"))
finally:
con.close()
def http_ok(self, port):
try:
with urllib.request.urlopen("http://127.0.0.1:%d/" % port, timeout=3) as r:
return r.status == 200
except OSError:
return False
def seed_real(self, name, port, binary):
d = self.runtime(name)
(d / "bin").mkdir(parents=True, exist_ok=True)
(d / "data").mkdir(exist_ok=True)
(d / "plugins").mkdir(exist_ok=True)
shutil.copy2(binary, d / "bin" / "agent-system")
(d / "svc.env").write_text("".join("%s=%s\n" % kv for kv in [
("kind", "agent"), ("env", name), ("pid", ""),
("addr", "127.0.0.1:%d" % port), ("tags", ""),
("source", "realcombo"), ("version", "real"), ("started", ""),
("pidcheck", str(d / "bin" / "agent-system")),
("exec", "--addr 127.0.0.1:%d --data %s --plugins %s --auth auto"
% (port, d / "data", d / "plugins")),
]))
@unittest.skipUnless(REAL and REAL_BIN,
"真实基座二进制专项 NOT_RUN:需 SVC_REAL_BIN")
def test_U3_real_agent_identity_lifecycle_sqlite(self):
"""真实组合二进制:领养 ensure → verify → 两次裸重启身份/锚点稳定;
capabilities v1 探测契约。"""
self.seed_real("rc1", 9471, self.real_bin)
result = self.svc("rc1", "start")
self.assertIn("已启动", result.stdout)
m = self.manifest("rc1")
self.assertIsNotNone(m, "start 应完成 manifest 领养")
iid = m["instance_id"]
self.assertEqual(self.identity_row("rc1"), [(iid, "sqlite")],
"领养须在真实库建身份行")
# schema 是真实基座写入的(非桩)
con = sqlite3.connect(str(self.runtime("rc1") / "data" / "agent-system.db"))
self.assertTrue(con.execute(
"SELECT count(*) FROM schema_migrations").fetchone()[0] >= 1)
con.close()
self.assertTrue(self.http_ok(9471))
# capabilities v1 契约(探测无副作用形态)
cap = subprocess.run([str(self.runtime("rc1") / "bin" / "agent-system"),
"identity", "capabilities"],
capture_output=True, text=True, timeout=15)
self.assertEqual(cap.returncode, 0, cap.stderr)
self.assertIn("identity-capabilities: v1", cap.stdout)
# 错 id verify 拒绝
bad = subprocess.run([str(self.runtime("rc1") / "bin" / "agent-system"),
"identity", "verify", "--instance-id",
"svc-rc1-zzzzzz999999",
"--data", str(self.runtime("rc1") / "data")],
capture_output=True, text=True, timeout=15)
self.assertEqual(bad.returncode, 1)
self.svc("rc1", "restart")
self.svc("rc1", "restart")
self.assertEqual(self.identity_row("rc1"), [(iid, "sqlite")],
"裸重启不得换身份")
self.assertEqual(self.manifest("rc1")["instance_id"], iid)
self.assertTrue(self.http_ok(9471))
self.svc("rc1", "stop")
@unittest.skipUnless(REAL_RDM_BIN, "真实 RDM 专项 NOT_RUN:需 SVC_REAL_RDM_BIN")
def test_U3_real_rdm_binary_lifecycle_sqlite(self):
"""真实 RDM 组合二进制:identity 接线(Backend 公共 CLI)→ serve 门禁 →
领域层可初始化。"""
rdm_bin = Path(REAL_RDM_BIN).resolve()
self.seed_real("rcr", 9473, rdm_bin)
result = self.svc("rcr", "start")
self.assertIn("已启动", result.stdout)
m = self.manifest("rcr")
iid = m["instance_id"]
self.assertEqual(self.identity_row("rcr"), [(iid, "sqlite")])
self.assertTrue(self.http_ok(9473))
# RDM 未知子命令明确退出(不落服务路径)
bogus = subprocess.run([str(self.runtime("rcr") / "bin" / "agent-system"),
"totally-bogus"],
capture_output=True, text=True, timeout=10)
self.assertEqual(bogus.returncode, 2, bogus.stderr)
self.svc("rcr", "stop")
@unittest.skipUnless(RDM_ORIGIN, "全轨部署 NOT_RUN:需 SVC_RDM_ORIGIN(RDM 源仓远端)")
def test_U3_real_rdm_full_deploy_track(self):
"""全轨:materialize(真实 make build)→ 候选身份门禁 → release 切换 →
伴生工作台 200 → 裸重启稳定。"""
env = dict(self.env)
env.update({"SVC_RDM_SRC_DIR": str(self.root / "rdmcache"),
"SVC_RDM_ORIGIN": RDM_ORIGIN})
result = self.svc("rcf", "deploy", "--from", "rdm", "--port", "9474", env=env)
self.assertIn("已切换 release", result.stdout)
d = self.runtime("rcf")
self.assertTrue((d / "current" / "bin" / "agent-system").exists())
iid = self.manifest("rcf")["instance_id"]
self.assertEqual(self.identity_row("rcf"), [(iid, "sqlite")])
# 伴生工作台(真实 web/)
addr = self.kv("rcfweb", "addr")
self.assertIsNotNone(addr, "伴生 rcfweb 未登记")
port = int(addr.rsplit(":", 1)[1])
self.assertTrue(self.http_ok(port))
self.assertIn("RDM", (d / "current" / "htdocs" / "index.html").read_text()
if (d / "current" / "htdocs" / "index.html").exists()
else "rdm", "工作台内容")
self.svc("rcf", "restart")
self.assertEqual(self.identity_row("rcf"), [(iid, "sqlite")])
self.assertTrue(self.http_ok(9474))
self.svc("rcf", "stop")
self.svc("rcfweb", "stop")
@unittest.skipUnless(PG and REAL_RDM_BIN,
"真实 PG 专项 NOT_RUN:需 SVC_TEST_PG=1 + AGENT_PG_TEST_DSN + SVC_REAL_RDM_BIN")
def test_U3_real_pg_identity_env_delivery(self):
"""真实 PostgreSQL:AGENT_SYSTEM_DSN 环境交付 ensure/verify(凭据不进
命令行);错 id fail-closed。"""
rdm_bin = Path(REAL_RDM_BIN).resolve()
data = self.root / "pgdata"
data.mkdir()
env = dict(os.environ)
env["AGENT_SYSTEM_DSN"] = os.environ["AGENT_PG_TEST_DSN"]
env.pop("SVC_STUB_TRACE", None)
iid = "svc-realcombo-pg-%d" % os.getpid()
run = lambda *a: subprocess.run(
[str(rdm_bin), "identity", *a, "--instance-id", iid,
"--dbref", "pg", "--data", str(data)],
env=env, capture_output=True, text=True, timeout=60)
r = run("ensure")
self.assertEqual(r.returncode, 0, r.stderr)
r = run("verify")
self.assertEqual(r.returncode, 0, r.stderr)
bad = subprocess.run(
[str(rdm_bin), "identity", "verify", "--instance-id",
"svc-realcombo-pg-wrong", "--dbref", "pg", "--data", str(data)],
env=env, capture_output=True, text=True, timeout=60)
self.assertEqual(bad.returncode, 1)
if __name__ == "__main__":
unittest.main()