Recover svc.sh database config across restarts and refuse on invalid binding #342

Merged
weichen merged 14 commits from feature/svc-db-config-recover into main 2026-10-11 10:53:44 +00:00
5 changed files with 1919 additions and 254 deletions
File diff suppressed because one or more lines are too long
+44 -15
View File
@@ -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
View File
@@ -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
+567
View File
@@ -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
View File
@@ -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")