Recover svc.sh database config across restarts and refuse on invalid binding #342
File diff suppressed because one or more lines are too long
+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 即可 |
|
||||
|
||||
+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