Merge origin/main into feature/rdm-increment-settings-duties
This commit is contained in:
@@ -387,7 +387,7 @@ jobs:
|
||||
# 凭据纪律与 Checkout 块同源:经 GIT_CONFIG_* 环境注入 URL 级 token 头,
|
||||
# 不落文件/命令行;allowlist 扩项 = Agent-System-RDM 及其 pins(同一
|
||||
# 组件集合),由本 PR 评审背书。SSH pins URL 以 insteadOf 改写到受信
|
||||
# https 基址后再取。
|
||||
# https 基址后再取。只追加 pin 的三个 40 位 hex,不 source pin 文件。
|
||||
SERVER="$(printf '%s' "$GITHUB_SERVER_URL" | sed 's:/*$::')"
|
||||
scheme="${GITHUB_SERVER_URL%%://*}"
|
||||
export GIT_CONFIG_COUNT=4
|
||||
@@ -400,15 +400,45 @@ jobs:
|
||||
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
|
||||
mapfile -t pins < <(python3 - <<'PY'
|
||||
import pathlib
|
||||
got = {}
|
||||
for line in pathlib.Path("tests/integration/lifecycle/rdm.pin").read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key not in ("sha", "prev_sha", "legacy_sha") or len(value) != 40:
|
||||
raise SystemExit("rdm.pin rejected")
|
||||
if any(c not in "0123456789abcdef" for c in value):
|
||||
raise SystemExit("rdm.pin rejected")
|
||||
got[key] = value
|
||||
if set(got) != {"sha", "prev_sha", "legacy_sha"}:
|
||||
raise SystemExit("rdm.pin rejected")
|
||||
print(got["prev_sha"])
|
||||
print(got["legacy_sha"])
|
||||
print(got["sha"])
|
||||
PY
|
||||
)
|
||||
[ "${#pins[@]}" -eq 3 ]
|
||||
# depth=1 会把三个 pin 变成互不相关的浅根,update 会把 prev→sha 误判成分叉。
|
||||
# 当前 first-parent 距离是 39,200 留余量,仍不拉全史。
|
||||
git -C rdm fetch --depth=200 origin "${pins[@]}"
|
||||
for rev in "${pins[@]}"; do
|
||||
git -C rdm checkout --detach "$rev"
|
||||
git -C rdm submodule update --init --recursive
|
||||
done
|
||||
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
|
||||
: > rdm/pin-build-digests.txt
|
||||
for rev in "${pins[@]}"; do
|
||||
git -C rdm checkout --detach "$rev"
|
||||
make -C rdm build
|
||||
sha256sum rdm/bin/agent-system-rdm | awk -v rev="$rev" '{print rev, $1}' >> rdm/pin-build-digests.txt
|
||||
done
|
||||
- name: Install Node 22
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -419,14 +449,20 @@ jobs:
|
||||
- name: Real-combination lifecycle gate (make test-lifecycle, required)
|
||||
env:
|
||||
AGENT_PG_TEST_DSN: postgres://ci_agent:ci_job_local_only@127.0.0.1:5432/agent_test?sslmode=disable
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 路径变量在脚本内解析(YAML env: 不做 shell 展开)。
|
||||
# 不设 SVC_RDM_ORIGIN:全轨 git 部署由 svc-realcombo 作业覆盖。
|
||||
# pins 物化没有 SSH known_hosts。token 只经 SVC_CI_GIT_TOKEN 进
|
||||
# ci_git_env 的 URL 级头,不写文件。
|
||||
export SVC_TEST_REAL=1 SVC_TEST_PG=1
|
||||
export SVC_REAL_BIN="$PWD/bin/agent-system"
|
||||
export SVC_REAL_RDM_BIN="$PWD/rdm/bin/agent-system-rdm"
|
||||
export SVC_RDM_SRC_DIR="$PWD/rdm"
|
||||
export SVC_CI_GIT_SERVER="$(printf '%s' "$GITHUB_SERVER_URL" | sed 's:/*$::')"
|
||||
export SVC_CI_GIT_TOKEN="$SUBMODULE_TOKEN"
|
||||
unset SUBMODULE_TOKEN
|
||||
make test-lifecycle
|
||||
|
||||
# U3(统一部署方案):真实 Dev/RDM 制品的 svc.sh 生命周期门禁——桩套件
|
||||
|
||||
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
@@ -49,6 +49,25 @@
|
||||
**仍不放行第二实例承接写请求**——缺独立 REPLAY/GATE,C3 未做。C5 的工件恢复(K4)已有 2026-10-13 本机 SELF,不是 GATE。
|
||||
- **双主机档**:未开始(C3),不放行。
|
||||
|
||||
## 下一步规格(2026-10-13,未执行)
|
||||
|
||||
上表的证据等级保持原样:C4 与 C5 仍是 SELF,C3 仍是未开始。
|
||||
独立重放、CI 快速档和双主机都还没有做。可执行规格:
|
||||
|
||||
- [独立重放](u7-release-replay.md)
|
||||
- [CI 快速档可行性](u7-release-ci-feasibility.md)
|
||||
- [第二台主机清单](u7-release-c3-host.md)
|
||||
|
||||
PR、提交同树与 CI 运行的读取记录在任务卡
|
||||
`.task/feature-u7-release-roadmap.md` 的证据表,来源 SELF。
|
||||
`policy/facts.md` 仍只登记长期基础设施事实;上述 PR 与运行不写入该表,
|
||||
因为它们不是长期事实,而且核验命令含管道与引号,不符合该表的命令格式。
|
||||
|
||||
协作存储方案正文仍是 Documents `05c13a42b14d70f878cc8a51c543e018a7f7a663` 的 r2.3。
|
||||
本次没有改 Documents 仓,也没有推进 gitlink。下面这段是准备写入方案、尚未写入的修订:
|
||||
|
||||
> r2.4:2026-10-13 只补了放行规格,没有做独立重放,没有把 C4/C5 接进 CI,没有第二台主机。证据等级不变。第二实例写流量仍不开放。规格见 Dev `docs/release-multi-instance.md` 的「下一步规格」。
|
||||
|
||||
## 已知边界与诚实声明
|
||||
|
||||
- U7 期间经双进程验收发现并修复三个跨实例缺陷(详见
|
||||
@@ -58,4 +77,4 @@
|
||||
- 容量详情与 SHA 绑定见 Documents
|
||||
`docs/records/2026-10-12-u7-capacity-window-report.md`;原始
|
||||
`results/<ts>/` 在测量 worktree(gitignore),不入库。
|
||||
- B6 pgbench、C4 断连演练与 C5 备份恢复(含 K4 工件)已有本机 SELF。C3 双主机仍待第二台机器。证据等级仍缺独立 REPLAY 或 CI GATE。
|
||||
- B6 pgbench、C4 断连演练与 C5 备份恢复(含 K4 工件)已有本机 SELF。C3 双主机仍待第二台机器。证据等级仍缺独立 REPLAY 或 CI GATE。下一步规格见上文「下一步规格」,规格本身不提升证据等级。
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 登录恢复流程合同(2026-10-13)
|
||||
|
||||
状态:**已实施**(Dev 任务 `feature/svc-login-recovery-flow`;用户 2026-10-13「按此方案实施」授权)。
|
||||
上游方案:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md`(r2);
|
||||
本文是该方案登录部分的合同定稿与三项裁定记录,U4/U5 规格后续以本文为准回写。
|
||||
|
||||
## 登录初衷(冻结)
|
||||
|
||||
1. 日常使用**用户名 + 密码**登录。
|
||||
2. 忘记密码时,由**管理员签发绑定该用户的一次性登录 Key**(`alk_`)。
|
||||
3. 用户兑换 Key 后进入**受限设密会话**,必须设置新密码;恢复设密**不要求旧密码**。
|
||||
4. 个人设置中的**主动改密仍要求当前密码**。
|
||||
5. 受限会话只开放身份查询、设密、退出;完成前不能进入工作台或访问业务接口。
|
||||
|
||||
## 三项裁定(对 r2 的合同分歧,用户按新方案裁定)
|
||||
|
||||
| 分歧 | r2 表述 | 裁定(本合同) |
|
||||
|---|---|---|
|
||||
| 登录页长期 API 密钥入口 | 保留并标注集成用途 | **移除日常登录入口**;程序化客户端直接带 Authorization 头。浏览器不再持久保存 `ask_` |
|
||||
| 管理员网页恢复方式 | 直接设置新密码(CLI 保留) | 网页改为**签发一次性登录 Key**;旧 `POST /api/admin/users/{owner}/password` 接口兼容保留(调用盘点:仅前端 UsersPanel 使用,已下线) |
|
||||
| must-change 首登会话 | full 会话 + 中间件动态拦截 | **登录即建 `set_password_only` 受限会话**,与恢复会话同形;`me.sessionScope` 统一驱动前端设密门 |
|
||||
|
||||
## 接口合同
|
||||
|
||||
| 接口 | 状态 | 行为 |
|
||||
|---|---|---|
|
||||
| `POST /api/auth/login` | 已有(修订) | 密码验证绑定认证版本;must-change/未设密建受限会话;响应带 `sessionScope` |
|
||||
| `POST /api/auth/login-key/redeem` | 已有 | 请求只有 `{key}`;身份由服务端绑定;按来源 IP 退避(10 次/30s) |
|
||||
| `GET /api/me` | 已有 | 返回 `sessionScope`/`authVia`/`mustChangePassword`/`passwordSet` |
|
||||
| `POST /api/auth/password` | 已有(修订) | 受限会话免当前密码(`CompleteSetPasswordChecked`,事务内复验);主动改密要求当前(单事务) |
|
||||
| `POST /api/auth/logout` | 已有 | 注销当前会话 |
|
||||
| `POST /api/admin/users/{owner}/login-keys` | **新增** | 管理员签发;明文仅响应一次;补发同事务撤销旧 Key 与受限会话 |
|
||||
| `GET /api/admin/users/{owner}/login-keys` | **新增** | 状态/有效期列表,无明文 |
|
||||
| `DELETE /api/admin/users/{owner}/login-keys/{id}` | **新增** | 撤销 Key 并作废该用户受限会话 |
|
||||
|
||||
统一字段:`sessionScope`(`full`|`set_password_only`)、`mustChangePassword`、`passwordSet`、`reloginRequired`。
|
||||
恢复设密成功固定返回登录页(`reloginRequired=true`),用新密码重新登录。
|
||||
|
||||
## 事务与安全要求(对应方案第六节)
|
||||
|
||||
1. 签发不改密码、不吊销正常会话、不动业务数据;补发使旧 Key 与其受限会话立即失效。
|
||||
2. 兑换与建会话原子提交;会话绑定签发时点的 `users.auth_version`。
|
||||
3. 设密事务内复验会话(存在/未过期/scope/版本),统一写密码、清标志、bump 版本、
|
||||
撤 Key、删会话、写审计(`account_events`,v28 迁移)。
|
||||
4. 密码登录会话创建校验认证版本,堵「旧密码验证在途 → 恢复完成 → 旧请求建会话」。
|
||||
5. 主动改密单事务(校验当前密码 → 更新 → 吊销其他会话保留当前)。
|
||||
6. 每次密码写入 bump `auth_version` 并作废全部旧会话;管理员/CLI 重置同事务。
|
||||
7. 会话 cookie 在 TLS(直连或 `X-Forwarded-Proto: https`)下置 `Secure`。
|
||||
8. SSE 长连接:恢复完成后旧会话行已删除,新请求即失效;存量连接的主动断流
|
||||
依赖后续会话失效推送(本合同未实现,遗留项见下)。
|
||||
|
||||
## 验收矩阵与现状
|
||||
|
||||
| 项 | 结果 | 依据 |
|
||||
|---|---|---|
|
||||
| 正常登录/错误凭据 | PASS(SQLite) | `login_recovery_test.go`、既有 e2e |
|
||||
| 只输 Key 识别账户、不能指定身份 | PASS | `TestAdminLoginKeyRecoveryFlow` |
|
||||
| 受限会话拒业务/管理员/签发入口 | PASS | 同上 + 中间件 allowlist |
|
||||
| 100 并发兑换恰好一次成功 | PASS | `TestLoginKeyConcurrentRedeem` |
|
||||
| 补发/撤销使旧 Key 与受限会话失效 | PASS | `TestIssueLoginKeyRevokesDerivedSessions`、`TestAdminLoginKeyReissueAndRevoke` |
|
||||
| 改密事务复验(被撤销会话不能改密) | PASS | `TestCompleteSetPasswordRevalidatesSession` |
|
||||
| 登录竞态(旧版本建会话被拒) | PASS | `TestLoginRaceBlockedByAuthVersion` |
|
||||
| 主动改密缺/错当前密码拒绝 | PASS | `TestActiveChangePasswordRequiresCurrent` |
|
||||
| must-change 首登受限会话 | PASS | `TestLoginMustChangeCreatesRestrictedSession` |
|
||||
| PostgreSQL 双库 | GATE(CI test-pg job) | store 测试走 `openTestStore`,`AGENT_PG_TEST_DSN` 下自动切真库 |
|
||||
| 浏览器 E2E(兑换→设密→重登) | NOT_RUN(本轮) | `e2e/changepw-form.playwright.ts` 已更新门形态;专用兑换 E2E 遗留 |
|
||||
| SSE 存量连接主动断流 | NOT_RUN(遗留) | 见「遗留项」 |
|
||||
|
||||
以上本地结果均为 SELF;GATE 以 PR HEAD 的 CI(含 test-pg)为准。
|
||||
|
||||
## 遗留项(不阻塞本合同)
|
||||
|
||||
- 登录页专用兑换 E2E(login-key.playwright.ts,U5 计划文件)。
|
||||
- SSE 长连接的认证版本主动复验/断流通知。
|
||||
- Documents 主方案 r2 的 U4/U5 规格按本文回写(与 PR #87 合入后再推进,避免文档冲突)。
|
||||
- 兑换入口跨实例限流(当前进程内退避,与 `loginGuard` 同档)。
|
||||
@@ -0,0 +1,41 @@
|
||||
# C3 第二台主机清单(主机未指定)
|
||||
|
||||
状态:清单。第二台主机还没有。本文件不申请机器,不部署,也不开放第二实例写流量。
|
||||
双主机档在 [放行检查单](release-multi-instance.md) 里保持「未开始」。
|
||||
|
||||
## 要验证的事
|
||||
|
||||
C3 是同一套 A6 验收在两台主机上各跑一遍,数据库连接走跨机网络。
|
||||
`make test-multi-instance` 在跑测试的那台机器上本地构建 `AGENT_BIN`,
|
||||
再按 `AGENT_PG_TEST_DSN` 连接 PostgreSQL。参见
|
||||
`tests/integration/multi-instance/README.md`。
|
||||
|
||||
做法:
|
||||
|
||||
1. 两台主机使用同一 Dev SHA,并物化相同的 backend gitlink。默认 SHA 与
|
||||
[重放规格](u7-release-replay.md) 相同;main 前进时两台一起改,并写明新 SHA。
|
||||
2. PostgreSQL 只建在其中一台。另一台把 DSN 指到这台的地址,连接要跨网络,
|
||||
而不是两台各用一个本机库假装跨机。
|
||||
3. 两台分别执行 `make test-multi-instance`。退出码、SHA 和 DSN 的主机与端口记入证据。
|
||||
密码只放在环境或受保护文件里,不进入任务卡、PR 和本清单。
|
||||
4. 演练用的 `u7drill` / `u7bk` 与端口 18980–18999 留给 C4/C5。
|
||||
A6 使用自己的库和文档中的示例端口,避免和演练栈抢资源。
|
||||
|
||||
## 负责人填写
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 第二台主机的名称与到达方式 | 待填写 |
|
||||
| 系统、架构、PostgreSQL 版本 | 待填写 |
|
||||
| 哪一台放置数据库,另一台如何访问该端口 | 待填写 |
|
||||
| 时钟同步方式 | 待填写 |
|
||||
| 可用窗口 | 待填写 |
|
||||
| 主机负责人 | 待填写 |
|
||||
|
||||
时钟要先对齐再跑。A6 的租约和跨实例等待依赖两台时钟接近。本次没有测量任何第二台主机。
|
||||
|
||||
## 完成后才改检查单
|
||||
|
||||
两台都退出码为 0,且证据写明 SHA、主机和命令之后,才把 C3 从「未开始」改为已执行,
|
||||
并标明 REPLAY 或 GATE。那次修改另需授权。C3 做完也不单独开放写流量;
|
||||
写流量还要独立 REPLAY 或已准入的 CI GATE。
|
||||
@@ -0,0 +1,54 @@
|
||||
# C4/C5 快速档接入 CI 的可行性(未改 workflow)
|
||||
|
||||
状态:只读评估。本次没有修改 `.gitea/workflows/ci.yaml`,没有在 `home-runner` 上执行演练,
|
||||
也没有形成 GATE。第二实例写流量保持关闭。
|
||||
|
||||
## 平台准入(2026-10-13 只读)
|
||||
|
||||
Dev `main` 的分支保护由 API 读出,证据在任务卡 `.task/feature-u7-release-roadmap.md`,
|
||||
来源 SELF:
|
||||
|
||||
- `enable_push` 为 false,`block_on_outdated_branch` 为 true
|
||||
- `enable_status_check` 为 false,`status_check_contexts` 为空,`required_approvals` 为 0
|
||||
|
||||
这与 `policy/platform-admission.md` 在 2026-10-05 的观察一致,只说明配置当前如此。
|
||||
它没有证明失败检查或陈旧候选会被拒绝。runner 隔离、token 权限和工作流来源本次没有核验。
|
||||
|
||||
因此,即便以后某个 job 变绿,在所有者按该文件完成准入之前,检查单不能把该次运行写成 GATE。
|
||||
改分支保护属于高后果操作,本评估不准备执行命令。
|
||||
|
||||
## 现有 CI 能提供什么
|
||||
|
||||
`test-pg` 在 `ubuntu-latest` 上安装 PostgreSQL、pgvector 和 Redis,并以 root 容器、
|
||||
无 sudo 为前提,用 `runuser` 或 `su` 切到 `postgres` 用户操作已有集群。
|
||||
现有库端口是 5432,Redis 是 6399。演练端口 18980–18999 与这两个端口不重叠。
|
||||
|
||||
workflow 没有写 `timeout-minutes`。平台默认时限本次没有读取。
|
||||
|
||||
## 脚本直接放进 CI 时会碰到的差异
|
||||
|
||||
这些差异来自当前脚本和 workflow 文本。除资源数字外,都还没有在 `home-runner` 上实测。
|
||||
|
||||
1. **磁盘与负载门禁。** `tests/ops/lib.sh` 的 `ops_resource_gate` 要求可用空间至少
|
||||
100GiB,且 load1 不超过 16,否则退出 3。作者机器在核验命令里打印了
|
||||
`local_avail_kb=1164165728`、`local_id=1000`。这是作者主机,不是 runner。
|
||||
runner 的磁盘和负载未测。未测通过前,job 不能假设自己过得了这道门。
|
||||
2. **`pg_ctl` 的 PATH。** `tests/ops/run_backup_drill.sh` 用 `command -v pg_ctl`,
|
||||
找不到就退出。`scripts/svc.sh` 的 `pg_bindir` 还会找 `pg_config --bindir` 和
|
||||
`/usr/lib/postgresql/*/bin`。Ubuntu 包里的 `pg_ctl` 通常在版本目录,不一定在 PATH。
|
||||
作者机器的 `pg_ctl` 来自 Homebrew,不能外推到 runner。
|
||||
3. **root 下的 `initdb`。** 演练通过 `scripts/svc.sh db create` 调用 `initdb`。
|
||||
workflow 写明运行器容器以 root 运行。PostgreSQL 的 `initdb` 拒绝 root。
|
||||
`test-pg` 避开了这条路,它启动发行版集群再切到 `postgres` 用户。
|
||||
本次没有在 runner 上执行 `initdb`。
|
||||
4. **C5 的固定等待。** `run_backup_drill.sh` 把标记写入时长写成 `-dur 5m`,
|
||||
恢复后还有 `sleep 120`。脚本没有快速档参数。K1–K7、工件 sha256 和跨用户 404 保持原判据。
|
||||
缩短标记窗口需要改脚本,并仍能测出不超过 300 秒的 RPO。本次不改脚本。
|
||||
5. **C4 的固定等待。** `run_fault_drill.sh --fault all` 含约 60 秒的断连窗口和若干秒级等待。
|
||||
本次没有计时一次完整演练。
|
||||
|
||||
## 若以后要做 job
|
||||
|
||||
job 与现有 `test`、`test-pg`、`svc-realcombo` 并列。实现前先在 runner 上确认磁盘、
|
||||
`pg_ctl` 位置,以及非 root 用户能否 `initdb`。通过后的证据要带运行链接、执行身份和完整 SHA。
|
||||
平台准入未完成时,检查单的写法保持「CI 运行已绿,GATE 未成立」。
|
||||
@@ -0,0 +1,101 @@
|
||||
# U7 独立重放规格(未执行)
|
||||
|
||||
状态:规格。本文件不记录一次已经完成的重放,也不改变
|
||||
[放行检查单](release-multi-instance.md) 的证据等级。第二实例写流量保持关闭。
|
||||
|
||||
2026-10-12 已决定不做容量重放。本规格只覆盖 C5、C4 和 A6,以及对象存储回落。
|
||||
|
||||
## 固定对象
|
||||
|
||||
从干净检出开始,物化子模块后再跑命令。默认对象是 Dev
|
||||
`3fb8210f6495a78e70a67af3661bdfe9c00fb9de`,其 gitlink 为:
|
||||
|
||||
| 组件 | SHA |
|
||||
|---|---|
|
||||
| Backend | `dc736e339d01c59e658abc63c81909059bd6ea02` |
|
||||
| Documents | `05c13a42b14d70f878cc8a51c543e018a7f7a663` |
|
||||
|
||||
合入提交与对应 PR head 在本地 `git rev-parse ^{tree}` 上同树。核验输出在任务卡
|
||||
`.task/feature-u7-release-roadmap.md` 的证据表,来源 SELF。若重放日 main 已前进,
|
||||
重放者改用新的完整 SHA,并重新读出 backend 与 documents 的 gitlink,写进重放记录。
|
||||
|
||||
## 重放者
|
||||
|
||||
重放者是另一个人,或另一台由该人操作的机器。先跑完命令并写出结论,再打开作者收据。
|
||||
作者保全目录 `u7-k4-retest-preserved-20261013` 与其中的 `task-finish.txt` 属于作者证据。
|
||||
|
||||
同一账号下另开一个 agent 会话、或切换角色,不能记成 REPLAY。
|
||||
`policy/platform-admission.md` 已写明不能把切换 Agent 角色当独立账号。
|
||||
重放者身份未指定前,本规格不启动。
|
||||
|
||||
## 环境
|
||||
|
||||
- 只使用名称前缀 `u7drill` 或 `u7bk` 的实例,端口落在 18980–18999。
|
||||
- 启动前确认环境里没有 `BLOBSTORE_S3_ENDPOINT`。演练脚本本身不设置该变量。
|
||||
应用把 `NewS3FromEnv` 返回的空 `*S3` 放进 `blobstore.Store` 接口,
|
||||
`blobstore.IsNil` 把它当成未配置,工件写入 `--artifacts` 目录。
|
||||
- 已构造的对象存储由 `TestFinishOpenKeepsConfiguredBlobStore` 覆盖,重放时一并跑该测试。
|
||||
本次不要求另起一台对象存储。
|
||||
- `tests/ops/lib.sh` 的资源门禁要求数据目录所在盘可用空间至少 100GiB,且 1 分钟负载不超过 16。
|
||||
不满足时脚本会停,不能改门禁来换一次通过。
|
||||
|
||||
## 最小重放集
|
||||
|
||||
按这个顺序执行。每条用 `CMD="<命令>" make evidence` 写入重放者自己的任务卡。
|
||||
工具写入的来源标签仍是 SELF;审阅记录里另行声明 REPLAY,并写明重放者、主机、命令和对象 SHA。
|
||||
|
||||
1. `tests/ops/self_check.sh`
|
||||
2. `tests/ops/run_backup_drill.sh`
|
||||
3. `tests/ops/run_fault_drill.sh --fault all`
|
||||
4. 用隔离的 PostgreSQL 跑 `make test-multi-instance`(见
|
||||
`tests/integration/multi-instance/README.md`)。这套库使用文档中的示例端口时,
|
||||
不要占用演练的 18980–18999。
|
||||
5. 在 `modules/backend` 执行:
|
||||
|
||||
```bash
|
||||
go test -count=1 -timeout 15m ./internal/blobstore/ ./internal/store/ \
|
||||
-run 'TestIsNil|TestNewS3FromEnvFallback|TestFinishOpenTypedNilS3UsesLocalArtifacts|TestFinishOpenKeepsConfiguredBlobStore|TestOpenPostgresTypedNilS3UsesLocalArtifacts'
|
||||
```
|
||||
|
||||
`TestOpenPostgresTypedNilS3UsesLocalArtifacts` 需要真实 PostgreSQL。没有 DSN 时该项记
|
||||
NOT_RUN,其余四项仍要跑。
|
||||
|
||||
## 判据
|
||||
|
||||
C5 以 `tests/ops/run_backup_drill.sh` 写出的 `verdict.json` 为准:
|
||||
|
||||
| 项 | 通过条件 |
|
||||
|---|---|
|
||||
| K1 | 归档成功且 `failed_count` 为 0,基础备份与逻辑备份清单校验通过 |
|
||||
| K2 | RPO 在 0 到 300 秒之间 |
|
||||
| K3 | 恢复后登录、项目、会话均为 200,且 RTO 不超过 3600 秒 |
|
||||
| K4 | 工件 `checked > 0`、`missing = 0`、`mismatch = 0`,跨用户访问项目和工件均为拒绝 |
|
||||
| K5 | `dup_seq = 0`、`seq_gap_convs = 0`,恢复后的行数落在备份点与故障点之间 |
|
||||
| K6 | 逻辑恢复的行数与备份点一致 |
|
||||
| K7 | 备份文件权限收紧,结果目录无凭据泄漏 |
|
||||
|
||||
C4 以故障脚本的 F1–F5 与 Redis 轨 G1–G3 为准。G4 降级不可见、G5 启动时 Redis 不通则拒绝,
|
||||
沿用检查单里的「发现」,不把它们改成产品失败。
|
||||
|
||||
A6 以 `make test-multi-instance` 退出码 0 为准。
|
||||
|
||||
K4 还要看应用日志里没有 `blobstore.(*S3).Put` 的空指针 panic,并且工件文件出现在该次
|
||||
`--artifacts` 目录。脚本只在拿不到工件 id 时才写 `finding-upload.txt`;那个文件里的
|
||||
panic 句子是脚本自带的说明,重放时以应用日志和 `artifacts-verify.json` 为准。
|
||||
|
||||
与 2026-10-13 SELF 目录 `backup-20261013-013354` 对照时,比较的是上述判据。
|
||||
RPO、RTO 的秒数可以不同。判据不一致则阻塞,并按失实登记。
|
||||
|
||||
## 重放记录模板
|
||||
|
||||
重放完成后把记录放到 Documents 的 `docs/records/`,并回写检查单的 C4、C5、A6 证据等级。
|
||||
那一步另需授权。记录至少包含:
|
||||
|
||||
- 重放者与主机(人,或人操作的另一台机器)
|
||||
- 开始前读取的 Dev、Backend、Documents 完整 SHA
|
||||
- 每条命令、退出码、结果目录名
|
||||
- `verdict.json` 里的 K1–K7、`rpo_s`、`rto_s`
|
||||
- C4 的 F1–F5 与 G1–G5
|
||||
- A6 退出码
|
||||
- 五项单测的通过或 NOT_RUN
|
||||
- 来源声明:审阅记录为 REPLAY;`make evidence` 行仍为 SELF
|
||||
+1
-1
Submodule modules/frontend updated: dcd2699933...9fc7da2ace
+16
-6
@@ -1435,8 +1435,9 @@ svc_launch_serve() {
|
||||
}
|
||||
|
||||
# svc_lifecycle_child ACTION [extra args...]:用清空的环境拉起本脚本的
|
||||
# __lifecycle。白名单:PATH/HOME/USER/LOGNAME/LANG/LC_*/TZ/TMPDIR、代理、SVC_*。
|
||||
# 从不传入 AGENT_SYSTEM_*。
|
||||
# __lifecycle。白名单:PATH/HOME/USER/LOGNAME/LANG/LC_*/TZ/TMPDIR、代理、SVC_*、
|
||||
# GIT_CONFIG_*(运行器把 SSH pin 改写成受信 HTTPS,token 只在环境里)。
|
||||
# 从不传入 AGENT_SYSTEM_*。serve 仍是 env -i,不继承这些变量。
|
||||
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}"
|
||||
@@ -1450,7 +1451,7 @@ svc_lifecycle_child() {
|
||||
while IFS= read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
args+=("$k=${!k}")
|
||||
done < <(compgen -e | grep -E '^(SVC_|LC_)' || true)
|
||||
done < <(compgen -e | grep -E '^(SVC_|LC_|GIT_CONFIG_)' || true)
|
||||
if [ -n "${RDM_DISABLE_EXEC_TOOLS:-}" ]; then
|
||||
args+=("RDM_DISABLE_EXEC_TOOLS=$RDM_DISABLE_EXEC_TOOLS")
|
||||
fi
|
||||
@@ -3364,7 +3365,6 @@ static_do_deploy() {
|
||||
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" frontend)" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
|
||||
src_dist="$wt/modules/frontend/dist"
|
||||
recycle_worktree "$SRC_SHA"
|
||||
;;
|
||||
release)
|
||||
c_err "[$ENV_NAME] release 制品内嵌前端(无独立 dist);static 请用 --from dev|local 或 --dir"
|
||||
@@ -3378,10 +3378,20 @@ static_do_deploy() {
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
[ -d "$src_dist" ] || { c_err "[$ENV_NAME] 前端产物缺失:$src_dist"; hist_event deploy fail "$(read_pid)" "dist 缺失"; return 1; }
|
||||
if [ ! -d "$src_dist" ]; then
|
||||
[ "$SRC_KINDSRC" = dev ] && recycle_worktree "$SRC_SHA"
|
||||
c_err "[$ENV_NAME] 前端产物缺失:$src_dist"
|
||||
hist_event deploy fail "$(read_pid)" "dist 缺失"
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ENV_DIR"
|
||||
rm -rf "${ENV_DIR:?}/htdocs"
|
||||
cp -r "$src_dist" "$ENV_DIR/htdocs" || { hist_event deploy fail "$(read_pid)" "dist 复制失败"; return 1; }
|
||||
cp -r "$src_dist" "$ENV_DIR/htdocs" || {
|
||||
[ "$SRC_KINDSRC" = dev ] && recycle_worktree "$SRC_SHA"
|
||||
hist_event deploy fail "$(read_pid)" "dist 复制失败"
|
||||
return 1
|
||||
}
|
||||
[ "$SRC_KINDSRC" = dev ] && recycle_worktree "$SRC_SHA"
|
||||
write_static_reg "$ENV_DIR/htdocs"
|
||||
c_ok "[$ENV_NAME] 前端产物就位:$ENV_DIR/htdocs(源 $SRC_LABEL)"
|
||||
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
|
||||
|
||||
@@ -1,18 +1,77 @@
|
||||
"""真实 Dev/RDM 组合门禁。由 make test-lifecycle 调用。
|
||||
|
||||
缺 PostgreSQL、RDM 源码或制品时直接失败,不静默跳过。
|
||||
登录依赖账户 CLI 公共化(账户方案 U7/U5),单独标 NOT_RUN,
|
||||
登录与 rdm.ping 依赖账户 CLI 公共化,单独标 NOT_RUN,
|
||||
不因此把已经跑过的真实组合场景记成失败。
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
PIN = Path(__file__).resolve().parent / "rdm.pin"
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def print_inventory(src, dev, rdm):
|
||||
print("组合清单")
|
||||
head = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True)
|
||||
print("dev_sha", head.stdout.strip() if head.returncode == 0 else "NOT_RUN")
|
||||
modules = subprocess.run(
|
||||
["git", "-C", str(ROOT), "submodule", "status", "--recursive"],
|
||||
capture_output=True, text=True)
|
||||
if modules.returncode == 0:
|
||||
for line in modules.stdout.splitlines():
|
||||
print("submodule", " ".join(line.split()[:2]))
|
||||
else:
|
||||
print("submodule NOT_RUN")
|
||||
print("pin")
|
||||
for line in PIN.read_text().splitlines():
|
||||
if line.strip() and not line.startswith("#"):
|
||||
print(" ", line.strip())
|
||||
print("dev_bin", dev, sha256_file(dev))
|
||||
print("rdm_bin", rdm, sha256_file(rdm))
|
||||
digests = Path(src) / "pin-build-digests.txt"
|
||||
if digests.is_file():
|
||||
for line in digests.read_text().splitlines():
|
||||
print("pin_build", line)
|
||||
else:
|
||||
print("pin_build NOT_RUN:本进程没有预构建三个 pin")
|
||||
print("pg_version", pg_version())
|
||||
print("登录 NOT_RUN:RDM 二进制没有 user/apikey,工作台登录依赖账户方案 U7/U5")
|
||||
print("rdm.ping NOT_RUN:需要已登录会话,本轮不公共化账户 CLI")
|
||||
|
||||
|
||||
def pg_version():
|
||||
parsed = urlparse(os.environ.get("AGENT_PG_TEST_DSN", ""))
|
||||
if not parsed.hostname:
|
||||
return "NOT_RUN"
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"PGHOST": parsed.hostname,
|
||||
"PGPORT": str(parsed.port or 5432),
|
||||
"PGUSER": parsed.username or "",
|
||||
"PGPASSWORD": parsed.password or "",
|
||||
"PGDATABASE": parsed.path.lstrip("/"),
|
||||
})
|
||||
proc = subprocess.run(["psql", "-tAc", "SHOW server_version"],
|
||||
env=env, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
return "NOT_RUN"
|
||||
return proc.stdout.strip() or "NOT_RUN"
|
||||
|
||||
|
||||
def main():
|
||||
missing = []
|
||||
if not os.environ.get("AGENT_PG_TEST_DSN"):
|
||||
@@ -30,29 +89,27 @@ def main():
|
||||
print("test-lifecycle 失败。缺:%s" % ", ".join(missing))
|
||||
return 1
|
||||
|
||||
print("组合清单")
|
||||
print("pin", PIN.read_text().strip())
|
||||
print("rdm_src", src)
|
||||
print("dev_bin", dev)
|
||||
print("rdm_bin", rdm)
|
||||
print("将运行 tests.test_svc_realcombo:SQLite 身份、RDM 二进制生命周期、PostgreSQL 身份环境交付")
|
||||
print("将运行 tests.integration.lifecycle.test_lifecycle_real:pin 上的真实候选与切换")
|
||||
if os.environ.get("SVC_RDM_ORIGIN"):
|
||||
print("将运行全轨部署:SVC_RDM_ORIGIN 已设置")
|
||||
else:
|
||||
print("全轨部署 NOT_RUN:未设置 SVC_RDM_ORIGIN(由 svc-realcombo 作业覆盖)")
|
||||
print("登录 NOT_RUN:RDM 二进制没有 user/apikey,工作台登录依赖账户方案 U7/U5")
|
||||
env = os.environ.copy()
|
||||
env["SVC_TEST_REAL"] = "1"
|
||||
env["SVC_TEST_PG"] = "1"
|
||||
env["SVC_REAL_BIN"] = dev
|
||||
env["SVC_REAL_RDM_BIN"] = rdm
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "unittest", "tests.test_svc_realcombo", "-q"],
|
||||
[sys.executable, "-m", "unittest",
|
||||
"tests.test_svc_realcombo",
|
||||
"tests.integration.lifecycle.test_lifecycle_real", "-q"],
|
||||
cwd=str(ROOT), env=env)
|
||||
print_inventory(src, dev, rdm)
|
||||
if proc.returncode != 0:
|
||||
print("test-lifecycle 失败:真实组合用例未通过")
|
||||
return proc.returncode
|
||||
print("test-lifecycle 可跑场景通过;登录与未设 origin 的全轨部署保持 NOT_RUN")
|
||||
print("test-lifecycle 可跑场景通过;登录与 rdm.ping 保持 NOT_RUN")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# RDM 制品钉扎。三行都是 40 位 hex。升级走 PR,不在 CI 里执行候选文件。
|
||||
sha=68de2aa41022c8a77206ea30c2164bec33632ee3
|
||||
prev_sha=00ae1494f5b45ae0b4ed955ee82debdfe830a1c3
|
||||
# legacy 没有 version --json。prev 是身份能力合入(#291)。sha 与 prev 钉同一
|
||||
# backend(0f192a4),schema 兼容,但产品树不同,切换能在 release 名上看到。
|
||||
sha=c6304224f995747ea3ffcb8d0e81f3ad46a96546
|
||||
prev_sha=3484ec225b0087a9065466ed436ea97a768691ef
|
||||
legacy_sha=72bb1fec79d64c2af5e49416640712320816c3b4
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
"""真实 RDM pin 的部署场景。
|
||||
|
||||
由 make test-lifecycle 调用。未设置 SVC_TEST_REAL 时整组跳过,普通 make test
|
||||
不因此失败。登录与 rdm.ping 不在这里实现:RDM 没有 user/apikey。
|
||||
"""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from pathlib import Path
|
||||
|
||||
from tests.test_svc_realcombo import ci_git_env
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from tests.test_svc_realcombo import ci_git_env
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[3]
|
||||
PIN = Path(__file__).resolve().parent / "rdm.pin"
|
||||
|
||||
_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)
|
||||
|
||||
REAL = os.environ.get("SVC_TEST_REAL") == "1"
|
||||
SRC = os.environ.get("SVC_RDM_SRC_DIR", "")
|
||||
REAL_BIN = os.environ.get("SVC_REAL_RDM_BIN", "")
|
||||
|
||||
|
||||
def load_pin():
|
||||
out = {}
|
||||
for line in PIN.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
@unittest.skipUnless(REAL and SRC and REAL_BIN,
|
||||
"真实 pin 场景 NOT_RUN:需 SVC_TEST_REAL=1、SVC_RDM_SRC_DIR、SVC_REAL_RDM_BIN")
|
||||
class LifecycleRealTests(tsr.SvcAutoRecoverTests):
|
||||
"""候选与切换都走 scripts/svc.sh。已装制品用 SVC_REAL_RDM_BIN,候选用 rdm@<pin>。"""
|
||||
|
||||
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()
|
||||
self.assertTrue(self.real_bin.is_file() and os.access(self.real_bin, os.X_OK))
|
||||
self.pin = load_pin()
|
||||
self.env["SVC_RDM_SRC_DIR"] = SRC
|
||||
self.env["RDM_DISABLE_EXEC_TOOLS"] = "1"
|
||||
self.env.update(ci_git_env())
|
||||
self.env.pop("AGENT_SYSTEM_DSN", None)
|
||||
self.env.pop("AGENT_SYSTEM_DATA", None)
|
||||
|
||||
def seed_established(self, name, port):
|
||||
"""用已具备 identity 的制品把实例跑起来,源种类写成 rdm,避免换源门禁抢在身份门禁前。"""
|
||||
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(self.real_bin, 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", "rdm@seed"), ("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(d / "data")
|
||||
self.svc(name, "start")
|
||||
|
||||
def initialize_store(self, data, env=None):
|
||||
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(self.real_bin), "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
|
||||
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)
|
||||
|
||||
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):
|
||||
con = sqlite3.connect(str(self.db_path(name)))
|
||||
try:
|
||||
return list(con.execute(
|
||||
"SELECT instance_id, dbref FROM agent_system_identity"))
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def svc(self, *args, check=True, env=None):
|
||||
proc = subprocess.run(
|
||||
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
|
||||
cwd=self.root, env=env if env is not None else self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=900)
|
||||
if check:
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
return proc
|
||||
|
||||
def db_path(self, name):
|
||||
return self.runtime(name) / "data" / "agent-system.db"
|
||||
|
||||
def db_inode(self, name):
|
||||
return self.db_path(name).stat().st_ino
|
||||
|
||||
def release_id(self, name):
|
||||
return os.path.basename(os.readlink(self.runtime(name) / "current"))
|
||||
|
||||
def alive(self, name):
|
||||
pid = self.pid_of(name)
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def free_port(self):
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
def deploy(self, name, sha, port, check=True):
|
||||
return self.svc(name, "deploy", "--from", "rdm@" + sha, "--port", str(port),
|
||||
check=check)
|
||||
|
||||
def test_legacy_candidate_refused_before_stop(self):
|
||||
"""无 identity 的 legacy_sha 在停旧服务前被拒绝,且不改已有库。"""
|
||||
self.seed_established("leg", self.free_port())
|
||||
pid = self.pid_of("leg")
|
||||
inode = self.db_inode("leg")
|
||||
before = sorted(p.name for p in (self.runtime("leg") / "data").iterdir())
|
||||
result = self.deploy("leg", self.pin["legacy_sha"], 19641, check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("不支持 identity", result.stdout)
|
||||
self.assertEqual(self.pid_of("leg"), pid)
|
||||
self.assertTrue(self.alive("leg"))
|
||||
self.assertEqual(self.db_inode("leg"), inode)
|
||||
after = sorted(p.name for p in (self.runtime("leg") / "data").iterdir())
|
||||
self.assertEqual(after, before)
|
||||
self.svc("leg", "stop")
|
||||
|
||||
def test_missing_row_refused_before_stop(self):
|
||||
port = self.free_port()
|
||||
self.seed_established("miss", port)
|
||||
pid = self.pid_of("miss")
|
||||
db = self.db_path("miss")
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
con.execute("DELETE FROM agent_system_identity")
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
result = self.deploy("miss", self.pin["sha"], port, check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("无法验证", result.stdout)
|
||||
self.assertEqual(self.pid_of("miss"), pid)
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
count = con.execute("SELECT count(*) FROM agent_system_identity").fetchone()[0]
|
||||
finally:
|
||||
con.close()
|
||||
self.assertEqual(count, 0)
|
||||
self.svc("miss", "stop")
|
||||
|
||||
def test_mismatch_refused_before_stop(self):
|
||||
port = self.free_port()
|
||||
self.seed_established("mis", port)
|
||||
pid = self.pid_of("mis")
|
||||
db = self.db_path("mis")
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
con.execute("UPDATE agent_system_identity SET instance_id='svc-foreign-mismatch'")
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
result = self.deploy("mis", self.pin["sha"], port, check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("无法验证", result.stdout)
|
||||
self.assertEqual(self.pid_of("mis"), pid)
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
row = con.execute("SELECT instance_id FROM agent_system_identity").fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
self.assertEqual(row[0], "svc-foreign-mismatch")
|
||||
self.svc("mis", "stop")
|
||||
|
||||
def test_prev_and_current_pin_switch_and_back(self):
|
||||
"""prev_sha 与 sha 来回部署,身份行保持,release 目录跟着源 SHA 走。"""
|
||||
port = self.free_port()
|
||||
result = self.deploy("sw", self.pin["prev_sha"], port, check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
first = self.release_id("sw")
|
||||
self.assertIn(self.pin["prev_sha"][:7], first)
|
||||
iid = self.manifest("sw")["instance_id"]
|
||||
row = self.identity_row("sw")
|
||||
result = self.deploy("sw", self.pin["sha"], port, check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
second = self.release_id("sw")
|
||||
self.assertIn(self.pin["sha"][:7], second)
|
||||
self.assertNotEqual(second, first)
|
||||
self.assertEqual(self.identity_row("sw"), row)
|
||||
result = self.deploy("sw", self.pin["prev_sha"], port, check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn(self.pin["prev_sha"][:7], self.release_id("sw"))
|
||||
self.assertEqual(self.manifest("sw")["instance_id"], iid)
|
||||
self.assertEqual(self.identity_row("sw"), row)
|
||||
self.svc("sw", "stop")
|
||||
web = self.runtime("swweb")
|
||||
if (web / "svc.pid").exists():
|
||||
self.svc("swweb", "stop")
|
||||
|
||||
def file_sha256(self, path):
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
def journal(self, name):
|
||||
path = self.runtime(name) / "deploy.journal"
|
||||
return json.loads(path.read_text()) if path.exists() else None
|
||||
|
||||
def set_source(self, name, source):
|
||||
path = self.runtime(name) / "svc.env"
|
||||
lines = []
|
||||
for line in path.read_text().splitlines():
|
||||
if line.startswith("source="):
|
||||
lines.append("source=" + source)
|
||||
else:
|
||||
lines.append(line)
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
def ensure_git(self):
|
||||
subprocess.run(["git", "init", "--initial-branch=main"], cwd=self.root,
|
||||
env=self.env, check=True, stdout=subprocess.DEVNULL)
|
||||
subprocess.run(["git", "add", "."], cwd=self.root, env=self.env, check=True,
|
||||
stdout=subprocess.DEVNULL)
|
||||
subprocess.run(["git", "commit", "-m", "fixture"], cwd=self.root, env=self.env,
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
def deploy_fault(self, name, sha, port, phase):
|
||||
env = dict(self.env)
|
||||
env["SVC_TEST_FAULTS"] = "1"
|
||||
env["SVC_FAULT_AT"] = phase
|
||||
return self.svc(name, "deploy", "--from", "rdm@" + sha, "--port", str(port),
|
||||
check=False, env=env)
|
||||
|
||||
def test_phase_interrupt_reconciles_on_real_artifacts(self):
|
||||
"""staging 在停旧前退出,旧进程与指针不动;switched 在指针已切后退出,下次 start 回退。"""
|
||||
port = self.free_port()
|
||||
result = self.deploy("ph", self.pin["prev_sha"], port, check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
pid = self.pid_of("ph")
|
||||
first = self.release_id("ph")
|
||||
digest = self.file_sha256(self.runtime("ph") / "current" / "bin" / "agent-system")
|
||||
row = self.identity_row("ph")
|
||||
|
||||
staged = self.deploy_fault("ph", self.pin["sha"], port, "staging")
|
||||
self.assertEqual(staged.returncode, 86, staged.stdout)
|
||||
self.assertIn("故障注入于 staging", staged.stdout)
|
||||
self.assertEqual(self.pid_of("ph"), pid)
|
||||
self.assertTrue(self.alive("ph"))
|
||||
self.assertEqual(self.release_id("ph"), first)
|
||||
self.assertEqual(self.journal("ph")["phase"], "staging")
|
||||
resumed = self.svc("ph", "start")
|
||||
self.assertIn("未切换", resumed.stdout)
|
||||
self.assertIsNone(self.journal("ph"))
|
||||
self.assertEqual(self.pid_of("ph"), pid)
|
||||
self.assertEqual(self.release_id("ph"), first)
|
||||
|
||||
switched = self.deploy_fault("ph", self.pin["sha"], port, "switched")
|
||||
self.assertEqual(switched.returncode, 86, switched.stdout)
|
||||
self.assertIn("故障注入于 switched", switched.stdout)
|
||||
self.assertIn(self.pin["sha"][:7], self.release_id("ph"))
|
||||
self.assertFalse(self.alive("ph"))
|
||||
self.assertEqual(self.journal("ph")["phase"], "switched")
|
||||
rolled = self.svc("ph", "start")
|
||||
self.assertIn("已回退上一版本", rolled.stdout)
|
||||
self.assertEqual(self.release_id("ph"), first)
|
||||
self.assertEqual(
|
||||
self.file_sha256(self.runtime("ph") / "current" / "bin" / "agent-system"),
|
||||
digest)
|
||||
self.assertEqual(self.identity_row("ph"), row)
|
||||
self.assertTrue(self.alive("ph"))
|
||||
self.assertIsNone(self.journal("ph"))
|
||||
self.svc("ph", "stop")
|
||||
|
||||
def test_build_failure_keeps_real_process(self):
|
||||
self.ensure_git()
|
||||
port = self.free_port()
|
||||
self.seed_established("bld", port)
|
||||
self.set_source("bld", "local@seed")
|
||||
pid = self.pid_of("bld")
|
||||
before = self.file_sha256("/proc/%d/exe" % pid)
|
||||
(self.root / "Makefile").write_text("build:\n\texit 1\n")
|
||||
result = self.svc("bld", "deploy", "--from", "local", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("构建失败", result.stdout)
|
||||
self.assertEqual(self.pid_of("bld"), pid)
|
||||
self.assertTrue(self.alive("bld"))
|
||||
self.assertEqual(self.file_sha256("/proc/%d/exe" % pid), before)
|
||||
self.svc("bld", "stop")
|
||||
|
||||
def test_start_exit_rolls_back_to_real_binary(self):
|
||||
"""serve 立即退出的包装会回退。version 与 identity 仍转给真实制品,门禁才看得到能力。"""
|
||||
self.ensure_git()
|
||||
port = self.free_port()
|
||||
self.seed_established("die", port)
|
||||
self.set_source("die", "local@seed")
|
||||
real = self.file_sha256(self.real_bin)
|
||||
shim = self.root / "crash-shim"
|
||||
shim.write_text(
|
||||
"#!/bin/bash\n"
|
||||
"real=%s\n"
|
||||
"if [ \"${1:-}\" = serve ]; then\n"
|
||||
" echo 'crash-shim: serve exits immediately' >&2\n"
|
||||
" exit 1\n"
|
||||
"fi\n"
|
||||
"exec \"$real\" \"$@\"\n" % self.real_bin)
|
||||
shim.chmod(0o755)
|
||||
(self.root / "Makefile").write_text(
|
||||
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n" % shim)
|
||||
result = self.svc("die", "deploy", "--from", "local", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("已回退上一版本", result.stdout)
|
||||
current = self.runtime("die") / "current" / "bin" / "agent-system"
|
||||
self.assertEqual(self.file_sha256(current), real)
|
||||
self.assertTrue(self.alive("die"))
|
||||
self.assertIsNone(self.journal("die"))
|
||||
self.svc("die", "stop")
|
||||
|
||||
def git(self, repo, *args):
|
||||
subprocess.run(["git", "-C", str(repo), *args], cwd=self.root, env=self.env,
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
def make_copy_repo(self):
|
||||
"""两笔提交的部署源。build 复制真实制品,frontend 只写 dist,避免整仓前端构建。"""
|
||||
src = self.root / "devsrc"
|
||||
(src / "modules" / "frontend" / "dist").mkdir(parents=True)
|
||||
(src / "Makefile").write_text(
|
||||
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n"
|
||||
"frontend:\n\tmkdir -p modules/frontend/dist\n"
|
||||
"\tcp web/index.html modules/frontend/dist/index.html\n" % self.real_bin)
|
||||
(src / "web").mkdir()
|
||||
(src / "web" / "index.html").write_text("old\n")
|
||||
self.git(src, "init", "--initial-branch=main")
|
||||
self.git(src, "add", ".")
|
||||
self.git(src, "commit", "-m", "old")
|
||||
old = subprocess.check_output(
|
||||
["git", "-C", str(src), "rev-parse", "HEAD"], text=True).strip()
|
||||
(src / "web" / "index.html").write_text("new\n")
|
||||
self.git(src, "add", ".")
|
||||
self.git(src, "commit", "-m", "new")
|
||||
new = subprocess.check_output(
|
||||
["git", "-C", str(src), "rev-parse", "HEAD"], text=True).strip()
|
||||
self.git(src, "update-ref", "refs/remotes/origin/main", "HEAD")
|
||||
return src, old, new
|
||||
|
||||
def move_ref(self, repo, sha):
|
||||
ref = "refs/remotes/origin/main"
|
||||
prev = subprocess.check_output(
|
||||
["git", "-C", str(repo), "rev-parse", ref], text=True).strip()
|
||||
|
||||
def restore():
|
||||
subprocess.run(["git", "-C", str(repo), "update-ref", ref, prev],
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
self.addCleanup(restore)
|
||||
subprocess.run(["git", "-C", str(repo), "update-ref", ref, sha],
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
def test_mixed_batch_updates_forward_and_deploy_reverses(self):
|
||||
"""dev、genui 走复制型源仓;rdm1、rdmcand 走 pin。update 只向前,回退必须显式 deploy。"""
|
||||
src, old, new = self.make_copy_repo()
|
||||
self.env["SVC_SRC_DIR"] = str(src)
|
||||
ports = {name: self.free_port() for name in ("dev", "genui", "rdm1", "rdmcand")}
|
||||
self.svc("dev", "deploy", "--from", "dev@" + old, "--port", str(ports["dev"]))
|
||||
self.svc("genui", "deploy", "--kind", "static", "--from", "dev@" + old,
|
||||
"--port", str(ports["genui"]))
|
||||
self.deploy("rdm1", self.pin["prev_sha"], ports["rdm1"])
|
||||
self.deploy("rdmcand", self.pin["prev_sha"], ports["rdmcand"])
|
||||
# deploy 会 fetch 并把 origin/main 拉回远端。更新前再钉回 pin,且 update --no-fetch。
|
||||
self.move_ref(SRC, self.pin["sha"])
|
||||
old_src = {
|
||||
"dev": self.kv("dev", "source"),
|
||||
"genui": self.kv("genui", "source"),
|
||||
"rdm1": self.kv("rdm1", "source"),
|
||||
"rdmcand": self.kv("rdmcand", "source"),
|
||||
}
|
||||
self.assertIn(old[:7], old_src["dev"])
|
||||
self.assertIn(self.pin["prev_sha"][:7], old_src["rdm1"])
|
||||
|
||||
for index, name in enumerate(("dev", "genui", "rdm1", "rdmcand")):
|
||||
if name in ("rdm1", "rdmcand"):
|
||||
# 上一服务的 deploy 子进程会 fetch,把 origin/main 拉回远端。
|
||||
subprocess.run(
|
||||
["git", "-C", SRC, "update-ref", "refs/remotes/origin/main",
|
||||
self.pin["sha"]],
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
result = self.svc("update", name, "--no-fetch")
|
||||
self.assertIn("已更新", result.stdout)
|
||||
for later in ("dev", "genui", "rdm1", "rdmcand")[index + 1:]:
|
||||
self.assertEqual(self.kv(later, "source"), old_src[later])
|
||||
self.assertIn(new[:7], self.kv("dev", "source"))
|
||||
self.assertIn(new[:7], self.kv("genui", "source"))
|
||||
self.assertIn(self.pin["sha"][:7], self.kv("rdm1", "source"))
|
||||
self.assertIn(self.pin["sha"][:7], self.kv("rdmcand", "source"))
|
||||
self.assertEqual((self.runtime("genui") / "htdocs" / "index.html").read_text(), "new\n")
|
||||
self.assertEqual(len(self.identity_row("rdm1")), 1)
|
||||
self.assertEqual(len(self.identity_row("rdmcand")), 1)
|
||||
|
||||
self.move_ref(src, old)
|
||||
self.move_ref(SRC, self.pin["prev_sha"])
|
||||
ahead = self.svc("update", "--check", "--no-fetch", "dev", "genui", "rdm1", "rdmcand")
|
||||
self.assertIn("领先", ahead.stdout)
|
||||
self.assertNotIn("已更新", ahead.stdout)
|
||||
self.assertIn(new[:7], self.kv("dev", "source"))
|
||||
self.assertIn(self.pin["sha"][:7], self.kv("rdmcand", "source"))
|
||||
|
||||
newest = {
|
||||
"dev": self.kv("dev", "source"),
|
||||
"genui": self.kv("genui", "source"),
|
||||
"rdm1": self.kv("rdm1", "source"),
|
||||
"rdmcand": self.kv("rdmcand", "source"),
|
||||
}
|
||||
reverse = (
|
||||
("rdmcand", ("rdmcand", "deploy", "--from", "rdm@" + self.pin["prev_sha"],
|
||||
"--port", str(ports["rdmcand"]))),
|
||||
("rdm1", ("rdm1", "deploy", "--from", "rdm@" + self.pin["prev_sha"],
|
||||
"--port", str(ports["rdm1"]))),
|
||||
("genui", ("genui", "deploy", "--kind", "static", "--from", "dev@" + old,
|
||||
"--port", str(ports["genui"]))),
|
||||
("dev", ("dev", "deploy", "--from", "dev@" + old, "--port", str(ports["dev"]))),
|
||||
)
|
||||
for index, (name, args) in enumerate(reverse):
|
||||
self.svc(*args)
|
||||
self.assertNotEqual(self.kv(name, "source"), newest[name])
|
||||
for later, _args in reverse[index + 1:]:
|
||||
self.assertEqual(self.kv(later, "source"), newest[later])
|
||||
self.assertEqual((self.runtime("genui") / "htdocs" / "index.html").read_text(), "old\n")
|
||||
for name in ("dev", "genui", "rdm1", "rdmcand", "rdm1web", "rdmcandweb"):
|
||||
if (self.runtime(name) / "svc.pid").exists():
|
||||
self.svc(name, "stop")
|
||||
|
||||
def marker_id(self, name):
|
||||
path = self.runtime(name) / "data" / ".agentx" / "data-marker.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))["instance_id"]
|
||||
|
||||
def test_missing_manifest_refuses_the_same_way(self):
|
||||
"""manifest 丢失后 deploy、update、recover 都停在归属冲突,不新建身份。"""
|
||||
src, _old, _new = self.make_copy_repo()
|
||||
self.env["SVC_SRC_DIR"] = str(src)
|
||||
self.move_ref(SRC, self.pin["sha"])
|
||||
names = ("nmdep", "nmupd", "nmrec")
|
||||
rows = {}
|
||||
for name in names:
|
||||
self.seed_established(name, self.free_port())
|
||||
self.svc(name, "stop")
|
||||
rows[name] = (self.identity_row(name), self.marker_id(name))
|
||||
(self.runtime(name) / "manifest.json").unlink()
|
||||
self.set_source("nmupd", "rdm@" + self.pin["prev_sha"])
|
||||
envf = self.runtime("nmrec") / "svc.env"
|
||||
envf.write_text(envf.read_text() + "autostart=1\n")
|
||||
|
||||
deployed = self.svc("nmdep", "deploy", "--from", "rdm@" + self.pin["sha"],
|
||||
"--port", str(self.free_port()), check=False)
|
||||
updated = self.svc("update", "nmupd", "--no-fetch", check=False)
|
||||
recovered = self.svc("recover", "nmrec", check=False)
|
||||
for result in (deployed, updated, recovered):
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("身份治理冲突", result.stdout)
|
||||
for name in names:
|
||||
self.assertIsNone(self.manifest(name))
|
||||
self.assertEqual((self.identity_row(name), self.marker_id(name)), rows[name])
|
||||
self.assertFalse(self.alive(name))
|
||||
|
||||
def test_pg_cross_instance_verify_refuses(self):
|
||||
dsn = os.environ.get("AGENT_PG_TEST_DSN", "")
|
||||
if not dsn:
|
||||
self.skipTest("PG 跨实例 NOT_RUN:未设置 AGENT_PG_TEST_DSN")
|
||||
dsn = self.private_pg_dsn(dsn)
|
||||
port = self.free_port()
|
||||
self.seed_established("pga", port)
|
||||
self.svc("pga", "stop")
|
||||
owner = self.manifest("pga")["instance_id"]
|
||||
data = self.runtime("pga") / "data"
|
||||
env = dict(self.env)
|
||||
env["AGENT_SYSTEM_DSN"] = dsn
|
||||
# 身份命令不建库。先用真实制品按环境 DSN 启动一次,让受控迁移落表。
|
||||
self.initialize_store(data, env)
|
||||
ensured = subprocess.run(
|
||||
[str(self.real_bin), "identity", "ensure", "--instance-id", owner,
|
||||
"--dbref", "pg", "--data", str(data)],
|
||||
env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=60)
|
||||
self.assertEqual(ensured.returncode, 0, ensured.stdout.replace(dsn, "<dsn>"))
|
||||
self.seed_established("pgb", self.free_port())
|
||||
self.svc("pgb", "stop")
|
||||
other = self.manifest("pgb")["instance_id"]
|
||||
self.assertNotEqual(other, owner)
|
||||
envf = self.runtime("pgb") / "svc.env"
|
||||
envf.write_text(envf.read_text() + "db=pg\ndsn=%s\n" % dsn)
|
||||
manifest = self.manifest("pgb")
|
||||
manifest["identity"]["db_mode"] = "pg"
|
||||
manifest["identity"]["db_ref"] = "pg"
|
||||
manifest["identity"]["db_marker"] = "established"
|
||||
(self.runtime("pgb") / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||
result = self.svc("pgb", "start", check=False)
|
||||
redacted = result.stdout.replace(dsn, "<dsn>")
|
||||
self.assertNotEqual(result.returncode, 0, redacted)
|
||||
self.assertFalse(self.alive("pgb"))
|
||||
still = subprocess.run(
|
||||
[str(self.real_bin), "identity", "verify", "--instance-id", owner,
|
||||
"--dbref", "pg", "--data", str(data)],
|
||||
env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=60)
|
||||
self.assertEqual(still.returncode, 0, still.stdout.replace(dsn, "<dsn>"))
|
||||
self.svc("pga", "stop", check=False)
|
||||
|
||||
def test_caller_dsn_stays_out_of_process_and_logs(self):
|
||||
sentinel = "credscan-sentinel-7c1e"
|
||||
self.seed_established("cred", self.free_port())
|
||||
self.svc("cred", "stop")
|
||||
env = dict(self.env)
|
||||
env["AGENT_SYSTEM_DSN"] = "postgres://scanner:%s@127.0.0.1/none" % sentinel
|
||||
self.svc("cred", "start", env=env)
|
||||
pid = self.pid_of("cred")
|
||||
cmdline = Path("/proc/%d/cmdline" % pid).read_bytes()
|
||||
environ = Path("/proc/%d/environ" % pid).read_bytes()
|
||||
self.assertNotIn(sentinel.encode(), cmdline)
|
||||
self.assertNotIn(sentinel.encode(), environ)
|
||||
for path in ("svc.log", "history.jsonl", "svc.env"):
|
||||
text = (self.runtime("cred") / path).read_text(errors="replace")
|
||||
self.assertNotIn(sentinel, text, path)
|
||||
self.svc("cred", "stop")
|
||||
|
||||
def private_pg_dsn(self, dsn):
|
||||
"""共享的 AGENT_PG_TEST_DSN 已被其他用例写入身份。本用例单独建库。"""
|
||||
parsed = urlparse(dsn)
|
||||
name = "d3pg_" + hashlib.sha256(os.urandom(8)).hexdigest()[:12]
|
||||
created = subprocess.run(
|
||||
["psql", dsn, "-v", "ON_ERROR_STOP=1", "-c",
|
||||
'CREATE DATABASE "%s"' % name],
|
||||
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
|
||||
self.assertEqual(created.returncode, 0, created.stdout.replace(dsn, "<dsn>"))
|
||||
self.addCleanup(self.drop_private_pg, dsn, name)
|
||||
return urlunparse(parsed._replace(path="/" + name))
|
||||
|
||||
def drop_private_pg(self, dsn, name):
|
||||
subprocess.run(
|
||||
["psql", dsn, "-c", 'DROP DATABASE IF EXISTS "%s" WITH (FORCE)' % name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
|
||||
|
||||
def event_count(self, name):
|
||||
db = self.runtime(name) / "data" / "rdm.db"
|
||||
con = sqlite3.connect("file:%s?mode=ro" % db, uri=True)
|
||||
try:
|
||||
return con.execute("SELECT count(*) FROM events").fetchone()[0]
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def test_bootstrap_domain_survives_upgrade(self):
|
||||
port = self.free_port()
|
||||
self.seed_established("boot", port)
|
||||
self.svc("boot", "stop")
|
||||
docs = self.root / "bootstrap-docs"
|
||||
docs.mkdir()
|
||||
(docs / "note.md").write_text("# 领域笔记\n\n升级后应仍在。\n")
|
||||
data = self.runtime("boot") / "data"
|
||||
ingested = subprocess.run(
|
||||
[str(self.real_bin), "-bootstrap", str(docs), "-data", str(data)],
|
||||
env=self.env, text=True, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, timeout=60)
|
||||
self.assertEqual(ingested.returncode, 0, ingested.stdout)
|
||||
before = self.event_count("boot")
|
||||
self.assertGreater(before, 0)
|
||||
result = self.deploy("boot", self.pin["sha"], port, check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(self.event_count("boot"), before)
|
||||
self.svc("boot", "stop")
|
||||
if (self.runtime("bootweb") / "svc.pid").exists():
|
||||
self.svc("bootweb", "stop")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -25,6 +25,14 @@ def checkout_script():
|
||||
return textwrap.dedent("\n".join(lines[start:end]))
|
||||
|
||||
|
||||
def rdm_clone_script():
|
||||
lines = WORKFLOW.read_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if "Clone Agent-System-RDM" in line)
|
||||
start = next(i for i in range(start, len(lines)) if lines[i].strip() == "run: |") + 1
|
||||
end = next(i for i in range(start, len(lines)) if lines[i].startswith(" - name:"))
|
||||
return textwrap.dedent("\n".join(lines[start:end]))
|
||||
|
||||
|
||||
class CheckoutTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
@@ -236,6 +244,54 @@ class CheckoutTests(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(any("untrusted" in path for path, auth in self.requests))
|
||||
|
||||
def test_rdm_pin_fetch_is_three_shas_on_the_allowlist(self):
|
||||
source = self.base / "Agent-System-RDM"
|
||||
source.mkdir()
|
||||
self.git(source, "init", "-q", "-b", "main")
|
||||
shas = []
|
||||
for index in range(3):
|
||||
(source / "Makefile").write_text(
|
||||
"build:\n\tmkdir -p bin\n\tprintf 'rev%d\\n' > bin/agent-system-rdm\n" % index)
|
||||
self.git(source, "add", ".")
|
||||
self.git(source, "-c", "user.name=Fixture",
|
||||
"-c", "user.email=fixture@example.invalid",
|
||||
"commit", "-q", "-m", "rev %d" % index)
|
||||
shas.append(self.git(source, "rev-parse", "HEAD"))
|
||||
target = self.published / "weichen" / "Agent-System-RDM.git"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.git(self.base, "clone", "-q", "--bare", str(source), str(target))
|
||||
self.git(target, "config", "uploadpack.allowReachableSHA1InWant", "true")
|
||||
self.git(target, "config", "uploadpack.allowAnySHA1InWant", "true")
|
||||
work = self.base / "rdm-job"
|
||||
pin = work / "tests" / "integration" / "lifecycle"
|
||||
pin.mkdir(parents=True)
|
||||
(pin / "rdm.pin").write_text(
|
||||
"sha=%s\nprev_sha=%s\nlegacy_sha=%s\n" % (shas[2], shas[1], shas[0]))
|
||||
script = rdm_clone_script()
|
||||
self.assertNotRegex(script, r"(?m)^\s*source\s")
|
||||
self.requests.clear()
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], cwd=work,
|
||||
env=self.env | {"SUBMODULE_TOKEN": SUB_TOKEN, "GITHUB_SERVER_URL": self.url},
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertNotIn(SUB_TOKEN, result.stdout + result.stderr)
|
||||
self.assertTrue(self.requests)
|
||||
for path, auth in self.requests:
|
||||
self.assertRegex(path, r"^/weichen/Agent-System-RDM\.git/")
|
||||
self.assertEqual(auth, "token " + SUB_TOKEN)
|
||||
for sha in shas:
|
||||
self.assertEqual(self.git(work / "rdm", "cat-file", "-t", sha), "commit")
|
||||
self.assertEqual(self.git(work / "rdm", "rev-parse", "HEAD"), shas[2])
|
||||
digests = (work / "rdm" / "pin-build-digests.txt").read_text()
|
||||
for sha in shas:
|
||||
self.assertIn(sha, digests)
|
||||
gate = WORKFLOW.read_text().split(
|
||||
"Real-combination lifecycle gate", 1)[1].split("\n svc-realcombo:", 1)[0]
|
||||
self.assertIn('export SVC_CI_GIT_TOKEN="$SUBMODULE_TOKEN"', gate)
|
||||
self.assertNotRegex(gate, r"(?m)^\s*source\s")
|
||||
self.assertNotIn(">>", gate)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user