Gate svc lifecycles on real Dev and RDM artifacts in CI (U3) #484
@@ -377,3 +377,255 @@ 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
|
||||
# 凭据环境须保持到 pins 子模块取完(insteadOf + token 头覆盖嵌套 SSH URL),
|
||||
# 之后再 unset。
|
||||
git -C rdm submodule update --init --recursive
|
||||
echo "RDM commit: $(git -C rdm rev-parse HEAD)"
|
||||
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
|
||||
make -C rdm build
|
||||
- name: Real-combination identity gates over real PostgreSQL (U3, required)
|
||||
env:
|
||||
AGENT_PG_TEST_DSN: postgres://ci_agent:ci_job_local_only@127.0.0.1:5432/agent_test?sslmode=disable
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 路径变量在脚本内解析(YAML env: 不做 shell 展开)。
|
||||
export SVC_TEST_REAL=1 SVC_TEST_PG=1
|
||||
export SVC_REAL_RDM_BIN="$PWD/rdm/bin/agent-system-rdm"
|
||||
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
|
||||
# 凭据环境须保持到 pins 子模块取完(insteadOf + token 头覆盖嵌套 SSH URL),
|
||||
# 之后再 unset。
|
||||
git -C rdm submodule update --init --recursive
|
||||
echo "RDM commit: $(git -C rdm rev-parse HEAD)"
|
||||
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
|
||||
make -C rdm build
|
||||
- name: Run real-combination lifecycle gates (SQLite, U3, required)
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 路径变量在脚本内解析(YAML env: 不做 shell 展开)。
|
||||
export SVC_TEST_REAL=1
|
||||
export SVC_REAL_BIN="$PWD/bin/agent-system"
|
||||
export SVC_REAL_RDM_BIN="$PWD/rdm/bin/agent-system-rdm"
|
||||
export SVC_RDM_ORIGIN="$PWD/rdm"
|
||||
# CI 无 SSH key:把受信基址与 URL 级 token 头经环境交给套件(仅环境传递)。
|
||||
export SVC_CI_GIT_SERVER="${GITHUB_SERVER_URL%/}"
|
||||
export SVC_CI_GIT_TOKEN="${SUBMODULE_TOKEN}"
|
||||
python3 -m unittest tests.test_svc_realcombo -v
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
Submodule modules/backend updated: acabb85e2a...41e7eea0c3
+33
-4
@@ -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
|
||||
@@ -3986,6 +3999,7 @@ do_stop() {
|
||||
proc_stop "$pid"
|
||||
rm -f "$ENV_PID"
|
||||
svc_clear_autostart
|
||||
svc_wait_port_released
|
||||
c_ok "[$ENV_NAME] 已停止"
|
||||
hist_event stop ok "$pid" "SIGTERM"
|
||||
;;
|
||||
@@ -3993,6 +4007,20 @@ do_stop() {
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
# svc_wait_port_released:停机后等子进程释放端口(最多 3s)。桩/组合产物的 http
|
||||
# 子进程经 ppid-watch 自杀(≤0.5s 语义),慢机上略晚;不等会让紧跟的 start 把
|
||||
# 已无父的孤儿占口判成「非本服务」拒启(U2 回退实测,CI 慢机 run 3774)。
|
||||
svc_wait_port_released() {
|
||||
local port="${ENV_ADDR##*:}" i=0
|
||||
[ -n "$port" ] || return 0
|
||||
while [ "$i" -lt 30 ] && port_listening "$port"; do
|
||||
sleep 0.1
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# do_stop_quiet:deploy 前置——未运行时静默返回(同时清除期望态:
|
||||
# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。
|
||||
do_stop_quiet() {
|
||||
@@ -4015,6 +4043,7 @@ do_stop_quiet() {
|
||||
proc_stop "$pid"
|
||||
rm -f "$ENV_PID"
|
||||
svc_clear_autostart
|
||||
svc_wait_port_released
|
||||
c_ok "[$ENV_NAME] 已停止"
|
||||
hist_event stop ok "$pid" "SIGTERM"
|
||||
;;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""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", "")
|
||||
# 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")),
|
||||
]))
|
||||
|
||||
@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 → 裸重启稳定。"""
|
||||
# 硬关须覆盖本测试全部调用(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"
|
||||
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()
|
||||
Reference in New Issue
Block a user