Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=673e687c5c85b9af3ce13ca30845d5e68de5dfa0. Impact: integrate upstream changes without rewriting task history. Refs: task-sync
This commit is contained in:
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
+5
-1
@@ -35,7 +35,11 @@ U2 起实施与部署分别评审。
|
||||
- [服务重启与全量数据恢复方案 r2](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),余量另立增量。
|
||||
衔接协作存储案;U2 首切片已由 svc-autorecover 合入 main(PR #396/#401);U1 实例身份已合入(Dev #415);余量另立增量。
|
||||
|
||||
- [svc 统一部署与一次性登录 Key 方案 r1](modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md):
|
||||
二进制/源码统一部署、环境准备、无初始密码开户与账户恢复;一次性 Key 仅兑换设密受限会话;
|
||||
2026-10-12 已批准;U1/U4 实施启动中;规格见 `docs/svc-deploy/`。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# U1 实例—账户绑定补齐(规格 v0.1)
|
||||
|
||||
状态:已启动实施(分支 `feature/svc-deploy-u1-account-binding`)。主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` §7 U1。
|
||||
前置:恢复方案 recovery U1 已合入 main(Dev #415)——`docs/recovery/u1-identity-manifest.md`、`scripts/svc_manifest.py`、启动身份校验与 `svc init`。
|
||||
本增量**不重做** manifest 层;只补齐账户操作与安装入口对既有身份合同的消费缺口。
|
||||
|
||||
基线:任务卡 R0;实施中。真实 PG 绑定专项缺环境标 NOT_RUN。
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
**目标:** 同一实例的启停、升级、开户、签发/撤销登录 Key 始终命中同一存储与数据根;缺失、不符或锁不可用时拒绝;为后续 `install` / doctor 提供稳定的实例解析入口。
|
||||
|
||||
非目标:不实现二进制下载(U2)、不实现 doctor/env prepare 全量(U3)、不实现一次性 Key 语义(U4)、不改登录页(U5)。
|
||||
|
||||
## 2. 不变量(相对 recovery U1)
|
||||
|
||||
| ID | 不变量 | 相对 #415 |
|
||||
|---|---|---|
|
||||
| B1 | 账户 CLI 只接受「实例名 → manifest 解析出的 data_root / db / secrets 引用」 | 新增消费方 |
|
||||
| B2 | 禁止回落默认 `./data` 或未声明 DSN | 新增硬拒绝 |
|
||||
| B3 | OpenExisting:目标不存在则失败;可选禁止自动 migrate | Backend 新增打开模式 |
|
||||
| B4 | 无 `flock` / 锁获取失败 → 拒绝冲突操作(deploy/start/stop/user/*) | 收紧现状「直通」 |
|
||||
| B5 | `install` / 兼容 `--from` 写入同一 manifest 身份字段,不另起配置根 | 入口骨架 |
|
||||
|
||||
## 3. 触点与文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `scripts/svc.sh` | `user` 子命令骨架:解析 `ENV_NAME` → 读 manifest/`svc_manifest.py` → 组装 `--data`/`AGENT_SYSTEM_DSN`/`--open-existing` 调 `$ENV_BIN`;锁检查收紧;`install` 可先做 dry-run 计划打印(完整 binary/source 属 U2) |
|
||||
| `scripts/svc_manifest.py` | 如需:导出「账户操作所需最小字段」查询(data_root、db mode/ref、auth);不改 schema=1 语义 |
|
||||
| `modules/backend/cmd/agent-system/main.go` | `user` 子命令增加 `--open-existing`(或全局 store 打开标志);缺库/缺目录失败 |
|
||||
| `modules/backend/internal/store/store.go` | `OpenExistingSQLite` / PG 等价:不 `MkdirAll` 创建新数据根;可选 `MigrateMode=requireCompatible\|forbid` |
|
||||
| `tests/test_svc_identity.py` 或新 `tests/test_svc_user_binding.py` | 错误 data、缺 manifest、无 flock 拒绝;正确绑定命中 |
|
||||
|
||||
## 4. OpenExisting 合同
|
||||
|
||||
```
|
||||
OpenExisting(path|dsn, opts):
|
||||
if storage path/parent missing → error (do not create)
|
||||
if SQLite file missing → error
|
||||
open DB
|
||||
if opts.migrate == forbid → read schema version only; mismatch → error
|
||||
if opts.migrate == requireCompatible → existing migrate(); reject unknown newer schema (keep current migrate gate)
|
||||
never create empty business DB as side effect of account recovery
|
||||
```
|
||||
|
||||
CLI 默认:账户运维命令使用 `OpenExisting` + `requireCompatible`。
|
||||
`svc init` / 首次服务启动仍走现有 Open(可创建)路径,与 recovery U1 一致。
|
||||
|
||||
## 5. 锁合同
|
||||
|
||||
- 复用 `runtime/<name>/.start.lock`。
|
||||
- `flock` 不可用或超时:退出非 0,写 hist_event,**不得**执行 deploy/start/stop/user。
|
||||
- 账户操作与生命周期互斥(同一锁)。
|
||||
|
||||
## 6. 验收用例(U1)
|
||||
|
||||
| # | 场景 | 判据 |
|
||||
|---|---|---|
|
||||
| T1 | 实例有合法 manifest;`svc <name> user list` | 使用 manifest data_root/DSN;列表与该库一致 |
|
||||
| T2 | 故意传错误 `--data` 或未绑定实例 | 拒绝;不创建目录;不写默认 `./data` |
|
||||
| T3 | 删除 data 根后再 user create | OpenExisting 失败;无新空库 |
|
||||
| T4 | 隐藏 `flock`(PATH 去掉)后 deploy/user | 拒绝;旧服务若在跑则仍在 |
|
||||
| T5 | recovery U1 既有 T1–T6 回归 | 全绿(不破坏 identity) |
|
||||
|
||||
真实 PG:绑定 dbref 与 marker 一致才允许 user 操作;缺环境标 NOT_RUN。
|
||||
|
||||
## 7. 完成门槛与依赖
|
||||
|
||||
- 完成条件见主提案 §7 U1 行。
|
||||
- 依赖:main 已含 recovery U1(#415)。
|
||||
- 被依赖:U4 CLI 签发、U5 `svc user` 接线、U2 `install` 写身份。
|
||||
|
||||
## 8. 任务卡建议(待授权后 `task-start`)
|
||||
|
||||
- 分支名:`feature/svc-deploy-u1-account-binding`
|
||||
- R0:与本提案合入后的 `origin/main`(或与 U4 兄弟共享批准时的同一 R0)
|
||||
- 交付模式:manual;实施需主提案阶段批准后再授权
|
||||
@@ -0,0 +1,4 @@
|
||||
# 草稿:feature/svc-deploy-u1-account-binding
|
||||
|
||||
状态:**已启动** → 任务卡 `.task/feature-svc-deploy-u1-account-binding.md`;工作区
|
||||
`/home/weichen/projects/Agent-System-Dev-wt-feature-svc-deploy-u1-account-binding`。
|
||||
@@ -0,0 +1,160 @@
|
||||
# U4 一次性登录凭据与认证限制(规格 v0.1)
|
||||
|
||||
状态:已启动实施(Backend/Dev 分支 `feature/svc-deploy-u4-login-key`)。主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` §7 U4 / §8。
|
||||
本增量在 **Backend**(及必要迁移/CLI)落地;Frontend 兑换与设密页属 **U5**,但 U4 须提供稳定 HTTP 合同供 U5 接线。
|
||||
|
||||
基线:任务卡 R0;Backend 实现提交见 gitlink。真实 PG 缺环境标 NOT_RUN。
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
**目标:** 无初始密码开户与已有账户恢复均通过一次性登录 Key;Key 只能经专用接口原子兑换为「仅可设密」的短期会话;服务端拒绝受限会话与待改密会话访问业务 API;设密在同一事务内完成且成功后强制重新密码登录。
|
||||
|
||||
非目标:不改长期 `api_keys` 的集成语义(除失陷显式撤销);不实现 svc 接线与登录页(U5);不做 doctor/部署(U1–U3)。
|
||||
|
||||
## 2. 现状锚点(必须改掉的行为)
|
||||
|
||||
| 现状 | 出处 | U4 动作 |
|
||||
|---|---|---|
|
||||
| CLI 省略密码 → `RandomPassword()` + 明文打印 | `app.CreateUserAccount` | 默认无密码 + 签发 activate Key;随机密码可保留为显式 `--password` / `--generate-password` |
|
||||
| `api_keys` 可重复使用、无 TTL | `store/auth.go` | **不改**长期 Key;新增独立表 |
|
||||
| Middleware 不看 `must_change` | `server/auth.go` | 统一拦截 |
|
||||
| `changePassword` 强制 `current` | `server/accounts.go` | 分型:受限会话走 set-initial / set-recovered |
|
||||
| `SetUserPassword` 总置 `must_change=1` | `store/auth.go` | 设密成功路径一次写清,避免半完成 |
|
||||
| Open 必 MkdirAll + migrate | `store/store.go` | 账户运维走 OpenExisting(与 U1 合同一致;可本增量先做 Backend API) |
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 `login_keys`(名称可微调,勿混入 `api_keys`)
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| `id` | 稳定凭据编号(对外审计/撤销用,非明文) |
|
||||
| `owner` | 目标用户 |
|
||||
| `purpose` | `activate` \| `recover` |
|
||||
| `key_hash` | SHA-256(或更强);明文前缀建议 `alk_`(activation/login key) |
|
||||
| `status` | `unused` \| `consumed` \| `revoked` \| `expired` |
|
||||
| `expires_at` | 默认 now+30m;签发时可配置(夹紧 5–120m) |
|
||||
| `consumed_at` | 可空 |
|
||||
| `created_at` / `created_by` | 审计(CLI 操作者或 `system`) |
|
||||
| `instance_id` | 可选;与部署身份关联时填写 |
|
||||
|
||||
唯一性:同一明文只能对应一行;未使用 Key 可按 (owner, purpose) 允许多条,但签发新 Key 时建议撤销同 purpose 的未使用旧 Key(规格默认:**签发即撤销同 owner+purpose 的 unused**)。
|
||||
|
||||
### 3.2 `users` 变更
|
||||
|
||||
- `password_hash`:允许 NULL / 空,表示未设置。
|
||||
- 保留 `must_change_password`:用于「已有随机密码首登」迁移路径;无密码账户用「无 hash」+ activate 流,不依赖假密码。
|
||||
- 角色:首期仍映射 `is_admin`;API 可收 `role=admin|user`,未知角色 **拒绝**(不静默降级)。
|
||||
|
||||
### 3.3 `sessions` 变更
|
||||
|
||||
增加 `scope`(或等价):`full` \| `set_password_only`。
|
||||
受限会话 TTL 默认 10 分钟。
|
||||
`CreateSession(owner, scope, ttl)`。
|
||||
|
||||
## 4. CLI 合同
|
||||
|
||||
```
|
||||
agent-system user create --owner <id> --role user|admin [--open-existing]
|
||||
→ 无 --password:password 未设置 + 同事务签发 activate Key;stdout 明文一次
|
||||
→ --password / --generate-password:兼容旧路径(must_change=1)
|
||||
|
||||
agent-system user issue-login-key --owner <id> --purpose activate|recover [--ttl 30m] [--open-existing]
|
||||
→ 精确命中已有用户;不改 password_hash;不吊销 full 会话;不改业务数据
|
||||
→ recover 不删除重建
|
||||
|
||||
agent-system user revoke-login-key --id <id> [--open-existing]
|
||||
agent-system user list # 含是否已设密、未使用登录 Key 计数(无明文)
|
||||
```
|
||||
|
||||
首次管理员:
|
||||
|
||||
```
|
||||
agent-system user bootstrap-admin --owner <id> [--open-existing]
|
||||
```
|
||||
|
||||
仅当:库已存在且 **无任何 users 行** 且 **无历史业务标记**(规格:至少 `projects`/`messages` 等核心表为空或实例 init marker 声明 fresh)。并发用事务/`UNIQUE` 保证单胜。有数据却无管理员 → **拒绝自动接管**,要求显式运维流程(本增量可只返回明确错误)。
|
||||
|
||||
失陷恢复:
|
||||
|
||||
```
|
||||
... issue-login-key --purpose recover --revoke-all-credentials
|
||||
```
|
||||
|
||||
默认 **不**撤销长期 `api_keys`;加旗标才撤销全部 api_keys + 可选吊销 full 会话。
|
||||
|
||||
## 5. HTTP 合同
|
||||
|
||||
| 方法 | 路径 | 认证 | 行为 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/auth/login-key/redeem` | 无(body: `{ "key": "alk_..." }`) | 事务:校验 unused+未过期 → 条件更新 consumed → 建 `set_password_only` 会话 → 设 cookie → 提交 |
|
||||
| POST | `/api/auth/password` | session | `scope=full`:要求 `current`(现行为);`scope=set_password_only`:忽略 current,要求 `next`+确认由客户端做,服务端校验强度 |
|
||||
| POST | `/api/auth/logout` | session | 允许受限会话 |
|
||||
| GET | `/api/me` | session | 允许受限会话;响应含 `sessionScope`、`mustChangePassword`、`passwordSet` |
|
||||
| 其他 `/api/*` | — | — | `set_password_only` **或** (`must_change` 且非上述白名单) → **403** |
|
||||
|
||||
禁止:用 Bearer / `?key=` 提交一次性登录 Key;redeem 外任何路径接受 `alk_`。
|
||||
|
||||
兑换并发:`UPDATE ... WHERE id=? AND status='unused' AND expires_at>now` 影响行数必须为 1;否则失败。消费后建会话失败 → **整事务回滚**(Key 仍为 unused)。
|
||||
|
||||
设密成功事务(受限或兼容 must_change):
|
||||
|
||||
1. 写 password_hash
|
||||
2. 清 must_change;标记已设密
|
||||
3. 作废该 owner 全部 unused/本 purpose 登录 Key
|
||||
4. 删除该 owner 全部会话(含当前受限会话)
|
||||
5. 清 cookie / 不发新 full 会话
|
||||
6. 响应指示 `reloginRequired: true`
|
||||
|
||||
半完成态禁止:密码已变但旧 Key 仍 unused;或 Key 已 consumed 但无会话且密码未变(兑换回滚已覆盖后者)。
|
||||
|
||||
## 6. 中间件伪代码
|
||||
|
||||
```
|
||||
after AuthOwner/SessionOwner:
|
||||
if session.scope == set_password_only:
|
||||
if path not in {GET /api/me, POST /api/auth/password, POST /api/auth/logout}:
|
||||
403
|
||||
else if user.must_change_password or not user.password_set:
|
||||
if path not in same allowlist:
|
||||
403
|
||||
// API key (long-lived): if must_change or not password_set → 403 on business routes
|
||||
// (key cannot complete password change)
|
||||
```
|
||||
|
||||
## 7. 测试计划(Backend)
|
||||
|
||||
| # | 场景 | 判据 |
|
||||
|---|---|---|
|
||||
| T1 | create 无密码 | DB hash 空;一条 unused activate Key;stdout 有明文一次 |
|
||||
| T2 | 同名 create | 冲突;原账户与 Key 不变 |
|
||||
| T3 | redeem 成功 | Key consumed;cookie 为 set_password_only;/api/me 200;/api/projects 403 |
|
||||
| T4 | 同 Key 并发 100 次 redeem | 恰好 1 成功 |
|
||||
| T5 | 过期 / 撤销 / 重放 | 均失败 |
|
||||
| T6 | 设密成功 | hash 在;Key 作废;会话全删;再带旧 cookie → 401;密码登录 success 且 scope=full |
|
||||
| T7 | 设密中途 DB 失败(注入) | 无「已改密但仍 unused Key」 |
|
||||
| T8 | redeem 后 CreateSession 失败 | Key 仍 unused |
|
||||
| T9 | recover 签发 | 不改 hash;不删业务行;可选保留 api_keys |
|
||||
| T10 | must_change 旧路径 + 中间件 | 业务 API 403;设密后可访问 |
|
||||
| T11 | OpenExisting 缺库 | CLI 失败不建目录 |
|
||||
|
||||
SQLite 与真实 PG 双跑;缺 PG 环境标 NOT_RUN。
|
||||
|
||||
## 8. 安全与交付
|
||||
|
||||
- 明文只在签发时交付;日志/evidence 只记 `id`/purpose/结果。
|
||||
- 不把 Key 放 URL、命令行参数名以外的 argv 常驻、或 localStorage(U5 约束)。
|
||||
- 对齐 OWASP Forgot Password / Session Management;保留现有 CSRF。
|
||||
|
||||
## 9. 完成门槛与依赖
|
||||
|
||||
- 完成条件见主提案 §7 U4 行。
|
||||
- 依赖:U1 OpenExisting 可并行由 Backend 先实现最小集,再与 svc 接线合并。
|
||||
- 被依赖:U5 登录页兑换与设密分型。
|
||||
|
||||
## 10. 任务卡建议(待授权后 `task-start`)
|
||||
|
||||
- 分支名:`feature/svc-deploy-u4-login-key`
|
||||
- 组件:Backend feature 分支 + Dev gitlink;Frontend 不在本增量强制改动(契约测试可用 httptest)
|
||||
- R0:与 U1 兄弟共享批准时的同一 `origin/main`
|
||||
- 交付模式:manual
|
||||
@@ -0,0 +1,4 @@
|
||||
# 草稿:feature/svc-deploy-u4-login-key
|
||||
|
||||
状态:**已启动** → 任务卡 `.task/feature-svc-deploy-u4-login-key.md`;工作区
|
||||
`/home/weichen/projects/Agent-System-Dev-wt-feature-svc-deploy-u4-login-key`。
|
||||
+1
-1
Submodule modules/backend updated: 52d75fd931...1d9e8f4a79
+1
-1
Submodule modules/documents updated: bd5fb18519...9d9b1523c8
+74
-6
@@ -892,18 +892,25 @@ port_owner() {
|
||||
|
||||
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
|
||||
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
|
||||
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失时降级为无锁直通(尽力而为)。
|
||||
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失或等待超时 → 拒绝(不得直通冲突操作)。
|
||||
# 锁随进程退出自动释放(fd 关闭);长驻循环(recover)用 svc_unlock 逐服务显式释放。
|
||||
SVC_LOCK_HELD=0
|
||||
svc_lock() {
|
||||
[ "$SVC_LOCK_HELD" = 1 ] && return 0
|
||||
command -v flock >/dev/null 2>&1 || { SVC_LOCK_HELD=1; return 0; }
|
||||
if ! command -v flock >/dev/null 2>&1; then
|
||||
c_err "[$ENV_NAME] 缺少 flock:拒绝生命周期/账户冲突操作(安装 util-linux 后重试)"
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ENV_DIR" 2>/dev/null || return 1
|
||||
exec 9>>"$ENV_DIR/.start.lock" || return 1
|
||||
if [ "$1" = "-n" ]; then
|
||||
flock -n 9 || { exec 9>&-; return 1; }
|
||||
else
|
||||
flock -w 300 9 || c_warn "[$ENV_NAME] 生命周期锁等待超时(继续执行,注意并发)"
|
||||
if ! flock -w 300 9; then
|
||||
exec 9>&-
|
||||
c_err "[$ENV_NAME] 生命周期锁等待超时:拒绝继续(避免并发冲突)"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
SVC_LOCK_HELD=1
|
||||
return 0
|
||||
@@ -1405,6 +1412,66 @@ materialize_release() {
|
||||
install_artifacts "$bin" "$art/plugins" || return 1
|
||||
}
|
||||
|
||||
# ---- agent:账户 CLI(绑定实例存储;禁止回落默认 ./data)----
|
||||
# 用法:scripts/svc.sh <env> user <create|list|...> [--owner …]
|
||||
# 从实例最终配置 / manifest identity 解析 data/DSN,强制 --open-existing,
|
||||
# 调用 ENV_BIN 对应版本。拒绝调用方覆盖 --data/--dsn/--open-existing。
|
||||
agent_do_user() {
|
||||
local a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--data|--dsn|--open-existing|--data=*|--dsn=*|--open-existing=*)
|
||||
c_err "[$ENV_NAME] 账户操作禁止覆盖存储绑定(勿传 --data/--dsn/--open-existing;由实例 manifest 决定)"
|
||||
return 2 ;;
|
||||
esac
|
||||
done
|
||||
svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁(账户操作与启停互斥)"; return 1; }
|
||||
# 有 manifest 时以 identity.data_root 为权威,拒绝 ENV_DATA 漂移到默认 ./data。
|
||||
if [ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1; then
|
||||
local mf_root
|
||||
mf_root="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m["identity"]["data_root"])' \
|
||||
"$ENV_DIR/manifest.json" 2>/dev/null || true)"
|
||||
if [ -n "$mf_root" ]; then
|
||||
ENV_DATA="$mf_root"
|
||||
fi
|
||||
fi
|
||||
case "${ENV_DATA:-}" in
|
||||
""|./data|data)
|
||||
c_err "[$ENV_NAME] 未绑定数据目录:拒绝账户操作(不回落默认 ./data)"
|
||||
return 1 ;;
|
||||
esac
|
||||
resolve_db_config || {
|
||||
c_err "[$ENV_NAME] 数据库绑定无效:拒绝账户操作(不回落默认 ./data)"
|
||||
return 1
|
||||
}
|
||||
if [ "$DB_MODE" = mock ]; then
|
||||
c_err "[$ENV_NAME] mock 档不支持账户操作"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -x "${ENV_BIN:-}" ]; then
|
||||
c_err "[$ENV_NAME] 实例二进制不存在:$ENV_BIN(先 deploy)"
|
||||
return 1
|
||||
fi
|
||||
if [ "$DB_MODE" = pg ] && [ -z "${DB_DSN:-}" ] && [ "${DB_SOURCE:-}" != conffile ]; then
|
||||
c_err "[$ENV_NAME] PostgreSQL 档缺少 DSN:拒绝账户操作"
|
||||
return 1
|
||||
fi
|
||||
# 嵌入式档:数据文件必须已存在(OpenExisting);PG 档由 --open-existing 在后端核验。
|
||||
if [ "$DB_MODE" != pg ]; then
|
||||
if [ ! -d "$ENV_DATA" ] || [ ! -f "$ENV_DATA/agent-system.db" ]; then
|
||||
c_err "[$ENV_NAME] 既有库不存在:$ENV_DATA/agent-system.db(OpenExisting:拒绝创建空库)"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
local bin_args=(--open-existing --data "$ENV_DATA")
|
||||
if [ "$DB_MODE" = pg ] && [ -n "${DB_DSN:-}" ]; then
|
||||
bin_args+=(--dsn "$DB_DSN")
|
||||
fi
|
||||
c_info "[$ENV_NAME] 账户 CLI → $ENV_BIN user $* (data=$ENV_DATA open-existing)"
|
||||
# 子命令参数在前,绑定 flags 在后(Go flag 不区分顺序)。
|
||||
AGENT_SYSTEM_DSN="${DB_DSN:-}" "$ENV_BIN" user "$@" "${bin_args[@]}"
|
||||
}
|
||||
|
||||
# ---- agent:部署与启动 ----
|
||||
|
||||
agent_do_deploy() {
|
||||
@@ -4124,18 +4191,19 @@ main() {
|
||||
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
|
||||
local action="deploy"
|
||||
case "${1:-}" in
|
||||
deploy|stop|start|restart|status|logs|db|config|backup|backups) action="$1"; shift ;;
|
||||
deploy|stop|start|restart|status|logs|db|config|init|user|backup|backups) action="$1"; shift ;;
|
||||
esac
|
||||
if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||
CLI_LOGS_N="$1"; shift
|
||||
fi
|
||||
# db / config / backup / backups 子命令:自带参数解析(位置参数不走 parse_args;
|
||||
# db / config / user / backup / backups:自带参数解析(位置参数不走 parse_args;
|
||||
# 未知 flags 由其自行拒绝)。
|
||||
if [ "$action" = db ] || [ "$action" = config ]; then
|
||||
if [ "$action" = db ] || [ "$action" = config ] || [ "$action" = user ]; then
|
||||
env_config "$ENV_NAME"
|
||||
case "$action" in
|
||||
db) svc_db_bind "$@" || exit $? ;;
|
||||
config) svc_config "$@" || exit $? ;;
|
||||
user) agent_do_user "$@" || exit $? ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -95,6 +95,13 @@ PYSRV
|
||||
child=$!
|
||||
wait "$child"
|
||||
;;
|
||||
user)
|
||||
printf 'user %s\n' "$*" >> "$trace_dir/cli.txt"
|
||||
case "${2:-}" in
|
||||
list) echo '[{"owner":"op","name":"op","isAdmin":true,"mustChangePassword":false}]' ;;
|
||||
create) echo '{"owner":"new","issued":true}' ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
*)
|
||||
exit 0 ;;
|
||||
esac
|
||||
@@ -156,10 +163,10 @@ class SvcAutoRecoverTests(unittest.TestCase):
|
||||
|
||||
# ---- 夹具 ----
|
||||
|
||||
def svc(self, *args, check=True):
|
||||
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=self.env, text=True,
|
||||
cwd=self.root, env=env if env is not None else self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
|
||||
if check:
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""svc.sh 账户 CLI 实例绑定(U1 规格 §6 T1–T5)。
|
||||
|
||||
覆盖:合法 manifest 下 user list 命中 data_root+open-existing(T1)、禁止覆盖
|
||||
--data(T2)、删除 data 根后 OpenExisting 拒绝且不建空库(T3)、无 flock 拒绝
|
||||
user/deploy(T4)。T5 身份回归由 test_svc_identity 同发现执行;此处仅冒烟。
|
||||
桩与夹具复用 tests/test_svc_recover.py;manifest 辅助对齐 test_svc_identity。
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
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 UserBindingTests(tsr.SvcAutoRecoverTests):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
|
||||
self.root / "scripts" / "svc_manifest.py")
|
||||
self.trace.mkdir(exist_ok=True)
|
||||
|
||||
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 running(self, name):
|
||||
pid = self.pid_of(name)
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def touch_db(self, name):
|
||||
data = Path(self.manifest(name)["identity"]["data_root"])
|
||||
data.mkdir(parents=True, exist_ok=True)
|
||||
(data / "agent-system.db").write_bytes(b"sqlite-stub")
|
||||
|
||||
def cli_trace(self):
|
||||
f = self.trace / "cli.txt"
|
||||
return f.read_text(encoding="utf-8") if f.exists() else ""
|
||||
|
||||
def clear_cli_trace(self):
|
||||
f = self.trace / "cli.txt"
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
|
||||
# ---- T1 合法 manifest:user list 绑定 data_root + --open-existing ----
|
||||
|
||||
def test_T1_user_list_binds_manifest_data(self):
|
||||
self.seed_agent("ub1", 9441)
|
||||
self.start_ok("ub1")
|
||||
self.svc("ub1", "stop")
|
||||
root = self.manifest("ub1")["identity"]["data_root"]
|
||||
self.touch_db("ub1")
|
||||
self.clear_cli_trace()
|
||||
result = self.svc("ub1", "user", "list")
|
||||
self.assertIn("op", result.stdout)
|
||||
trace = self.cli_trace()
|
||||
self.assertIn("user list", trace)
|
||||
self.assertIn("--open-existing", trace)
|
||||
self.assertIn("--data %s" % root, trace)
|
||||
self.assertNotIn("./data", trace)
|
||||
|
||||
# ---- T2 禁止覆盖 --data;未绑定默认目录拒绝 ----
|
||||
|
||||
def test_T2_reject_override_data_flag(self):
|
||||
self.seed_agent("ub2", 9442)
|
||||
self.start_ok("ub2")
|
||||
self.svc("ub2", "stop")
|
||||
self.touch_db("ub2")
|
||||
other = self.root / "evil-data"
|
||||
other.mkdir()
|
||||
(other / "agent-system.db").write_bytes(b"x")
|
||||
result = self.svc("ub2", "user", "list", "--data", str(other), check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(any(k in result.stdout for k in ("禁止覆盖", "勿传")), result.stdout)
|
||||
self.assertFalse((self.trace / "cli.txt").exists(), "拒绝后不得调用二进制")
|
||||
|
||||
def test_T2b_reject_literal_default_data(self):
|
||||
d = self.runtime("ub2b")
|
||||
(d / "bin").mkdir(parents=True)
|
||||
shutil.copy2(self.stub, d / "bin" / "agent-system")
|
||||
(d / "svc.env").write_text("".join("%s=%s\n" % kv for kv in [
|
||||
("kind", "agent"), ("env", "ub2b"), ("pid", ""),
|
||||
("addr", "127.0.0.1:9443"), ("tags", ""),
|
||||
("source", "local@deadbee"), ("version", "stub"), ("started", ""),
|
||||
("pidcheck", str(d / "bin" / "agent-system")),
|
||||
("exec", "--addr 127.0.0.1:9443 --data ./data --plugins %s" % (d / "plugins")),
|
||||
]))
|
||||
env = dict(self.env, SVC_UB2B_DATA="./data")
|
||||
result = self.svc("ub2b", "user", "list", check=False, env=env)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(any(k in result.stdout for k in ("未绑定数据目录", "不回落")),
|
||||
result.stdout)
|
||||
|
||||
# ---- T3 删除 data 根:OpenExisting 失败,不建空库 ----
|
||||
|
||||
def test_T3_missing_data_root_rejected(self):
|
||||
self.seed_agent("ub3", 9444)
|
||||
self.start_ok("ub3")
|
||||
self.svc("ub3", "stop")
|
||||
root = Path(self.manifest("ub3")["identity"]["data_root"])
|
||||
self.touch_db("ub3")
|
||||
shutil.rmtree(root)
|
||||
self.assertFalse(root.exists())
|
||||
result = self.svc("ub3", "user", "create", "--owner", "x", "--name", "x",
|
||||
check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(any(k in result.stdout for k in ("既有库不存在", "OpenExisting")),
|
||||
result.stdout)
|
||||
self.assertFalse(root.exists(), "拒绝后不得重建数据根")
|
||||
self.assertFalse((root / "agent-system.db").exists())
|
||||
|
||||
# ---- T4 隐藏 flock:user/deploy 拒绝;已运行服务仍在 ----
|
||||
|
||||
def test_T4_no_flock_rejects_user_and_deploy(self):
|
||||
self.seed_agent("ub4", 9445)
|
||||
self.start_ok("ub4")
|
||||
self.assertTrue(self.running("ub4"))
|
||||
self.touch_db("ub4")
|
||||
# 构造不含 flock 的 PATH:链入常用外部命令,刻意排除 flock。
|
||||
bin_dir = self.root / "nobin"
|
||||
bin_dir.mkdir()
|
||||
for name in (
|
||||
"bash", "python3", "python", "dirname", "basename", "tr", "cksum",
|
||||
"cut", "mkdir", "cat", "sed", "grep", "head", "rm", "mv", "cp",
|
||||
"ls", "chmod", "uname", "date", "sleep", "kill", "ps", "awk",
|
||||
"sort", "uniq", "env", "printf", "realpath", "readlink", "stat",
|
||||
"tee", "wc", "find", "xargs", "touch", "ln", "mktemp", "id",
|
||||
"git", "make", "tar", "gzip", "which", "command",
|
||||
):
|
||||
src = shutil.which(name)
|
||||
if src:
|
||||
dest = bin_dir / name
|
||||
if not dest.exists():
|
||||
dest.symlink_to(src)
|
||||
self.assertIsNone(shutil.which("flock", path=str(bin_dir)))
|
||||
env = dict(self.env, PATH=str(bin_dir))
|
||||
r_user = self.svc("ub4", "user", "list", check=False, env=env)
|
||||
self.assertNotEqual(r_user.returncode, 0)
|
||||
self.assertIn("缺少 flock", r_user.stdout)
|
||||
r_dep = self.svc("ub4", "deploy", "--dry-run", check=False, env=env)
|
||||
self.assertNotEqual(r_dep.returncode, 0, r_dep.stdout)
|
||||
self.assertIn("缺少 flock", r_dep.stdout)
|
||||
self.assertTrue(self.running("ub4"), "拒绝后旧服务应仍在跑")
|
||||
self.svc("ub4", "stop")
|
||||
|
||||
# ---- T5 recovery U1 身份冒烟(完整套件见 test_svc_identity)----
|
||||
|
||||
def test_T5_identity_regression_smoke(self):
|
||||
self.seed_agent("ub5", 9446)
|
||||
self.start_ok("ub5")
|
||||
iid = self.manifest("ub5")["instance_id"]
|
||||
self.svc("ub5", "stop")
|
||||
self.start_ok("ub5")
|
||||
self.assertEqual(self.manifest("ub5")["instance_id"], iid)
|
||||
self.svc("ub5", "stop")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user