Add a reversible release switch and a lifecycle pin
Why: an update that stops the old process and overwrites the binary cannot get the previous version back, and a migration the old binary cannot read must be refused before that stop. CI also had no fixed RDM SHA to build from. Evidence: python3 -m unittest tests.test_svc_identity tests.test_svc_recover tests.test_svc_deploy_txn tests.test_lifecycle_pin passed. make test-lifecycle exits 2 when AGENT_PG_TEST_DSN is unset. Impact: installs land in releases/<rid> and current switches with mv -T. Deploy keeps the desired state and rolls back a non-terminal journal. Schema compatFloor above the installed target is refused unless --migrate-schema --confirm is paired with a backup. test-lifecycle fails closed without PostgreSQL and an RDM checkout. Login stays NOT_RUN. Refs: recovery plan r3.1 D2 and D3; Backend feature/d1-identity-open Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -377,3 +377,5 @@ 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
Submodule modules/backend updated: 69ea7dcc14...5c26a86331
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]}"
|
||||
+224
-20
@@ -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 ;;
|
||||
@@ -1307,9 +1309,53 @@ svc_deploy_identity_gate() {
|
||||
}
|
||||
;;
|
||||
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_*)
|
||||
@@ -1741,23 +1787,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
|
||||
}
|
||||
|
||||
# materialize_dev_commit FULL SHA TARGET:隔离 worktree 物化+构建;stdout 只回显 worktree
|
||||
@@ -2029,6 +2213,11 @@ agent_do_deploy() {
|
||||
return 1
|
||||
fi
|
||||
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
|
||||
svc_reconcile_journal || return 1
|
||||
SVC_WAS_RUNNING=0
|
||||
pid_alive "$(read_pid)" && SVC_WAS_RUNNING=1
|
||||
SVC_FROM_DESIRED="running"
|
||||
svc_journal_write prepared || return 1
|
||||
# U1/U2(构建前置):先构建/物化候选并过身份门禁,再停旧服务——构建失败或候选
|
||||
# 不合格时旧服务继续运行、旧制品与登记不动(2026-10-12 rdm1/rdmcand 事故:停服
|
||||
# →覆盖→启动即拒,只留「登记版本≠磁盘制品」的混合状态且无恢复路径)。
|
||||
@@ -2039,7 +2228,8 @@ agent_do_deploy() {
|
||||
make -C "$ROOT" build || { hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"; return 1; }
|
||||
svc_deploy_identity_gate "$BIN" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"; return 1; }
|
||||
do_stop_quiet
|
||||
svc_journal_write stopping || return 1
|
||||
do_stop_quiet --keep-desired
|
||||
install_artifacts "$BIN" "$ROOT/plugins" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
|
||||
;;
|
||||
@@ -2051,7 +2241,8 @@ agent_do_deploy() {
|
||||
recycle_worktree "$SRC_SHA"
|
||||
return 1
|
||||
fi
|
||||
do_stop_quiet
|
||||
svc_journal_write stopping || return 1
|
||||
do_stop_quiet --keep-desired
|
||||
install_artifacts "$wt/bin/agent-system" "$wt/plugins" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
|
||||
recycle_worktree "$SRC_SHA"
|
||||
@@ -2066,7 +2257,8 @@ agent_do_deploy() {
|
||||
fi
|
||||
svc_deploy_identity_gate "$relbin" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"; return 1; }
|
||||
do_stop_quiet
|
||||
svc_journal_write stopping || return 1
|
||||
do_stop_quiet --keep-desired
|
||||
materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; }
|
||||
;;
|
||||
rdm)
|
||||
@@ -2079,7 +2271,8 @@ agent_do_deploy() {
|
||||
recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR"
|
||||
return 1
|
||||
fi
|
||||
do_stop_quiet
|
||||
svc_journal_write stopping || return 1
|
||||
do_stop_quiet --keep-desired
|
||||
install_artifacts "$wt/bin/agent-system-rdm" "$wt/.svc-empty-plugins" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
|
||||
# RDM 自带零构建工作台(web/)落 htdocs/;收尾自动部署伴生 ${name}web。
|
||||
@@ -2104,7 +2297,12 @@ agent_do_deploy() {
|
||||
hist_event deploy fail "$(read_pid)" "新产物不支持环境 DSN($SRC_LABEL)"
|
||||
return 1
|
||||
fi
|
||||
agent_do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; return 1; }
|
||||
svc_journal_write starting || return 1
|
||||
if ! agent_do_start; then
|
||||
hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"
|
||||
svc_release_rollback || return 3
|
||||
return 1
|
||||
fi
|
||||
case "$SRC_KINDSRC" in
|
||||
rdm)
|
||||
if [ "$RDM_UI_OK" != 1 ]; then
|
||||
@@ -2120,8 +2318,11 @@ agent_do_deploy() {
|
||||
esac
|
||||
if ! assert_ui_ready; then
|
||||
hist_event deploy fail "$(read_pid)" "ui-missing"
|
||||
svc_release_rollback || return 3
|
||||
return 1
|
||||
fi
|
||||
svc_journal_write verifying || return 1
|
||||
svc_journal_write committed || return 1
|
||||
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
|
||||
return 0
|
||||
}
|
||||
@@ -3830,6 +4031,7 @@ cmd_set() {
|
||||
# ---- 生命周期分派 ----
|
||||
|
||||
do_start() {
|
||||
svc_reconcile_journal || return 1
|
||||
if pid_alive "$(read_pid)"; then
|
||||
c_warn "[$ENV_NAME] 已在运行 pid=$(read_pid)(如需换参数/新版本请用 deploy/restart)"
|
||||
return 0
|
||||
@@ -3872,19 +4074,21 @@ do_stop() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# do_stop_quiet:deploy 前置——未运行时静默返回(同时清除期望态:
|
||||
# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。
|
||||
# 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
|
||||
@@ -3893,7 +4097,7 @@ 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
|
||||
c_ok "[$ENV_NAME] 已停止"
|
||||
hist_event stop ok "$pid" "SIGTERM"
|
||||
;;
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user