Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=866086c2a080716195c3dd35e6ef362016347c94. Impact: integrate upstream changes without rewriting task history. Refs: task-sync
This commit is contained in:
+8
-2
@@ -1,6 +1,6 @@
|
||||
# feature/collab-storage-u4-migrator 任务卡
|
||||
|
||||
任务主记录:Dev PR #337 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/337(backend 组件 PR #144/#145、documents 组件 PR #65 已合入);状态:实施完成待评审;owner:weichen
|
||||
任务主记录:Dev PR #337 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/337(backend 组件 PR #144/#145、documents 组件 PR #65 已合入);状态:已完成(回填);owner:weichen
|
||||
|
||||
## 基线与授权
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
## 结果与验证
|
||||
|
||||
- 结果:U4 交付——bin/storage-migrate(make build 集成):inventory(只读盘点+完整性+工件清单哈希)、backup(VACUUM INTO+对账)、migrate(目标建 schema 后按行复制:布尔映射、BLOB→BYTEA、生成列与 FTS 影子表排除、SERIAL 推进;migration_control 身份登记与表级状态;仅同快照续传;非空/他身份/未知 owner 拒绝;逐表计数+主键序哈希链终验;报告无密钥无正文)。附带:架构基线棘轮(surfacesvc 边,随 backend #143 事实滚动)、Dev modules/core 钉到 452daf3(对齐 backend 内部钉)、copyOrder 补并行会话新表(#145,清单守护测试立功)。
|
||||
- 验证:make test(含治理/边界/指针全绿,基线棘轮后)、make test-backend-pg(真 PG 18.6+Redis,迁移工具 8 测试含幂等/续传/拒绝/未知 owner 两轮绿)、make build(双产物)三段 evidence exit=0;CI GATE 待本 PR。诚实边界:正式切换窗口未开(须批准);迁移粒度为表级(行级断点属后续需求);恢复演练在测试尺度完成(生产级 pg_dump/PITR 属运维启用后基线,手册 §10 已列)。
|
||||
@@ -322,3 +322,9 @@
|
||||
}
|
||||
```
|
||||
| 同步前验收 | b3b48f5efa2a6a25f16f6413d8b9c89715f22960 | 4fd2c8ce32d62c483ff543a1ad17555fda925ee8 | 2026-10-11T08:57:51Z |
|
||||
|
||||
## 闭合记录(card-autoclose 回填)
|
||||
|
||||
- 依据:merge 0bda404ac0c0169edc8ac00688528e29ff327c3d(PR #337)已包含于 origin/main,分支成果已落盘;本段为合入后回填。
|
||||
- 合并主题:Merge pull request 'U4: storage-migrate tool, switch/drill runbook, baseline ratchet (pin #144/#145/#65, core 452daf3)' (#337) from feature/collab-storage-u4-migrator into main
|
||||
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
|
||||
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
File diff suppressed because one or more lines are too long
+2
-1
@@ -24,7 +24,8 @@ U2 起实施与部署分别评审。
|
||||
- [RDM 两树一网集成、完善与追溯审计展示方案](modules/documents/docs/proposals/2026-10-11-rdm-two-trees-trace-audit-integration.md):
|
||||
每项目一套笔记树、文档树与追溯网;项目绑定与读取授权闭环、原子事件历史、统一审计读服务
|
||||
与四入口工作区。方案 r1 已获用户评审通过(2026-10-11,五阶段一并,KTD4 双层管理员裁定);
|
||||
实施自 U1 起另行立项授权,业务实施与运行验收 NOT_RUN。
|
||||
U1 项目身份与读取授权闭环已实施合入(RDM PR #195,2026-10-11;超管槽待基座能力接线,
|
||||
申请见 RDM 仓 docs/proposals/2026-10-11-u1-base-extension-requests.md);U2–U5 与运行验收未启动。
|
||||
|
||||
- [RDM 模板与两树一网对话文档生成方案 r1.1](modules/documents/docs/proposals/2026-10-11-rdm-template-dialog-generation.md):69 类历史资产盘点、模板版本与章节实例化、持久提案、文档级审签及 DOCX;待用户评审,业务实施与运行验收 NOT_RUN。 首批项目建议书/竞品分析、随后扩展全部文档已确认(2026-10-11),其余方案与实施仍待评审。
|
||||
|
||||
|
||||
+44
-15
@@ -230,18 +230,21 @@ scripts/svc.sh <agent名> db <pg名> --no-restart # 只改登记,下次 start
|
||||
scripts/svc.sh <agent名> db --clear # 解除绑定(回内嵌 SQLite 档)
|
||||
```
|
||||
|
||||
每个 agent 服务都可以对应一个数据库实例中的库;绑定后 DSN 持久化在 `svc.env`
|
||||
(`exec=` 的 `--dsn` + `dbref=<pg名>/<库名>`,权限 600),跨重启保留。
|
||||
每个 agent 服务都可以对应一个数据库实例中的库;绑定登记在 `svc.env`
|
||||
(`dbref=<pg名>/<库名>` + `db=pg`,权限 600),跨重启保留——**DSN 不落 `exec=`**,
|
||||
启动时由实例登记重建并经 `AGENT_SYSTEM_DSN` 环境变量交付(实例端口/密码变更自动跟随)。
|
||||
|
||||
- **缺库自动建**:实例运行中且目标库不存在时自动 `CREATE DATABASE`(幂等);
|
||||
实例未运行且目标库非其默认库 → 提示先 `start` 实例;
|
||||
- **运行中自动重启生效**(复用新 `exec=`);`--no-restart` 只写登记;
|
||||
- **旧产物预检**:当前二进制不识别 `--dsn` 时警告(绑定已登记但重启按内嵌档跑);
|
||||
- **运行中自动重启生效**(按新登记重建 DSN);`--no-restart` 只写登记;
|
||||
- **产物能力预检**:当前二进制不消费环境变量 DSN 时拒绝绑定(先 deploy 新版本);
|
||||
mock 档与绑定共存同样拒绝(先 `--clear` 或去 mock);
|
||||
- 展示:`list` 的 **DB 列**、`status` 的"数据库"行、`info` 数据库节的**绑定服务**行
|
||||
(反向映射:哪些服务指向该实例)都能看到服务↔数据库对应关系;
|
||||
- 未用本命令、直接 `deploy --dsn` 的服务,显示层会按 DSN 的 host:port 反查匹配已
|
||||
登记实例(显示 `<pg名>/<库>`;匹配不上显示 `外部@host:port`);
|
||||
- `--clear` 后服务回到内嵌 SQLite 档(数据在原 `--data` 目录,切回即见旧数据)。
|
||||
- 未用本命令、直接 `deploy --dsn` 的服务:命中本机实例登记的会升级为托管绑定
|
||||
(`dbref=`),否则持久化为 `dsn=` 字段并在显示层标注 `外部@host:port`;
|
||||
- `--clear` 显式解绑:记录 `db=sqlite`、清除 `dbref`/`dsn`(数据在原 `--data` 目录,
|
||||
切回即见旧数据);此后残留 `SVC_*_DSN` 不再悄悄重新选库。
|
||||
|
||||
### 4.6 config(agent 服务的配置文件更新/生效)
|
||||
|
||||
@@ -549,11 +552,17 @@ scripts/svc.sh db copy pgdev pgprod --db app --force # 目标非空时覆盖
|
||||
```
|
||||
命令行参数
|
||||
> --config FILE(--env FILE 别名)
|
||||
> svc.env 持久化(addr=、exec=)
|
||||
> svc.env 持久化(addr=、exec=、dbref=/dsn=/db=——数据库配置专键)
|
||||
> SVC_<NAME>_* 环境变量(.svc.env 自动加载,同层)
|
||||
> 默认值
|
||||
```
|
||||
|
||||
数据库配置由脚本在启动时**统一解析**(`start`/`restart`/`deploy`/`update`/`db`
|
||||
共用单点):托管绑定(`dbref=`)从实例登记重建 DSN,外部直连读受保护 `dsn=` 字段,
|
||||
配置文件读 `conffile` 内的 `dsn` 键。连接串不进 `exec=` 与命令行,经
|
||||
`AGENT_SYSTEM_DSN` 环境变量交付进程。**修改端口、监听地址或部署版本不改变数据库
|
||||
选择**;绑定失效或配置冲突时拒绝启动,绝不静默回退内嵌 SQLite(见 §9.4)。
|
||||
|
||||
### 9.2 配置文件
|
||||
|
||||
dotenv 格式:`KEY=VALUE`,支持 `#` 注释、成对引号、`export` 前缀;只识别合法变量名,
|
||||
@@ -578,10 +587,21 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
|
||||
|
||||
### 9.4 持久化语义
|
||||
|
||||
- `deploy`/`start` 成功后把当前态写入 `svc.env`(600):`addr`、`exec`(agent 上次
|
||||
完整启动参数)等;
|
||||
- `deploy`/`start` 成功后把当前态写入 `svc.env`(600,临时文件原子替换):`addr`、
|
||||
`exec`(agent 上次完整启动参数,**只存非敏感项**)等;启动失败不覆盖上次配置;
|
||||
- 裸 `start`/`restart` 复用 `exec=`;任何显式参数(CLI、`--config`、`SVC_*`)出现即
|
||||
**整体重建**启动参数;
|
||||
**整体重建**启动参数;两种路径共用同一数据库配置解析——数据库选择与认证设置不随
|
||||
端口/地址/版本变化,`conffile` 引用随重建保留;
|
||||
- **数据库配置可恢复**:托管绑定 `dbref=<pg>/<库>` 在启动时由实例登记重建 DSN
|
||||
(实例端口/密码变更自动跟随,密码不重复保存);显式外部直连(`deploy --dsn`)持久化
|
||||
为受保护 `dsn=` 字段;显式解绑记录 `db=sqlite`(清除 `dbref`/`dsn`,此后残留的
|
||||
`SVC_*_DSN` 不再悄悄重新选库,显式 `--dsn` 仍可覆盖);存量 `exec=` 里的 `--dsn`
|
||||
首次启动时自动迁移为上述形态(幂等);
|
||||
- **绑定失效拒绝启动**(返回非零并输出脱敏原因,不静默回退内嵌 SQLite):
|
||||
`dbref` 实例未登记/类型错误/登记字段不全、已选 PostgreSQL 但无可恢复 DSN、直接
|
||||
DSN 与 `dbref` 指向不同库、配置文件 `dsn` 与绑定不一致、mock 档与数据库配置共存、
|
||||
产物不消费环境变量 DSN(`AGENT_SYSTEM_DSN`,旧制品)。`restart`/`deploy` 在停止
|
||||
旧进程**之前**完成这些静态检查——配置性拒绝不制造停机窗口;
|
||||
- 标签合并去重、跨重启保留;postgres 的用户/密码/库名/认证模式持久化,重启不丢。
|
||||
|
||||
## 10. 登记文件与事件流
|
||||
@@ -599,8 +619,10 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
|
||||
| `version` | 产物版本串(`agent-system version` 输出) |
|
||||
| `started` | 最近启动时间 |
|
||||
| `pidcheck` | 进程判定标记(见 §6.1) |
|
||||
| `exec` | agent 上次完整启动参数(含 `--dsn`,密码在其中) |
|
||||
| `dbref` | agent 数据库绑定 `<pg名>/<库名>`(`<name> db` 命令写入,跨重启保留;见 §4.5) |
|
||||
| `exec` | agent 上次完整启动参数(非敏感项;DSN 不在其中,经环境变量交付) |
|
||||
| `db` | agent 数据库模式:`pg` / `sqlite`(显式解绑)/ `mock`;从未配置则无此键 |
|
||||
| `dbref` | agent 托管数据库绑定 `<pg名>/<库名>`(启动时由实例登记重建 DSN;见 §4.5) |
|
||||
| `dsn` | agent 外部直连 DSN(受保护;未命中本机实例登记的直连存放于此) |
|
||||
| `conffile` | agent 配置文件绝对路径(`<name> config` 命令写入,跨重启保留;见 §4.6) |
|
||||
| `user` / `password` / `dbname` / `authmode` / `pgdata` | postgres 专属 |
|
||||
| `htdocs` | static 专属 |
|
||||
@@ -635,7 +657,9 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
|
||||
- **秘密与数据**:`svc.env`(含 DB 密码)与备份 600;环境/数据目录 700;DSN 默认掩码;
|
||||
dotenv 不执行文件内容;`restore`/`copy` 覆盖非空库必须显式 `--force`;备份/克隆只增不删。
|
||||
- **兼容承诺**:`kind=` 缺省视为 agent;旧格式 `svc.env`(仅 addr/data)在 `list`/
|
||||
`status` 正常显示;旧产物二进制不识别的 `--dsn/--auth` 自动省略并提示。
|
||||
`status` 正常显示;存量 `exec=` 里的 `--dsn` 首次启动自动迁移为可恢复配置(幂等);
|
||||
旧产物二进制不识别的 `--auth`/`--config` 自动省略并提示;不消费环境变量 DSN 的
|
||||
旧产物在需要交付数据库配置时**拒绝启动/部署**(不静默按内嵌 SQLite 运行)。
|
||||
|
||||
## 12. 退出码
|
||||
|
||||
@@ -652,7 +676,12 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
|
||||
| `端口被占用(非本服务)` | 按提示的占用者处理,或换端口(`--port` / `set --port --apply`) |
|
||||
| `进程启动后即退出` | `scripts/svc.sh <name> logs 200` / postgres 看 `runtime/<name>/pg.log`;history 里有 fail 记录 |
|
||||
| `20s 健康检查未通过` | 进程活着但未响应:查日志与端口;agent 常见原因是 DSN 指向未启动的库 |
|
||||
| `该产物不支持 --dsn/--auth` | 提示而非错误:旧版本制品自动省略了不支持的 flag;需要该能力请部署新提交 |
|
||||
| `该产物不支持部分启动参数` | 提示而非错误:旧版本制品自动省略了不支持的 flag;需要该能力请部署新提交 |
|
||||
| `拒绝启动:dbref=… 无法重建 DSN` | 实例未登记/登记不全:恢复实例登记,或 `db <pg名>` 重新绑定;不会回退 SQLite |
|
||||
| `拒绝启动:直接 DSN 与 dbref 指向不同数据库` | 配置冲突:重新绑定或 `db --clear` 后重新直连,二者取一 |
|
||||
| `拒绝启动:产物不消费环境变量 DSN` | 旧制品:deploy 新提交,或改用携带 dsn 的配置文件(`<name> config`) |
|
||||
| `拒绝启动:mock 档与数据库配置冲突` | 先 `db --clear` 再用 mock,或去掉 `--mock` / `SVC_*_MOCK` |
|
||||
| `拒绝启动:非回环地址且认证判定为关` | 绑定数据库 / 显式 `--auth on` / 回环地址 / `SVC_*_INSECURE_LISTEN=1` 显式放行 |
|
||||
| `环境产物缺失` | 该环境没有部署过(或旧版脚本部署):先 `deploy` |
|
||||
| `pg_ctl start 失败` | 看 pg.log 常见:端口被占、pgdata 权限(须 700)、postmaster 残留 |
|
||||
| `pidfile 指向的进程已不存在` | 脚本自动清理 pidfile;直接重新 start 即可 |
|
||||
|
||||
+1
-1
Submodule modules/backend updated: a466838440...6abf112765
+1
-1
Submodule modules/core updated: 452daf3ffa...7a98a7e7fe
+1
-1
Submodule modules/frontend updated: 3ed10e1220...8108655e61
+1
-1
Submodule modules/ui updated: 76d6633736...8d19dc1096
@@ -3,6 +3,7 @@
|
||||
"backend/app backend/cardsvc",
|
||||
"backend/app backend/cmdbus",
|
||||
"backend/app backend/config",
|
||||
"backend/app backend/genui",
|
||||
"backend/app backend/hostapi",
|
||||
"backend/app backend/ingest",
|
||||
"backend/app backend/intentgate",
|
||||
|
||||
+439
-146
@@ -53,10 +53,15 @@
|
||||
# db copy:--db X --force set:--port N --host H --apply --tag T
|
||||
#
|
||||
# ════════════════════ 配置优先级 ════════════════════
|
||||
# 命令行 > --config/--env 文件 > svc.env 持久化(exec=/addr=)> SVC_<NAME>_* 环境变量
|
||||
# (.svc.env 自动加载,同层)> 默认值。裸 start/restart 复用上次启动配置;显式参数
|
||||
# 出现即覆盖。任意环境 X 的变量名:SVC_<X 大写,- → _>_ADDR/DATA/DSN/AUTH/MOCK,
|
||||
# 以及 INSECURE_LISTEN / ALLOWED_HOSTS(传给进程的 AGENT_SYSTEM_*,旧二进制会忽略)。
|
||||
# 命令行 > --config/--env 文件 > svc.env 持久化(exec=/addr=/dbref=/dsn=/db=)
|
||||
# > SVC_<NAME>_* 环境变量(.svc.env 自动加载,同层)> 默认值。裸 start/restart 复用
|
||||
# 上次启动配置;显式参数出现即覆盖。任意环境 X 的变量名:SVC_<X 大写,- → _>_
|
||||
# ADDR/DATA/DSN/AUTH/MOCK,以及 INSECURE_LISTEN / ALLOWED_HOSTS(传给进程的
|
||||
# AGENT_SYSTEM_*,旧二进制会忽略)。
|
||||
# 数据库配置统一解析:托管绑定(dbref=)从实例登记重建 DSN,外部直连存受保护
|
||||
# dsn= 字段;连接串不进 exec=/命令行,经 AGENT_SYSTEM_DSN 环境变量交付。绑定失效
|
||||
# 或配置冲突(直接 DSN 与 dbref 不同库、mock 与绑定并存、db=pg 无可恢复 DSN)时
|
||||
# 拒绝启动,绝不静默回退内嵌 SQLite;显式解绑(db=sqlite)后环境残留不再重新选库。
|
||||
#
|
||||
# ════════════════════ 服务模型与登记 ════════════════════
|
||||
# - 服务名 ^[a-z][a-z0-9-]{0,15}$(保留字不可用);dev=8788 / prod=8787 默认端口,
|
||||
@@ -202,7 +207,11 @@ svc.env 字段:
|
||||
version 产物版本串(agent-system version 输出)
|
||||
started 最近启动时间
|
||||
pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory)
|
||||
exec agent 上次完整启动参数(裸 start/restart 复用;显式参数出现即重建)
|
||||
exec agent 上次完整启动参数(裸 start/restart 复用;显式参数出现即重建)。
|
||||
只存非敏感参数——DSN 不在其中,数据库配置走 db=/dbref=/dsn= 专键
|
||||
db agent 数据库模式:pg | sqlite(显式解绑)| mock;从未配置则无此键
|
||||
dbref agent 托管数据库绑定 <pg名>/<库名>(启动时由实例登记重建 DSN)
|
||||
dsn agent 外部直连 DSN(受保护,600;未命中本机实例时使用)
|
||||
user/password/dbname/authmode/pgdata postgres 专属(密码 600 保存)
|
||||
htdocs static 专属
|
||||
|
||||
@@ -248,7 +257,9 @@ EOF
|
||||
行为细节:
|
||||
- deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程)
|
||||
- agent 启动后等健康检查(20s),随后按 --model-*/--tavily-keys 播种设置(幂等)
|
||||
- 旧版本产物不认识 --dsn/--auth 时自动省略(探测 serve 用法)并提示
|
||||
- 旧版本产物不认识 --auth/--config 时自动省略(探测 serve 用法)并提示;
|
||||
不消费环境变量 DSN(AGENT_SYSTEM_DSN)的旧产物在需要交付数据库配置时
|
||||
拒绝部署/启动(不静默按内嵌 SQLite 运行)
|
||||
- static 的 release 源不支持(制品内嵌前端);--dir PATH 可直接登记并服务
|
||||
既有目录(不构建、不复制,source=dir@<路径>)
|
||||
- deploy 全程事件入 history.jsonl(begin/ok/fail)
|
||||
@@ -268,9 +279,10 @@ EOF
|
||||
scripts/svc.sh <name> stop 停止(agent/static: TERM→KILL;postgres: pg_ctl -m fast)
|
||||
scripts/svc.sh <name> restart 停止 + 启动(裸 restart 复用上次配置)
|
||||
scripts/svc.sh <name> db [pg名|--clear] [--db X] agent 数据库绑定:缺省查看当前
|
||||
绑定与可用实例;给 pg名=绑定/切换(DSN 写入 exec=,dbref= 登记,
|
||||
实例运行中缺库自动建,服务运行中自动重启生效);--clear 解绑回内嵌
|
||||
SQLite 档;--no-restart 只改登记
|
||||
绑定与可用实例;给 pg名=绑定/切换(dbref= 登记,启动时由实例登记
|
||||
重建 DSN 并经环境变量交付,实例运行中缺库自动建,服务运行中自动
|
||||
重启生效);--clear 显式解绑(db=sqlite,清除直接 DSN,环境残留
|
||||
SVC_*_DSN 不再重新选库);--no-restart 只改登记
|
||||
scripts/svc.sh <name> config [file|--clear] [--no-restart] agent 配置文件:缺省查看当前
|
||||
文件与生效值;给 file=更换(agent 二进制 --config,JSON schema 校验
|
||||
通过才切换,运行中自动重启即时生效);--clear 移除回命令行参数形态。
|
||||
@@ -335,9 +347,9 @@ EOF
|
||||
scripts/svc.sh prod db pg1 --db prod # 绑定 pg1/prod 库(缺库自动建)
|
||||
scripts/svc.sh prod db pg2 --no-restart # 切换实例但暂不重启(下次 start 生效)
|
||||
scripts/svc.sh prod db # 查看当前绑定 + 可用实例
|
||||
scripts/svc.sh prod db --clear # 解绑回内嵌 SQLite 档
|
||||
绑定写入 svc.env(exec= 的 --dsn + dbref=,600);info / list 的 DB 列与数据库节
|
||||
"绑定服务"行展示服务↔实例对应关系。
|
||||
scripts/svc.sh prod db --clear # 显式解绑(db=sqlite,回内嵌档)
|
||||
绑定登记于 svc.env(dbref= + db=pg,600;DSN 启动时由实例登记重建,经环境变量
|
||||
交付);info / list 的 DB 列与数据库节 "绑定服务" 行展示服务↔实例对应关系。
|
||||
|
||||
密码管理:密码明文存 runtime/<name>/svc.env(600);--password 未给时自动生成
|
||||
(head -c 18 /dev/urandom)。auth-mode 默认 scram-sha-256(pg_hba 全线)。
|
||||
@@ -383,7 +395,7 @@ EOF
|
||||
优先级(高 → 低):
|
||||
1. 命令行参数(--port/--dsn/--tag/...)
|
||||
2. --config FILE(或 --env FILE,兼容别名;=FILE 写法亦可)
|
||||
3. svc.env 持久化(addr=、exec=——上次启动的实际参数)
|
||||
3. svc.env 持久化(addr=、exec=、dbref=/dsn=/db=——数据库配置专键)
|
||||
4. SVC_<NAME>_* 环境变量与 .svc.env(自动加载,同层)
|
||||
5. 默认值(dev=8788 / prod=8787 / 其余派生端口)
|
||||
|
||||
@@ -401,11 +413,18 @@ dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只
|
||||
播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。
|
||||
|
||||
持久化语义:
|
||||
- deploy/start 成功(健康探测通过)后把当前态写入 svc.env(600);启动失败不覆盖
|
||||
上次配置——裸 start 仍按上次可用参数重试
|
||||
- deploy/start 成功(健康探测通过)后把当前态写入 svc.env(600,临时文件原子替换);
|
||||
启动失败不覆盖上次配置——裸 start 仍按上次可用参数重试
|
||||
- 裸 start/restart 复用 exec=(上次完整参数);任何显式参数(或 --config 文件、
|
||||
SVC_* 变量)出现即整体重建参数——重建时 dbref 绑定的 --dsn 自动从实例登记
|
||||
重建保留(mock 档或显式 --dsn 除外)
|
||||
SVC_* 变量)出现即整体重建参数;两种路径共用同一数据库配置解析——改端口/地址/
|
||||
版本不改变数据库选择,conffile 引用随重建保留
|
||||
- 数据库配置可恢复:托管绑定 dbref= 在启动时由实例登记重建 DSN(实例端口/密码
|
||||
变更自动跟随);外部直连存受保护 dsn= 字段;显式解绑记 db=sqlite(环境残留
|
||||
SVC_*_DSN 不再重新选库)。DSN 经 AGENT_SYSTEM_DSN 环境变量交付,不进 exec=
|
||||
与命令行;存量 exec= 内的 --dsn 启动时自动迁移(幂等)
|
||||
- 绑定失效或配置冲突时拒绝启动(不静默回退 SQLite):dbref 实例缺失/登记不全、
|
||||
直接 DSN 与 dbref 指向不同库、mock 与绑定并存、db=pg 无可恢复 DSN、产物不
|
||||
消费环境 DSN;restart/deploy 在停止旧进程前先做这些静态检查
|
||||
- tags 随每次登记合并去重,跨重启保留
|
||||
|
||||
服务级配置文件(与上述 svc.sh dotenv 层不同——这是 agent 二进制自己的 JSON 配置):
|
||||
@@ -661,21 +680,43 @@ svc_merge_tags() {
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# record_state EXTRA_KEY=VALUE...:把当前态+持久化配置写入 svc.env(600)。
|
||||
# 基本键:kind/env/pid/addr/tags/source/version/started/pidcheck/exec + dbref/
|
||||
# conffile(跨重启保留的数据库绑定与配置文件);kind 特有键(user/password/dbname/
|
||||
# authmode/pgdata/htdocs...)经参数追加。
|
||||
# record_state EXTRA_KEY=VALUE...:把当前态+持久化配置写入 svc.env(600,临时文件
|
||||
# 原子替换,避免截断/半写)。基本键:kind/env/pid/addr/tags/source/version/started/
|
||||
# pidcheck/exec + db/dbref/dsn/conffile(跨重启保留的数据库配置与配置文件引用);
|
||||
# kind 特有键(user/password/dbname/authmode/pgdata/htdocs...)经参数追加。
|
||||
# agent 服务经统一解析启动后按生效态写 db=(pg/mock;显式 sqlite)与 dbref=/dsn=
|
||||
# (托管绑定不重复存 DSN,外部直连存受保护 dsn=);未走解析的调用保持旧值。
|
||||
record_state() {
|
||||
# 先读完旧文件再开重定向(块内 {} > svc.env 会先截断文件,读旧值必须在其之前)。
|
||||
local pid prev_src prev_dbref prev_conffile merged_tags
|
||||
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn merged_tags
|
||||
pid="$(read_pid)"
|
||||
prev_src=""
|
||||
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
|
||||
prev_dbref="$(svc_get dbref)"
|
||||
prev_conffile="$(svc_get conffile)"
|
||||
prev_db="$(svc_get db)"
|
||||
prev_dsn="$(svc_get dsn)"
|
||||
SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}"
|
||||
merged_tags="$(svc_merge_tags)"
|
||||
local db_out="" dbref_out="" dsn_out=""
|
||||
if [ "${SVC_KIND:-agent}" = agent ]; then
|
||||
if [ -n "${SVC_DB_MODE:-}" ]; then
|
||||
case "$SVC_DB_MODE" in
|
||||
pg)
|
||||
# 只在有可恢复来源(dbref=/dsn=/conffile)时记 pg 标记——纯环境层来源
|
||||
# 不落标记,环境撤除后回到原语义,不产生"db=pg 却无可恢复 DSN"的死锁。
|
||||
[ -n "$SVC_DB_REF" ] || [ -n "$SVC_DB_DSN_SAVE" ] || [ "$SVC_DB_SOURCE" = conffile ] && db_out=pg
|
||||
;;
|
||||
mock) db_out=mock ;;
|
||||
sqlite) [ "$SVC_DB_SOURCE" != none ] && db_out=sqlite ;; # 显式解绑才记;从未配置不写
|
||||
esac
|
||||
dbref_out="${SVC_DB_REF:-}"
|
||||
dsn_out="${SVC_DB_DSN_SAVE:-}"
|
||||
else
|
||||
db_out="$prev_db"; dbref_out="$prev_dbref"; dsn_out="$prev_dsn"
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$ENV_DIR"
|
||||
local tmp="$ENV_DIR/svc.env.tmp.$$"
|
||||
{
|
||||
printf 'kind=%s\n' "${SVC_KIND:-agent}"
|
||||
printf 'env=%s\n' "$ENV_NAME"
|
||||
@@ -687,11 +728,14 @@ record_state() {
|
||||
printf 'started=%s\n' "$(date -Is)"
|
||||
printf 'pidcheck=%s\n' "${SVC_PIDCHECK:-}"
|
||||
printf 'exec=%s\n' "${SVC_EXEC:-}"
|
||||
[ -n "$prev_dbref" ] && printf 'dbref=%s\n' "$prev_dbref"
|
||||
[ -n "$db_out" ] && printf 'db=%s\n' "$db_out"
|
||||
[ -n "$dbref_out" ] && printf 'dbref=%s\n' "$dbref_out"
|
||||
[ -n "$dsn_out" ] && printf 'dsn=%s\n' "$dsn_out"
|
||||
[ -n "$prev_conffile" ] && printf 'conffile=%s\n' "$prev_conffile"
|
||||
[ $# -gt 0 ] && printf '%s\n' "$@"
|
||||
} > "$ENV_DIR/svc.env"
|
||||
chmod 600 "$ENV_DIR/svc.env"
|
||||
} > "$tmp"
|
||||
chmod 600 "$tmp"
|
||||
mv -f "$tmp" "$ENV_DIR/svc.env"
|
||||
}
|
||||
|
||||
# ---- pid 管理(泛化:pidfile + cmdline 标记双校验)----
|
||||
@@ -756,6 +800,8 @@ env_config() {
|
||||
ENV_LOG="$ENV_DIR/svc.log"
|
||||
ENV_BIN_DIR="$ENV_DIR/bin"
|
||||
ENV_BIN="$ENV_BIN_DIR/agent-system"
|
||||
# 数据库配置解析态按实例重置——批量 update/restart 逐服务解析时不串用上一服务的 DB_*。
|
||||
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
|
||||
local registered=""
|
||||
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
|
||||
SVC_KIND="${registered:-agent}"
|
||||
@@ -810,7 +856,7 @@ agent_has_explicit_cfg() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# adjust_args_for_bin BIN ARGS:按二进制 serve 用法过滤旧产物不认识的 --dsn/--auth。
|
||||
# adjust_args_for_bin BIN ARGS:按二进制 serve 用法过滤旧产物不认识的 --auth/--config。
|
||||
# 匹配按"去横线的旗标名 + 1-2 个横线"(Go flag 帮助打印单横线 -dsn,2026-10-04 实测
|
||||
# 旧实现 grep -- '--dsn' 永不命中,把现役二进制的 --dsn/--auth 误当不支持而丢弃)。
|
||||
adjust_args_for_bin() {
|
||||
@@ -819,7 +865,7 @@ adjust_args_for_bin() {
|
||||
local filtered="" w drop_next=0 flag
|
||||
for w in $args; do
|
||||
case "$w" in
|
||||
--dsn|--auth)
|
||||
--auth|--config)
|
||||
flag="${w#--}"
|
||||
if printf '%s\n' "$out" | grep -Eq -- "(^|[[:space:]])-{1,2}${flag}([[:space:]]|=|$)"; then
|
||||
filtered="$filtered $w"
|
||||
@@ -1087,6 +1133,12 @@ materialize_release() {
|
||||
agent_do_deploy() {
|
||||
resolve_source
|
||||
if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi
|
||||
# 停旧之前的静态配置验证:数据库配置可解析(绑定有效/无冲突)、conffile 存在、
|
||||
# 现产物承接能力——配置性问题不制造停机窗口(登记保持可恢复,失败直接返回)。
|
||||
if ! agent_static_preflight; then
|
||||
hist_event deploy fail "$(read_pid)" "预检拒绝:数据库配置/配置文件无效(登记未变更)"
|
||||
return 1
|
||||
fi
|
||||
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
|
||||
do_stop_quiet
|
||||
local wt
|
||||
@@ -1108,6 +1160,13 @@ agent_do_deploy() {
|
||||
materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; }
|
||||
;;
|
||||
esac
|
||||
# 新产物承接能力复核(环境 DSN 交付):不支持即失败——绑定保持可恢复,不降级启动。
|
||||
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ] && ! bin_env_dsn_supported "$ENV_BIN"; then
|
||||
c_err "[$ENV_NAME] 新产物不消费环境变量 DSN(AGENT_SYSTEM_DSN)——部署中止;旧绑定保留在 svc.env"
|
||||
c_err " 恢复:换部署版本,或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <file.json>)"
|
||||
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; }
|
||||
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
|
||||
return 0
|
||||
@@ -1198,61 +1257,266 @@ addr_is_loopback() {
|
||||
esac
|
||||
}
|
||||
|
||||
# agent_args_authed ARGS [DSN_ENV]:按二进制 AuthEnabled 口径(config.go:
|
||||
# 显式 on;或非显式 off 且 StorageDSN 非空)判断认证是否开启。DSN 两个来源等价:
|
||||
# 命令行 --dsn(历史兼容)与 AGENT_SYSTEM_DSN 环境变量(G1-7 起的标准通道,
|
||||
# 第二参数传其值)——只看参数串会把「非回环 + auto + env DSN」误判为认证关
|
||||
#(2026-10-10 dev 实测误拒)。--mock 只影响存储档不影响认证。
|
||||
agent_args_authed() {
|
||||
case "$1" in
|
||||
*--auth[=\ ]on*) return 0 ;;
|
||||
*--auth[=\ ]off*) return 1 ;;
|
||||
esac
|
||||
[ -n "${2:-}" ] && return 0
|
||||
case "$1" in *--dsn\ *) return 0 ;; esac
|
||||
return 1
|
||||
# dsn_target DSN → host:port/db(配置一致性比较用;解析失败回空串)。
|
||||
dsn_target() {
|
||||
local t="${1%%\?*}"
|
||||
case "$t" in *://*) t="${t#*://}" ;; esac
|
||||
case "$t" in *@*) t="${t#*@}" ;; esac
|
||||
local hostport="${t%%/*}" db="${t#*/}"
|
||||
[ "$db" = "$t" ] && db=""
|
||||
printf '%s/%s' "$hostport" "$db"
|
||||
}
|
||||
|
||||
# agent_listen_rejected EXEC_ARGS DEFAULT_ADDR INSECURE DSN_ENV:启动预检判定(0=拒绝)。
|
||||
# 与二进制 listen guard 同口径:回环或显式放行(insecure)不拦;认证开不拦;
|
||||
# 仅非回环 + 未放行 + 认证关才拦。
|
||||
# dsn_ref_match DSN:DSN 的 host:port/db 命中已登记 postgres 实例时回显 <inst>/<db>(无匹配回空)。
|
||||
dsn_ref_match() {
|
||||
local tgt pg paddr
|
||||
tgt="$(dsn_target "$1")"
|
||||
[ -n "$tgt" ] || return 0
|
||||
while IFS= read -r pg; do
|
||||
[ "$(svc_kind "$pg")" = postgres ] || continue
|
||||
paddr="$(env_kv_of "$pg" addr)"
|
||||
[ -n "$paddr" ] || continue
|
||||
if [ "$tgt" = "$paddr/${tgt#*/}" ]; then
|
||||
printf '%s/%s' "$pg" "${tgt#*/}"
|
||||
return 0
|
||||
fi
|
||||
done < <(registered_names)
|
||||
return 0
|
||||
}
|
||||
|
||||
# auth_pick CLI ENV PERSISTED:生效认证方式(缺省 auto)。
|
||||
auth_pick() {
|
||||
case "${1:-}" in ?*) printf '%s' "$1"; return ;; esac
|
||||
case "${2:-}" in ?*) printf '%s' "$2"; return ;; esac
|
||||
case "${3:-}" in ?*) printf '%s' "$3"; return ;; esac
|
||||
printf auto
|
||||
}
|
||||
|
||||
# migrate_legacy_db_config:存量 exec= 的 --dsn 迁移为可恢复配置(dbref=/dsn=)并从 exec
|
||||
# 剥离——此后 exec= 只存非敏感启动参数,DSN 经环境变量传递(G1-7)。幂等:再次运行时
|
||||
# exec= 已无 --dsn,直接返回。命中已登记实例 → dbref(密码经实例登记存放,不重复保存);
|
||||
# 否则 → 受保护 dsn= 字段。已有 dbref= 时 exec 的 DSN 只作历史残留处理,直接剥离。
|
||||
migrate_legacy_db_config() {
|
||||
local exec_old dsn new_exec ref
|
||||
[ -f "$ENV_DIR/svc.env" ] || return 0
|
||||
exec_old="$(svc_get exec)"
|
||||
case "$exec_old" in *--dsn\ *) ;; *) return 0 ;; esac
|
||||
dsn="$(printf '%s' "$exec_old" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
|
||||
new_exec="$(printf '%s' "$exec_old" | sed -E 's/(^| )--dsn [^ ]+//; s/ +/ /g; s/^ //; s/ $//')"
|
||||
svc_set_key exec "$new_exec"
|
||||
if [ -n "$(svc_get dbref)" ]; then
|
||||
c_info "[$ENV_NAME] 存量 exec DSN 与 dbref=$(svc_get dbref) 并存,已剥离 exec 残留(绑定不受影响)"
|
||||
return 0
|
||||
fi
|
||||
ref="$(dsn_ref_match "$dsn")"
|
||||
if [ -n "$ref" ]; then
|
||||
svc_set_key dbref "$ref"
|
||||
svc_set_key db pg
|
||||
c_info "[$ENV_NAME] 存量 exec DSN 已迁移为托管绑定 dbref=$ref(DSN 改由实例登记重建)"
|
||||
else
|
||||
svc_set_key dsn "$dsn"
|
||||
svc_set_key db pg
|
||||
c_info "[$ENV_NAME] 存量 exec DSN 已迁移至受保护 dsn= 字段(未命中本机实例登记)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- 数据库配置统一解析(start/restart/deploy/update/db 共用单点)----
|
||||
# 核心约束:修改端口、监听地址或部署版本不改变数据库选择;绑定失效或配置冲突时
|
||||
# 拒绝启动(返回 1),绝不静默回退内嵌 SQLite。
|
||||
# 结果全局变量(入口全部重置——批量 update 逐服务重解析,不串用):
|
||||
# DB_MODE pg | sqlite | mock
|
||||
# DB_SOURCE cli | env | dbref | dsn | conffile | db | mock | none
|
||||
# DB_REF <inst>/<db>(命中/保持托管绑定时;否则空)
|
||||
# DB_DSN 有效连接串(sqlite/mock 为空;conffile 来源=文件内值,仅用于判定不外传)
|
||||
# DB_AUTH on | off | auto(生效值,对齐二进制 AuthEnabled 口径)
|
||||
# DB_DSN_SAVE 需持久化到 dsn= 的外部直连 DSN(仅 cli/dsn 来源且未命中实例)
|
||||
resolve_db_config() {
|
||||
DB_MODE="sqlite"; DB_SOURCE="none"; DB_REF=""; DB_DSN=""; DB_AUTH="auto"; DB_DSN_SAVE=""
|
||||
|
||||
migrate_legacy_db_config
|
||||
|
||||
local env_auth env_mock env_dsn
|
||||
env_auth="$(env_var "$ENV_NAME" AUTH)"
|
||||
env_mock="$(env_var "$ENV_NAME" MOCK)"
|
||||
env_dsn="$(env_var "$ENV_NAME" DSN)"
|
||||
|
||||
local p_dbref p_dsn p_db p_conf p_exec p_auth
|
||||
p_dbref="$(svc_get dbref)"
|
||||
p_dsn="$(svc_get dsn)"
|
||||
p_db="$(svc_get db)"
|
||||
p_conf="$(svc_get conffile)"
|
||||
p_exec="$(svc_get exec)"
|
||||
p_auth="$(printf '%s' "$p_exec" | sed -n 's/.*--auth \([^ ]*\).*/\1/p')"
|
||||
[ -n "$p_auth" ] || p_auth="$(svc_get auth)"
|
||||
|
||||
DB_AUTH="$(auth_pick "$CLI_AUTH" "$env_auth" "$p_auth")"
|
||||
case "$DB_AUTH" in on|off|auto) ;; *)
|
||||
c_err "[$ENV_NAME] 拒绝启动:无效 auth 值「$DB_AUTH」(可用 on/off/auto)"
|
||||
return 1 ;;
|
||||
esac
|
||||
|
||||
local mock=0
|
||||
{ [ "$CLI_MOCK" = 1 ] || [ "$env_mock" = "1" ]; } && mock=1
|
||||
[ "$mock" = 0 ] && case "$p_exec" in *--mock*) mock=1 ;; esac
|
||||
|
||||
# conffile 的 dsn 键(二进制自行读文件,连接串不经环境重复传递)。
|
||||
local cf_dsn=""
|
||||
if [ -n "$p_conf" ] && [ -f "$p_conf" ]; then
|
||||
cf_dsn="$(grep -oE '"dsn"[[:space:]]*:[[:space:]]*"[^"]*"' "$p_conf" 2>/dev/null | head -1 | sed -E 's/.*"([^"]*)"$/\1/')"
|
||||
fi
|
||||
|
||||
# mock 档与数据库配置共存 = 冲突,拒绝(行为要明确,不隐式降级)。
|
||||
if [ "$mock" = 1 ]; then
|
||||
if [ -n "$p_dbref" ] || [ -n "$p_dsn" ] || [ -n "$CLI_DSN" ] || { [ -n "$env_dsn" ] && [ "$p_db" != "sqlite" ]; }; then
|
||||
c_err "[$ENV_NAME] 拒绝启动:mock 档与数据库配置冲突(dbref=${p_dbref:-无}、dsn=${p_dsn:+已设、}CLI/环境 DSN=${CLI_DSN:+已设}${env_dsn:+已设})"
|
||||
c_err " 先解绑(scripts/svc.sh $ENV_NAME db --clear)再用 mock,或去掉 --mock / SVC_$(printf '%s' "$ENV_NAME" | tr 'a-z-' 'A-Z_')_MOCK"
|
||||
return 1
|
||||
fi
|
||||
DB_MODE=mock; DB_SOURCE=mock
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 显式解绑(db=sqlite):残留 SVC_*_DSN 不再悄悄重新选库;CLI 显式 --dsn 仍可覆盖。
|
||||
if [ -n "$env_dsn" ] && [ "$p_db" = "sqlite" ] && [ -z "$CLI_DSN" ]; then
|
||||
c_info "[$ENV_NAME] 已显式解绑(db=sqlite),忽略环境层 SVC_*_DSN 残留(显式 --dsn 或重新绑定可覆盖)"
|
||||
env_dsn=""
|
||||
fi
|
||||
|
||||
# 有效 DSN 选择:CLI > 环境 > 持久化(dbref / dsn) > conffile > 无(SQLite)。
|
||||
local eff_dsn="" eff_src="" bt
|
||||
if [ -n "$CLI_DSN" ]; then
|
||||
eff_dsn="$CLI_DSN"; eff_src=cli
|
||||
elif [ -n "$env_dsn" ]; then
|
||||
eff_dsn="$env_dsn"; eff_src=env
|
||||
elif [ -n "$p_dbref" ] && [ -n "$p_dsn" ]; then
|
||||
bt="$(dsn_from_dbref "$p_dbref" 2>/dev/null || true)"
|
||||
if [ -n "$bt" ] && [ "$(dsn_target "$bt")" = "$(dsn_target "$p_dsn")" ]; then
|
||||
eff_dsn="$p_dsn"; eff_src=dsn
|
||||
else
|
||||
c_err "[$ENV_NAME] 拒绝启动:配置冲突——dbref=$p_dbref 与 dsn= 指向不同数据库,不能静默取其一"
|
||||
c_err " 保留其一:重新绑定(scripts/svc.sh $ENV_NAME db <pg实例>)或 db --clear 后重新直连"
|
||||
return 1
|
||||
fi
|
||||
elif [ -n "$p_dbref" ]; then
|
||||
if ! eff_dsn="$(dsn_from_dbref "$p_dbref")"; then
|
||||
c_err "[$ENV_NAME] 拒绝启动:dbref=$p_dbref 无法重建 DSN(实例未登记 / 类型错误 / addr·user·password 登记不全)"
|
||||
c_err " 恢复实例登记或重新绑定(scripts/svc.sh $ENV_NAME db <pg实例>);不回退内嵌 SQLite"
|
||||
return 1
|
||||
fi
|
||||
eff_src=dbref
|
||||
elif [ -n "$p_dsn" ]; then
|
||||
eff_dsn="$p_dsn"; eff_src=dsn
|
||||
elif [ -n "$cf_dsn" ]; then
|
||||
eff_dsn="$cf_dsn"; eff_src=conffile
|
||||
fi
|
||||
|
||||
# 直接 DSN(CLI/环境/持久化)与 dbref 并存:目标库必须一致,否则配置冲突。
|
||||
if [ -n "$eff_dsn" ] && [ -n "$p_dbref" ] && [ "$eff_src" != dbref ]; then
|
||||
if ! bt="$(dsn_from_dbref "$p_dbref")"; then
|
||||
c_err "[$ENV_NAME] 拒绝启动:dbref=$p_dbref 无效(无法重建 DSN)且存在直接 DSN——先修复或解除绑定"
|
||||
return 1
|
||||
fi
|
||||
if [ "$(dsn_target "$bt")" != "$(dsn_target "$eff_dsn")" ]; then
|
||||
c_err "[$ENV_NAME] 拒绝启动:直接 DSN($(mask_dsn "$eff_dsn"))与 dbref=$p_dbref 指向不同数据库,不能静默取其一"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# conffile 一致性:文件内 dsn 与有效来源并存时必须同库。
|
||||
if [ -n "$cf_dsn" ] && [ "$eff_src" != conffile ] && [ -n "$eff_dsn" ] \
|
||||
&& [ "$(dsn_target "$cf_dsn")" != "$(dsn_target "$eff_dsn")" ]; then
|
||||
c_err "[$ENV_NAME] 拒绝启动:配置文件 dsn($(mask_dsn "$cf_dsn"))与数据库绑定指向不同库——先对齐(改文件或换绑定)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 已选 PostgreSQL 但拿不到任何有效 DSN → 拒绝(不降级 SQLite)。
|
||||
if [ -z "$eff_dsn" ] && [ "$p_db" = "pg" ]; then
|
||||
c_err "[$ENV_NAME] 拒绝启动:已登记 db=pg 但无可恢复 DSN(dbref/dsn/conffile 均缺失)"
|
||||
c_err " 重新绑定(scripts/svc.sh $ENV_NAME db <pg实例>)或 db --clear 显式回内嵌 SQLite"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$eff_dsn" ]; then
|
||||
DB_MODE=pg; DB_DSN="$eff_dsn"; DB_SOURCE="$eff_src"
|
||||
local ref=""
|
||||
case "$eff_src" in
|
||||
cli|dsn)
|
||||
if [ -n "$p_dbref" ]; then
|
||||
DB_REF="$p_dbref" # 一致性已验证:保持既有托管绑定
|
||||
else
|
||||
ref="$(dsn_ref_match "$eff_dsn")"
|
||||
if [ -n "$ref" ]; then DB_REF="$ref" # 直连命中本机实例 → 升级托管绑定(密码不重复保存)
|
||||
else DB_DSN_SAVE="$eff_dsn"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
env)
|
||||
[ -n "$p_dbref" ] && DB_REF="$p_dbref" # 环境层本身不持久化;已有绑定/直连保持
|
||||
[ -n "$p_dsn" ] && DB_DSN_SAVE="$p_dsn" # 环境覆盖不丢已持久化的直连配置
|
||||
;;
|
||||
dbref)
|
||||
DB_REF="$p_dbref"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
DB_MODE=sqlite
|
||||
[ "$p_db" = "sqlite" ] && DB_SOURCE=db # 显式解绑的选择跨重启保留
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# db_auth_enabled:启动预检的认证判定,对齐二进制 AuthEnabled:显式 on → 开;
|
||||
# 显式 off → 关;auto → 存在有效 DSN(含环境与配置文件来源)即开。
|
||||
db_auth_enabled() {
|
||||
case "$DB_AUTH" in
|
||||
on) return 0 ;;
|
||||
off) return 1 ;;
|
||||
*) [ -n "$DB_DSN" ] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# agent_listen_rejected EXEC_ARGS DEFAULT_ADDR INSECURE:启动预检整体判定(0=放行,
|
||||
# 1=拒绝)。与二进制 listen guard 同口径:回环或显式放行(insecure)不拦;认证开
|
||||
# (db_auth_enabled,依赖解析出的 DB_AUTH/DB_DSN)不拦;仅非回环 + 未放行 + 认证关
|
||||
# 才拦。抽出纯判定便于治理测试覆盖(tests/test_svc_precheck.py)。
|
||||
agent_listen_rejected() {
|
||||
local addr; addr="$(printf '%s' "$1" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
|
||||
addr="${addr:-$2}"
|
||||
addr_is_loopback "${addr%:*}" && return 1
|
||||
[ -n "$3" ] && return 1
|
||||
agent_args_authed "$1" "$4" && return 1
|
||||
db_auth_enabled && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# agent_reuse_exec EXEC_ARGS OUT_VAR:复用启动(无显式参数,复用 svc.env exec=)
|
||||
# 时的 exec 参数与 DSN 解析。处理后的 exec 参数经 nameref OUT_VAR 回写;DSN 经
|
||||
# 全局 DSN_ENV 传递(agent_do_start 内已 local 化)。必须直接调用而非放进命令
|
||||
# 替换——$() 子壳里的 DSN_ENV 赋值回不到调用方。两步:先做 G1-7 迁移(存量
|
||||
# --dsn 剥到环境变量);exec= 不含 DSN 且 dbref= 登记在案时从实例登记重建——
|
||||
# 否则 G1-7 后 exec= 永不含 DSN,纯 start/restart 复用会静默回落 SQLite,数据库
|
||||
# 绑定丢失。
|
||||
agent_reuse_exec() {
|
||||
local -n out_var="$2"
|
||||
# 内部变量不得叫 exec_args:与 nameref 目标同名会在函数作用域自我遮蔽,
|
||||
# 回写落到局部、调用方拿不到值。
|
||||
local cur="$1" bound_dsn
|
||||
case "$cur" in
|
||||
*--dsn\ *)
|
||||
DSN_ENV="$(printf '%s' "$cur" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
|
||||
cur="$(printf '%s' "$cur" | sed 's/--dsn [^ ]*//' | sed 's/[[:space:]]\{2,\}/ /g; s/^ //; s/ $//')"
|
||||
c_info "[$ENV_NAME] DSN 已从命令行迁移到环境变量(G1-7)"
|
||||
;;
|
||||
esac
|
||||
if [ -z "${DSN_ENV:-}" ] && [ -n "$(svc_get dbref)" ]; then
|
||||
if bound_dsn="$(dsn_from_dbref "$(svc_get dbref)")"; then
|
||||
DSN_ENV="$bound_dsn"
|
||||
c_info "[$ENV_NAME] 数据库绑定 $(svc_get dbref) 随复用启动保留(AGENT_SYSTEM_DSN)"
|
||||
else
|
||||
c_warn "[$ENV_NAME] dbref=$(svc_get dbref) 无法重建 DSN(实例缺失或登记不全),按无数据库绑定启动"
|
||||
fi
|
||||
# bin_env_dsn_supported BIN:二进制是否消费环境层 AGENT_SYSTEM_DSN(经 config explain
|
||||
# 探测:explain 对 DSN 值脱敏但用户名保留,探测标记用户即可)。旧产物不支持环境层时
|
||||
# 返回 1——调用方拒绝以环境传递 DSN(防静默按内嵌 SQLite 运行)。
|
||||
bin_env_dsn_supported() {
|
||||
local out
|
||||
out="$(AGENT_SYSTEM_DSN='postgresql://svcprobe:svcprobe@127.0.0.1:9/svcprobe' "$1" config explain 2>/dev/null || true)"
|
||||
case "$out" in *svcprobe*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# agent_static_preflight:停止旧进程 / 更换产物之前可静态检查的配置验证——数据库配置
|
||||
# 可解析(含绑定有效性与冲突)、conffile 存在、现产物能承接环境 DSN。失败返回 1(输出
|
||||
# 脱敏原因;除存量迁移外不写登记)。
|
||||
agent_static_preflight() {
|
||||
resolve_db_config || return 1
|
||||
local cf
|
||||
cf="$(svc_get conffile)"
|
||||
if [ -n "$cf" ] && [ ! -f "$cf" ]; then
|
||||
c_err "[$ENV_NAME] 拒绝启动:配置文件不存在:$cf(恢复文件或 config --clear)"
|
||||
return 1
|
||||
fi
|
||||
out_var="$cur"
|
||||
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ] && [ -x "$ENV_BIN" ] \
|
||||
&& ! bin_env_dsn_supported "$ENV_BIN"; then
|
||||
c_err "[$ENV_NAME] 拒绝启动:当前产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),无法按绑定非降级交付数据库配置"
|
||||
c_err " 更新产物(deploy 新版本),或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <file.json>)"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
agent_do_start() {
|
||||
@@ -1264,46 +1528,34 @@ agent_do_start() {
|
||||
return 1
|
||||
fi
|
||||
SVC_VERSION="$("$ENV_BIN" version 2>/dev/null || true)"
|
||||
# 组装启动参数:显式配置 → 重建;否则复用上次持久化 exec=。
|
||||
# DSN_ENV 必须 local 并清零:G1-7 起 DSN 经它传给启动守卫环境,全局残留会让
|
||||
# update --all 同进程连续启动时前一个服务的 DSN 泄漏进后一个的 AGENT_SYSTEM_DSN。
|
||||
local exec_args DSN_ENV=""
|
||||
# 统一解析数据库配置(复用与重建两路共用;失败拒绝启动,绝不静默回退 SQLite)。
|
||||
# 2026-10-11 修复:复用 exec= 分支此前不查 dbref——G1-7 迁移把 --dsn 剥入环境后
|
||||
# record_state 写回不含 DSN 的 exec=,重新绑定后的第二次裸 restart 即丢库。
|
||||
if ! resolve_db_config; then
|
||||
hist_event start fail "" "数据库配置解析拒绝(绑定失效/配置冲突)"
|
||||
return 1
|
||||
fi
|
||||
SVC_DB_MODE="$DB_MODE"; SVC_DB_SOURCE="$DB_SOURCE"; SVC_DB_REF="$DB_REF"; SVC_DB_DSN_SAVE="$DB_DSN_SAVE"
|
||||
# 组装启动参数:显式配置 → 重建;否则复用上次持久化 exec=(DSN 已由解析迁移出 exec)。
|
||||
local exec_args
|
||||
if ! agent_has_explicit_cfg && [ -n "$(svc_get exec)" ]; then
|
||||
agent_reuse_exec "$(svc_get exec)" exec_args
|
||||
exec_args="$(svc_get exec)"
|
||||
c_info "[$ENV_NAME] 复用上次配置(svc.env exec=;显式参数可覆盖):$(mask_dsn "$exec_args")"
|
||||
else
|
||||
local args=""
|
||||
if [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$ENV_NAME" MOCK)" = "1" ]; then
|
||||
local args="" explicit_auth="${CLI_AUTH:-$(env_var "$ENV_NAME" AUTH)}"
|
||||
if [ "$DB_MODE" = mock ]; then
|
||||
args="--mock"
|
||||
else
|
||||
local dsn="${CLI_DSN:-$(env_var "$ENV_NAME" DSN)}"
|
||||
local auth="${CLI_AUTH:-$(env_var "$ENV_NAME" AUTH)}"
|
||||
# dbref 兜底:显式参数重建启动参数时,持久化的数据库绑定不再静默丢失
|
||||
#(2026-10-05 dev 事故:restart --addr 走重建分支丢 --dsn → auto 判认证关 → 守卫拒启)。
|
||||
if [ -z "$dsn" ] && [ -n "$(svc_get dbref)" ]; then
|
||||
local bound_dsn
|
||||
if bound_dsn="$(dsn_from_dbref "$(svc_get dbref)")"; then
|
||||
dsn="$bound_dsn"
|
||||
c_info "[$ENV_NAME] 数据库绑定 $(svc_get dbref) 随参数重建保留"
|
||||
else
|
||||
c_warn "[$ENV_NAME] dbref=$(svc_get dbref) 无法重建 DSN(实例缺失或登记不全),按无数据库绑定启动"
|
||||
fi
|
||||
fi
|
||||
# G1-7:DSN 经环境变量传入(AGENT_SYSTEM_DSN),不再进命令行——命令行对
|
||||
# 同机任何进程可见(含智能体生成脚本的 /proc/<pid>/cmdline),2026-10-09
|
||||
# 方案 §3.4 实测。基座 config 优先级 CLI>env,旧 exec= 里的 --dsn 仍生效
|
||||
# (向后兼容),但新建/重建的启动配置从此不带 DSN 明文。
|
||||
DSN_ENV="$dsn"
|
||||
if [ -n "$dsn" ] || [ -n "$auth" ] || [ "$ENV_NAME" != dev ]; then
|
||||
[ -z "$auth" ] && auth=auto
|
||||
args="$args --auth $auth"
|
||||
fi
|
||||
elif [ "$DB_MODE" = pg ] || [ -n "$explicit_auth" ] || [ "$ENV_NAME" != dev ]; then
|
||||
args="--auth $DB_AUTH"
|
||||
fi
|
||||
# 保留既有配置文件引用:显式参数重建时不丢 --config(conffile= 持久化跨重启)。
|
||||
local conf; conf="$(svc_get conffile)"
|
||||
[ -n "$conf" ] && [ -f "$conf" ] && args="$args --config $conf"
|
||||
local adjusted; adjusted="$(adjust_args_for_bin "$ENV_BIN" "$args")"
|
||||
# adjust_args_for_bin 返回值已去首空格——比较前对 args 同样归一,否则每次
|
||||
# 带 --dsn/--auth 的重建都会误报"旧版本二进制已自动省略"(参数实际全保留)。
|
||||
# adjust_args_for_bin 返回值已去首空格——比较前对 args 同样归一,否则每次带
|
||||
# --auth/--config 的重建都会误报"旧版本二进制已自动省略"(参数实际全保留)。
|
||||
if [ "$adjusted" != "${args# }" ]; then
|
||||
c_warn "[$ENV_NAME] 该产物不支持 --dsn/--auth(旧版本二进制),已自动省略:$(mask_dsn "${args:-<无>}") → $(mask_dsn "${adjusted:-<无>}")"
|
||||
c_warn "[$ENV_NAME] 该产物不支持部分启动参数(旧版本二进制),已自动省略:${args:-<无>} → ${adjusted:-<无>}"
|
||||
fi
|
||||
args="$adjusted"
|
||||
exec_args="--addr $ENV_ADDR --data $ENV_DATA --plugins ${CLI_PLUGINS:-$ENV_DIR/plugins} $args $CLI_EXTRA"
|
||||
@@ -1318,12 +1570,12 @@ agent_do_start() {
|
||||
[ -n "$hosts" ] && guard_env+=("AGENT_SYSTEM_ALLOWED_HOSTS=$hosts")
|
||||
# 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作——
|
||||
# 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。
|
||||
# 认证判定与二进制同口径(agent_listen_rejected → agent_args_authed):
|
||||
# env DSN(AGENT_SYSTEM_DSN)同样算「已配置 DSN」,不再因命令行不见 --dsn
|
||||
# 而把非回环+auto 误判为认证关(2026-10-10 dev 实测误拒)。
|
||||
local pf_addr; pf_addr="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
|
||||
pf_addr="${pf_addr:-$ENV_ADDR}"
|
||||
if agent_listen_rejected "$exec_args" "$ENV_ADDR" "$insecure" "${DSN_ENV:-}"; then
|
||||
# 认证判定依据统一解析的最终有效 DSN 与 auth(含环境变量与配置文件来源),
|
||||
# 与二进制 AuthEnabled 口径一致(2026-10-11 修复:此前只看命令行 --dsn,
|
||||
# DSN 迁入环境变量后 auto 被误判为未开认证;PR #317 同因,本方案为其超集)。
|
||||
if agent_listen_rejected "$exec_args" "$ENV_ADDR" "$insecure"; then
|
||||
local pf_addr; pf_addr="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
|
||||
pf_addr="${pf_addr:-$ENV_ADDR}"
|
||||
c_err "[$ENV_NAME] 拒绝启动:非回环地址 $pf_addr 且认证判定为关(auto 无 DSN 即关)。"
|
||||
c_err " 任选其一:--addr 127.0.0.1:${pf_addr##*:};数据库绑定 scripts/svc.sh $ENV_NAME db <pg实例>;"
|
||||
c_err " 显式 --auth on;或环境 SVC_$(printf '%s' "$ENV_NAME" | tr 'a-z-' 'A-Z_')_INSECURE_LISTEN=1 显式放行。"
|
||||
@@ -1337,8 +1589,19 @@ agent_do_start() {
|
||||
fi
|
||||
# 直接 nohup 简单命令:$! 即服务进程本身,pidfile 语义才成立。
|
||||
# 守卫变量走环境,不写进 exec=;旧二进制不认识这两个变量,会忽略。
|
||||
# G1-7:DSN 同样走环境(AGENT_SYSTEM_DSN;基座 config 环境档已支持)。
|
||||
[ -n "${DSN_ENV:-}" ] && guard_env+=("AGENT_SYSTEM_DSN=$DSN_ENV")
|
||||
# G1-7:DSN 经环境变量传入(AGENT_SYSTEM_DSN),不进命令行——命令行对同机任何
|
||||
# 进程可见(含智能体生成脚本的 /proc/<pid>/cmdline)。conffile 来源由二进制自行
|
||||
# 读文件,不重复传递。环境层交付前再核一次产物能力(start 可能紧跟 deploy 换产物),
|
||||
# 不支持即拒绝——绝不静默按内嵌 SQLite 运行。
|
||||
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ]; then
|
||||
if ! bin_env_dsn_supported "$ENV_BIN"; then
|
||||
c_err "[$ENV_NAME] 拒绝启动:产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),无法按绑定交付数据库配置(不回退 SQLite)"
|
||||
c_err " 更新产物(deploy 新版本),或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <file.json>)"
|
||||
hist_event start fail "" "产物不支持环境 DSN,拒绝启动"
|
||||
return 1
|
||||
fi
|
||||
guard_env+=("AGENT_SYSTEM_DSN=$DB_DSN")
|
||||
fi
|
||||
if [ "${#guard_env[@]}" -gt 0 ];then
|
||||
nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 &
|
||||
else
|
||||
@@ -1714,13 +1977,16 @@ check_port_available() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# svc_set_key KEY VALUE:改写当前服务 svc.env 的单键(先读后写,避开重定向截断陷阱)。
|
||||
# svc_set_key KEY VALUE:改写当前服务 svc.env 的单键(先读后写 + 临时文件原子替换,
|
||||
# 避免重定向截断与半写)。
|
||||
svc_set_key() {
|
||||
local key="$1" val="$2" rest
|
||||
local key="$1" val="$2" rest tmp
|
||||
[ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; }
|
||||
rest="$(grep -v "^$key=" "$ENV_DIR/svc.env" || true)"
|
||||
{ [ -n "$rest" ] && printf '%s\n' "$rest"; printf '%s=%s\n' "$key" "$val"; } > "$ENV_DIR/svc.env"
|
||||
chmod 600 "$ENV_DIR/svc.env"
|
||||
tmp="$ENV_DIR/svc.env.tmp.$$"
|
||||
{ [ -n "$rest" ] && printf '%s\n' "$rest"; printf '%s=%s\n' "$key" "$val"; } > "$tmp"
|
||||
chmod 600 "$tmp"
|
||||
mv -f "$tmp" "$ENV_DIR/svc.env"
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -2177,9 +2443,10 @@ EOF
|
||||
# ---- agent 服务的数据库绑定(<name> db ...)----
|
||||
|
||||
# svc_db_bind [pg名] [--db X] [--clear] [--no-restart]:查看 / 绑定切换 / 解除
|
||||
# agent 服务的数据库。绑定 = 目标实例的 DSN 写入持久化 exec=(--dsn,密码 600 存放)
|
||||
# 并登记 dbref=<pg>/<库>;实例运行中时确保目标库存在(缺则建);服务运行中则自动
|
||||
# 重启生效(--no-restart 只改登记)。--clear 解除绑定(回内嵌 SQLite 档)。
|
||||
# agent 服务的数据库。绑定 = dbref=<pg>/<库> + db=pg 登记(600);DSN 不落 exec=,
|
||||
# 启动时由实例登记重建并经 AGENT_SYSTEM_DSN 环境变量交付(实例运行中缺库自动建;
|
||||
# 服务运行中自动重启生效,--no-restart 只改登记)。--clear 显式解绑:db=sqlite、
|
||||
# 清除 dbref 与直接 dsn=,残留 SVC_*_DSN 不再悄悄重新选库。
|
||||
svc_db_bind() {
|
||||
[ "$SVC_KIND" = agent ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 无数据库绑定(仅 agent 服务)"; return 2; }
|
||||
[ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 无登记(先 deploy)"; return 2; }
|
||||
@@ -2194,7 +2461,7 @@ svc_db_bind() {
|
||||
[$ENV_NAME] 数据库绑定:
|
||||
scripts/svc.sh $ENV_NAME db 查看当前绑定与可用实例
|
||||
scripts/svc.sh $ENV_NAME db <pg名> [--db X] [--no-restart] 绑定/切换(运行中自动重启生效)
|
||||
scripts/svc.sh $ENV_NAME db --clear [--no-restart] 解除绑定(回内嵌 SQLite 档)
|
||||
scripts/svc.sh $ENV_NAME db --clear [--no-restart] 解除绑定(显式回内嵌 SQLite 档)
|
||||
EOF
|
||||
return 0 ;;
|
||||
-*) c_err "[$ENV_NAME] db 未知参数:$1(可用:<pg名> | --clear,--db X,--no-restart)"; return 2 ;;
|
||||
@@ -2205,12 +2472,17 @@ EOF
|
||||
|
||||
# 无参:查看当前绑定 + 可用实例。
|
||||
if [ -z "$target" ] && [ "$clear" = 0 ]; then
|
||||
local ref dsn pg st
|
||||
local ref dsn pg st mode
|
||||
mode="$(svc_get db)"
|
||||
ref="$(db_ref_of "$ENV_NAME")"
|
||||
if [ -n "$ref" ]; then
|
||||
printf ' [%s] 数据库绑定:%s\n' "$ENV_NAME" "$ref"
|
||||
dsn="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
|
||||
dsn="$(svc_get dsn)"
|
||||
[ -z "$dsn" ] && dsn="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
|
||||
[ -z "$dsn" ] && [ -n "$(svc_get dbref)" ] && dsn="$(dsn_from_dbref "$(svc_get dbref)" 2>/dev/null || true)"
|
||||
[ -n "$dsn" ] && printf ' DSN %s\n' "$(mask_dsn "$dsn")"
|
||||
elif [ "$mode" = "sqlite" ]; then
|
||||
printf ' [%s] 数据库绑定:已显式解绑(db=sqlite,data=%s;残留 SVC_*_DSN 不再自动选库)\n' "$ENV_NAME" "$ENV_DATA"
|
||||
else
|
||||
printf ' [%s] 数据库绑定:无(内嵌 SQLite 档,data=%s)\n' "$ENV_NAME" "$ENV_DATA"
|
||||
fi
|
||||
@@ -2228,7 +2500,7 @@ EOF
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 解除绑定。
|
||||
# 解除绑定(显式选择 SQLite:清除 dbref 与直接 dsn,环境残留不再重新选库)。
|
||||
if [ "$clear" = 1 ]; then
|
||||
[ -n "$target" ] && { c_err "--clear 不接受服务名参数"; return 2; }
|
||||
local exec_old new_exec
|
||||
@@ -2238,8 +2510,10 @@ EOF
|
||||
svc_set_key exec "$new_exec"
|
||||
fi
|
||||
svc_del_key dbref
|
||||
svc_del_key dsn
|
||||
svc_set_key db sqlite
|
||||
hist_event set ok "$(read_pid)" "db unbind"
|
||||
c_ok "[$ENV_NAME] 已解除数据库绑定(内嵌 SQLite 档;数据目录 $ENV_DATA)"
|
||||
c_ok "[$ENV_NAME] 已解除数据库绑定(显式 db=sqlite;数据目录 $ENV_DATA)"
|
||||
if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then
|
||||
do_stop; do_start || { c_err "[$ENV_NAME] 重启失败——解绑已登记,排查后 start"; return 1; }
|
||||
c_ok "[$ENV_NAME] 已重启(内嵌 SQLite 档)"
|
||||
@@ -2260,7 +2534,21 @@ EOF
|
||||
tpass="$(env_kv_of "$target" password)"
|
||||
regdb="$(env_kv_of "$target" dbname)"
|
||||
tdb="${CLI_DB_NAME:-$regdb}"
|
||||
{ [ -n "$tuser" ] && [ -n "$tpass" ]; } || { c_err "[$target] 登记缺 user/password(重新 db create)"; return 2; }
|
||||
{ [ -n "$tuser" ] && [ -n "$tpass" ] && [ -n "$taddr" ]; } || { c_err "[$target] 登记缺 addr/user/password(重新 db create)"; return 2; }
|
||||
# mock 档与绑定冲突:拒绝——绑定后按 mock 运行等于静默丢库。
|
||||
local exec_now mock_now=0
|
||||
exec_now="$(svc_get exec)"
|
||||
{ [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$ENV_NAME" MOCK)" = "1" ]; } && mock_now=1
|
||||
case "$exec_now" in *--mock*) mock_now=1 ;; esac
|
||||
if [ "$mock_now" = 1 ]; then
|
||||
c_err "[$ENV_NAME] 拒绝绑定:当前为 mock 档(--mock)与数据库配置冲突;先去 mock(重新 deploy 不带 --mock)或保持 mock 不绑定"
|
||||
return 2
|
||||
fi
|
||||
# 产物承接能力预检:绑定后 DSN 经环境变量交付,产物须消费 AGENT_SYSTEM_DSN。
|
||||
if [ -x "$ENV_BIN" ] && ! bin_env_dsn_supported "$ENV_BIN"; then
|
||||
c_err "[$ENV_NAME] 拒绝绑定:当前产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),绑定后无法生效;先更新产物再绑定"
|
||||
return 2
|
||||
fi
|
||||
# 实例运行中:确保目标库存在(缺则建,幂等)。
|
||||
st="$(svc_pid_state "$target")"
|
||||
if [ "${st%% *}" = alive ]; then
|
||||
@@ -2275,26 +2563,19 @@ EOF
|
||||
c_warn "[$target] 未运行——库 $tdb 暂未创建(先 start $target 再重跑,或绑定默认库 $regdb)"
|
||||
fi
|
||||
dsn="postgresql://$tuser:$tpass@$thost:$tport/$tdb"
|
||||
# 产物支持预检(旧二进制不识别 --dsn 时提示;重启复用 exec 不会自动过滤)。
|
||||
if [ -x "$ENV_BIN" ]; then
|
||||
local adj
|
||||
adj="$(adjust_args_for_bin "$ENV_BIN" "--dsn $dsn")"
|
||||
[ "$adj" != "--dsn $dsn" ] && \
|
||||
c_warn "[$ENV_NAME] 当前产物不识别 --dsn(旧版本二进制)——绑定已登记,但重启后将按内嵌档运行"
|
||||
# 写持久化配置(托管绑定形态):dbref= + db=pg;清除直接 dsn=;剥离 exec 残留 --dsn。
|
||||
# DSN 不落 exec=——启动时由实例登记重建(实例端口/密码变更自动跟随,密码不重复保存)。
|
||||
if printf '%s' "$exec_now" | grep -q -- '--dsn '; then
|
||||
local new_exec
|
||||
new_exec="$(printf '%s' "$exec_now" | sed -E 's/(^| )--dsn [^ ]+//; s/ +/ /g; s/^ //; s/ $//')"
|
||||
svc_set_key exec "$new_exec"
|
||||
fi
|
||||
# 写持久化配置:exec= 的 --dsn 替换或追加;dbref= 登记。
|
||||
local exec_old new_exec
|
||||
exec_old="$(svc_get exec)"
|
||||
if printf '%s' "$exec_old" | grep -q -- '--dsn '; then
|
||||
new_exec="$(printf '%s' "$exec_old" | sed -E "s|--dsn [^ ]+|--dsn $dsn|")"
|
||||
else
|
||||
new_exec="${exec_old:+$exec_old }--dsn $dsn"
|
||||
fi
|
||||
svc_set_key exec "$new_exec"
|
||||
svc_set_key dbref "$target/$tdb"
|
||||
svc_set_key db pg
|
||||
svc_del_key dsn
|
||||
hist_event set ok "$(read_pid)" "db bind → $target/$tdb"
|
||||
c_ok "[$ENV_NAME] 数据库绑定:$target/$tdb(DSN $(mask_dsn "$dsn");svc.env 600 保存,info 可查)"
|
||||
# 运行中:重启生效(start 复用新 exec=)。
|
||||
c_ok "[$ENV_NAME] 数据库绑定:$target/$tdb(启动时由实例登记重建 DSN:$(mask_dsn "$dsn");svc.env 600 保存,info 可查)"
|
||||
# 运行中:重启生效(start 按新登记重建 DSN 交付)。
|
||||
if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then
|
||||
c_info "[$ENV_NAME] 重启以切换数据库……"
|
||||
do_stop
|
||||
@@ -2413,6 +2694,17 @@ do_stop_quiet() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# do_restart:agent 先完成可静态检查的配置验证(数据库绑定可解析、conffile 存在、
|
||||
# 产物承接能力)再停旧进程——配置性拒绝不制造停机窗口,登记保持可恢复;其余类型
|
||||
# 直接停止后启动。
|
||||
do_restart() {
|
||||
if [ "$SVC_KIND" = agent ]; then
|
||||
agent_static_preflight || { hist_event start fail "" "restart 预检拒绝(数据库配置/配置文件无效)"; return 1; }
|
||||
fi
|
||||
do_stop
|
||||
do_start
|
||||
}
|
||||
|
||||
cmd_rm() {
|
||||
local name="${1:-}"
|
||||
[ -n "$name" ] || { c_err "用法:scripts/svc.sh rm <name> [--purge]"; exit 2; }
|
||||
@@ -2470,6 +2762,7 @@ db_ref_of() {
|
||||
ref="$(env_kv_of "$name" dbref)"
|
||||
if [ -n "$ref" ]; then printf '%s' "$ref"; return 0; fi
|
||||
dsn="$(printf '%s' "$(env_kv_of "$name" exec)" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
|
||||
[ -z "$dsn" ] && dsn="$(env_kv_of "$name" dsn)"
|
||||
if [ -z "$dsn" ]; then
|
||||
cf="$(env_kv_of "$name" conffile)"
|
||||
[ -z "$cf" ] && cf="$(printf '%s' "$(env_kv_of "$name" exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')"
|
||||
@@ -2607,13 +2900,13 @@ mask_dsn() {
|
||||
printf '%s' "$1" | sed -E 's#(postgres(ql)?://[^:/@[:space:]]+:)[^@[:space:]]+@#\1***@#g'
|
||||
}
|
||||
|
||||
# mask_kv LINE:KEY=VALUE 行的敏感值掩码——password 键与键名含 KEY/PASS/SECRET/
|
||||
# mask_kv LINE:KEY=VALUE 行的敏感值掩码——password/dsn 键与键名含 KEY/PASS/SECRET/
|
||||
# TOKEN/DSN 的值整段掩为 ***;其余行只掩值内 DSN 密码(exec= 里可能带完整连接串)。
|
||||
mask_kv() {
|
||||
local line="$1" k="${1%%=*}"
|
||||
if [ "$k" != "$1" ]; then
|
||||
case "$k" in
|
||||
password|*KEY*|*PASS*|*SECRET*|*TOKEN*|*DSN*)
|
||||
password|dsn|*KEY*|*PASS*|*SECRET*|*TOKEN*|*DSN*)
|
||||
printf '%s=***' "$k"; return 0 ;;
|
||||
esac
|
||||
fi
|
||||
@@ -3109,7 +3402,7 @@ main() {
|
||||
esac ;;
|
||||
stop) do_stop ;;
|
||||
start) do_start ;;
|
||||
restart) do_stop; do_start ;;
|
||||
restart) do_restart ;;
|
||||
status) env_status_detail ;;
|
||||
logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
"""svc.sh 数据库配置可恢复性与启动门禁回归(2026-10-11 修复)。
|
||||
|
||||
对 scripts/svc.sh 的数据库配置统一解析做端到端验证:连续重启 / 部署 / 更新保绑定、
|
||||
绑定失效拒绝启动(绝不静默回退内嵌 SQLite)、认证预检与环境变量 DSN 对齐、显式
|
||||
解绑阻止环境残留重新选库、凭据不进命令行与日志。全部使用临时目录 + 桩二进制
|
||||
(bash + python3 http.server 模拟 agent-system),不触碰真实 runtime/ 与真实数据库。
|
||||
真实 PostgreSQL + 真实产物的验证在隔离环境另行执行并记录于任务卡。
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
|
||||
|
||||
# 桩 agent-system:version / config explain(环境层 DSN 感知,供能力探测)/ serve
|
||||
# (记录参数与 AGENT_SYSTEM_* 环境后用 http.server 应答健康探测)。TERM 转发给子
|
||||
# 进程保证 stop 干净;bash 自身不 exec——pidcheck(二进制路径)须留在 cmdline。
|
||||
# SVC_STUB_NO_ENV_DSN=1 模拟不支持 AGENT_SYSTEM_DSN 的旧产物。
|
||||
STUB = r"""#!/usr/bin/env bash
|
||||
set -u
|
||||
trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
|
||||
mkdir -p "$trace_dir"
|
||||
case "${1:-}" in
|
||||
version)
|
||||
echo "stub-agent-system 1.0"
|
||||
exit 0 ;;
|
||||
config)
|
||||
if [ "${2:-}" = "explain" ]; then
|
||||
echo "config file: (none)"
|
||||
echo "field value source"
|
||||
if [ -n "${AGENT_SYSTEM_DSN:-}" ] && [ -z "${SVC_STUB_NO_ENV_DSN:-}" ]; then
|
||||
printf 'dsn postgresql://%s env\n' "$AGENT_SYSTEM_DSN"
|
||||
else
|
||||
echo "dsn - default"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
exit 0 ;;
|
||||
serve)
|
||||
shift
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
echo "Usage: agent-system serve -addr H:P -data DIR -plugins DIR -auth MODE -config FILE"
|
||||
exit 0 ;;
|
||||
esac
|
||||
{
|
||||
printf 'args: %s\n' "$*"
|
||||
[ -n "${AGENT_SYSTEM_DSN:-}" ] && printf 'AGENT_SYSTEM_DSN=%s\n' "$AGENT_SYSTEM_DSN"
|
||||
[ -n "${AGENT_SYSTEM_AUTH:-}" ] && printf 'AGENT_SYSTEM_AUTH=%s\n' "$AGENT_SYSTEM_AUTH"
|
||||
} > "$trace_dir/serve.txt"
|
||||
addr=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac
|
||||
done
|
||||
port="${addr##*:}"; host="${addr%:*}"
|
||||
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
|
||||
python3 -m http.server "$port" --bind "$host" --directory "$trace_dir" >/dev/null 2>&1 &
|
||||
child=$!
|
||||
wait "$child"
|
||||
;;
|
||||
*)
|
||||
exit 0 ;;
|
||||
esac
|
||||
"""
|
||||
|
||||
|
||||
class SvcDbRecoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.mkdtemp(prefix="svc-db-regress-")
|
||||
self.addCleanup(shutil.rmtree, tmp, True)
|
||||
self.root = Path(tmp)
|
||||
(self.root / "scripts").mkdir(parents=True)
|
||||
shutil.copy2(SVC_SCRIPT, self.root / "scripts" / "svc.sh")
|
||||
self.trace = self.root / "trace"
|
||||
self.stub = self.root / "stub-agent-system"
|
||||
self.stub.write_text(STUB)
|
||||
self.stub.chmod(0o755)
|
||||
(self.root / "plugins").mkdir()
|
||||
(self.root / "Makefile").write_text(
|
||||
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n" % self.stub)
|
||||
self.env = os.environ.copy()
|
||||
self.env.update({
|
||||
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_AUTHOR_NAME": "svc regression", "GIT_COMMITTER_NAME": "svc regression",
|
||||
"GIT_AUTHOR_EMAIL": "svc-test@example.invalid",
|
||||
"GIT_COMMITTER_EMAIL": "svc-test@example.invalid",
|
||||
"SVC_STUB_TRACE": str(self.trace),
|
||||
"SVC_SRC_DIR": str(self.root / "srccache"),
|
||||
})
|
||||
for key in list(self.env):
|
||||
if key.startswith("SVC_") and key not in ("SVC_STUB_TRACE", "SVC_SRC_DIR"):
|
||||
self.env.pop(key)
|
||||
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
|
||||
self.env.pop(key, None)
|
||||
# 根目录须为 git 仓(deploy --from local 取 HEAD 作 source 标签)。
|
||||
self.git(self.root, "init", "--initial-branch=main", ".")
|
||||
self.git(self.root, "add", ".")
|
||||
self.git(self.root, "commit", "-m", "fixture")
|
||||
|
||||
def git(self, cwd, *args):
|
||||
proc = subprocess.run(["git", *args], cwd=cwd, env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
return proc
|
||||
|
||||
def tearDown(self):
|
||||
# 兜底清理:停掉本测试遗留的服务进程,避免端口跨用例泄漏。
|
||||
runtime = self.root / "runtime"
|
||||
if runtime.is_dir():
|
||||
for d in sorted(runtime.iterdir()):
|
||||
if (d / "svc.pid").exists():
|
||||
subprocess.run(
|
||||
["bash", str(self.root / "scripts" / "svc.sh"), d.name, "stop"],
|
||||
cwd=self.root, env=self.env, timeout=60,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
# ---- 夹具 ----
|
||||
|
||||
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=120)
|
||||
if check:
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
return proc
|
||||
|
||||
def runtime(self, name):
|
||||
return self.root / "runtime" / name
|
||||
|
||||
def kv(self, name, key):
|
||||
envfile = self.runtime(name) / "svc.env"
|
||||
if not envfile.exists():
|
||||
return None
|
||||
for line in envfile.read_text().splitlines():
|
||||
if line.startswith(key + "="):
|
||||
return line[len(key) + 1:]
|
||||
return None
|
||||
|
||||
def register_pg(self, name, port, password="pgpw-A1", user="agent",
|
||||
dbname="agent", complete=True):
|
||||
d = self.runtime(name)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
rows = [("kind", "postgres"), ("env", name), ("addr", "127.0.0.1:%d" % port),
|
||||
("tags", ""), ("source", "postgres@local"), ("version", "16"),
|
||||
("user", user)]
|
||||
if complete:
|
||||
rows.append(("password", password))
|
||||
rows += [("dbname", dbname), ("authmode", "scram-sha-256"),
|
||||
("pgdata", str(d / "pgdata"))]
|
||||
(d / "svc.env").write_text("".join("%s=%s\n" % kv for kv in rows))
|
||||
|
||||
def seed_agent(self, name, port, exec_args, extra_rows=(), addr=None):
|
||||
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.stub, d / "bin" / "agent-system")
|
||||
rows = [("kind", "agent"), ("env", name), ("pid", ""),
|
||||
("addr", addr or ("127.0.0.1:%d" % port)), ("tags", ""),
|
||||
("source", "local@deadbee"), ("version", "stub"), ("started", ""),
|
||||
("pidcheck", str(d / "bin" / "agent-system")),
|
||||
("exec", exec_args)]
|
||||
merged = dict(rows)
|
||||
merged.update(dict(extra_rows)) # extra 覆盖基础键(如 source/version)
|
||||
(d / "svc.env").write_text(
|
||||
"".join("%s=%s\n" % kv for kv in merged.items()))
|
||||
|
||||
def serve_record(self):
|
||||
f = self.trace / "serve.txt"
|
||||
return f.read_text() if f.exists() else ""
|
||||
|
||||
def clear_serve_record(self):
|
||||
f = self.trace / "serve.txt"
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
|
||||
def start_ok(self, name, *args):
|
||||
self.clear_serve_record()
|
||||
result = self.svc(name, "start", *args)
|
||||
self.assertIn("已启动", result.stdout)
|
||||
return result
|
||||
|
||||
def pid_alive(self, name):
|
||||
pidf = self.runtime(name) / "svc.pid"
|
||||
if not pidf.exists():
|
||||
return False
|
||||
try:
|
||||
os.kill(int(pidf.read_text().strip()), 0)
|
||||
return True
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
return False
|
||||
|
||||
# ---- 1. 存量 exec= DSN:连续启动两次均恢复,进程参数无秘密 ----
|
||||
|
||||
def test_legacy_exec_dsn_survives_consecutive_restarts(self):
|
||||
self.register_pg("pg1", 18890, password="legacy-pw-1")
|
||||
dsn = "postgresql://agent:legacy-pw-1@127.0.0.1:18890/agent"
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18791,
|
||||
"--addr 127.0.0.1:18791 --data %s --plugins %s --auth auto --dsn %s"
|
||||
% (d / "data", d / "plugins", dsn))
|
||||
for _ in range(2):
|
||||
self.clear_serve_record()
|
||||
self.svc("dev", "restart")
|
||||
record = self.serve_record()
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, record)
|
||||
self.assertNotIn("--dsn", record.splitlines()[0])
|
||||
self.assertNotIn("legacy-pw-1", record.splitlines()[0])
|
||||
self.assertEqual(self.kv("dev", "dbref"), "pg1/agent")
|
||||
self.assertEqual(self.kv("dev", "db"), "pg")
|
||||
self.assertNotIn("--dsn", self.kv("dev", "exec"))
|
||||
self.assertIsNone(self.kv("dev", "dsn"))
|
||||
# 停止后的第三次裸 start 同样恢复同一数据库配置。
|
||||
self.svc("dev", "stop")
|
||||
self.start_ok("dev")
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
|
||||
self.assertEqual(self.kv("dev", "dbref"), "pg1/agent")
|
||||
|
||||
# ---- 2. 仅 dbref:裸 restart 与重新部署均保留绑定 ----
|
||||
|
||||
def test_dbref_binding_survives_bare_restart_and_redeploy(self):
|
||||
self.register_pg("pg1", 18891, password="bind-pw-2")
|
||||
dsn = "postgresql://agent:bind-pw-2@127.0.0.1:18891/agent"
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18792,
|
||||
"--addr 127.0.0.1:18792 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("dbref", "pg1/agent"), ("db", "pg")])
|
||||
self.clear_serve_record()
|
||||
self.svc("dev", "restart")
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
|
||||
self.assertEqual(self.kv("dev", "dbref"), "pg1/agent")
|
||||
# 重新部署(换产物)不改变数据库选择。
|
||||
self.clear_serve_record()
|
||||
self.svc("dev", "deploy", "--from", "local")
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
|
||||
self.assertEqual(self.kv("dev", "dbref"), "pg1/agent")
|
||||
self.assertEqual(self.kv("dev", "db"), "pg")
|
||||
# 查看视图展示托管绑定。
|
||||
view = self.svc("dev", "db")
|
||||
self.assertIn("pg1/agent", view.stdout)
|
||||
|
||||
# ---- 3. 改地址不改数据库与认证 ----
|
||||
|
||||
def test_addr_change_keeps_database_and_auth(self):
|
||||
self.register_pg("pg1", 18892, password="addr-pw-3")
|
||||
dsn = "postgresql://agent:addr-pw-3@127.0.0.1:18892/agent"
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18793,
|
||||
"--addr 127.0.0.1:18793 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("dbref", "pg1/agent"), ("db", "pg")])
|
||||
self.clear_serve_record()
|
||||
self.svc("dev", "restart", "--addr", "127.0.0.1:18794")
|
||||
record = self.serve_record()
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, record)
|
||||
self.assertIn("--auth auto", record)
|
||||
self.assertEqual(self.kv("dev", "dbref"), "pg1/agent")
|
||||
self.assertEqual(self.kv("dev", "addr"), "127.0.0.1:18794")
|
||||
self.assertIn("--addr 127.0.0.1:18794", self.kv("dev", "exec"))
|
||||
self.assertEqual(self.kv("dev", "db"), "pg")
|
||||
|
||||
# ---- 4. 外部直连与配置文件来源:后续无参数启动仍恢复 ----
|
||||
|
||||
def test_external_dsn_and_conffile_recover_on_bare_start(self):
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18795,
|
||||
"--addr 127.0.0.1:18795 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"))
|
||||
external = "postgresql://extu:ext-pw-4@10.9.8.7:5432/extdb"
|
||||
self.clear_serve_record()
|
||||
self.svc("dev", "deploy", "--from", "local", "--dsn", external)
|
||||
self.assertEqual(self.kv("dev", "dsn"), external)
|
||||
self.assertEqual(self.kv("dev", "db"), "pg")
|
||||
self.assertIsNone(self.kv("dev", "dbref"))
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % external, self.serve_record())
|
||||
self.svc("dev", "stop")
|
||||
self.start_ok("dev")
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % external, self.serve_record())
|
||||
# 显式重新绑定 = 切换(托管绑定替换直连,dsn= 清除,不视为冲突)。
|
||||
self.register_pg("pgother", 18893, password="other-pw")
|
||||
self.svc("dev", "db", "pgother", "--no-restart")
|
||||
self.assertEqual(self.kv("dev", "dbref"), "pgother/agent")
|
||||
self.assertIsNone(self.kv("dev", "dsn"))
|
||||
|
||||
# conffile 来源:DSN 由文件提供,不经环境重复传递;db=pg 标记保留。
|
||||
conf = self.root / "conf-db.json"
|
||||
conf.write_text('{"dsn": "postgresql://cfu:cf-pw-5@127.0.0.1:18894/cfdb",'
|
||||
' "auth": "auto"}')
|
||||
self.svc("dev", "db", "--clear", "--no-restart")
|
||||
self.svc("dev", "config", conf, "--no-restart")
|
||||
self.assertEqual(self.kv("dev", "conffile"), str(conf))
|
||||
self.svc("dev", "stop")
|
||||
self.start_ok("dev")
|
||||
self.assertNotIn("AGENT_SYSTEM_DSN=", self.serve_record())
|
||||
self.assertEqual(self.kv("dev", "db"), "pg")
|
||||
self.assertEqual(self.kv("dev", "conffile"), str(conf))
|
||||
self.svc("dev", "stop")
|
||||
self.start_ok("dev")
|
||||
self.assertNotIn("AGENT_SYSTEM_DSN=", self.serve_record())
|
||||
self.assertEqual(self.kv("dev", "conffile"), str(conf))
|
||||
|
||||
# ---- 5. 拒启场景:不降级 SQLite、不启动进程、登记可恢复、停机前发现 ----
|
||||
|
||||
def test_refusals_keep_registration_without_sqlite_fallback(self):
|
||||
d = self.runtime("a1")
|
||||
self.seed_agent("a1", 18796,
|
||||
"--addr 127.0.0.1:18796 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("dbref", "pgmissing/agent"), ("db", "pg")])
|
||||
self.clear_serve_record()
|
||||
result = self.svc("a1", "restart", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝", result.stdout)
|
||||
self.assertEqual(self.serve_record(), "") # 未启动任何进程
|
||||
self.assertEqual(self.kv("a1", "dbref"), "pgmissing/agent") # 可恢复配置保留
|
||||
|
||||
# 实例登记不全(缺 password)→ 拒启。
|
||||
self.register_pg("pginc", 18895, complete=False)
|
||||
d = self.runtime("a2")
|
||||
self.seed_agent("a2", 18797,
|
||||
"--addr 127.0.0.1:18797 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("dbref", "pginc/agent"), ("db", "pg")])
|
||||
result = self.svc("a2", "restart", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝", result.stdout)
|
||||
|
||||
# dbref 与 dsn= 指向不同库 → 配置冲突拒启。
|
||||
self.register_pg("pg1", 18896, password="clash-pw")
|
||||
d = self.runtime("a3")
|
||||
self.seed_agent("a3", 18798,
|
||||
"--addr 127.0.0.1:18798 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("dbref", "pg1/agent"), ("db", "pg"),
|
||||
("dsn", "postgresql://u:p@127.0.0.1:18999/otherdb")])
|
||||
result = self.svc("a3", "restart", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝", result.stdout)
|
||||
|
||||
# db=pg 但无可恢复 DSN → 拒启。
|
||||
d = self.runtime("a4")
|
||||
self.seed_agent("a4", 18799,
|
||||
"--addr 127.0.0.1:18799 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("db", "pg")])
|
||||
result = self.svc("a4", "restart", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝", result.stdout)
|
||||
self.assertEqual(self.kv("a4", "db"), "pg")
|
||||
|
||||
def test_restart_refusal_happens_before_stopping_running_service(self):
|
||||
self.register_pg("pg1", 18897, password="run-pw")
|
||||
d = self.runtime("a5")
|
||||
self.seed_agent("a5", 18800,
|
||||
"--addr 127.0.0.1:18800 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("dbref", "pg1/agent"), ("db", "pg")])
|
||||
self.start_ok("a5")
|
||||
self.assertTrue(self.pid_alive("a5"))
|
||||
# 破坏实例登记(去掉 password)后 restart:预检拒绝发生在停止旧进程之前。
|
||||
self.register_pg("pg1", 18897, password="run-pw", complete=False)
|
||||
self.clear_serve_record()
|
||||
result = self.svc("a5", "restart", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝", result.stdout)
|
||||
self.assertTrue(self.pid_alive("a5")) # 服务未被停止
|
||||
self.assertEqual(self.serve_record(), "") # 也没有重启新进程
|
||||
self.assertEqual(self.kv("a5", "dbref"), "pg1/agent")
|
||||
|
||||
# ---- 6. 认证预检与后端口径一致(环境变量 DSN 覆盖 auto)----
|
||||
|
||||
def test_auth_preflight_aligns_with_effective_env_dsn(self):
|
||||
# 非回环 + auto + 无任何 DSN → 预检快速拒绝,不启动进程。
|
||||
d = self.runtime("nl1")
|
||||
self.seed_agent("nl1", 18801,
|
||||
"--addr 10.255.255.1:18801 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"))
|
||||
self.clear_serve_record()
|
||||
result = self.svc("nl1", "start", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝启动", result.stdout)
|
||||
self.assertEqual(self.serve_record(), "")
|
||||
|
||||
# 环境变量提供 DSN → auto 判为已开认证:预检放行(桩绑定非回环地址失败即退,
|
||||
# 区别于预检拒绝——证明进入启动路径且 DSN 经环境交付)。
|
||||
d = self.runtime("nl2")
|
||||
self.seed_agent("nl2", 18802,
|
||||
"--addr 10.255.255.1:18802 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"), addr="10.255.255.1:18802")
|
||||
env_dsn = "postgresql://envu:env-pw@127.0.0.1:18898/agent"
|
||||
env2 = dict(self.env, SVC_NL2_DSN=env_dsn)
|
||||
self.clear_serve_record()
|
||||
result = self.svc("nl2", "start", check=False, env=env2)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("拒绝启动", result.stdout)
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % env_dsn, self.serve_record())
|
||||
|
||||
# 显式 auth=off 覆盖环境 DSN → 仍拒绝。
|
||||
d = self.runtime("nl3")
|
||||
self.seed_agent("nl3", 18803,
|
||||
"--addr 10.255.255.1:18803 --data %s --plugins %s --auth off"
|
||||
% (d / "data", d / "plugins"), addr="10.255.255.1:18803")
|
||||
self.clear_serve_record()
|
||||
result = self.svc("nl3", "start", check=False,
|
||||
env=dict(self.env, SVC_NL3_DSN=env_dsn))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝启动", result.stdout)
|
||||
self.assertEqual(self.serve_record(), "")
|
||||
|
||||
# ---- 7. 显式解绑:环境残留不再悄悄重新选库 ----
|
||||
|
||||
def test_explicit_unbind_blocks_residual_env_dsn(self):
|
||||
self.register_pg("pg1", 18899, password="unbind-pw")
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18804,
|
||||
"--addr 127.0.0.1:18804 --data %s --plugins %s"
|
||||
% (d / "data", d / "plugins"))
|
||||
self.svc("dev", "db", "pg1", "--no-restart")
|
||||
self.assertEqual(self.kv("dev", "dbref"), "pg1/agent")
|
||||
self.assertEqual(self.kv("dev", "db"), "pg")
|
||||
self.assertIsNone(self.kv("dev", "dsn"))
|
||||
self.assertNotIn("--dsn", self.kv("dev", "exec") or "")
|
||||
|
||||
residual = "postgresql://other:other-pw@127.0.0.1:18901/other"
|
||||
env_residual = dict(self.env, SVC_DEV_DSN=residual)
|
||||
# 绑定状态下环境残留指向别库 → 冲突拒绝,不静默选其一。
|
||||
result = self.svc("dev", "restart", check=False, env=env_residual)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝", result.stdout)
|
||||
|
||||
# 显式解绑后:同一环境残留被忽略,按内嵌档启动。
|
||||
self.svc("dev", "db", "--clear", "--no-restart")
|
||||
self.assertIsNone(self.kv("dev", "dbref"))
|
||||
self.assertIsNone(self.kv("dev", "dsn"))
|
||||
self.assertEqual(self.kv("dev", "db"), "sqlite")
|
||||
self.clear_serve_record()
|
||||
self.svc("dev", "restart", env=env_residual)
|
||||
self.assertNotIn("AGENT_SYSTEM_DSN=", self.serve_record())
|
||||
self.assertEqual(self.kv("dev", "db"), "sqlite")
|
||||
|
||||
# ---- 8. mock 冲突与旧产物:行为明确,不隐式降级 ----
|
||||
|
||||
def test_mock_conflict_and_legacy_binary_refuse(self):
|
||||
self.register_pg("pg1", 18902, password="mock-pw")
|
||||
d = self.runtime("mk1")
|
||||
self.seed_agent("mk1", 18805,
|
||||
"--addr 127.0.0.1:18805 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("dbref", "pg1/agent"), ("db", "pg")])
|
||||
result = self.svc("mk1", "deploy", "--from", "local", "--mock", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("mock", result.stdout)
|
||||
self.assertIn("拒绝", result.stdout)
|
||||
self.assertEqual(self.kv("mk1", "dbref"), "pg1/agent")
|
||||
|
||||
# 旧产物(不消费环境变量 DSN)绑定服务 restart → 拒绝,提示可操作出路。
|
||||
d = self.runtime("lb1")
|
||||
self.seed_agent("lb1", 18806,
|
||||
"--addr 127.0.0.1:18806 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("dbref", "pg1/agent"), ("db", "pg")])
|
||||
env_legacy = dict(self.env, SVC_STUB_NO_ENV_DSN="1")
|
||||
result = self.svc("lb1", "restart", check=False, env=env_legacy)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("环境变量 DSN", result.stdout)
|
||||
self.assertEqual(self.kv("lb1", "dbref"), "pg1/agent")
|
||||
|
||||
# ---- 9. 多实例顺序更新不串用 DSN;凭据不进日志与历史 ----
|
||||
|
||||
def test_sequential_restarts_no_cross_dsn_and_no_secret_leak(self):
|
||||
self.register_pg("pg1", 18903, password="iso-pw-one")
|
||||
self.register_pg("pg2", 18904, password="iso-pw-two")
|
||||
d1, d2 = self.runtime("dev"), self.runtime("prod")
|
||||
self.seed_agent("dev", 18807,
|
||||
"--addr 127.0.0.1:18807 --data %s --plugins %s --auth auto"
|
||||
% (d1 / "data", d1 / "plugins"),
|
||||
extra_rows=[("dbref", "pg1/agent"), ("db", "pg")])
|
||||
self.seed_agent("prod", 18808,
|
||||
"--addr 127.0.0.1:18808 --data %s --plugins %s --auth auto"
|
||||
% (d2 / "data", d2 / "plugins"),
|
||||
extra_rows=[("dbref", "pg2/agent"), ("db", "pg")])
|
||||
dsn1 = "postgresql://agent:iso-pw-one@127.0.0.1:18903/agent"
|
||||
dsn2 = "postgresql://agent:iso-pw-two@127.0.0.1:18904/agent"
|
||||
for name, dsn in (("dev", dsn1), ("prod", dsn2)):
|
||||
self.clear_serve_record()
|
||||
self.svc(name, "restart")
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
|
||||
self.assertEqual(self.kv(name, "dbref"),
|
||||
"pg1/agent" if name == "dev" else "pg2/agent")
|
||||
# 交叉重启仍正确(验证解析态不跨实例串用)。
|
||||
self.clear_serve_record()
|
||||
self.svc("prod", "restart")
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn2, self.serve_record())
|
||||
|
||||
# 凭据不进 svc.log、history.jsonl 与 exec=(命令行断言见各启动用例的 serve 记录)。
|
||||
for name in ("dev", "prod"):
|
||||
log = self.runtime(name) / "svc.log"
|
||||
hist = self.runtime(name) / "history.jsonl"
|
||||
blob = (log.read_text() if log.exists() else "") \
|
||||
+ (hist.read_text() if hist.exists() else "") \
|
||||
+ (self.kv(name, "exec") or "")
|
||||
self.assertNotIn("iso-pw-one", blob)
|
||||
self.assertNotIn("iso-pw-two", blob)
|
||||
# 最后一次 serve 的命令行参数行同样无秘密。
|
||||
args_line = self.serve_record().splitlines()[0] if self.serve_record() else ""
|
||||
self.assertNotIn("iso-pw-two", args_line)
|
||||
self.assertNotIn("--dsn", args_line)
|
||||
# svc.env 权限 600。
|
||||
mode = (self.runtime("dev") / "svc.env").stat().st_mode & 0o777
|
||||
self.assertEqual(mode, 0o600)
|
||||
|
||||
# ---- 10. update(dev 源)保留绑定 ----
|
||||
|
||||
def make_src_repo(self):
|
||||
remote = self.root / "srcremote.git"
|
||||
work = self.root / "srcwork"
|
||||
src = self.root / "srccache"
|
||||
run = lambda cwd, *args: subprocess.run(
|
||||
["git", *args], cwd=cwd, env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60,
|
||||
check=True)
|
||||
run(self.root, "init", "--bare", "--initial-branch=main", str(remote))
|
||||
run(self.root, "init", "--initial-branch=main", str(work))
|
||||
makefile = "build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n" % self.stub
|
||||
(work / "Makefile").write_text(makefile)
|
||||
run(work, "add", ".")
|
||||
run(work, "commit", "-m", "v1")
|
||||
(work / "marker").write_text("v2\n")
|
||||
run(work, "add", ".")
|
||||
run(work, "commit", "-m", "v2")
|
||||
run(work, "remote", "add", "origin", str(remote))
|
||||
run(work, "push", "-u", "origin", "main")
|
||||
run(self.root, "clone", str(remote), str(src))
|
||||
shas = run(src, "log", "--format=%h", "origin/main").stdout.split()
|
||||
return shas[1], shas[0] # (旧, 新)
|
||||
|
||||
def test_update_preserves_binding(self):
|
||||
old_sha, new_sha = self.make_src_repo()
|
||||
self.register_pg("pg1", 18905, password="upd-pw")
|
||||
dsn = "postgresql://agent:upd-pw@127.0.0.1:18905/agent"
|
||||
d = self.runtime("dev")
|
||||
self.seed_agent("dev", 18809,
|
||||
"--addr 127.0.0.1:18809 --data %s --plugins %s --auth auto"
|
||||
% (d / "data", d / "plugins"),
|
||||
extra_rows=[("dbref", "pg1/agent"), ("db", "pg"),
|
||||
("source", "dev@%s" % old_sha)])
|
||||
self.clear_serve_record()
|
||||
self.svc("update", "dev")
|
||||
self.assertEqual(self.kv("dev", "source"), "dev@%s" % new_sha)
|
||||
self.assertEqual(self.kv("dev", "dbref"), "pg1/agent")
|
||||
self.assertEqual(self.kv("dev", "db"), "pg")
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
|
||||
# 更新后裸 restart 仍保绑定。
|
||||
self.clear_serve_record()
|
||||
self.svc("dev", "restart")
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+110
-93
@@ -1,17 +1,23 @@
|
||||
"""svc.sh 启动预检与复用 DSN 解析的治理回归。
|
||||
"""svc.sh 启动预检与数据库配置统一解析的治理回归。
|
||||
|
||||
背景(2026-10-10 dev 实测,记忆 svc-g17-precheck-dsn-bug):G1-7 把 DSN 从
|
||||
命令行迁到 AGENT_SYSTEM_DSN 环境变量后——
|
||||
背景(2026-10-10/10-11 dev 事故,PR #317 最小修复 → feature/svc-db-config-recover
|
||||
方案超集):G1-7 把 DSN 从命令行迁到 AGENT_SYSTEM_DSN 环境变量后——
|
||||
|
||||
1. 预检 agent_args_authed 只看参数串,「非回环 + auth auto + env DSN」被误判
|
||||
认证关而拒启;二进制 AuthEnabled(modules/backend/internal/config/config.go:
|
||||
显式 on,或非显式 off 且 DSN 非空——env 为合法 DSN 来源)会放行。
|
||||
1. 预检只看参数串,「非回环 + auth auto + env DSN」被误判认证关而拒启;二进制
|
||||
AuthEnabled(config.go:显式 on,或非显式 off 且 DSN 非空——env 为合法 DSN
|
||||
来源)会放行。现由 db_auth_enabled(依赖 resolve_db_config 产出的 DB_AUTH/
|
||||
DB_DSN)统一判定,覆盖 on/off/auto 与命令行/环境/配置文件来源。
|
||||
2. 复用启动路径(无显式参数、复用 exec=)不再有 DSN:dbref 兜底只在重建分支,
|
||||
纯 start/restart 会静默回落 SQLite。
|
||||
3. DSN_ENV 为全局且每次启动不重置:update --all 同进程连续启动会跨服务泄漏 DSN。
|
||||
纯 start/restart 会静默回落 SQLite。现 start/restart/deploy/update/db 共用
|
||||
resolve_db_config,且存量 exec= 的 --dsn 迁移为 dbref=/dsn= 专键(幂等)。
|
||||
3. DSN 跨服务串用(同进程连续启动):现 env_config 按实例重置解析态、DSN 经
|
||||
局部 guard_env 交付,无跨服务全局通道。
|
||||
|
||||
行为变化(经用户 2026-10-11 方案批准,取代 PR #317 的对应语义):dbref 无法重建
|
||||
DSN 时从「告警后按无绑定启动」改为**拒绝启动**;本测试按新语义断言。
|
||||
|
||||
测试通过 source scripts/svc.sh 调用纯函数/fixture 函数(文件尾有 source 守卫),
|
||||
不启动真实服务、不触碰真实 runtime/。
|
||||
不启动真实服务、不触碰真实 runtime/。端到端生命周期回归见 tests/test_svc.py。
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -47,58 +53,52 @@ def _bash(script: str, *args: str) -> str:
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _authed(args: str, dsn_env: str = "") -> bool:
|
||||
def _authed(auth_mode: str, dsn: str = "", exec_args: str = "") -> bool:
|
||||
"""db_auth_enabled 判定:auth 值 + 有效 DSN(模拟 resolve_db_config 产出)。"""
|
||||
out = _bash(
|
||||
'source "$1"; shift; if agent_args_authed "$1" "${2:-}"; then echo authed; else echo denied; fi',
|
||||
args,
|
||||
dsn_env,
|
||||
'source "$1"; shift; DB_AUTH="$1"; DB_DSN="$2"; '
|
||||
'if db_auth_enabled; then echo authed; else echo denied; fi',
|
||||
auth_mode or "auto",
|
||||
dsn,
|
||||
)
|
||||
return out.strip() == "authed"
|
||||
|
||||
|
||||
def _rejected(exec_args: str, default_addr: str = "0.0.0.0:8788",
|
||||
insecure: str = "", dsn_env: str = "") -> bool:
|
||||
insecure: str = "", auth_mode: str = "auto", dsn: str = "") -> bool:
|
||||
"""agent_listen_rejected 判定(认证部分经 DB_AUTH/DB_DSN 全局)。"""
|
||||
out = _bash(
|
||||
"source \"$1\"; shift; if agent_listen_rejected \"$1\" \"$2\" \"$3\" \"$4\"; "
|
||||
"then echo rejected; else echo allowed; fi",
|
||||
"source \"$1\"; shift; DB_AUTH=\"$4\"; DB_DSN=\"$5\"; "
|
||||
"if agent_listen_rejected \"$1\" \"$2\" \"$3\"; then echo rejected; else echo allowed; fi",
|
||||
exec_args,
|
||||
default_addr,
|
||||
insecure,
|
||||
dsn_env,
|
||||
auth_mode or "auto",
|
||||
dsn,
|
||||
)
|
||||
return out.strip() == "rejected"
|
||||
|
||||
|
||||
class AgentArgsAuthedMatrix(unittest.TestCase):
|
||||
"""认证判定与二进制 AuthEnabled 口径一致(config.go:301,默认 auth=auto)。"""
|
||||
class DbAuthEnabledMatrix(unittest.TestCase):
|
||||
"""认证判定与二进制 AuthEnabled 口径一致(config.go,默认 auth=auto)。"""
|
||||
|
||||
def test_auth_on_explicit(self):
|
||||
self.assertTrue(_authed("--addr 1.2.3.4:80 --auth on"))
|
||||
self.assertTrue(_authed("--addr 1.2.3.4:80 --auth=on"))
|
||||
self.assertTrue(_authed("on"))
|
||||
|
||||
def test_auth_off_beats_env_dsn(self):
|
||||
def test_auth_off_beats_dsn(self):
|
||||
# 显式 off 优先:与二进制 AuthMode=="off" → AuthEnabled=false 一致。
|
||||
self.assertFalse(_authed("--auth off", PG_DSN))
|
||||
self.assertFalse(_authed("--auth=off", PG_DSN))
|
||||
self.assertFalse(_authed("off", PG_DSN))
|
||||
|
||||
def test_auto_with_env_dsn_is_authed(self):
|
||||
# 2026-10-10 误拒主场景:dbref 重建/G1-7 迁移后 DSN 只在环境变量里。
|
||||
self.assertTrue(_authed("--auth auto", PG_DSN))
|
||||
|
||||
def test_default_auto_with_env_dsn_is_authed(self):
|
||||
# exec= 里连 --auth 都没有(config 默认 auto):env DSN 即认证开。
|
||||
self.assertTrue(_authed(NONLOOP_EXEC, PG_DSN))
|
||||
def test_auto_with_effective_dsn_is_authed(self):
|
||||
# 2026-10-10 误拒主场景:dbref 重建/环境交付后 DSN 只在 AGENT_SYSTEM_DSN。
|
||||
self.assertTrue(_authed("auto", PG_DSN))
|
||||
|
||||
def test_auto_without_dsn_is_denied(self):
|
||||
self.assertFalse(_authed("--auth auto"))
|
||||
self.assertFalse(_authed(NONLOOP_EXEC))
|
||||
|
||||
def test_legacy_cli_dsn_still_authed(self):
|
||||
# 向后兼容:历史 exec= 的命令行 --dsn 仍是合法 DSN 来源。
|
||||
self.assertTrue(_authed(f"--addr 1.2.3.4:80 --dsn {PG_DSN}"))
|
||||
self.assertFalse(_authed("auto"))
|
||||
self.assertFalse(_authed("", ""))
|
||||
|
||||
def test_no_auth_no_dsn_denied(self):
|
||||
self.assertFalse(_authed("--addr 127.0.0.1:80 --mock"))
|
||||
self.assertFalse(_authed("auto", "", "--mock"))
|
||||
|
||||
|
||||
class AgentListenRejectedPrecheck(unittest.TestCase):
|
||||
@@ -106,13 +106,14 @@ class AgentListenRejectedPrecheck(unittest.TestCase):
|
||||
|
||||
def test_incident_combo_not_rejected(self):
|
||||
# 原事故组合:非回环 + auto + DSN 仅在环境变量 → 必须放行(曾误拒)。
|
||||
self.assertFalse(_rejected(NONLOOP_EXEC, dsn_env=PG_DSN))
|
||||
self.assertFalse(_rejected(NONLOOP_EXEC, dsn=PG_DSN))
|
||||
|
||||
def test_nonloop_no_dsn_rejected(self):
|
||||
self.assertTrue(_rejected(NONLOOP_EXEC))
|
||||
|
||||
def test_nonloop_auth_off_with_env_dsn_rejected(self):
|
||||
self.assertTrue(_rejected("--addr 0.0.0.0:8788 --auth off", dsn_env=PG_DSN))
|
||||
def test_nonloop_auth_off_with_dsn_rejected(self):
|
||||
self.assertTrue(_rejected("--addr 0.0.0.0:8788 --auth off",
|
||||
dsn=PG_DSN, auth_mode="off"))
|
||||
|
||||
def test_loopback_not_rejected(self):
|
||||
self.assertFalse(_rejected("--addr 127.0.0.1:8788"))
|
||||
@@ -126,8 +127,8 @@ class AgentListenRejectedPrecheck(unittest.TestCase):
|
||||
self.assertFalse(_rejected("--data /tmp/x", default_addr="127.0.0.1:8788"))
|
||||
|
||||
|
||||
class AgentReuseExec(unittest.TestCase):
|
||||
"""复用启动的 DSN 解析:G1-7 迁移 + dbref 重建(防静默回落 SQLite)。"""
|
||||
class ResolveDbConfigFixtures(unittest.TestCase):
|
||||
"""统一解析:复用/迁移语义(防静默回落 SQLite;坏绑定拒绝)。"""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
@@ -143,71 +144,87 @@ class AgentReuseExec(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _reuse(self, exec_line: str) -> tuple:
|
||||
# 直接调用(非 $()):agent_reuse_exec 经 nameref 回写 exec 参数、
|
||||
# 全局 DSN_ENV 传 DSN——命令替换会把 DSN_ENV 赋值留在子壳。
|
||||
def _resolve(self, exec_line: str, dbref: str = "", extra: str = "",
|
||||
rewrite: bool = True) -> tuple:
|
||||
"""写 fixture svc.env 后 resolve_db_config;回 (rc, DB_MODE/DSN/REF/SAVE)。
|
||||
rewrite=False 时不覆写登记(幂等用例:在同一登记上二次解析)。"""
|
||||
if rewrite:
|
||||
content = f"kind=agent\nenv=dev\nexec={exec_line}\n"
|
||||
if dbref:
|
||||
content += f"dbref={dbref}\n"
|
||||
if extra:
|
||||
content += extra + "\n"
|
||||
(self.dev_dir / "svc.env").write_text(content)
|
||||
out = _bash(
|
||||
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=dev; ENV_DIR="$2"; DSN_ENV=""; '
|
||||
'agent_reuse_exec "$3" exec_args; '
|
||||
'printf "EXEC=%s\\n" "$exec_args"; printf "DSN=%s\\n" "${DSN_ENV:-}"',
|
||||
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=dev; ENV_DIR="$2"; '
|
||||
'if resolve_db_config; then rc=ok; else rc=fail; fi; '
|
||||
'printf "RC=%s\\nMODE=%s\\nDSN=%s\\nREF=%s\\nSAVE=%s\\n" '
|
||||
'"$rc" "$DB_MODE" "$DB_DSN" "$DB_REF" "$DB_DSN_SAVE"',
|
||||
str(self.rt),
|
||||
str(self.dev_dir),
|
||||
exec_line,
|
||||
)
|
||||
lines = dict(part.split("=", 1) for part in out.strip().splitlines())
|
||||
return lines["EXEC"], lines["DSN"]
|
||||
|
||||
def _write_dev_env(self, exec_line: str, dbref: str = "") -> None:
|
||||
content = f"kind=agent\nenv=dev\nexec={exec_line}\n"
|
||||
if dbref:
|
||||
content += f"dbref={dbref}\n"
|
||||
(self.dev_dir / "svc.env").write_text(content)
|
||||
return dict(part.split("=", 1) for part in out.strip().splitlines())
|
||||
|
||||
def test_post_g17_exec_restores_dsn_from_dbref(self):
|
||||
# G1-7 后 exec= 不含 DSN:复用启动必须从 dbref= 重建(否则回落 SQLite)。
|
||||
self._write_dev_env("--addr 0.0.0.0:8788 --auth auto", dbref="pg1/agentdb")
|
||||
exec_args, dsn = self._reuse("--addr 0.0.0.0:8788 --auth auto")
|
||||
self.assertEqual(dsn, PG_DSN)
|
||||
self.assertNotIn("--dsn", exec_args)
|
||||
# G1-7 后 exec= 不含 DSN:解析必须从 dbref= 重建(否则回落 SQLite)。
|
||||
got = self._resolve("--addr 0.0.0.0:8788 --auth auto", dbref="pg1/agentdb")
|
||||
self.assertEqual(got["RC"], "ok")
|
||||
self.assertEqual(got["MODE"], "pg")
|
||||
self.assertEqual(got["DSN"], PG_DSN)
|
||||
self.assertEqual(got["REF"], "pg1/agentdb")
|
||||
self.assertEqual(got["SAVE"], "")
|
||||
|
||||
def test_legacy_exec_dsn_migrates_to_env(self):
|
||||
# 存量 exec= 的明文 --dsn:剥到环境变量,命令行不再可见。
|
||||
def test_legacy_exec_dsn_migrates_to_recoverable_keys(self):
|
||||
# 存量 exec= 的明文 --dsn:迁移为可恢复配置(命中实例 → dbref=),
|
||||
# exec= 不再携带 DSN。
|
||||
legacy = f"--addr 0.0.0.0:8788 --dsn {PG_DSN}"
|
||||
self._write_dev_env(legacy, dbref="pg1/agentdb")
|
||||
exec_args, dsn = self._reuse(legacy)
|
||||
self.assertEqual(dsn, PG_DSN)
|
||||
self.assertNotIn("--dsn", exec_args)
|
||||
self.assertEqual(exec_args, "--addr 0.0.0.0:8788")
|
||||
got = self._resolve(legacy)
|
||||
self.assertEqual(got["RC"], "ok")
|
||||
self.assertEqual(got["DSN"], PG_DSN)
|
||||
self.assertEqual(got["REF"], "pg1/agentdb")
|
||||
envfile = (self.dev_dir / "svc.env").read_text()
|
||||
self.assertNotIn("--dsn", envfile)
|
||||
self.assertIn("dbref=pg1/agentdb", envfile)
|
||||
|
||||
def test_migration_dsn_wins_over_dbref(self):
|
||||
# 命令行 --dsn 与 dbref 并存:迁移值优先,dbref 不覆盖。
|
||||
other = "postgresql://other:pw@10.9.9.9:5432/otherdb"
|
||||
legacy = f"--addr 0.0.0.0:8788 --dsn {other}"
|
||||
self._write_dev_env(legacy, dbref="pg1/agentdb")
|
||||
_, dsn = self._reuse(legacy)
|
||||
self.assertEqual(dsn, other)
|
||||
def test_legacy_exec_dsn_without_instance_goes_to_dsn_key(self):
|
||||
# 未命中本机实例:迁移到受保护 dsn= 字段。
|
||||
external = "postgresql://u:pw@10.9.9.9:5432/otherdb"
|
||||
got = self._resolve(f"--addr 127.0.0.1:8788 --dsn {external}")
|
||||
self.assertEqual(got["RC"], "ok")
|
||||
self.assertEqual(got["DSN"], external)
|
||||
self.assertEqual(got["SAVE"], external)
|
||||
self.assertIn(f"dsn={external}", (self.dev_dir / "svc.env").read_text())
|
||||
|
||||
def test_no_dsn_no_dbref_stays_empty(self):
|
||||
self._write_dev_env("--addr 127.0.0.1:8788")
|
||||
exec_args, dsn = self._reuse("--addr 127.0.0.1:8788")
|
||||
self.assertEqual(dsn, "")
|
||||
self.assertEqual(exec_args, "--addr 127.0.0.1:8788")
|
||||
def test_migration_is_idempotent(self):
|
||||
# 第二次解析:exec 已无 --dsn,登记保持稳定。
|
||||
self._resolve(f"--addr 0.0.0.0:8788 --dsn {PG_DSN}")
|
||||
first = (self.dev_dir / "svc.env").read_text()
|
||||
got = self._resolve("", rewrite=False)
|
||||
self.assertEqual(got["RC"], "ok")
|
||||
self.assertEqual(got["DSN"], PG_DSN)
|
||||
self.assertEqual((self.dev_dir / "svc.env").read_text(), first)
|
||||
|
||||
def test_broken_dbref_warns_and_starts_unbound(self):
|
||||
# 实例登记不全:不伪造 DSN,保持为空(启动侧另有告警)。
|
||||
self._write_dev_env("--addr 127.0.0.1:8788", dbref="missing/agentdb")
|
||||
_, dsn = self._reuse("--addr 127.0.0.1:8788")
|
||||
self.assertEqual(dsn, "")
|
||||
def test_no_dsn_no_dbref_is_sqlite(self):
|
||||
got = self._resolve("--addr 127.0.0.1:8788")
|
||||
self.assertEqual(got["RC"], "ok")
|
||||
self.assertEqual(got["MODE"], "sqlite")
|
||||
self.assertEqual(got["DSN"], "")
|
||||
|
||||
def test_broken_dbref_refuses(self):
|
||||
# 行为变化(方案批准):实例登记不全 → 拒绝启动,不静默回落 SQLite。
|
||||
got = self._resolve("--addr 127.0.0.1:8788", dbref="missing/agentdb")
|
||||
self.assertEqual(got["RC"], "fail")
|
||||
self.assertEqual(got["DSN"], "")
|
||||
|
||||
|
||||
class DsnEnvScopeIsolation(unittest.TestCase):
|
||||
"""DSN_ENV 每次启动隔离(agent_do_start 内 local 化,防 update --all 串库)。"""
|
||||
class ResolverStateIsolation(unittest.TestCase):
|
||||
"""解析态按实例隔离(env_config 重置,防 update --all 串库)。"""
|
||||
|
||||
def test_agent_do_start_localizes_dsn_env(self):
|
||||
# 棘轮:agent_do_start 必须以 local DSN_ENV="" 起始,删除即回归
|
||||
# 「同进程连续启动跨服务泄漏 AGENT_SYSTEM_DSN」。
|
||||
def test_env_config_resets_resolver_state(self):
|
||||
# 棘轮:env_config 必须重置 SVC_DB_*(删除即回归「同进程连续启动跨服务
|
||||
# 泄漏数据库配置」——DSN_ENV 全局时代的事故形态)。
|
||||
out = _bash(
|
||||
'source "$1"; declare -f agent_do_start | grep -c \'local exec_args DSN_ENV=""\''
|
||||
'source "$1"; declare -f env_config | grep -c \'SVC_DB_MODE=""\''
|
||||
)
|
||||
self.assertEqual(out.strip(), "1")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user