298 lines
14 KiB
Python
298 lines
14 KiB
Python
"""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 socket
|
||
import sqlite3
|
||
import subprocess
|
||
import tempfile
|
||
import time
|
||
import unittest
|
||
import urllib.error
|
||
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", "")
|
||
# CI 通道(可选):无 SSH key 的运行器上,把受信基址 + URL 级 token 头注入
|
||
# svc.sh 派生的 git 子进程(cache 克隆与 pins 物化的 submodule 取件走 SSH URL
|
||
# 会被 insteadOf 改写)。token 只经环境传递,不进文件/命令行/输出。
|
||
CI_SERVER = os.environ.get("SVC_CI_GIT_SERVER", "").rstrip("/")
|
||
CI_TOKEN = os.environ.get("SVC_CI_GIT_TOKEN", "")
|
||
|
||
|
||
def ci_git_env():
|
||
if not (CI_SERVER and CI_TOKEN):
|
||
return {}
|
||
scheme = CI_SERVER.split("://", 1)[0]
|
||
return {
|
||
"GIT_CONFIG_COUNT": "4",
|
||
"GIT_CONFIG_KEY_0": "protocol.%s.allow" % scheme,
|
||
"GIT_CONFIG_VALUE_0": "always",
|
||
"GIT_CONFIG_KEY_1": "http.%s/.extraheader" % CI_SERVER,
|
||
"GIT_CONFIG_VALUE_1": "AUTHORIZATION: token " + CI_TOKEN,
|
||
"GIT_CONFIG_KEY_2": "url.%s/weichen/.insteadOf" % CI_SERVER,
|
||
"GIT_CONFIG_VALUE_2": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/",
|
||
"GIT_CONFIG_KEY_3": "core.hooksPath",
|
||
"GIT_CONFIG_VALUE_3": "/dev/null",
|
||
}
|
||
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):
|
||
# CI 运行器无 bwrap:RDM 沙箱 fail-closed 会拒启——硬关 exec 工具
|
||
#(产品提供的最高兜底,RDM_DISABLE_EXEC_TOOLS=1)后生命周期语义不变。
|
||
self.env["RDM_DISABLE_EXEC_TOOLS"] = "1"
|
||
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")),
|
||
]))
|
||
self.initialize_store(binary, d / "data")
|
||
|
||
def initialize_store(self, binary, data, env=None):
|
||
"""Create the fixture schema through normal startup before identity CLI.
|
||
|
||
identity ensure/verify operate on an existing store and deliberately do
|
||
not create a missing database or migrate an uninitialized one.
|
||
"""
|
||
init_env = dict(self.env if env is None else env)
|
||
init_env["RDM_DISABLE_EXEC_TOOLS"] = "1"
|
||
init_env.setdefault("SUPER_ADMIN_OWNER", "superadmin")
|
||
init_env.setdefault("SUPER_ADMIN_PASSWORD", "realcombo-super-admin-password")
|
||
if env is None:
|
||
init_env.pop("AGENT_SYSTEM_DSN", None)
|
||
with socket.socket() as sock:
|
||
sock.bind(("127.0.0.1", 0))
|
||
port = sock.getsockname()[1]
|
||
log_path = self.root / ("initialize-%s.log" % data.name)
|
||
with log_path.open("wb") as log:
|
||
proc = subprocess.Popen(
|
||
[str(binary), "serve", "--addr", "127.0.0.1:%d" % port,
|
||
"--data", str(data), "--auth", "auto"],
|
||
cwd=self.root, env=init_env, stdout=log, stderr=log)
|
||
try:
|
||
for _ in range(200):
|
||
self.assertIsNone(proc.poll(),
|
||
"fixture startup exited; see %s" % log_path)
|
||
try:
|
||
urllib.request.urlopen(
|
||
"http://127.0.0.1:%d/api/health" % port, timeout=1).close()
|
||
break
|
||
except urllib.error.HTTPError as response:
|
||
status = response.code
|
||
response.close()
|
||
if status == 401:
|
||
break # Authenticated health returning 401 is ready too.
|
||
self.fail("fixture health returned %d; see %s" % (status, log_path))
|
||
except OSError:
|
||
time.sleep(0.1)
|
||
else:
|
||
self.fail("fixture startup timed out; see %s" % log_path)
|
||
finally:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=10)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
proc.wait(timeout=10)
|
||
|
||
@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)
|
||
doc = json.loads(cap.stdout)
|
||
self.assertEqual(doc["protocol"], 1)
|
||
self.assertEqual(doc["identity"]["open"], "existing-no-migrate")
|
||
self.assertIn("ensure", doc["identity"]["ops"])
|
||
self.assertIn("verify", doc["identity"]["ops"])
|
||
# 错 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 → 裸重启稳定。"""
|
||
# 硬关须覆盖本测试全部调用(deploy/start/restart/stop 的 serve 都要),
|
||
# 不只 deploy 一处——CI 无 bwrap 时 fail-closed 拒启。
|
||
self.env["RDM_DISABLE_EXEC_TOOLS"] = "1"
|
||
env = dict(self.env)
|
||
env.update(ci_git_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, check=False)
|
||
if result.returncode != 0:
|
||
log = self.runtime("rcf") / "svc.log"
|
||
tail = log.read_text(encoding="utf-8", errors="replace")[-2000:] \
|
||
if log.exists() else "(no svc.log)"
|
||
self.fail("deploy failed:\n%s\n--- svc.log tail ---\n%s"
|
||
% (result.stdout, tail))
|
||
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"
|
||
plugins = data / "plugins"
|
||
plugins.mkdir(parents=True)
|
||
env = dict(os.environ)
|
||
env["AGENT_SYSTEM_DSN"] = os.environ["AGENT_PG_TEST_DSN"]
|
||
env["RDM_DISABLE_EXEC_TOOLS"] = "1"
|
||
env.pop("SVC_STUB_TRACE", None)
|
||
self.initialize_store(rdm_bin, data, env)
|
||
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()
|