Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=2c016931044bebdc4a114c1ca1127d1b40d4ab17. Impact: integrate upstream changes without rewriting task history. Refs: task-sync # Conflicts: # modules/documents
This commit is contained in:
@@ -377,3 +377,257 @@ 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: RDM lifecycle pin is three fixed SHAs
|
||||
run: python3 -m unittest tests.test_lifecycle_pin -q && bash scripts/fetch-rdm-pin.sh tests/integration/lifecycle/rdm.pin >/dev/null
|
||||
- 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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ PLUGINS := plugins
|
||||
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
|
||||
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version
|
||||
|
||||
version:
|
||||
@echo "agent-system $(VERSION)"
|
||||
@@ -112,6 +112,14 @@ test-backend-pg:
|
||||
@test -n "$${AGENT_REDIS_TEST_ADDR}" || { echo "test-backend-pg: AGENT_REDIS_TEST_ADDR 未设置(真实 Redis 必需,不静默跳过)"; exit 1; }
|
||||
cd $(BACKEND) && go test -count=1 ./...
|
||||
|
||||
# 真实 Dev/RDM + PostgreSQL 生命周期门禁。缺环境直接失败,不静默跳过。
|
||||
# 登录入口未就绪时套件保持失败(NOT_RUN),不把 D3 报成完成。
|
||||
test-lifecycle:
|
||||
@test -n "$${AGENT_PG_TEST_DSN}" || { echo "test-lifecycle: AGENT_PG_TEST_DSN 未设置(真实 PostgreSQL 必需,不静默跳过)"; exit 1; }
|
||||
@test -n "$${SVC_RDM_SRC_DIR}" || { echo "test-lifecycle: SVC_RDM_SRC_DIR 未设置(RDM 检出必需,不静默跳过)"; exit 1; }
|
||||
bash scripts/fetch-rdm-pin.sh tests/integration/lifecycle/rdm.pin >/dev/null
|
||||
python3 tests/integration/lifecycle/lifecycle_suite.py
|
||||
|
||||
# 双进程 HTTP 级验收(协作存储方案 U7):两个真实 serve 进程共享隔离 PG。
|
||||
# 缺环境时明确失败而非静默跳过;见 tests/integration/multi-instance/README.md。
|
||||
test-multi-instance:
|
||||
|
||||
+3
-3
@@ -38,10 +38,10 @@ U2 起实施与部署分别评审。
|
||||
|
||||
- [RDM 最终目标核对与完整交付方案 r1](modules/documents/docs/proposals/2026-10-11-rdm-final-target-completion.md):系统级项目、上限内权限覆盖、私域协作、全部文档三权与完整业务链;34 UR/68 SR/10 F 逐条映射;方案已随 #340 合入(2026-10-11),U1 需求基线核对经 RDM #199/#200 落地,U2 起未启动。
|
||||
|
||||
- [服务重启与全量数据恢复方案 r2](modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md):
|
||||
- [服务重启与全量数据恢复方案 r3.1](modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md):
|
||||
svc 启停、异常进程/主机退出后的依赖、配置/数据身份、任务/HITL/副作用、文件与组合备份恢复;
|
||||
r1 经六角色自审、r2 吸收外部源码复核;2026-10-12 用户批准 r2(五阶段一并),实施、故障注入、部署与运行验收 NOT_RUN;
|
||||
衔接协作存储案;U2 首切片已由 svc-autorecover 合入 main(PR #396/#401);U1 实例身份已合入(Dev #415);余量另立增量。
|
||||
r2 已批准。r3.1 增补 D1 实例身份收尾、D2 可回滚切换、D3 真实组合门禁,并记录 2026-10-12 rdmcand 数据归属:会话不在 dev 库的 rdmcand 窗口内,领域事件为空,不跨实例搬迁。
|
||||
D1/D2/D3 按实施计划推进到可合入;r3 其余未批条款仍待人工评审。
|
||||
|
||||
- [svc 统一部署与一次性登录 Key 方案 r1](modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md):
|
||||
二进制/源码统一部署、环境准备、无初始密码开户与账户恢复;一次性 Key 仅兑换设密受限会话;
|
||||
|
||||
+1
-1
Submodule modules/backend updated: acabb85e2a...0f192a4813
+1
-1
Submodule modules/documents updated: 8d8600024d...b184bfb109
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# 按 tests/integration/lifecycle/rdm.pin 取出固定 SHA。只接受 40 位 hex。
|
||||
# 不执行 pin 文件,不接受 URL。检出由调用方用已允许的 RDM origin 完成。
|
||||
set -euo pipefail
|
||||
pin="${1:-tests/integration/lifecycle/rdm.pin}"
|
||||
[ -f "$pin" ] || { echo "rdm.pin 不存在:$pin" >&2; exit 1; }
|
||||
declare -A got=()
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
key="${line%%=*}"
|
||||
val="${line#*=}"
|
||||
case "$key" in
|
||||
sha|prev_sha|legacy_sha) ;;
|
||||
*) echo "rdm.pin 未知键:$key" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$val" in
|
||||
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
|
||||
*) echo "rdm.pin 的 $key 不是 40 位 hex" >&2; exit 1 ;;
|
||||
esac
|
||||
got["$key"]="$val"
|
||||
done < "$pin"
|
||||
[ "${#got[@]}" -eq 3 ] || { echo "rdm.pin 必须同时有 sha、prev_sha、legacy_sha" >&2; exit 1; }
|
||||
printf '%s\n' "${got[sha]}"
|
||||
+566
-109
@@ -700,6 +700,8 @@ parse_args() {
|
||||
--apply) CLI_APPLY=1; shift ;;
|
||||
--force) CLI_FORCE=1; shift ;;
|
||||
--allow-source-switch) CLI_ALLOW_SOURCE_SWITCH=1; shift ;;
|
||||
--migrate-schema) CLI_MIGRATE_SCHEMA=1; shift ;;
|
||||
--confirm) need_value "$@"; CLI_CONFIRM="$2"; shift 2 ;;
|
||||
--online) CLI_ONLINE=1; shift ;;
|
||||
--file) need_value "$@"; CLI_FILE="$2"; shift 2 ;;
|
||||
--out) need_value "$@"; CLI_OUT="$2"; shift 2 ;;
|
||||
@@ -1055,12 +1057,19 @@ svc_manifest_migrate() {
|
||||
out="$(python3 "$MANIFEST_PY" "$ENV_DIR" migrate 2>&1)" || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
c_ok "[$ENV_NAME] manifest:$(printf '%s' "$out" | tail -1)"
|
||||
# 领养即受控建身份:DB marker 只在领养(此处)与显式 init 建立;普通启动只
|
||||
# 验证不补行——缺行即事件(2026-10-12 裁定,rdmcand 实况:每次启动静默
|
||||
# ensure 会掩盖身份丢失)。领养时旧产物可能无 identity 能力而跳过补行,由
|
||||
# 部署候选门禁 svc_deploy_identity_gate 在下次换产物前用候选补齐。
|
||||
# fresh:库文件已在时用当前制品补行并升为 established;无能力则记 pending。
|
||||
# 库还不存在时保持 fresh,等启动建库后再 ensure。不在这里为缺库建文件。
|
||||
resolve_db_config 2>/dev/null || true
|
||||
svc_db_identity_ensure || return 1
|
||||
local marker data_root
|
||||
marker="$(svc_db_marker)"
|
||||
if [ "$marker" = fresh ]; then
|
||||
data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")"
|
||||
if ! svc_bin_has_identity; then
|
||||
svc_db_marker_set pending || return 1
|
||||
elif [ -f "$data_root/agent-system.db" ]; then
|
||||
svc_db_identity_ensure || return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if [ "$rc" -eq 4 ]; then
|
||||
@@ -1086,8 +1095,35 @@ svc_manifest_verify() {
|
||||
c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)"
|
||||
return 1
|
||||
fi
|
||||
# U1 I3:文件校验通过后再碰库——避免损坏 manifest 时身份 CLI 先 traceback。
|
||||
svc_db_identity_verify || return 1
|
||||
# 文件校验通过后再碰库。fresh 允许库尚未建立;established 只验证;
|
||||
# pending 仅当库文件已在且制品有能力时补齐。无能力制品不得启动已治理实例。
|
||||
local kind marker data_root
|
||||
kind="$(svc_get kind)"
|
||||
[ "$kind" = agent ] || return 0
|
||||
marker="$(svc_db_marker)"
|
||||
case "$marker" in
|
||||
fresh) return 0 ;;
|
||||
pending)
|
||||
if ! svc_bin_has_identity; then
|
||||
c_err "[$ENV_NAME] 身份尚未建立(pending),当前制品不能验证身份,拒绝启动"
|
||||
return 1
|
||||
fi
|
||||
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 ]; then
|
||||
c_err "[$ENV_NAME] 身份 pending 但库文件不存在,拒绝启动(不新建空库)"
|
||||
return 1
|
||||
fi
|
||||
svc_db_identity_ensure || return 1
|
||||
svc_db_identity_verify || return 1
|
||||
;;
|
||||
*)
|
||||
if ! svc_bin_has_identity; then
|
||||
c_err "[$ENV_NAME] 已建立身份的实例只接受能验证身份的制品,拒绝启动"
|
||||
return 1
|
||||
fi
|
||||
svc_db_identity_verify || return 1
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1112,28 +1148,84 @@ print("%s\t%s\t%s" % (m["instance_id"], dbref, data))
|
||||
PY
|
||||
}
|
||||
|
||||
# svc_bin_has_identity [BIN]:产物是否具备 identity 管理子命令(ensure/verify)。
|
||||
# 探测必须无副作用、有界:timeout 护栏(未知子命令直落服务态的产物会挂死或占口);
|
||||
# serve 痕迹与身份门禁报错文本不算能力证据——2026-10-12 rdm1/rdmcand 事故中 RDM
|
||||
# 产物对 identity 未知位置参数落入服务启动路径,报错恰好含「identity ensure」字样,
|
||||
# 旧实现 grep ensure 误报为有能力。仅 usage 形态(同时含 identity 与 ensure、
|
||||
# 无服务态痕迹、未超时)才认可。
|
||||
svc_bin_has_identity() {
|
||||
# svc_bin_caps [BIN]:只执行 version --json,在空目录、清空的环境里。非 JSON 或
|
||||
# protocol<1 一律无能力。旧制品对 version 立即退出,不会进入服务路径。
|
||||
# 按二进制摘要缓存;缓存目录默认在临时目录,不写进实例运行时。
|
||||
svc_bin_caps() {
|
||||
local bin="${1:-${ENV_BIN:-}}"
|
||||
[ -x "$bin" ] || return 1
|
||||
local out rc=0
|
||||
out="$(timeout 5 "$bin" identity 2>&1)" || rc=$?
|
||||
[ "$rc" -eq 124 ] && return 1
|
||||
case "$out" in
|
||||
*serving*|*"agent-system starting"*|*"identity missing"*|*"listen"*) return 1 ;;
|
||||
esac
|
||||
printf '%s' "$out" | grep -q identity && printf '%s' "$out" | grep -q ensure
|
||||
local digest cache tmp out
|
||||
digest="$(sha256sum "$bin" 2>/dev/null | awk '{print $1}')"
|
||||
[ -n "$digest" ] || return 1
|
||||
cache="${SVC_CAPS_CACHE_DIR:-${TMPDIR:-/tmp}}/svc-caps-${digest}"
|
||||
if [ -f "$cache" ]; then
|
||||
cat "$cache"
|
||||
return 0
|
||||
fi
|
||||
tmp="$(mktemp -d)"
|
||||
out="$(cd "$tmp" && timeout -k 2 5 env -i PATH=/usr/bin:/bin HOME="$tmp" "$bin" version --json 2>/dev/null)" || true
|
||||
rm -rf "$tmp"
|
||||
out="$(printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert int(d.get("protocol",0))>=1; print(json.dumps(d,separators=(",",":")))' 2>/dev/null)" || return 1
|
||||
printf '%s\n' "$out" > "$cache"
|
||||
printf '%s\n' "$out"
|
||||
}
|
||||
|
||||
# svc_bin_has_identity [BIN]:能力文档声明 identity.ops 含 ensure 与 verify。
|
||||
svc_bin_has_identity() {
|
||||
local bin="${1:-${ENV_BIN:-}}" doc
|
||||
doc="$(svc_bin_caps "$bin")" || return 1
|
||||
printf '%s' "$doc" | python3 -c 'import json,sys; d=json.load(sys.stdin); ops=(d.get("identity") or {}).get("ops") or []; raise SystemExit(0 if "ensure" in ops and "verify" in ops else 1)'
|
||||
}
|
||||
|
||||
# svc_db_marker:manifest.identity.db_marker。缺字段的旧 manifest 视为 established
|
||||
#(已在治理下跑过的实例,缺行即事件,不再自动补)。
|
||||
svc_db_marker() {
|
||||
manifest_ok || { printf '%s' established; return 0; }
|
||||
python3 - "$ENV_DIR" <<'PY'
|
||||
import json, os, sys
|
||||
p = os.path.join(sys.argv[1], "manifest.json")
|
||||
m = json.load(open(p, encoding="utf-8"))
|
||||
print((m.get("identity") or {}).get("db_marker") or "established")
|
||||
PY
|
||||
}
|
||||
|
||||
svc_db_marker_set() {
|
||||
local marker="$1"
|
||||
manifest_ok || return 0
|
||||
python3 - "$ENV_DIR" "$marker" <<'PY'
|
||||
import json, os, sys
|
||||
env_dir, marker = sys.argv[1], sys.argv[2]
|
||||
path = os.path.join(env_dir, "manifest.json")
|
||||
m = json.load(open(path, encoding="utf-8"))
|
||||
ident = dict(m.get("identity") or {})
|
||||
if ident.get("db_marker") == marker:
|
||||
sys.exit(0)
|
||||
ident["db_marker"] = marker
|
||||
m["identity"] = ident
|
||||
m["revision"] = int(m.get("revision") or 0) + 1
|
||||
tmp = path + ".new"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(m, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
PY
|
||||
}
|
||||
|
||||
# svc_identity_run BIN OP:身份子命令。DSN 只经环境(AGENT_SYSTEM_DSN),不进 argv。
|
||||
svc_identity_run() {
|
||||
local bin="$1" op="$2" iid="$3" dbref="$4" data="$5"
|
||||
local -a args=(env -i "PATH=$PATH" "HOME=${HOME:-/tmp}" "TMPDIR=${TMPDIR:-/tmp}"
|
||||
"AGENT_SYSTEM_INSTANCE_ID=$iid" "AGENT_SYSTEM_DBREF=$dbref" "AGENT_SYSTEM_DATA=$data")
|
||||
if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then
|
||||
args+=("AGENT_SYSTEM_DSN=$DB_DSN")
|
||||
fi
|
||||
args+=("$bin" identity "$op" --instance-id "$iid" --dbref "$dbref" --data "$data")
|
||||
"${args[@]}"
|
||||
}
|
||||
|
||||
svc_db_identity_ensure() {
|
||||
# 显式建立 / 领养补行(I2→I3 引导):表空插入;一致幂等;不一致 fail-closed。
|
||||
# 非 agent 或无 identity 子命令的旧产物:跳过(不硬断 I2)。
|
||||
# 可选参数 BIN:用指定产物(如部署候选)执行——默认当前安装产物。
|
||||
# 仅 fresh/pending 或显式 adopt 调用。established 缺行不得走这里自动补。
|
||||
# 非 agent 或无能力:跳过。可选参数 BIN:部署候选。
|
||||
local bin="${1:-${ENV_BIN:-}}"
|
||||
manifest_ok || return 0
|
||||
local kind; kind="$(svc_get kind)"
|
||||
@@ -1142,16 +1234,13 @@ svc_db_identity_ensure() {
|
||||
local line iid dbref data
|
||||
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")
|
||||
if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then
|
||||
args+=(--dsn "$DB_DSN")
|
||||
fi
|
||||
local out rc=0
|
||||
out="$("$bin" "${args[@]}" 2>&1)" || rc=$?
|
||||
out="$(svc_identity_run "$bin" ensure "$iid" "$dbref" "$data" 2>&1)" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
c_err "[$ENV_NAME] DB 身份 ensure 失败:$out"
|
||||
return 1
|
||||
fi
|
||||
svc_db_marker_set established || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1159,22 +1248,18 @@ svc_db_identity_verify() {
|
||||
manifest_ok || return 0
|
||||
local kind; kind="$(svc_get kind)"
|
||||
[ "$kind" = agent ] || return 0
|
||||
svc_bin_has_identity || return 0
|
||||
local bin="${1:-${ENV_BIN:-}}"
|
||||
svc_bin_has_identity "$bin" || return 0
|
||||
local line iid dbref data
|
||||
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")
|
||||
if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then
|
||||
args+=(--dsn "$DB_DSN")
|
||||
fi
|
||||
local out rc=0
|
||||
out="$("$ENV_BIN" "${args[@]}" 2>&1)" || rc=$?
|
||||
out="$(svc_identity_run "$bin" verify "$iid" "$dbref" "$data" 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)"
|
||||
c_err " 先调查库文件与身份行;确认归属后 scripts/svc.sh $ENV_NAME adopt --confirm $iid"
|
||||
return 1
|
||||
fi
|
||||
# 供 serve 二次门禁;verify 成功后导出。
|
||||
AGENT_SYSTEM_INSTANCE_ID="$iid"
|
||||
AGENT_SYSTEM_DBREF="$dbref"
|
||||
export AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF
|
||||
@@ -1203,15 +1288,151 @@ svc_deploy_identity_gate() {
|
||||
return 1
|
||||
fi
|
||||
resolve_db_config 2>/dev/null || true
|
||||
svc_db_identity_ensure "$srcbin" || {
|
||||
c_err "[$ENV_NAME] 候选补齐 DB 身份失败(外宾行/不一致 fail-closed);旧服务/旧制品/登记保持不变"
|
||||
return 1
|
||||
}
|
||||
local marker; marker="$(svc_db_marker)"
|
||||
case "$marker" in
|
||||
fresh|pending)
|
||||
# 库文件还不存在时不补行、不建库;留给启动后的受控 ensure。
|
||||
local data_root
|
||||
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);旧服务/旧制品/登记保持不变"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# established:只验证,不补行。缺行或不匹配在停旧服务前拒绝。
|
||||
svc_db_identity_verify "$srcbin" || {
|
||||
c_err "[$ENV_NAME] 候选无法验证已建立的 DB 身份;旧服务/旧制品/登记保持不变"
|
||||
return 1
|
||||
}
|
||||
;;
|
||||
esac
|
||||
svc_schema_compatible "$ENV_BIN" "$srcbin" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# svc_schema_compatible INSTALLED CANDIDATE:候选 compatFloor 高于已装制品的
|
||||
# schema.target 时,迁移会让旧制品读不了库。停旧之前拒绝。缺 schema 字段视为兼容。
|
||||
svc_schema_compatible() {
|
||||
local installed="$1" candidate="$2"
|
||||
local icaps ccaps
|
||||
icaps="$(svc_bin_caps "$installed" 2>/dev/null || true)"
|
||||
ccaps="$(svc_bin_caps "$candidate" 2>/dev/null || true)"
|
||||
python3 - "$icaps" "$ccaps" <<'PY'
|
||||
import json, sys
|
||||
def load(raw):
|
||||
try:
|
||||
return json.loads(raw) if raw else {}
|
||||
except Exception:
|
||||
return {}
|
||||
old, new = load(sys.argv[1]), load(sys.argv[2])
|
||||
osch, nsch = old.get("schema") or {}, new.get("schema") or {}
|
||||
if "target" not in osch or "compatFloor" not in nsch:
|
||||
sys.exit(0)
|
||||
if int(osch["target"]) < int(nsch["compatFloor"]):
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
PY
|
||||
local rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "${CLI_MIGRATE_SCHEMA:-0}" = 1 ]; then
|
||||
local iid backup
|
||||
iid="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["instance_id"])' "$ENV_DIR/manifest.json")"
|
||||
[ "$CLI_CONFIRM" = "$iid" ] || { c_err "[$ENV_NAME] --migrate-schema 需要 --confirm $iid"; return 1; }
|
||||
backup="$(agent_backup_root)"
|
||||
if ! find "$backup" -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then
|
||||
c_err "[$ENV_NAME] forward-only 迁移必须先 backup"
|
||||
return 1
|
||||
fi
|
||||
c_warn "[$ENV_NAME] forward-only schema 迁移已确认,回滚不会覆盖业务库"
|
||||
return 0
|
||||
fi
|
||||
c_err "[$ENV_NAME] 候选 schema 与已装制品不兼容,停旧服务前拒绝"
|
||||
c_err " 若必须前向迁移:scripts/svc.sh $ENV_NAME deploy --migrate-schema --confirm <instance_id>(会先 backup)"
|
||||
return 1
|
||||
}
|
||||
|
||||
# svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 running,
|
||||
# 显式 stop 置 stopped;进程自行死亡不动——recover 据 svc.env 派生的 autostart 判断)。
|
||||
# svc_write_serve_env:首次显式启动把白名单环境(不含凭据、不含 AGENT_SYSTEM_*)
|
||||
# 写入 0600 的 serve.env。裸启动与 recover 复用它,避免交互壳与 watchdog 环境不同。
|
||||
svc_write_serve_env() {
|
||||
local f="$ENV_DIR/serve.env"
|
||||
[ -f "$f" ] && return 0
|
||||
( umask 077
|
||||
{
|
||||
printf 'PATH=%s\n' "${PATH:-/usr/bin:/bin}"
|
||||
printf 'HOME=%s\n' "${HOME:-/tmp}"
|
||||
printf 'USER=%s\n' "${USER:-}"
|
||||
printf 'LOGNAME=%s\n' "${LOGNAME:-${USER:-}}"
|
||||
printf 'LANG=%s\n' "${LANG:-C.UTF-8}"
|
||||
printf 'TZ=%s\n' "${TZ:-UTC}"
|
||||
printf 'TMPDIR=%s\n' "${TMPDIR:-/tmp}"
|
||||
} > "$f"
|
||||
)
|
||||
chmod 0600 "$f" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# svc_launch_serve ARGS:env -i 只带 serve.env 与本次构造的 guard_env。
|
||||
svc_launch_serve() {
|
||||
local exec_args="$1"
|
||||
local -a args=(env -i)
|
||||
local line
|
||||
if [ -f "$ENV_DIR/serve.env" ]; then
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
[ -z "$line" ] && continue
|
||||
case "$line" in
|
||||
AGENT_SYSTEM_*|*' '*) continue ;;
|
||||
*=*) args+=("$line") ;;
|
||||
esac
|
||||
done < "$ENV_DIR/serve.env"
|
||||
fi
|
||||
if [ "${#guard_env[@]}" -gt 0 ]; then
|
||||
args+=("${guard_env[@]}")
|
||||
fi
|
||||
# 桩只靠 SVC_STUB_* 找到记录目录。这些变量不写入 serve.env,避免 recover
|
||||
# 把某次测试路径固化进实例;生产调用方没有它们。
|
||||
# RDM_DISABLE_EXEC_TOOLS 是产品兜底开关,必须到达 serve,否则无 bwrap 时拒启。
|
||||
local k
|
||||
while IFS= read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
args+=("$k=${!k}")
|
||||
done < <(compgen -e | grep -E '^SVC_STUB_' || true)
|
||||
if [ -n "${RDM_DISABLE_EXEC_TOOLS:-}" ]; then
|
||||
args+=("RDM_DISABLE_EXEC_TOOLS=$RDM_DISABLE_EXEC_TOOLS")
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
nohup "${args[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
|
||||
}
|
||||
|
||||
# svc_lifecycle_child ACTION [extra args...]:用清空的环境拉起本脚本的
|
||||
# __lifecycle。白名单:PATH/HOME/USER/LOGNAME/LANG/LC_*/TZ/TMPDIR、代理、SVC_*。
|
||||
# 从不传入 AGENT_SYSTEM_*。
|
||||
svc_lifecycle_child() {
|
||||
local -a args=(env -i "PATH=${PATH:-/usr/bin:/bin}" "HOME=${HOME:-/tmp}"
|
||||
"USER=${USER:-}" "LOGNAME=${LOGNAME:-${USER:-}}" "LANG=${LANG:-C.UTF-8}"
|
||||
"TZ=${TZ:-UTC}" "TMPDIR=${TMPDIR:-/tmp}")
|
||||
local k
|
||||
for k in http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy NO_PROXY; do
|
||||
if [ -n "${!k:-}" ]; then
|
||||
args+=("$k=${!k}")
|
||||
fi
|
||||
done
|
||||
while IFS= read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
args+=("$k=${!k}")
|
||||
done < <(compgen -e | grep -E '^(SVC_|LC_)' || true)
|
||||
if [ -n "${RDM_DISABLE_EXEC_TOOLS:-}" ]; then
|
||||
args+=("RDM_DISABLE_EXEC_TOOLS=$RDM_DISABLE_EXEC_TOOLS")
|
||||
fi
|
||||
args+=(bash "$0" __lifecycle "$@")
|
||||
"${args[@]}"
|
||||
}
|
||||
|
||||
svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired running >/dev/null 2>&1 && return 0; svc_set_key autostart 1 >/dev/null 2>&1 || true; }
|
||||
svc_clear_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired stopped >/dev/null 2>&1 && return 0; svc_del_key autostart 2>/dev/null || true; }
|
||||
|
||||
@@ -1590,23 +1811,161 @@ resolve_source() { # → SRC_KINDSRC(local|dev|release) + SRC_LABEL(+SRC_FULL/
|
||||
esac
|
||||
}
|
||||
|
||||
install_artifacts() { # SRCBIN SRCPLUGINS:agent 产物复制进服务目录。
|
||||
# svc_fault_check PHASE:仅当 SVC_TEST_FAULTS=1 且 ROOT 在临时目录时,于该阶段退出。
|
||||
svc_fault_check() {
|
||||
local phase="$1"
|
||||
[ "${SVC_TEST_FAULTS:-}" = 1 ] || return 0
|
||||
[ "${SVC_FAULT_AT:-}" = "$phase" ] || return 0
|
||||
case "$ROOT" in
|
||||
/tmp/*|/var/tmp/*) ;;
|
||||
*) c_err "[$ENV_NAME] 忽略故障注入:ROOT 不在临时目录"; return 0 ;;
|
||||
esac
|
||||
c_err "[$ENV_NAME] 故障注入于 $phase"
|
||||
exit 86
|
||||
}
|
||||
|
||||
svc_journal_write() {
|
||||
local phase="$1"
|
||||
SVC_OP_ID="${SVC_OP_ID:-op-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
|
||||
python3 - "$ENV_DIR/deploy-journal.json" "$phase" "$SVC_OP_ID" \
|
||||
"${SVC_FROM_RID:-}" "${SVC_TO_RID:-}" "${SVC_FROM_DESIRED:-running}" \
|
||||
"${SVC_WAS_RUNNING:-0}" <<'PY'
|
||||
import json, os, sys
|
||||
path, phase, op, fr, to, desired, running = sys.argv[1:]
|
||||
doc = {
|
||||
"op_id": op,
|
||||
"from_rid": fr,
|
||||
"to_rid": to,
|
||||
"from_desired": desired,
|
||||
"was_running": running == "1",
|
||||
"phase": phase,
|
||||
}
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = path + ".new"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(doc, f, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
os.chmod(path, 0o600)
|
||||
PY
|
||||
svc_fault_check "$phase"
|
||||
}
|
||||
|
||||
svc_journal_phase() {
|
||||
[ -f "$ENV_DIR/deploy-journal.json" ] || return 0
|
||||
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("phase",""))' "$ENV_DIR/deploy-journal.json"
|
||||
}
|
||||
|
||||
# svc_release_switch RID:current 用 ln -s + mv -T 原子切换,bin/plugins 保持稳定链接。
|
||||
svc_release_switch() {
|
||||
local rid="$1"
|
||||
local rel="$ENV_DIR/releases/$rid"
|
||||
[ -d "$rel/bin" ] || { c_err "[$ENV_NAME] 版本目录缺少 bin:$rel"; return 1; }
|
||||
ln -s "$rid" "$ENV_DIR/current.new"
|
||||
mv -Tf "$ENV_DIR/current.new" "$ENV_DIR/current"
|
||||
ln -sfn "current/bin" "$ENV_DIR/bin"
|
||||
ln -sfn "current/plugins" "$ENV_DIR/plugins"
|
||||
if [ -d "$rel/htdocs" ]; then
|
||||
ln -sfn "current/htdocs" "$ENV_DIR/htdocs"
|
||||
fi
|
||||
}
|
||||
|
||||
svc_release_stage() { # SRC BIN PLUGINS → 打印 rid
|
||||
local srcbin="$1" srcplug="$2"
|
||||
local digest sha rid dest
|
||||
digest="$(sha256sum "$srcbin" | awk '{print substr($1,1,12)}')"
|
||||
sha="$(printf '%s' "${SRC_SHA:-inplace}" | cut -c1-12)"
|
||||
rid="${SRC_KINDSRC:-inplace}-${sha}-${digest}"
|
||||
dest="$ENV_DIR/releases/$rid"
|
||||
if [ ! -d "$dest/bin" ]; then
|
||||
rm -rf "${dest}.partial"
|
||||
mkdir -p "${dest}.partial/bin" "${dest}.partial/plugins"
|
||||
cp -f "$srcbin" "${dest}.partial/bin/agent-system" || return 1
|
||||
chmod 755 "${dest}.partial/bin/agent-system"
|
||||
if [ -d "$srcplug" ]; then
|
||||
cp -a "$srcplug/." "${dest}.partial/plugins/" || return 1
|
||||
fi
|
||||
python3 - "${dest}.partial/release.json" "$rid" "${SRC_LABEL:-}" "$digest" <<'PY'
|
||||
import json, sys
|
||||
path, rid, label, digest = sys.argv[1:]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump({"rid": rid, "source": label, "bin_digest12": digest}, f)
|
||||
f.write("\n")
|
||||
PY
|
||||
mv "${dest}.partial" "$dest"
|
||||
fi
|
||||
printf '%s' "$rid"
|
||||
}
|
||||
|
||||
svc_release_rollback() {
|
||||
local from
|
||||
from="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("from_rid",""))' "$ENV_DIR/deploy-journal.json" 2>/dev/null || true)"
|
||||
svc_journal_write rolling_back || true
|
||||
if [ -z "$from" ] || [ ! -d "$ENV_DIR/releases/$from/bin" ]; then
|
||||
svc_journal_write aborted || true
|
||||
c_err "[$ENV_NAME] 无法回滚:没有上一版本"
|
||||
return 3
|
||||
fi
|
||||
svc_release_switch "$from" || { svc_journal_write aborted || true; return 3; }
|
||||
local was
|
||||
was="$(python3 -c 'import json,sys; print("1" if json.load(open(sys.argv[1])).get("was_running") else "0")' "$ENV_DIR/deploy-journal.json")"
|
||||
if [ "$was" = 1 ]; then
|
||||
if agent_do_start; then
|
||||
svc_journal_write rolled_back
|
||||
return 0
|
||||
fi
|
||||
svc_journal_write aborted || true
|
||||
c_err "[$ENV_NAME] 回滚后的旧版本也未能启动,期望态保持"
|
||||
return 3
|
||||
fi
|
||||
svc_journal_write rolled_back
|
||||
return 0
|
||||
}
|
||||
|
||||
svc_reconcile_journal() {
|
||||
[ -f "$ENV_DIR/deploy-journal.json" ] || return 0
|
||||
local phase
|
||||
phase="$(svc_journal_phase)"
|
||||
case "$phase" in
|
||||
""|committed|rolled_back|aborted) return 0 ;;
|
||||
esac
|
||||
c_warn "[$ENV_NAME] 发现未完成的切换(phase=$phase),回滚到上一版本"
|
||||
svc_release_rollback
|
||||
}
|
||||
|
||||
install_artifacts() { # SRCBIN SRCPLUGINS:写入不可变版本目录并原子切换 current。
|
||||
local srcbin="$1" srcplug="$2"
|
||||
# 覆盖前必须无本服务占口进程——否则留下 exe=(deleted) 孤儿,后续 start 误报非本服务。
|
||||
if ! reclaim_own_port; then
|
||||
c_err "[$ENV_NAME] 安装前端口仍被占用,拒绝覆盖二进制(避免 exe deleted 孤儿)"
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ENV_BIN_DIR"
|
||||
rm -rf "$ENV_DIR/plugins"
|
||||
cp -f "$srcbin" "$ENV_BIN" || return 1
|
||||
if [ -d "$srcplug" ]; then
|
||||
cp -r "$srcplug" "$ENV_DIR/plugins" || return 1
|
||||
else
|
||||
c_warn "[$ENV_NAME] 源插件目录缺失:$srcplug(按空插件集部署)"
|
||||
mkdir -p "$ENV_DIR/plugins"
|
||||
if [ -d "$ENV_BIN_DIR" ] && [ ! -L "$ENV_BIN_DIR" ] && [ -x "$ENV_BIN" ]; then
|
||||
local old_digest old_rid
|
||||
old_digest="$(sha256sum "$ENV_BIN" | awk '{print substr($1,1,12)}')"
|
||||
old_rid="inplace-${old_digest}"
|
||||
if [ ! -d "$ENV_DIR/releases/$old_rid/bin" ]; then
|
||||
mkdir -p "$ENV_DIR/releases/$old_rid"
|
||||
mv "$ENV_BIN_DIR" "$ENV_DIR/releases/$old_rid/bin"
|
||||
if [ -d "$ENV_DIR/plugins" ] && [ ! -L "$ENV_DIR/plugins" ]; then
|
||||
mv "$ENV_DIR/plugins" "$ENV_DIR/releases/$old_rid/plugins"
|
||||
else
|
||||
mkdir -p "$ENV_DIR/releases/$old_rid/plugins"
|
||||
fi
|
||||
fi
|
||||
SVC_FROM_RID="$old_rid"
|
||||
svc_release_switch "$old_rid"
|
||||
elif [ -L "$ENV_DIR/current" ]; then
|
||||
SVC_FROM_RID="$(readlink "$ENV_DIR/current")"
|
||||
fi
|
||||
c_ok "[$ENV_NAME] 产物就位:$ENV_BIN(plugins:$ENV_DIR/plugins)"
|
||||
local rid
|
||||
rid="$(svc_release_stage "$srcbin" "$srcplug")" || return 1
|
||||
SVC_TO_RID="$rid"
|
||||
svc_journal_write switched || return 1
|
||||
svc_release_switch "$rid" || return 1
|
||||
c_ok "[$ENV_NAME] 产物就位:$ENV_BIN(release $rid)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- U2:不可变版本目录(releases/)+ current 原子指针 + 切换 journal + 失败回退 ----
|
||||
@@ -1641,11 +2000,11 @@ svc_release_adopt_legacy() {
|
||||
c_info "[$ENV_NAME] 领养 legacy 制品为首个 release:releases/$id"
|
||||
}
|
||||
|
||||
# svc_release_id:源标签净化的版本目录名(rdm-ddc09eb-20261012T131500)。
|
||||
# svc_release_id:源标签 + 时钟 + 调用进程身份,同源同秒部署仍使用不同目录。
|
||||
svc_release_id() {
|
||||
local label
|
||||
label="$(printf '%s' "${SRC_LABEL:-manual}" | tr '@' '-' | tr -cd 'a-zA-Z0-9._-' | cut -c1-40)"
|
||||
printf '%s-%s' "$label" "$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
printf '%s-%s-%s' "$label" "$(date -u +%Y%m%dT%H%M%SZ)" "$BASHPID"
|
||||
}
|
||||
|
||||
# svc_release_stage ID SRCBIN SRCPLUGINS [HTDOCS]:候选产物入不可变版本目录。
|
||||
@@ -1653,8 +2012,10 @@ svc_release_id() {
|
||||
svc_release_stage() {
|
||||
local id="$1" srcbin="$2" srcplug="$3" htdocs="${4:-}"
|
||||
local dir="$ENV_DIR/releases/$id"
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir/bin" || return 1
|
||||
mkdir -p "$ENV_DIR/releases" || return 1
|
||||
# 排他创建:重复 id 必须拒绝,不能覆盖仍用于运行或回退的历史制品。
|
||||
mkdir "$dir" || { c_err "[$ENV_NAME] release 目录已存在或无法创建:$dir"; return 1; }
|
||||
mkdir "$dir/bin" || return 1
|
||||
cp -f "$srcbin" "$dir/bin/agent-system" || return 1
|
||||
if [ -d "$srcplug" ]; then
|
||||
cp -r "$srcplug" "$dir/plugins" || return 1
|
||||
@@ -2243,6 +2604,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
|
||||
@@ -2741,11 +3110,8 @@ agent_do_start() {
|
||||
fi
|
||||
# 9>&-:显式关闭锁 fd——服务进程绝不能继承 .start.lock(长驻持有会令后续
|
||||
# 生命周期操作在锁上自锁死等;2026-10-11 自恢复任务实测发现)。
|
||||
if [ "${#guard_env[@]}" -gt 0 ];then
|
||||
nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
|
||||
else
|
||||
nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
|
||||
fi
|
||||
svc_write_serve_env
|
||||
svc_launch_serve "$exec_args"
|
||||
echo $! > "$ENV_PID"
|
||||
# 健康探测:取 exec 里的 --addr(复用持久化配置时可能与当前派生值不同)。
|
||||
local probe="${ENV_ADDR}"
|
||||
@@ -2758,6 +3124,16 @@ agent_do_start() {
|
||||
if curl -s -o /dev/null -m 2 "http://$phost:${probe##*:}/" 2>/dev/null; then
|
||||
# 健康通过才写 svc.env——启动失败不再用新参数覆盖上次的可用配置
|
||||
#(2026-10-05 dev 事故:失败后坏 exec= 持久化,裸 start 复用导致二次失败)。
|
||||
# fresh:库由本次启动建立后再 ensure,升为 established。失败则停掉进程,
|
||||
# 不把半套身份登记成已启动。
|
||||
if [ "$(svc_db_marker 2>/dev/null || true)" = fresh ]; then
|
||||
if ! svc_db_identity_ensure; then
|
||||
kill "$(read_pid)" 2>/dev/null || true
|
||||
c_err "[$ENV_NAME] 启动后建立 DB 身份失败,已停进程"
|
||||
hist_event start fail "$(read_pid)" "fresh ensure 失败"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
record_state
|
||||
svc_mark_autostart
|
||||
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)"
|
||||
@@ -3986,6 +4362,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,19 +4370,34 @@ do_stop() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# do_stop_quiet:deploy 前置——未运行时静默返回(同时清除期望态:
|
||||
# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。
|
||||
# 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 [--keep-desired]:deploy 前置。切换事务必须保留期望态,
|
||||
# 失败回滚后仍按原期望拉起。显式 stop 仍清除期望态。未运行时静默返回。
|
||||
do_stop_quiet() {
|
||||
local keep=0
|
||||
[ "${1:-}" = --keep-desired ] && keep=1
|
||||
local pid; pid="$(read_pid)"
|
||||
if [ -z "$pid" ]; then
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart
|
||||
[ "$keep" = 1 ] || svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
if ! pid_alive "$pid"; then
|
||||
rm -f "$ENV_PID"
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart
|
||||
[ "$keep" = 1 ] || svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
case "$SVC_KIND" in
|
||||
@@ -4014,7 +4406,8 @@ do_stop_quiet() {
|
||||
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
|
||||
proc_stop "$pid"
|
||||
rm -f "$ENV_PID"
|
||||
svc_clear_autostart
|
||||
[ "$keep" = 1 ] || svc_clear_autostart
|
||||
svc_wait_port_released
|
||||
c_ok "[$ENV_NAME] 已停止"
|
||||
hist_event stop ok "$pid" "SIGTERM"
|
||||
;;
|
||||
@@ -4681,29 +5074,8 @@ cmd_update() {
|
||||
esac
|
||||
c_info "[$name] 更新:$from → $tov($deploy_from)"
|
||||
hist_event update begin "$(read_pid)" "from=$from to=$tov"
|
||||
CLI_FROM="$deploy_from"
|
||||
svc_lock wait
|
||||
# U1:批量更新与单实例生命周期同入口——锁内 manifest 迁移(领养/身份治理;
|
||||
# 2026-10-12 rdm1 实况:update 直入 agent_do_deploy 绕过迁移,多次更新后仍无
|
||||
# manifest)。归属冲突按该服务更新失败计,其余服务继续。
|
||||
if ! svc_manifest_migrate; then
|
||||
hist_event update fail "$(read_pid)" "from=$from to=$tov(manifest 迁移中止:身份治理冲突)"
|
||||
c_err "[$name] 更新失败(manifest 迁移中止),其余服务继续"
|
||||
svc_unlock
|
||||
CLI_FROM=""
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
# U2:批内同样处理中断切换(deploy 可跨越 blocked)。
|
||||
if ! svc_deploy_journal_resolve deploy; then
|
||||
hist_event update fail "$(read_pid)" "from=$from to=$tov(中断切换处置失败)"
|
||||
c_err "[$name] 更新失败(deploy.journal 处置),其余服务继续"
|
||||
svc_unlock
|
||||
CLI_FROM=""
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
if agent_do_deploy; then
|
||||
# 每个服务独立子进程、清空环境,避免批内 AGENT_SYSTEM_* 串实例。
|
||||
if svc_lifecycle_child deploy "$name" --from "$deploy_from"; then
|
||||
hist_event update ok "$(read_pid)" "from=$from to=$tov"
|
||||
c_ok "[$name] 已更新:$from → $tov"
|
||||
updated=$((updated + 1))
|
||||
@@ -4712,8 +5084,6 @@ cmd_update() {
|
||||
c_err "[$name] 更新失败($from → $tov),其余服务继续"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
svc_unlock
|
||||
CLI_FROM=""
|
||||
done
|
||||
c_info "更新汇总:成功 $updated / 失败 $failed / 跳过 $skipped / 其余最新或不适用"
|
||||
[ "$failed" -eq 0 ] && return 0 || return 1
|
||||
@@ -4772,25 +5142,21 @@ EOF
|
||||
[ "$(env_kv_of "$n" autostart)" = "1" ] || { skipped=$((skipped + 1)); continue; }
|
||||
local st; st="$(svc_pid_state "$n")"
|
||||
if [ "${st%% *}" = alive ]; then skipped=$((skipped + 1)); continue; fi
|
||||
env_config "$n"
|
||||
if ! svc_lock -n; then
|
||||
c_warn "[$n] 生命周期锁被占用(deploy/手动操作进行中),本轮跳过"
|
||||
skipped=$((skipped + 1)); continue
|
||||
fi
|
||||
# U2:拉起前处置中断的切换(switched → 回退上一完整版本再按期望态启动)。
|
||||
if ! svc_deploy_journal_resolve recover; then
|
||||
c_warn "[$n] 存在阻断的切换记录,本轮跳过(见 deploy.journal)"
|
||||
failed=$((failed + 1)); rc=1; svc_unlock; continue
|
||||
fi
|
||||
c_info "[$n] recover:autostart=1 且进程已死,重新拉起(复用持久化配置)"
|
||||
if do_start; then
|
||||
# 子进程自己抢锁;锁被占用时 __lifecycle 以 75 退出,本轮记跳过。
|
||||
local child_rc=0
|
||||
svc_lifecycle_child recover "$n" || child_rc=$?
|
||||
env_config "$n"
|
||||
if [ "$child_rc" -eq 0 ]; then
|
||||
hist_event recover ok "$(read_pid)" "autostart revival"
|
||||
recovered=$((recovered + 1))
|
||||
elif [ "$child_rc" -eq 75 ]; then
|
||||
c_warn "[$n] 生命周期锁被占用(deploy/手动操作进行中),本轮跳过"
|
||||
skipped=$((skipped + 1))
|
||||
else
|
||||
hist_event recover fail "" "autostart revival 失败(见 start fail 事件与 svc.log)"
|
||||
failed=$((failed + 1)); rc=1
|
||||
fi
|
||||
svc_unlock
|
||||
done
|
||||
if [ "$recovered" -gt 0 ] || [ "$failed" -gt 0 ]; then
|
||||
c_event "recover 汇总:拉回 $recovered / 失败 $failed / 无需动作 $skipped"
|
||||
@@ -4916,7 +5282,52 @@ EOF
|
||||
|
||||
# ---- main ----
|
||||
|
||||
# svc_lifecycle_main ACTION NAME [--from SPEC] [--quiet]
|
||||
# 锁内:迁移 → 身份策略(在各动作里)→ 动作 → 解锁。供 update/recover 子进程使用。
|
||||
svc_lifecycle_main() {
|
||||
local action="${1:-}" name="${2:-}"
|
||||
[ -n "$action" ] && [ -n "$name" ] || { c_err "__lifecycle 需要动作与服务名"; exit 2; }
|
||||
shift 2
|
||||
valid_env "$name" || exit 2
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--from) CLI_FROM="$2"; shift 2 ;;
|
||||
--quiet) CLI_QUIET=1; shift ;;
|
||||
*) c_err "__lifecycle 未知参数:$1"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
env_config "$name"
|
||||
if ! svc_lock -n; then
|
||||
exit 75
|
||||
fi
|
||||
case "$action" in
|
||||
deploy|recover|start) svc_manifest_migrate || { svc_unlock; exit 1; } ;;
|
||||
esac
|
||||
# 对账必须发生在干净子进程里:父进程的调用方环境不得进入部署。
|
||||
case "$action" in
|
||||
deploy|recover) svc_deploy_journal_resolve || { svc_unlock; exit 1; } ;;
|
||||
esac
|
||||
local rc=0
|
||||
case "$action" in
|
||||
deploy)
|
||||
case "$SVC_KIND" in
|
||||
agent) agent_do_deploy || rc=$? ;;
|
||||
static) static_do_deploy && static_do_start || rc=$? ;;
|
||||
*) c_err "[$name] 该类型不支持 deploy"; rc=2 ;;
|
||||
esac
|
||||
;;
|
||||
recover|start) do_start || rc=$? ;;
|
||||
*) c_err "未知生命周期动作:$action"; rc=2 ;;
|
||||
esac
|
||||
svc_unlock
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
main() {
|
||||
if [ "${1:-}" = "__lifecycle" ]; then
|
||||
shift
|
||||
svc_lifecycle_main "$@"
|
||||
fi
|
||||
[ $# -ge 1 ] || { usage_short; exit 2; }
|
||||
# 预扫 --config/--env(FILE 与 =FILE 两种写法,可出现在任意位置)。
|
||||
local svc_envfile="" pre=()
|
||||
@@ -4963,7 +5374,7 @@ main() {
|
||||
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
|
||||
local action="deploy"
|
||||
case "${1:-}" in
|
||||
deploy|stop|start|restart|status|logs|db|config|init|user|backup|backups) action="$1"; shift ;;
|
||||
deploy|stop|start|restart|status|logs|db|config|init|adopt|user|backup|backups) action="$1"; shift ;;
|
||||
esac
|
||||
if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||
CLI_LOGS_N="$1"; shift
|
||||
@@ -4987,6 +5398,15 @@ main() {
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
local adopt_confirm=""
|
||||
if [ "$action" = adopt ]; then
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--confirm) adopt_confirm="$2"; shift 2 ;;
|
||||
*) c_err "adopt 用法:scripts/svc.sh $ENV_NAME adopt --confirm <instance_id>"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
parse_args "$@"
|
||||
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ] || [ "$CLI_ALLOW_SOURCE_SWITCH" = 1 ]; } && [ "$action" != deploy ]; then
|
||||
c_err "--from/--pick/--dry-run/--dir/--allow-source-switch 仅用于 deploy 动作"
|
||||
@@ -4996,12 +5416,12 @@ main() {
|
||||
# 生命周期动作持 .start.lock(wait 模式;进程退出自动释放)——deploy 全程含
|
||||
# 构建窗口,防 recover/并发 start 在停机-换产物间隙抢跑。只读动作不加锁。
|
||||
case "$action" in
|
||||
deploy|stop|start|restart|init) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;;
|
||||
deploy|stop|start|restart|init|adopt) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;;
|
||||
esac
|
||||
# U1 身份:锁内一次性迁移(幂等;含既有数据领养 marker)。归属冲突(rc=4)中止;
|
||||
# 其余失败保持 legacy 治理继续运行。
|
||||
case "$action" in
|
||||
start|restart|deploy|init) svc_manifest_migrate || exit 1 ;;
|
||||
start|restart|deploy|init|adopt) svc_manifest_migrate || exit 1 ;;
|
||||
esac
|
||||
# U2:入口处理中断的切换(deploy.journal)。blocked 拒绝除 deploy 外的动作
|
||||
#(显式换制品即解除路径);start/restart 触发回退判定。
|
||||
@@ -5019,22 +5439,59 @@ main() {
|
||||
start) do_start ;;
|
||||
restart) do_restart ;;
|
||||
init)
|
||||
# 显式建立/查看身份(manifest+数据 marker+DB marker)。迁移钩子已先行:有
|
||||
# svc.env 无 manifest 时 migrate 已建好,此处只报告;无 svc.env 引导走首次 start。
|
||||
# 只用于 fresh/pending 的显式建立。established 缺行不得用 init 补,走 adopt。
|
||||
command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || {
|
||||
c_err "[$ENV_NAME] init 需要 python3 与 scripts/svc_manifest.py"; exit 2; }
|
||||
if [ -f "$ENV_DIR/manifest.json" ]; then
|
||||
c_ok "[$ENV_NAME] 已在 manifest 治理下(init 幂等):"
|
||||
case "$(svc_db_marker)" in
|
||||
established)
|
||||
c_err "[$ENV_NAME] 身份已建立。缺行或不匹配时先调查,再 scripts/svc.sh $ENV_NAME adopt --confirm <instance_id>"
|
||||
exit 1 ;;
|
||||
esac
|
||||
c_ok "[$ENV_NAME] 已在 manifest 治理下(init 补齐 fresh/pending):"
|
||||
elif [ -f "$ENV_DIR/svc.env" ]; then
|
||||
python3 "$MANIFEST_PY" "$ENV_DIR" init \
|
||||
|| { c_err "[$ENV_NAME] init 失败(数据根冲突等,见上)"; exit 1; }
|
||||
else
|
||||
c_err "[$ENV_NAME] 尚无 svc.env:先用 start 完成首次登记;init 用于显式化已有实例"; exit 2
|
||||
fi
|
||||
# I3:尽力解析 DB 后 Ensure(sqlite 无需 DSN;pg 需绑定已登记)。
|
||||
resolve_db_config 2>/dev/null || true
|
||||
svc_db_identity_ensure || { c_err "[$ENV_NAME] DB 身份 ensure 失败"; exit 1; }
|
||||
python3 "$MANIFEST_PY" "$ENV_DIR" status ;;
|
||||
adopt)
|
||||
# 显式重审已建立实例的 DB 身份。先打印库大小、行数、最近写入,再要求 --confirm。
|
||||
[ -n "$adopt_confirm" ] || { c_err "adopt 必须 --confirm <instance_id>"; exit 2; }
|
||||
[ -f "$ENV_DIR/manifest.json" ] || { c_err "[$ENV_NAME] 无 manifest,不能 adopt"; exit 1; }
|
||||
local iid
|
||||
iid="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["instance_id"])' "$ENV_DIR/manifest.json")"
|
||||
[ "$adopt_confirm" = "$iid" ] || { c_err "[$ENV_NAME] --confirm 与 instance_id 不符($iid)"; exit 1; }
|
||||
resolve_db_config 2>/dev/null || true
|
||||
python3 - "$ENV_DIR" <<'PY'
|
||||
import json, os, sqlite3, sys, time
|
||||
env_dir = sys.argv[1]
|
||||
m = json.load(open(os.path.join(env_dir, "manifest.json"), encoding="utf-8"))
|
||||
data = (m.get("identity") or {}).get("data_root") or ""
|
||||
db = os.path.join(data, "agent-system.db")
|
||||
print("adopt 证据 instance_id=%s" % m["instance_id"])
|
||||
if not os.path.isfile(db):
|
||||
print("库文件不存在:%s" % db)
|
||||
sys.exit(0)
|
||||
st = os.stat(db)
|
||||
print("库大小=%d 字节 最近写入=%s" % (st.st_size, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime))))
|
||||
con = sqlite3.connect(db)
|
||||
try:
|
||||
for table in ("agent_system_identity", "conversations", "users"):
|
||||
try:
|
||||
n = con.execute("SELECT COUNT(*) FROM %s" % table).fetchone()[0]
|
||||
print("%s 行数=%s" % (table, n))
|
||||
except sqlite3.Error:
|
||||
print("%s 行数=(无此表)" % table)
|
||||
finally:
|
||||
con.close()
|
||||
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" ;;
|
||||
status) env_status_detail ;;
|
||||
logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;;
|
||||
esac
|
||||
|
||||
@@ -177,7 +177,12 @@ def build(env_dir, kv, allow_create):
|
||||
"service_group": name,
|
||||
"created_at": now_iso(),
|
||||
"desired": "running" if kv.get("autostart") == "1" else "stopped",
|
||||
"identity": {"data_root": data_root, "db_mode": db_mode, "db_ref": db_ref},
|
||||
"identity": {
|
||||
"data_root": data_root,
|
||||
"db_mode": db_mode,
|
||||
"db_ref": db_ref,
|
||||
"db_marker": "fresh",
|
||||
},
|
||||
"kv": {k: v for k, v in kv.items() if k != "autostart"},
|
||||
}
|
||||
save(env_dir, m)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""真实 Dev/RDM 组合门禁。由 make test-lifecycle 调用。
|
||||
|
||||
缺 PostgreSQL、RDM 源码或制品时直接失败,不静默跳过。
|
||||
RDM 登录依赖账户 CLI 公共化(账户方案 U7/U5),当前标 NOT_RUN,本套件因此不能报完成。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
PIN = Path(__file__).resolve().parent / "rdm.pin"
|
||||
|
||||
|
||||
def main():
|
||||
missing = []
|
||||
if not os.environ.get("AGENT_PG_TEST_DSN"):
|
||||
missing.append("AGENT_PG_TEST_DSN")
|
||||
if not os.environ.get("SVC_RDM_SRC_DIR") or not Path(os.environ.get("SVC_RDM_SRC_DIR", "")).is_dir():
|
||||
missing.append("SVC_RDM_SRC_DIR")
|
||||
if not (ROOT / "bin" / "agent-system").is_file() and not os.environ.get("SVC_DEV_BIN"):
|
||||
missing.append("Dev 制品(make build 或 SVC_DEV_BIN)")
|
||||
print("组合清单")
|
||||
print("pin", PIN.read_text().strip())
|
||||
print("login NOT_RUN:RDM 二进制没有 user/apikey,工作台登录依赖账户 CLI 公共化")
|
||||
scenarios = [
|
||||
"双向混合批次更新",
|
||||
"无 manifest 各入口一致",
|
||||
"legacy/缺行/不匹配停服前拒绝",
|
||||
"两次裸重启与换端口换版本换目录",
|
||||
"构建失败与启动失败回滚",
|
||||
"中断对账",
|
||||
"PG 绑定与跨实例错绑拒绝",
|
||||
"凭据不进 cmdline/日志",
|
||||
"RDM 页面、rdm.ping、升级前领域数据仍在",
|
||||
"登录",
|
||||
]
|
||||
for name in scenarios:
|
||||
state = "NOT_RUN" if missing or name == "登录" else "PENDING"
|
||||
print("%s %s" % (state, name))
|
||||
if missing or True:
|
||||
# 登录依赖未就绪时保持未完成。缺环境同样失败。
|
||||
print("test-lifecycle 未完成。缺:%s" % (", ".join(missing) if missing else "登录入口"))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,4 @@
|
||||
# RDM 制品钉扎。三行都是 40 位 hex。升级走 PR,不在 CI 里执行候选文件。
|
||||
sha=68de2aa41022c8a77206ea30c2164bec33632ee3
|
||||
prev_sha=00ae1494f5b45ae0b4ed955ee82debdfe830a1c3
|
||||
legacy_sha=72bb1fec79d64c2af5e49416640712320816c3b4
|
||||
@@ -0,0 +1,43 @@
|
||||
"""rdm.pin 格式:只接受固定 SHA,拒绝可执行内容。"""
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
PIN = Path(__file__).resolve().parents[1] / "tests" / "integration" / "lifecycle" / "rdm.pin"
|
||||
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def load_pin(text):
|
||||
out = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
raise ValueError("bad pin line")
|
||||
key, value = line.split("=", 1)
|
||||
if key not in ("sha", "prev_sha", "legacy_sha"):
|
||||
raise ValueError("unknown pin key")
|
||||
if not HEX40.match(value):
|
||||
raise ValueError("sha must be 40 hex")
|
||||
out[key] = value
|
||||
if set(out) != {"sha", "prev_sha", "legacy_sha"}:
|
||||
raise ValueError("pin keys incomplete")
|
||||
return out
|
||||
|
||||
|
||||
class RdmPinTests(unittest.TestCase):
|
||||
def test_pin_is_three_fixed_shas(self):
|
||||
doc = load_pin(PIN.read_text())
|
||||
self.assertEqual(len(doc["sha"]), 40)
|
||||
self.assertNotEqual(doc["sha"], doc["legacy_sha"])
|
||||
|
||||
def test_rejects_url_and_short_sha(self):
|
||||
with self.assertRaises(ValueError):
|
||||
load_pin("sha=https://example.invalid/repo.git\nprev_sha=" + "a" * 40 + "\nlegacy_sha=" + "b" * 40 + "\n")
|
||||
with self.assertRaises(ValueError):
|
||||
load_pin("sha=abc\nprev_sha=" + "a" * 40 + "\nlegacy_sha=" + "b" * 40 + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,6 +28,10 @@ trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
|
||||
mkdir -p "$trace_dir"
|
||||
case "${1:-}" in
|
||||
version)
|
||||
if [ "${2:-}" = "--json" ]; then
|
||||
printf '%s\n' '{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}'
|
||||
exit 0
|
||||
fi
|
||||
echo "stub-agent-system 1.0"
|
||||
exit 0 ;;
|
||||
config)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""部署切换事务:构建失败不动旧服务,schema 不兼容在停服前拒绝,中断后对账回滚。"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"tsi", SOURCE / "tests" / "test_svc_identity.py")
|
||||
tsi = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tsi)
|
||||
|
||||
|
||||
def caps(target, floor):
|
||||
return (
|
||||
'{"protocol":1,"product":"agent-system","version":"stub",'
|
||||
'"identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},'
|
||||
'"cli":{"unknownSubcommand":"reject"},'
|
||||
'"schema":{"target":%d,"compatFloor":%d}}\n' % (target, floor)
|
||||
)
|
||||
|
||||
|
||||
class DeployTxnTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fx = tsi.IdentityTests("test_T1_bare_restart_stable_identity")
|
||||
self.fx.setUp()
|
||||
|
||||
def tearDown(self):
|
||||
self.fx.tearDown()
|
||||
self.fx.doCleanups()
|
||||
|
||||
def test_schema_incompatible_refuses_before_stop(self):
|
||||
fx = self.fx
|
||||
old = fx.root / "stub-old"
|
||||
new = fx.root / "stub-new"
|
||||
text = (SOURCE / "tests" / "test_svc_recover.py").read_text()
|
||||
# 复用桩的 identity/serve,只替换 version --json。
|
||||
old.write_text(text[text.find("STUB = r'''") and 0:])
|
||||
# 直接基于已写入的 recover 桩文件内容不够稳。用 fx.stub 再替换 JSON。
|
||||
base = fx.stub.read_text()
|
||||
marker = '\'{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}\''
|
||||
old.write_text(base.replace(marker, "'" + caps(1, 0).strip() + "'"))
|
||||
new.write_text(base.replace(marker, "'" + caps(5, 5).strip() + "'"))
|
||||
old.chmod(0o755)
|
||||
new.chmod(0o755)
|
||||
fx.stub.write_text(old.read_text())
|
||||
fx.stub.chmod(0o755)
|
||||
fx.seed_agent("txn", 9471)
|
||||
fx.start_ok("txn")
|
||||
pid = fx.pid_of("txn")
|
||||
script = fx.root / "scripts" / "svc.sh"
|
||||
probe = fx.svc(
|
||||
"txn", "status", check=False)
|
||||
self.assertEqual(probe.returncode, 0, probe.stdout)
|
||||
out = __import__("subprocess").run(
|
||||
["bash", "-c",
|
||||
'source "%s" >/dev/null 2>&1; ENV_NAME=txn; ENV_DIR="%s"; ENV_BIN="%s"; '
|
||||
'if svc_schema_compatible "%s" "%s"; then echo OK; else echo NO; fi'
|
||||
% (script, fx.runtime("txn"), fx.runtime("txn") / "bin" / "agent-system",
|
||||
fx.runtime("txn") / "bin" / "agent-system", new)],
|
||||
cwd=fx.root, env=fx.env, text=True,
|
||||
stdout=__import__("subprocess").PIPE,
|
||||
stderr=__import__("subprocess").STDOUT, timeout=30)
|
||||
self.assertIn("NO", out.stdout, out.stdout)
|
||||
self.assertEqual(fx.pid_of("txn"), pid)
|
||||
fx.svc("txn", "stop")
|
||||
|
||||
def test_forward_only_requires_confirm_and_backup(self):
|
||||
fx = self.fx
|
||||
fx.seed_agent("fwd", 9472)
|
||||
fx.start_ok("fwd")
|
||||
iid = json.loads((fx.runtime("fwd") / "manifest.json").read_text())["instance_id"]
|
||||
base = fx.stub.read_text()
|
||||
marker = '\'{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}\''
|
||||
installed = fx.root / "stub-installed"
|
||||
candidate = fx.root / "stub-forward"
|
||||
installed.write_text(base.replace(marker, "'" + caps(1, 0).strip() + "'"))
|
||||
candidate.write_text(base.replace(marker, "'" + caps(5, 5).strip() + "'"))
|
||||
installed.chmod(0o755)
|
||||
candidate.chmod(0o755)
|
||||
script = fx.root / "scripts" / "svc.sh"
|
||||
envdir = fx.runtime("fwd")
|
||||
|
||||
def run(extra):
|
||||
return __import__("subprocess").run(
|
||||
["bash", "-c",
|
||||
'source "%s" >/dev/null 2>&1; ENV_NAME=fwd; ENV_DIR="%s"; '
|
||||
'ROOT="%s"; %s '
|
||||
'if svc_schema_compatible "%s" "%s"; then echo OK; else echo NO; fi'
|
||||
% (script, envdir, fx.root, extra, installed, candidate)],
|
||||
cwd=fx.root, env=fx.env, text=True,
|
||||
stdout=__import__("subprocess").PIPE,
|
||||
stderr=__import__("subprocess").STDOUT, timeout=30).stdout
|
||||
|
||||
self.assertIn("NO", run(""))
|
||||
self.assertIn("NO", run('CLI_MIGRATE_SCHEMA=1; CLI_CONFIRM="%s";' % iid))
|
||||
backup = fx.root / "runtime-backups" / "fwd" / "20261012T000000Z"
|
||||
backup.mkdir(parents=True)
|
||||
(backup / "ok").write_text("snapshot\n")
|
||||
self.assertIn("OK", run('CLI_MIGRATE_SCHEMA=1; CLI_CONFIRM="%s";' % iid))
|
||||
fx.svc("fwd", "stop")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+19
-23
@@ -229,7 +229,12 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
|
||||
" echo \"system identity: system identity missing: agent_system_identity 无行"
|
||||
"(先 svc init / identity ensure,禁止静默建空身份)\" >&2\n"
|
||||
" exit 1 ;;")
|
||||
p.write_text(head + noid_case + tail2)
|
||||
body = head + noid_case + tail2
|
||||
body = body.replace(
|
||||
'\'{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}\'',
|
||||
'"not-json"',
|
||||
)
|
||||
p.write_text(body)
|
||||
p.chmod(0o755)
|
||||
return p
|
||||
|
||||
@@ -316,11 +321,8 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
|
||||
覆盖→启动即拒,只剩混合状态)。"""
|
||||
remote, old_sha, new_sha = self.make_rdm_repo(self.noid_stub())
|
||||
self.seed_agent("rdx", 9451)
|
||||
self.start_ok("rdx") # 领养:manifest + DB 行
|
||||
self.svc("rdx", "stop")
|
||||
self.install_noid_bin("rdx") # 磁盘产物退化为无 identity(事故现场)
|
||||
self.start_ok("rdx") # 已建立身份,有能力制品在运行
|
||||
self.set_kv("rdx", "source", "rdm@%s" % old_sha)
|
||||
self.start_ok("rdx") # 校验跳过,照常运行
|
||||
pid = self.pid_of("rdx")
|
||||
bin_before = (self.runtime("rdx") / "bin" / "agent-system").read_bytes()
|
||||
|
||||
@@ -339,17 +341,12 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
|
||||
self.svc("rdx", "stop")
|
||||
|
||||
def test_U1_update_ensures_missing_row_before_stop(self):
|
||||
"""领养时未能补行的实例(旧产物无 identity):候选有能力时,update 在
|
||||
停服前用候选补齐 DB 身份行——完整更新成功;serve 拿到的是本服务自己的
|
||||
身份(非批内残留)。"""
|
||||
"""已建立身份后删行:update 在停服前拒绝,不自动补行,旧进程保持。
|
||||
补行只走 adopt --confirm。"""
|
||||
old_sha, new_sha = self.make_src_repo()
|
||||
self.seed_agent("rowx", 9452)
|
||||
self.start_ok("rowx")
|
||||
self.svc("rowx", "stop")
|
||||
self.install_noid_bin("rowx")
|
||||
self.set_kv("rowx", "source", "dev@%s" % old_sha)
|
||||
self.start_ok("rowx")
|
||||
# 模拟领养时未补行(rdm1 现状):清空 DB 身份行
|
||||
db = Path(self.manifest("rowx")["identity"]["data_root"]) / "agent-system.db"
|
||||
con = sqlite3.connect(str(db))
|
||||
con.execute("DELETE FROM agent_system_identity")
|
||||
@@ -357,15 +354,11 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
|
||||
con.close()
|
||||
|
||||
self.clear_serve_record()
|
||||
result = self.svc("update", "rowx")
|
||||
self.assertIn("已更新", result.stdout)
|
||||
self.assertEqual(self.kv("rowx", "source"), "dev@%s" % new_sha)
|
||||
iid = self.manifest("rowx")["instance_id"]
|
||||
self.assertEqual(self.db_identity_row("rowx"), [(iid, "sqlite")],
|
||||
"候选应在停服前补齐身份行")
|
||||
self.assertTrue(self.running("rowx"))
|
||||
self.assertIn("AGENT_SYSTEM_INSTANCE_ID=%s" % iid, self.serve_record(),
|
||||
"serve 只允许拿到本服务自己的身份")
|
||||
result = self.svc("update", "rowx", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("候选无法验证已建立的 DB 身份", result.stdout)
|
||||
self.assertTrue(self.running("rowx"), "缺行必须在停旧服务前拒绝")
|
||||
self.assertEqual(self.db_identity_row("rowx"), [], "established 不得自动补行")
|
||||
self.svc("rowx", "stop")
|
||||
|
||||
def test_U1_bare_start_missing_row_is_event_init_recovers(self):
|
||||
@@ -387,7 +380,10 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
|
||||
self.assertFalse(self.running("rowm"))
|
||||
self.assertEqual(self.db_identity_row("rowm"), [], "缺行不得被 start 静默补建")
|
||||
|
||||
self.svc("rowm", "init") # 修复路径:显式 ensure
|
||||
refused = self.svc("rowm", "init", check=False)
|
||||
self.assertNotEqual(refused.returncode, 0, refused.stdout)
|
||||
self.assertIn("adopt", refused.stdout)
|
||||
self.svc("rowm", "adopt", "--confirm", iid)
|
||||
self.assertEqual(self.db_identity_row("rowm"), [(iid, "sqlite")])
|
||||
self.start_ok("rowm")
|
||||
self.svc("rowm", "stop")
|
||||
@@ -412,7 +408,7 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
|
||||
"报错文本含 ensure 不得误报为有能力")
|
||||
hang = self.root / "stub-hang"
|
||||
hang.write_text(
|
||||
'#!/usr/bin/env bash\ncase "${1:-}" in identity) sleep 30 ;; '
|
||||
'#!/usr/bin/env bash\ncase "${1:-}" in version) sleep 30 ;; '
|
||||
'*) exec "%s" "$@" ;; esac\n' % self.stub)
|
||||
hang.chmod(0o755)
|
||||
self.assertIn("rc=1", probe(hang), "探测挂死须有界返回无能力")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""生命周期隔离:调用方的 AGENT_SYSTEM_* 不得进入 serve,serve.env 为 0600。"""
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
_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)
|
||||
|
||||
|
||||
class LifecycleIsolationTests(tsr.SvcAutoRecoverTests):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
|
||||
self.root / "scripts" / "svc_manifest.py")
|
||||
|
||||
def test_caller_identity_env_does_not_reach_serve(self):
|
||||
self.seed_agent("iso", 9461)
|
||||
env = dict(self.env)
|
||||
env["AGENT_SYSTEM_DSN"] = "postgres://leak-user:leak-pass@127.0.0.1/leak"
|
||||
env["AGENT_SYSTEM_DATA"] = "/tmp/leak-data"
|
||||
env["AGENT_SYSTEM_INSTANCE_ID"] = "svc-foreign-leak"
|
||||
self.svc("iso", "start", env=env)
|
||||
pid = self.pid_of("iso")
|
||||
self.assertIsNotNone(pid)
|
||||
raw = Path("/proc/%d/environ" % pid).read_bytes()
|
||||
self.assertNotIn(b"leak-pass", raw)
|
||||
self.assertNotIn(b"/tmp/leak-data", raw)
|
||||
self.assertNotIn(b"svc-foreign-leak", raw)
|
||||
serve_env = self.runtime("iso") / "serve.env"
|
||||
self.assertTrue(serve_env.is_file())
|
||||
self.assertEqual(serve_env.stat().st_mode & 0o777, 0o600)
|
||||
text = serve_env.read_text()
|
||||
self.assertNotIn("AGENT_SYSTEM_", text)
|
||||
self.assertNotIn("leak-pass", text)
|
||||
self.svc("iso", "stop")
|
||||
|
||||
def test_two_bare_restarts_keep_identity(self):
|
||||
self.seed_agent("iso2", 9462)
|
||||
self.svc("iso2", "start")
|
||||
manifest = json.loads((self.runtime("iso2") / "manifest.json").read_text())
|
||||
iid = manifest["instance_id"]
|
||||
data = manifest["identity"]["data_root"]
|
||||
self.svc("iso2", "stop")
|
||||
self.svc("iso2", "start")
|
||||
self.svc("iso2", "stop")
|
||||
self.svc("iso2", "start")
|
||||
again = json.loads((self.runtime("iso2") / "manifest.json").read_text())
|
||||
self.assertEqual(again["instance_id"], iid)
|
||||
self.assertEqual(again["identity"]["data_root"], data)
|
||||
self.assertEqual(again["identity"].get("db_marker"), "established")
|
||||
self.svc("iso2", "stop")
|
||||
@@ -0,0 +1,295 @@
|
||||
"""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"
|
||||
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", "on"],
|
||||
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()
|
||||
@@ -28,6 +28,10 @@ trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
|
||||
mkdir -p "$trace_dir"
|
||||
case "${1:-}" in
|
||||
version)
|
||||
if [ "${2:-}" = "--json" ]; then
|
||||
printf '%s\n' '{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}'
|
||||
exit 0
|
||||
fi
|
||||
echo "stub-agent-system 1.0"
|
||||
exit 0 ;;
|
||||
config)
|
||||
|
||||
@@ -9,6 +9,7 @@ tests/test_svc_identity.py(IdentityTests:manifest 治理 + 源仓轨道 + no
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
@@ -89,6 +90,21 @@ class U2ReleaseTests(tsi.IdentityTests):
|
||||
# ---- T1:release 布局 + 启动失败回退 + 期望态恢复 ----
|
||||
|
||||
def test_U2_layout_switch_and_rollback_on_start_failure(self):
|
||||
# Both deployments intentionally share the same clock tick: a failed
|
||||
# candidate must never overwrite the version needed for rollback.
|
||||
clock_bin = self.root / "fixed-clock"
|
||||
clock_bin.mkdir()
|
||||
real_date = shutil.which("date")
|
||||
clock = clock_bin / "date"
|
||||
clock.write_text(
|
||||
'#!/bin/bash\n'
|
||||
'if [[ "$*" == *"+%Y%m%dT%H%M%SZ"* ]]; then\n'
|
||||
' echo 20261012T120000Z\n'
|
||||
'else\n'
|
||||
f' exec {shlex.quote(real_date)} "$@"\n'
|
||||
'fi\n')
|
||||
clock.chmod(0o755)
|
||||
self.env["PATH"] = str(clock_bin) + os.pathsep + self.env["PATH"]
|
||||
self.make_src_repo() # 预克隆 srccache(本用例走 --from local,仅保环境一致)
|
||||
self.seed_agent("u2a", 9461)
|
||||
self.start_ok("u2a") # legacy 运行中(期望态=running)
|
||||
|
||||
Reference in New Issue
Block a user