#!/usr/bin/env bash # svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(postgres) 三类服务的 # 部署、生命周期与数据库运维。每个服务自包含于 runtime//,登记与配置持久化 # (svc.env + history.jsonl),全程不触碰主检出 git 状态与其他已启动服务。 # # ════════════════════ 命令一览 ════════════════════ # scripts/svc.sh [action] [参数...] 服务级:deploy(默认)|start|stop|restart|status|logs [N] # |db [pg名|--clear](agent 数据库绑定/切换) # |config [file|--clear](agent 配置文件更新/生效) # |backup [--out D] [--keep N](agent 数据目录快照) # |backups [--prune](快照列表 / 按保留数清理) # scripts/svc.sh list [--tag T] 服务总览(status 等价) # scripts/svc.sh info [name] [--tag T] [--reveal] 综合报告(只读):服务/数据库/配置快照/最近事件 # scripts/svc.sh set [--port N --apply] [--tag T] 变更登记(换端口/标签) # scripts/svc.sh rm [--purge] 注销服务(--purge 连数据目录删除) # scripts/svc.sh db create [参数] 创建 PostgreSQL 实例(initdb) # scripts/svc.sh db dsn [--reveal] [--db X] 连接串(默认掩码密码) # scripts/svc.sh db backup [--db X] [--out F] 备份(pg_dump -Fc + 自检) # scripts/svc.sh db backups 备份列表 # scripts/svc.sh db restore --file F [--db X] [--force] 恢复(覆盖非空库须 --force) # scripts/svc.sh db clone [--port P] [--password PW] [--online] 克隆实例 # scripts/svc.sh db copy [--db X] [--force] 跨实例搬库(双方须运行) # scripts/svc.sh db ls 实例内数据库列表 # scripts/svc.sh db set [...] 等价于 set # scripts/svc.sh commits [dev|release|rdm] [N] 可部署提交列表(默认 dev,N=20) # scripts/svc.sh history [name] [N] 事件历史(默认全部,最近 20 条) # scripts/svc.sh update [name] [--check] [--all] 检测远端最新并更新过时服务(与当前工作区状态无关) # scripts/svc.sh recover [name] [--quiet] 自恢复:把 autostart=1 但已死的服务重新拉起 # (崩溃/OOM 被杀/重启后由 watchdog 定时调用) # scripts/svc.sh watchdog install|remove|status 自恢复看护:systemd 用户级 timer(每分钟 recover) # scripts/svc.sh init 显式建立/查看实例身份(manifest+数据 marker;U1) # scripts/svc.sh help [主题] 分主题详细帮助 # # ════════════════════ 部署来源(agent/static 的 deploy)════════════════════ # (缺省)dev@远端最新 不指定 --from 时默认部署源仓 origin/main 最新提交—— # 更新/部署不依赖当前工作区状态(脏树、任意分支均可) # --from dev[@] 同缺省;@ 显式指定提交。物化与构建在独立部署源仓的 # 隔离 worktree(runtime/.worktrees/)进行,不触碰当前工作区 # --from local 构建当前工作区(唯一写主检出构建产物的路径,均 gitignore) # --from release[@<版本|sha>] Releases 仓制品(sha256 对 versions//manifest.json 校验) # --from rdm[@] 下游组合仓 Agent-System-RDM(独立 RDM 源仓物化 pins/ 后 # make build 出 agent-system-rdm 组合二进制;web/ 工作台静态 # 资产随环境落 htdocs/,供伴生 static 服务 --dir 使用) # --pick [dev|release|rdm] 交互式编号选提交;--dry-run 只打印部署计划 # # ════════════════════ 常用参数(服务级)════════════════════ # --config FILE / --env FILE dotenv 配置文件(KEY=VALUE;--env 为兼容别名) # --addr host:port / --host H / --port P 监听地址(pg=TCP,static=HTTP) # --tag T 标签(可重复;登记于 svc.env,list --tag 过滤) # --kind agent|static deploy 时指定类型(postgres 用 db create 建) # --dir PATH static 直接服务既有目录(不构建) # --plugins/--dsn/--auth/--mock/--data agent 运行参数 # db [--db X] [--no-restart] agent 绑定/切换数据库(实例运行中缺库自动建; # --clear 解绑回内嵌 SQLite;无参查看当前绑定与可用实例) # config [--no-restart] agent 更换使用的配置文件(schema 校验通过才切换, # 运行中自动重启即时生效;--clear 移除;无参查看当前配置与生效值) # --model-base/--model-key/--model-name 与 --light-*/--tavily-keys 启动后播种设置 # (认证开启时自动铸临时密钥完成写入,--seed-owner 指定目标账号) # -- <剩余参数> 原样透传给 agent-system serve(escape hatch) # db create:--user U(默认 agent)--password P(缺省自动生成)--db NAME --auth-mode scram-sha-256|md5 # db backup:--db X --out F db restore:--file F --db X --force # db clone:--port P --host H --password PW --tag T --online # db copy:--db X --force set:--port N --host H --apply --tag T # backup:--out DIR --keep N backups:--prune --keep N(agent 数据目录快照) # # ════════════════════ 配置优先级 ════════════════════ # 命令行 > --config/--env 文件 > svc.env 持久化(exec=/addr=/dbref=/dsn=/db=) # > SVC__* 环境变量(.svc.env 自动加载,同层)> 默认值。裸 start/restart 复用 # 上次启动配置;显式参数出现即覆盖。任意环境 X 的变量名:SVC__ # 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 默认端口, # 其余按名字确定性派生(8800-9099,避让已登记/监听端口),SVC__ADDR 可覆盖 # - runtime//:bin|htdocs|data|pgdata|plugins|backups + svc.pid + # svc.log|pg.log + svc.env(登记+持久化配置,600)+ history.jsonl(事件流) # + .start.lock(生命周期互斥锁,recover 与 deploy/手动操作防竞态) # - runtime-backups///:agent 数据目录快照(svc.env+history+data; # *.db 经 sqlite3 .backup 一致性导出并自检,MANIFEST 记逐文件 sha256。放 runtime/ # 之外——runtime 被整体误删时快照仍在,rm --purge 随服务一并删除) # - kind:agent(后端单二进制,内嵌前端)/ static(前端静态服务)/ postgres(数据库); # 无 kind= 的旧登记自动视为 agent;start/restart 使用环境内产物 # # ════════════════════ 示例 ════════════════════ # scripts/svc.sh db create pg1 --port 8890 --tag core # 建库 # scripts/svc.sh pg1 start && scripts/svc.sh db dsn pg1 # 起库 + 看 DSN # scripts/svc.sh prod deploy --from release@0.1.0 --tag stable # scripts/svc.sh prod deploy --from dev@ --dsn "$(scripts/svc.sh db dsn pg1 --reveal)" # scripts/svc.sh ui-r4 deploy --kind static --from dev@ --tag ui # scripts/svc.sh ui-qa deploy --kind static --dir ../some/dist # scripts/svc.sh set pg1 --port 8895 --apply # 换端口并重启生效 # scripts/svc.sh db backup pg1 && scripts/svc.sh db backups pg1 # scripts/svc.sh dev backup && scripts/svc.sh dev backups # agent 数据目录快照 + 列表 # scripts/svc.sh db clone pg1 pg2 --port 8896 # 物理克隆一套到新端口 # scripts/svc.sh dev db pg1 --db dev # 把 dev 服务绑定到 pg1/dev 库(自动重启) # scripts/svc.sh dev db pg2 --no-restart # 切换但不重启(下次 start 生效) # scripts/svc.sh dev db --clear # 解绑回内嵌 SQLite 档 # scripts/svc.sh dev config .svc/dev.json # 更换配置文件(校验+重启即时生效) # scripts/svc.sh dev config # 查看当前配置文件与生效值 # scripts/svc.sh db copy pg1 pg2 --db app # 把 pg1 的 app 库搬到 pg2 # scripts/svc.sh db restore pg2 --file --db app --force # scripts/svc.sh list --tag core && scripts/svc.sh history # scripts/svc.sh info && scripts/svc.sh info pg1 --reveal # 全景报告 / 单服务深度(含掩码值) # scripts/svc.sh watchdog install # 装看护 timer:崩溃/OOM 被杀后自动拉回 # kill -9 && sleep 65 && scripts/svc.sh list # start 成功记 autostart=1,一分钟内自动恢复 # scripts/svc.sh pg1 restart && scripts/svc.sh rm tmp1 --purge # make svc ENV=dev ARGS="--env .env.dev --port 9000" # # ════════════════════ 安全约束 ════════════════════ # - 只写 runtime//、runtime/.worktrees/、runtime-backups// 与独立部署源仓(SVC_SRC_DIR,默认 # ~/.cache/agent-system-svc/src,可随时删除重建);不改主检出 git 状态、不动他人现场; # --from local 是唯一写主检出构建产物(bin/、node_modules/、dist/,均 gitignore) # 的路径,dev@sha/release/static 构建全部在隔离 worktree(自部署源仓物化); # - 只停自己启动的进程:pidfile + /proc//cmdline 标记双校验;postgres 走 # pg_ctl -m fast 优雅停机,SIGKILL 不用于 postmaster; # - 端口被非本脚本进程占用时报错并给出占用者,不抢不杀; # - svc.env/备份文件权限 600;DSN 默认掩码密码(--reveal 才显示); # - 备份/克隆只增不删(backup --keep / backups --prune 的显式保留策略除外); # restore/copy 覆盖非空库必须显式 --force(drop 重建再恢复); # pg 备份在 runtime//backups/,agent 快照在 runtime-backups//(在 # runtime/ 之外,防整删事故连带),均随 rm --purge 一并删除; # - 物理克隆期间源实例短暂停止(一致性要求),完成后自动复启;--online 不停源。 # # 完整文档:docs/svc.md # <> set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RUNTIME="$ROOT/runtime" BIN="$ROOT/bin/agent-system" SVC_ENVFILE="" # 独立部署源仓:脚本自有的干净克隆(默认 ~/.cache/agent-system-svc/src,SVC_SRC_DIR 覆盖)。 # dev 源的解析/列举/worktree 物化全部基于它——更新与部署因此不依赖当前工作区状态 # (脏树、任意分支均可);目录可随时删除,下次使用自动重建。 SRC_DIR="${SVC_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc/src}" # 独立 RDM 源仓(下游组合仓 Agent-System-RDM 的干净克隆;SVC_RDM_SRC_DIR 覆盖路径, # SVC_RDM_ORIGIN 覆盖克隆来源)。rdm 源的解析/列举/物化全部基于它,语义与 SRC_DIR 一致。 RDM_SRC_DIR="${SVC_RDM_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc/rdm-src}" RDM_ORIGIN_DEFAULT="ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-RDM.git" # 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。 c_info() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; } c_ok() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; } # c_event:事件级输出,不受 --quiet 抑制——watchdog 静默周期里有实际拉回/失败 # 仍要在 journal 留痕(跳过类的噪声才被 --quiet 吞掉)。 c_event() { printf '\033[1;36m[svc]\033[0m %s\n' "$*" >&2; } c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*" >&2; } c_err() { printf '\033[1;31m[svc]\033[0m %s\n' "$*" >&2; } usage() { sed -n '2,/^# <>$/p' "${BASH_SOURCE[0]}" | sed '$d;s/^# \{0,1\}//' } # usage_short:一屏速览(无参数 / -h / 用法错误时显示;完整帮助走 --help / help <主题>)。 usage_short() { cat >&2 <<'EOF' svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(postgres) 统一部署、生命周期与运维 常用: scripts/svc.sh list [--tag T] 服务总览 scripts/svc.sh info [name] [--tag T --reveal] 综合报告(只读):服务/数据库/配置快照 scripts/svc.sh deploy [--from dev@] 部署并启动(缺省=远端 main 最新) scripts/svc.sh start|stop|restart|logs [N] 生命周期与日志 scripts/svc.sh status 服务详情(版本/来源/DSN 等) scripts/svc.sh db [pg名|--clear] agent 数据库绑定/切换(缺省查看) scripts/svc.sh backup [--out D] [--keep N] agent 数据目录快照(SQLite 一致性备份) scripts/svc.sh backups [--prune] 快照列表 / 按保留数清理 scripts/svc.sh config [file|--clear] agent 配置文件更新/即时生效(缺省查看) scripts/svc.sh db create pg1 --port 8890 建数据库 scripts/svc.sh db dsn|backup|backups|restore|clone|copy|ls|set pg1 ... 数据库运维 scripts/svc.sh set pg1 --port 8895 --apply 换端口(--tag 改标签) scripts/svc.sh rm pg1 [--purge] 注销(--purge 连数据删除) scripts/svc.sh commits [dev|release] [N] 可部署提交 scripts/svc.sh history [name] [N] 事件历史 scripts/svc.sh update [name] [--check] 刷新过时服务到远端最新(不依赖工作区状态) scripts/svc.sh recover [name] [--quiet] 自恢复:拉回 autostart=1 但已死的服务 scripts/svc.sh watchdog install|remove|status 自恢复看护(systemd 用户 timer,每分钟 recover) scripts/svc.sh user [...] 账户 CLI(绑定实例;见 help accounts) scripts/svc.sh rdm-admin [show] RDM 域管理员台账查看(含基座用户核验) scripts/svc.sh rdm-admin transfer --to RDM 域管理员受信移交(锁死恢复通道) 帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle accounts db dbops config recovery examples safety) 文档:docs/svc.md EOF } # HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。 HELP_TOPICS="model deploy lifecycle accounts rdm-admin db dbops config recovery examples safety" cmd_help() { local topic="${1:-}" case "$topic" in ""|topics|list) cat >&2 <;完整 --help;文档 docs/svc.md) model 服务模型:命名、目录布局、svc.env 字段、history 事件、pidcheck deploy 部署:三来源(local/dev@sha/release@v)、--pick、--dry-run、static --dir lifecycle 生命周期:start/stop/restart/logs/status、set 变更、rm 注销 accounts 账户 CLI:create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin rdm-admin RDM 域管理员台账:查看 / 受信移交(锁死恢复通道) db 数据库服务:create/start/dsn/ls/set(换端口) dbops 数据库运维:backup/backups/restore/clone/copy config 配置分层:优先级、--config 文件、SVC_* 环境变量、播种参数 recovery 自恢复:recover、watchdog、autostart examples 配方:常见场景的完整命令序列 safety 安全约束与边界 EOF return 0 ;; model) cat >&2 <<'EOF' 【model】服务模型与登记 服务名:^[a-z][a-z0-9-]{0,15}$(小写字母开头,可含数字与连字符,≤16 位)。 保留字不可用作服务名:status list add set db rm create dsn commits history update deploy stop start restart logs help info version。 目录布局(每个服务自包含,互不串版本): runtime// bin/agent-system agent 产物(deploy 复制就位) htdocs/ static 前端产物(--dir 模式则登记外部目录) data/ agent 数据目录(SQLite 与工件;--data 可改) pgdata/ postgres 数据目录(initdb 产物) plugins/ agent 插件(随 deploy 复制) backups/ pg_dump 备份(0600) svc.pid pidfile(只停 cmdline 校验通过的进程) svc.log | pg.log 日志(postgres 用 pg.log) svc.env 登记 + 持久化配置(dotenv,600) history.jsonl 事件流(append-only,一行一个 JSON) .start.lock 生命周期互斥锁(recover 与 deploy/手动 start 防竞态) runtime-backups/// agent 数据目录快照:svc.env + history.jsonl + data/(*.db 经 sqlite3 .backup 导出一致性副本并入 WAL 内容,integrity_check 自检;-wal/-shm 不入快照; MANIFEST 记逐文件 sha256)。位于 runtime/ 之外——runtime 被整体误删时快照 仍在;rm --purge 随服务一并删除。 svc.env 字段: kind agent|static|postgres(旧登记无此键视为 agent) env 服务名 pid 最近启动的 pid addr 监听地址 host:port(持久化——下次 start 复用) tags 标签(逗号分隔) source 部署来源(local@ | dev@ | release@<版本> | dir@<路径> | postgres@local | clone@<名>) version 产物版本串(agent-system version 输出) started 最近启动时间 pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory) exec agent 上次完整启动参数(裸 start/restart 复用;显式参数出现即重建)。 只存非敏感参数——DSN 不在其中,数据库配置走 db=/dbref=/dsn= 专键 autostart 期望运行态(成功 start 置 1;显式 stop 移除;进程死亡保留—— recover/watchdog 据此拉回,见 help recovery) db agent 数据库模式:pg | sqlite(显式解绑)| mock;从未配置则无此键 dbref agent 托管数据库绑定 /<库名>(启动时由实例登记重建 DSN) dsn agent 外部直连 DSN(受保护,600;未命中本机实例时使用) user/password/dbname/authmode/pgdata postgres 专属(密码 600 保存) htdocs static 专属 history.jsonl 事件(scripts/svc.sh history 查看): create db create / 物理克隆登记 deploy begin|ok|fail(含来源) start ok|fail|warn(fail 含原因:进程即退/健康检查超时) recover ok|fail(watchdog/手动 recover 拉回死亡服务) stop ok|fail set 登记变更(addr 旧 → 新) backup ok|fail(pg:库/文件/大小;agent:快照目录/大小) restore ok|fail(库、文件、表数) clone ok|fail(物理/在线) copy ok|fail rm 注销(purge 与否) 端口规则:dev 默认 127.0.0.1:8788、prod 127.0.0.1:8787;其余按名字确定性派生 (8800-9099,与已登记端口/监听端口冲突时递增扫描);SVC__ADDR 显式覆盖。 EOF return 0 ;; deploy) cat >&2 <<'EOF' 【deploy】部署与启动(agent / static;省略 action 即 deploy) 语法:scripts/svc.sh deploy [--kind agent|static] [--from 源] [参数...] 部署来源(--from,缺省=dev 远端最新): (缺省)/ dev 部署独立部署源仓的 origin/main 最新提交——与当前工作区 状态无关(脏树、任意分支均可)。 dev@ 显式指定提交,物化与构建同上。 两者统一流程:在独立部署源仓(SVC_SRC_DIR,默认 ~/.cache/agent-system-svc/src, 首次使用自动从 origin 克隆)上 git worktree add --detach 出 runtime/.worktrees/,递归物化子模块组合(精确落位 gitlink), make build/frontend,产物复制后回收 worktree。全程不触碰当前工作区。 local 构建当前工作区(make build / make frontend),产物复制到 服务目录。注意:这是唯一会写主检出构建产物(bin/、 node_modules/、dist/——均 gitignore)的路径,也是唯一 依赖当前工作区状态的来源。 release[@<版本|sha>] 取 Releases 仓 build//agent-system(+plugins), sha256 对 versions//manifest.json 校验,不符即拒绝。 rdm[@] 部署下游组合仓 Agent-System-RDM:独立 RDM 源仓 (SVC_RDM_SRC_DIR,默认 ~/.cache/agent-system-svc/rdm-src, 首次按 SVC_RDM_ORIGIN——缺省 RDM 远端——克隆)worktree 物化 pins/ 子模块后 make build(纯 Go,无需 npm),产物 bin/agent-system-rdm 复制为环境二进制;插件目录按空集安装 (组合插件编译进二进制),web/ 工作台静态资产随环境落 htdocs/,并自动部署伴生 web(--kind static --dir)。 注意:rdm 源不支持 --mock 与 --config;模型设置播种 (--model-*/--tavily-keys)仅基座产物支持。 --pick [dev|release|rdm] 交互式编号选择(需 TTY;列表与提示走 stderr) --dry-run 只打印部署计划不执行 --allow-source-switch 同名服务换源种类(dev↔rdm↔release↔local)须显式确认; 同种类换 sha(含 update)不需要 行为细节: - deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程) - agent 启动后等健康检查(20s),随后按 --model-*/--tavily-keys 播种设置(幂等) - 部署收尾按源种类做 UI 探活:dev/local/release 要求本服务 GET /=200; rdm 要求伴生 web GET /=200(失败记 ui-missing,deploy 非 ok) - 旧版本产物不认识 --auth/--config 时自动省略(探测 serve 用法)并提示; 不消费环境变量 DSN(AGENT_SYSTEM_DSN)的旧产物在需要交付数据库配置时 拒绝部署/启动(不静默按内嵌 SQLite 运行) - static 的 release 源不支持(制品内嵌前端);--dir PATH 可直接登记并服务 既有目录(不构建、不复制,source=dir@<路径>) - deploy 全程事件入 history.jsonl(begin/ok/fail) 示例: scripts/svc.sh prod deploy --from release@0.1.0 --tag stable scripts/svc.sh prod deploy --from dev@73b2802 --dry-run scripts/svc.sh dev deploy --from rdm@310c3fe --allow-source-switch scripts/svc.sh ui-r4 deploy --kind static --from dev@73b2802 scripts/svc.sh ui-qa deploy --kind static --dir ../frontend/dist EOF return 0 ;; lifecycle) cat >&2 <<'EOF' 【lifecycle】生命周期与登记变更 scripts/svc.sh start 启动(agent/static/postgres 分别分派;已在运行则提示) scripts/svc.sh stop 停止(agent/static: TERM→KILL;postgres: pg_ctl -m fast) scripts/svc.sh restart 停止 + 启动(裸 restart 复用上次配置) scripts/svc.sh db [pg名|--clear] [--db X] agent 数据库绑定:缺省查看当前 绑定与可用实例;给 pg名=绑定/切换(dbref= 登记,启动时由实例登记 重建 DSN 并经环境变量交付,实例运行中缺库自动建,服务运行中自动 重启生效);--clear 显式解绑(db=sqlite,清除直接 DSN,环境残留 SVC_*_DSN 不再重新选库);--no-restart 只改登记 scripts/svc.sh config [file|--clear] [--no-restart] agent 配置文件:缺省查看当前 文件与生效值;给 file=更换(agent 二进制 --config,JSON schema 校验 通过才切换,运行中自动重启即时生效);--clear 移除回命令行参数形态。 绑定后改文件内容 + restart 即生效(agent 无热重载,重启秒级) scripts/svc.sh logs [N] 跟随日志(默认 50 行起;如 svc.sh pg1 logs 200) scripts/svc.sh status 单服务详情(kind/状态/地址/来源/版本/标签/DSN/目录) scripts/svc.sh list [--tag T] 全服务表格:NAME KIND STATE PID ADDR SOURCE TAGS scripts/svc.sh info [name] [--tag T --reveal] 综合报告(只读):服务表+运行汇总 / postgres 实例与内部库 / 配置文件内容快照(敏感值默认掩码)/ 最近事件;带 name 时输出单服务深度报告 scripts/svc.sh history [name] [N] 事件历史(首参为纯数字时视为条数) scripts/svc.sh user … 账户 CLI(绑定实例 manifest/OpenExisting;详见 help accounts) 变更登记(不动进程,除 --apply): scripts/svc.sh set --tag T... 三类服务通用,合并去重 scripts/svc.sh set --port N [--host H] 未运行:直接改登记 scripts/svc.sh set --port N --apply 运行中:停止→改登记→重启 - 运行中的 postgres 改端口必须 --apply(防止登记与实际监听不一致) - agent/static 换端口用 restart --port N(进程与登记同步更新) - 新端口会先校验:未被其他服务登记占用且未在监听 注销: scripts/svc.sh rm 停止并移除登记(目录/数据/历史保留) scripts/svc.sh rm --purge 连 runtime// 整个目录一起删除 数据目录快照(agent;SQLite 一致性导出,详见 docs/svc.md): scripts/svc.sh backup [--out DIR] [--keep N] 快照到 runtime-backups// scripts/svc.sh backups [--prune] [--keep N] 列表 / 按保留数清理旧快照 自动恢复(崩溃 / OOM 被杀 / 机器重启后自动拉回;详见 help recovery): scripts/svc.sh recover [name] [--quiet] 把 autostart=1 但已死的服务重新拉起 scripts/svc.sh watchdog install systemd 用户 timer 每分钟执行 recover 自动更新(一条命令把服务刷到远端最新;与当前工作区状态无关): scripts/svc.sh update --check 只报告:每服务 当前来源 vs 最新(fetch 后比较) scripts/svc.sh update 更新所有"过时且运行中"的服务 scripts/svc.sh update dev prod 点名更新(已停止的过时服务也会更新并启动) scripts/svc.sh update --all 含已停止服务;--no-fetch 跳过网络 - local@/dev@ 源:一律重新部署为 dev@<远端 main 最新>(独立部署源仓 + 隔离 worktree 构建;主检出脏净、所在分支均不影响,也不 pull 主检出) - 领先远端 main / 与远端分叉 / 提交不在远端:报告但不动作(不盲目降级) - release@源:部署 Releases 仓最新版本(sha256 校验) - postgres / static --dir 不参与更新;每次更新写 history(update 事件) EOF return 0 ;; accounts|account|user) cat >&2 <<'EOF' 【accounts】实例绑定的账户 CLI(U1 已接线;帮助可发现性) 绑定合同(禁止回落默认 ./data): - 必须先有实例登记与 data 根(manifest identity.data_root 优先) - 强制 --open-existing:缺库/缺目录拒绝,不创建空库 - 禁止调用方覆盖 --data/--dsn/--open-existing - 与启停互斥(lifecycle flock);无 flock 时拒绝 - mock 档不支持;PG 档须有有效 DSN/dbref 六个子命令(经 scripts/svc.sh user … 转发到实例二进制): scripts/svc.sh user create --owner [--role user|admin] [--password …] scripts/svc.sh user list scripts/svc.sh user reset-password --owner [--password …] scripts/svc.sh user issue-login-key --owner [--purpose activate|recover] scripts/svc.sh user revoke-login-key --id scripts/svc.sh user bootstrap-admin --owner [--password …] 凭据只在 stdout 展示一次;日志与 history 只记凭据 ID。后端原子开户/改密/审计 补齐见 Documents 方案 U7(本帮助主题不替代该增量)。 更完整说明:docs/svc.md、docs/svc-deploy/u1-instance-account-binding.md EOF return 0 ;; rdm-admin) cat >&2 <<'EOF' 【rdm-admin】RDM 域管理员台账(rdm.db rdm_admins 单行;受信运维通道) scripts/svc.sh rdm-admin [show] 查看当前 RDM 域管理员(台账行 + 基座用户核验; 台账管理员不在基座 users 表时警示——典型为 验收/引导残留占用共享环境台账) scripts/svc.sh rdm-admin transfer --to 受信移交(锁死恢复通道;与 serve 二进制 -bootstrap-admin 同级信任,镜像 rdm.perm.transfer 的单行改写语义) 背景(2026-10-13 8788 dev 实测):U7 验收在共享 dev runtime 执行 -bootstrap-admin u7-admin 后,操作者 weichen 在系统管理「RDM 角色权限」被拒 (权限拒绝:不是系统管理员)——KTD4 下基座 isAdmin 不自动获得 RDM 域管理权。 KTD4 裁定不变:正式移交仍走 rdm.perm.transfer(有审计);本通道仅限锁死恢复。 边界:绑定合同同 accounts(flock/manifest data_root/既有库,mock 拒绝);仅内嵌 SQLite 档(PG 档请以现任管理员走 rdm.perm.transfer);transfer 目标必须在基座 users 表(typo 防线);空台账不适用本命令(用 -bootstrap-admin 引导)。 EOF return 0 ;; db) cat >&2 <<'EOF' 【db】数据库服务:创建 / 连接 / 换端口 scripts/svc.sh db create [--port P] [--host H] [--user U] [--password P] [--db NAME] [--auth-mode scram-sha-256|md5] [--tag T...] initdb 到 runtime//pgdata(目录 700,密码文件即用即删); 默认用户/库均为 agent,缺省密码自动生成(db dsn --reveal 查看); 只登记不启动——scripts/svc.sh start 启动。 scripts/svc.sh start|stop|restart pg_ctl 生命周期:start 带 -p -k <环境目录>(socket 不落 pgdata)、 listen_addresses 随 --host;首次启动自动创建登记库(已存在则跳过); 就绪判定 pg_isready;stop 用 -m fast 优雅回写,失败退 SIGTERM,绝不 KILL。 scripts/svc.sh db dsn [--reveal] [--db X] 连接串 postgresql://user:pass@host:port/db;默认掩码密码(stdout 纯净可组合)。 scripts/svc.sh db ls 实例内数据库列表(须运行中)。 scripts/svc.sh db set [...] / scripts/svc.sh set [...] 换端口/主机/标签:见 help lifecycle(运行中改端口需 --apply)。 连接其他服务:DSN 可直接组合进 agent 部署—— scripts/svc.sh prod deploy --from dev@ --dsn "$(scripts/svc.sh db dsn pg1 --reveal)" (旧版本产物不识别 --dsn 时会自动省略并提示。) 更常用:让已部署的 agent 服务绑定/切换数据库(不用重新 deploy)—— scripts/svc.sh prod db pg1 # 绑定 pg1 默认库(运行中自动重启生效) 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 # 显式解绑(db=sqlite,回内嵌档) 绑定登记于 svc.env(dbref= + db=pg,600;DSN 启动时由实例登记重建,经环境变量 交付);info / list 的 DB 列与数据库节 "绑定服务" 行展示服务↔实例对应关系。 密码管理:密码明文存 runtime//svc.env(600);--password 未给时自动生成 (head -c 18 /dev/urandom)。auth-mode 默认 scram-sha-256(pg_hba 全线)。 EOF return 0 ;; dbops) cat >&2 <<'EOF' 【dbops】数据库运维:备份 / 恢复 / 克隆 / 搬库 备份: scripts/svc.sh db backup [--db X] [--out FILE] pg_dump -Fc 走 TCP(实例须运行中)→ 默认 runtime//backups/-<库>.dump (目录 700、文件 600);完成后 pg_restore --list 自检归档,失败即删。 scripts/svc.sh db backups 列出备份(时间 / 大小 / 文件名)。 恢复(唯一可能覆盖数据的操作,非空库必须 --force): scripts/svc.sh db restore --file F [--db X] [--force] 目标实例须运行中;F 须为合法 pg_dump 归档(先自检); 目标库不存在→建库后恢复;存在但空→直接恢复;存在且非空→拒绝(--force 才 DROP WITH (FORCE) + 重建 + 恢复);完成后回显表数。 克隆(整实例,绑定新端口): scripts/svc.sh db clone [--port P] [--host H] [--password PW] [--tag T] [--online] 默认物理克隆:短暂停止源(一致性)→ cp -a pgdata(剔除 postmaster.pid/.opts)→ 登记目标(新端口:--port 或派生;用户/库/认证继承,密码可换;标签继承+可加; source=clone@)→ 自动复启源。目标不自动启动(提示 start 命令)。 --online:不停源,dump/restore 逻辑复制源登记库到目标——要求目标已 db create + start。 搬库(单库、跨实例): scripts/svc.sh db copy [--db X] [--force] 双方都须运行中;--db X 同时命名源与目标库(默认取源登记库); 目标库非空须 --force(语义同 restore)。 注意:备份/克隆只增不删;备份随 rm --purge 一并删除;所有操作入 history.jsonl。 EOF return 0 ;; config) cat >&2 <<'EOF' 【config】配置分层与持久化 优先级(高 → 低): 1. 命令行参数(--port/--dsn/--tag/...) 2. --config FILE(或 --env FILE,兼容别名;=FILE 写法亦可) 3. svc.env 持久化(addr=、exec=、dbref=/dsn=/db=——数据库配置专键) 4. SVC__* 环境变量与 .svc.env(自动加载,同层) 5. 默认值(dev=8788 / prod=8787 / 其余派生端口) dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只识别合法变量名, 不 source 执行文件内容。建议 .env.dev/.env.prod 等加入 gitignore。 环境变量(X 为服务名大写,- → _): SVC_X_ADDR / SVC_X_DATA / SVC_X_DSN / SVC_X_AUTH / SVC_X_MOCK SVC_RELEASES_DIR Releases 仓路径(默认 ../Agent-System-Releases) SVC_SRC_DIR 独立部署源仓路径(默认 ~/.cache/agent-system-svc/src) SVC_PG_BIN postgres 工具链 bindir(默认 pg_config --bindir 探测) SVC_MODEL_BASE/KEY/NAME 主模型(agent 启动健康后播种) SVC_LIGHT_BASE/KEY/NAME 轻量模型 SVC_TAVILY_KEYS Tavily 密钥池 SVC_SEED_OWNER 播种目标账号(缺省取库内首个管理员) 播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。 认证开启(PG 档默认)时播种自动铸临时密钥(name=svc-seed,用后即回收), 不依赖手动到设置页配置。 持久化语义: - deploy/start 成功(健康探测通过)后把当前态写入 svc.env(600,临时文件原子替换); 启动失败不覆盖上次配置——裸 start 仍按上次可用参数重试 - 裸 start/restart 复用 exec=(上次完整参数);任何显式参数(或 --config 文件、 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 配置): scripts/svc.sh config [file|--clear] 更换/移除/查看(见 help lifecycle); 切换前 schema 校验(agent-system config explain -config ),运行中自动重启 即时生效;登记于 svc.env conffile=,info/status 可见。 EOF return 0 ;; recovery) cat >&2 <<'EOF' 【recovery】自恢复:autostart 期望态 + recover + watchdog 期望运行态(autostart 键,svc.env): - start / restart / deploy / update 成功启动(健康检查通过)→ autostart=1 - 显式 stop(含 deploy/update 前置停机)→ 移除 autostart(保持停止是明确意图) - 进程自行死亡(崩溃、OOM 被杀、机器重启后)→ 键保留,等待恢复 - rm 注销后无 svc.env,recover 不再触碰 scripts/svc.sh recover [name] [--quiet] 扫描已登记服务(postgres 优先于 agent/static,保证绑定实例先起), 对 autostart=1 且 pidfile+cmdline 校验已死的服务执行裸 start (复用持久化 exec= 与数据库绑定);每次尝试写 recover ok/fail 事件。 逐服务持 .start.lock(非阻塞,被 deploy/手动 start 持有时跳过本轮)。 进程反复即退时自然限频为约 1 次/分钟(健康探测 20s + timer 周期)。 scripts/svc.sh watchdog install 安装 systemd 用户级 timer(每分钟 recover): ~/.config/systemd/user/agent-system-svc-recover.{service,timer} (OnBootSec=1min + OnUnitActiveSec=60s;开机自启依赖 loginctl enable-linger,未开时安装会提示) scripts/svc.sh watchdog remove 停用并移除 timer scripts/svc.sh watchdog status timer 状态 + autostart=1 的服务清单 无 systemd 用户会话的环境可退化为 cron: * * * * * /scripts/svc.sh recover --quiet >/dev/null 2>&1 语义边界: - recover 只用既有登记拉起,不重建产物、不改变任何配置; - deploy/update 失败(构建失败、预检拒绝)→ 服务保持停止等人工介入, watchdog 不盲目复活(前置停机已清 autostart); - 端口被外部进程占用时 start 报错放弃(不抢不杀,watchdog 每轮重试无害)。 EOF return 0 ;; examples) cat >&2 <<'EOF' 【examples】常见配方 ① 起一套演示后端(缺省部署远端 main 最新;--from local 才构建当前工作区): scripts/svc.sh dev deploy --mock scripts/svc.sh dev deploy --from local --mock ② 建库 + 后端连库: scripts/svc.sh db create pg1 --port 8890 scripts/svc.sh pg1 start scripts/svc.sh prod deploy --from dev@ \ --dsn "$(scripts/svc.sh db dsn pg1 --reveal)" ③ 按提交部署 + 回退: scripts/svc.sh commits dev # 看可部署提交 scripts/svc.sh v1 deploy --from dev@73b2802 --tag test scripts/svc.sh v1 deploy --from release@0.1.0 # 回退到发布版本 ④ 双版本对比(同机并行): scripts/svc.sh a deploy --from dev@ --port 8801 scripts/svc.sh b deploy --from dev@ --port 8802 ⑤ 前端静态服务: scripts/svc.sh ui deploy --kind static --from dev@ # 构建并服务 dist scripts/svc.sh ui2 deploy --kind static --dir ../frontend/dist # 直接服务目录 ⑥ 备份 → 改坏数据 → 恢复: scripts/svc.sh db backup pg1 ... (随意操作) scripts/svc.sh db restore pg1 --file runtime/pg1/backups/<最新>.dump --force ⑦ 克隆一套隔离环境(不动原库): scripts/svc.sh db clone pg1 pg1test --port 8896 scripts/svc.sh pg1test start scripts/svc.sh qa deploy --from dev@ --dsn "$(scripts/svc.sh db dsn pg1test --reveal)" ⑧ 把开发库搬到 prod 实例: scripts/svc.sh db copy pgdev pgprod --db app --force ⑨ 换端口: scripts/svc.sh set pg1 --port 8895 --apply ⑩ 总览与追溯: scripts/svc.sh list --tag test scripts/svc.sh history pg1 50 EOF return 0 ;; safety) cat >&2 <<'EOF' 【safety】安全约束与边界 写入边界: - 只写 runtime//、runtime/.worktrees/(构建 worktree,用后即收)与独立 部署源仓(SVC_SRC_DIR,默认 ~/.cache/agent-system-svc/src,可随时删除重建); - 不改主检出 git 状态;--from local 是唯一写主检出构建产物(bin/、node_modules/、 dist/——均 gitignore)的路径,也是唯一依赖当前工作区状态的来源; - rm --purge 只删 runtime//,且要求服务已登记。 进程边界: - 只停"自己 pidfile + /proc cmdline 标记双校验"都通过的进程; - postgres 走 pg_ctl -m fast,失败退 SIGTERM,SIGKILL 不用于 postmaster; - agent/static:TERM 优雅退出,10s 未退才 KILL。 端口边界: - 被非本脚本进程占用:报错并给出占用者,不抢不杀; - 显式选端口(--port / set --port / clone --port)会同时查登记与监听; - 派生端口在 8800-9099 内避让已登记与监听端口。 秘密与数据: - svc.env(含 DB 密码)与备份文件权限 600;数据/环境目录 700; - DSN 默认掩码密码,--reveal 才显示; - restore/copy 覆盖非空库必须显式 --force;备份/克隆只增不删; - dotenv 解析不执行文件内容(防注入)。 EOF return 0 ;; *) c_err "未知帮助主题:$topic(可选:$HELP_TOPICS;总览:scripts/svc.sh help)" return 2 ;; esac } # load_env_file FILE:dotenv 安全解析——逐行 KEY=VALUE 赋值(剥 export 前缀与成对 # 引号,忽略注释/空行/非法行),不 source 执行文件内容。 load_env_file() { local f="$1" line k v [ -f "$f" ] || { c_err "配置文件不存在:$f"; exit 2; } while IFS= read -r line || [ -n "$line" ]; do line="${line%$'\r'}" case "$line" in ''|'#'*) continue ;; esac [[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*= ]] || continue k="${line%%=*}" k="${k#"${k%%[![:space:]]*}"}" # 左去空白 k="${k#export}" k="${k#"${k%%[![:space:]]*}"}" v="${line#*=}" v="${v#"${v%%[![:space:]]*}"}" # 值左去空白 v="${v%\"}"; v="${v%\'}"; v="${v#\"}"; v="${v#\'}" # 剥成对引号 printf -v "$k" '%s' "$v" done < "$f" } # ---- 命令行参数(覆盖层)---- CLI_ADDR=""; CLI_HOST=""; CLI_PORT=""; CLI_DATA=""; CLI_DSN="" CLI_AUTH=""; CLI_MOCK=0; CLI_PLUGINS=""; CLI_EXTRA=""; CLI_LOGS_N="" CLI_MBASE=""; CLI_MKEY=""; CLI_MNAME="" CLI_LBASE=""; CLI_LKEY=""; CLI_LNAME=""; CLI_TAVILY="" CLI_SEED_OWNER="" CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0 CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0 CLI_APPLY=0; CLI_FORCE=0; CLI_ONLINE=0; CLI_FILE=""; CLI_OUT="" CLI_ALLOW_SOURCE_SWITCH=0 CLI_CHECK=0; CLI_ALL=0; CLI_NOFETCH=0; CLI_QUIET=0 CLI_DB_USER=""; CLI_DB_PASSWORD=""; CLI_DB_NAME=""; CLI_DB_AUTH="" CLI_TAGS=() need_value() { [ $# -ge 2 ] || { c_err "参数 $1 需要值"; exit 2; }; } parse_args() { while [ $# -gt 0 ]; do case "$1" in --addr) need_value "$@"; CLI_ADDR="$2"; shift 2 ;; --host) need_value "$@"; CLI_HOST="$2"; shift 2 ;; --port) need_value "$@"; CLI_PORT="$2"; shift 2 ;; --data) need_value "$@"; CLI_DATA="$2"; shift 2 ;; --dsn) need_value "$@"; CLI_DSN="$2"; shift 2 ;; --auth) need_value "$@"; CLI_AUTH="$2"; shift 2 ;; --plugins) need_value "$@"; CLI_PLUGINS="$2"; shift 2 ;; --mock) CLI_MOCK=1; shift ;; --model-base) need_value "$@"; CLI_MBASE="$2"; shift 2 ;; --model-key) need_value "$@"; CLI_MKEY="$2"; shift 2 ;; --model-name) need_value "$@"; CLI_MNAME="$2"; shift 2 ;; --light-base) need_value "$@"; CLI_LBASE="$2"; shift 2 ;; --light-key) need_value "$@"; CLI_LKEY="$2"; shift 2 ;; --light-name) need_value "$@"; CLI_LNAME="$2"; shift 2 ;; --tavily-keys) need_value "$@"; CLI_TAVILY="$2"; shift 2 ;; --seed-owner) need_value "$@"; CLI_SEED_OWNER="$2"; shift 2 ;; --from) need_value "$@"; CLI_FROM="$2"; shift 2 ;; --pick) CLI_PICK="dev" case "${2:-}" in dev|release|rdm) CLI_PICK="$2"; shift 2 ;; *) shift ;; esac ;; --dry-run) CLI_DRYRUN=1; shift ;; --kind) need_value "$@"; CLI_KIND="$2"; shift 2 ;; --dir) need_value "$@"; CLI_DIR="$2"; shift 2 ;; --tag) need_value "$@"; CLI_TAGS+=("$2"); shift 2 ;; --purge) CLI_PURGE=1; shift ;; --reveal) CLI_REVEAL=1; shift ;; --apply) CLI_APPLY=1; shift ;; --force) CLI_FORCE=1; shift ;; --allow-source-switch) CLI_ALLOW_SOURCE_SWITCH=1; shift ;; --migrate-schema) CLI_MIGRATE_SCHEMA=1; shift ;; --confirm) need_value "$@"; CLI_CONFIRM="$2"; shift 2 ;; --online) CLI_ONLINE=1; shift ;; --file) need_value "$@"; CLI_FILE="$2"; shift 2 ;; --out) need_value "$@"; CLI_OUT="$2"; shift 2 ;; --user) need_value "$@"; CLI_DB_USER="$2"; shift 2 ;; --password) need_value "$@"; CLI_DB_PASSWORD="$2"; shift 2 ;; --db) need_value "$@"; CLI_DB_NAME="$2"; shift 2 ;; --auth-mode) need_value "$@"; CLI_DB_AUTH="$2"; shift 2 ;; --) shift; CLI_EXTRA="$*"; break ;; -h|--help) usage; exit 0 ;; *) c_err "未知参数:$1"; usage_short; exit 2 ;; esac done } # addr_compose 基础值(host:port)按 CLI 的 --addr/--host/--port 组合出最终地址。 addr_compose() { local base="$1" host port host="${base%:*}"; port="${base##*:}" [ -n "$CLI_ADDR" ] && { echo "$CLI_ADDR"; return 0; } [ -n "$CLI_HOST" ] && host="$CLI_HOST" [ -n "$CLI_PORT" ] && port="$CLI_PORT" echo "$host:$port" } # ---- 名称与端口 ---- RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version recover watchdog backup backups " # valid_env NAME:服务名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用)。 valid_env() { case "$RESERVED_ENVS" in *" $1 "*) return 1 ;; esac [[ "$1" =~ ^[a-z][a-z0-9-]{0,15}$ ]] } # env_var NAME SUFFIX:取 SVC__ 变量值(无则空)。 env_var() { local v="SVC_$(printf '%s' "$1" | tr 'a-z-' 'A-Z_')_$2" printf '%s' "${!v:-}" } ports_recorded() { local p="$1" skip="$2" d e a for d in "$RUNTIME"/*/; do [ -f "${d}svc.env" ] || continue e="$(basename "$d")" [ "$e" = "$skip" ] && continue a="$(sed -n 's/^addr=.*:\([0-9]\+\)$/\1/p' "${d}svc.env")" [ "$a" = "$p" ] && return 0 done return 1 } # port_listening PORT:端口是否被监听。优先 ss;不可用或无命中时退化为 bash 内建 # /dev/tcp 连接探测(CI 最小容器无 iproute2 时端口检查会失明——2026-10-11 实测: # thief 占口,recover 未识别照常启动,健康探测打到 thief 上误报已启动)。 port_listening() { local p="$1" if command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}' && return 0 fi (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0 return 1 } # derive_port NAME:由服务名确定性派生端口(8800+hash%300),冲突时在 8800..9099 递增。 derive_port() { local name="$1" p i=0 p=$(( 8800 + ($(printf '%s' "$name" | cksum | cut -d' ' -f1) % 300) )) while [ "$i" -le 300 ]; do if ! ports_recorded "$p" "$name" && ! port_listening "$p"; then echo "$p"; return 0 fi p=$(( 8800 + (p - 8800 + 1) % 300 )) i=$((i + 1)) done return 1 } # svc_get KEY:读当前服务 svc.env 的键值(无则空)。 svc_get() { [ -f "$ENV_DIR/svc.env" ] || return 0 sed -n "s/^$1=//p" "$ENV_DIR/svc.env" | head -1 } # svc_kind NAME:服务类型(svc.env kind=,无登记或无 kind 键 → agent,兼容旧登记)。 svc_kind() { local k="" [ -f "$RUNTIME/$1/svc.env" ] && k="$(sed -n 's/^kind=//p' "$RUNTIME/$1/svc.env" | head -1)" [ -n "$k" ] && printf '%s' "$k" || printf agent } # ---- 登记与事件 ---- jclean() { printf '%s' "$1" | tr -d '"\001-\037'; } # hist_event EVENT RESULT PID DETAIL:向 runtime//history.jsonl 追加事件。 hist_event() { mkdir -p "$ENV_DIR" 2>/dev/null || return 0 printf '{"ts":"%s","event":"%s","env":"%s","kind":"%s","source":"%s","result":"%s","pid":"%s","addr":"%s","detail":"%s"}\n' \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ "$(jclean "$1")" "$(jclean "$ENV_NAME")" "$(jclean "$SVC_KIND")" "$(jclean "${SRC_LABEL:-}")" \ "$(jclean "$2")" "$(jclean "$3")" "$(jclean "$ENV_ADDR")" "$(jclean "$4")" \ >> "$ENV_DIR/history.jsonl" 2>/dev/null || true } # svc_merge_tags:合并已有 tags 与本次 --tag(去重,逗号连接)。 svc_merge_tags() { local prev t out="" seen=" " prev="$(svc_get tags)" for t in $(printf '%s' "$prev" | tr ',' ' ') ${CLI_TAGS[@]+"${CLI_TAGS[@]}"}; do [ -n "$t" ] || continue case "$seen" in *" $t "*) continue ;; esac seen="$seen $t " out="${out:+$out,}$t" done printf '%s' "$out" } # 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() { 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" printf 'pid=%s\n' "${pid:-}" printf 'addr=%s\n' "$ENV_ADDR" printf 'tags=%s\n' "$merged_tags" printf 'source=%s\n' "${SVC_SOURCE:-}" printf 'version=%s\n' "${SVC_VERSION:-}" printf 'started=%s\n' "$(date -Is)" printf 'pidcheck=%s\n' "${SVC_PIDCHECK:-}" printf 'exec=%s\n' "${SVC_EXEC:-}" [ -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' "$@" } > "$tmp" chmod 600 "$tmp" # U1 身份:manifest 治理下的整份提交(bulk-set 走 temp→fsync→rename 原子链, # revision 递增,svc.env 由 manifest 派生);失败保留旧配置,不产生半写态。 if manifest_ok; then if python3 "$MANIFEST_PY" "$ENV_DIR" bulk-set < "$tmp"; then rm -f "$tmp" return 0 fi c_err "[$ENV_NAME] manifest 提交失败,保留旧配置(详见上方错误)" rm -f "$tmp" return 1 fi mv -f "$tmp" "$ENV_DIR/svc.env" } # ---- pid 管理(泛化:pidfile + cmdline 标记双校验)---- pid_alive_mark() { # PID MARKER:cmdline 含 MARKER 视为我们的进程 local pid="$1" mark="$2" cmd [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || return 1 cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1 case "$cmd" in *"$mark"*) return 0 ;; *) return 1 ;; esac } # pid_alive PID:当前服务的 pid 校验(内存 SVC_PIDCHECK → svc.env pidcheck → agent-system)。 # 启动路径在 record_state 之前就会调本函数;static 首启若只读 svc.env 会误回退到 # agent-system,把 python http.server 判成「进程即退」。 pid_alive() { local pid="$1" mark mark="${SVC_PIDCHECK:-}" [ -z "$mark" ] && mark="$(svc_get pidcheck)" [ -z "$mark" ] && mark="agent-system" pid_alive_mark "$pid" "$mark" } read_pid() { [ -f "$ENV_PID" ] && cat "$ENV_PID" 2>/dev/null || true; } # proc_stop PID(agent/static):TERM 优雅 → KILL 兜底(postgres 不走此路径)。 proc_stop() { local pid="$1" kill -TERM "$pid" for _ in $(seq 1 100); do pid_alive "$pid" || return 0 sleep 0.1 done c_warn "[$ENV_NAME] 10s 未退出,SIGKILL 兜底" kill -KILL "$pid" 2>/dev/null || true return 0 } port_owner() { local port="${ENV_ADDR##*:}" if command -v ss >/dev/null 2>&1; then ss -ltnp 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {print $0; exit}' fi } # port_owner_pid:从 ss 行解析占口 PID(无 ss / 无 pid= 字段则空失败)。 port_owner_pid() { local line pid line="$(port_owner)" [ -n "$line" ] || return 1 pid="$(printf '%s' "$line" | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | head -1)" [ -n "$pid" ] || return 1 printf '%s' "$pid" } # cmdline_matches_own PID:进程 cmdline 是否含本服务标记(pidcheck/ENV_BIN; # 路径被 cp 覆盖后 argv 仍常保留原路径;basename + ENV_DIR 作收紧兜底)。 cmdline_matches_own() { local pid="$1" mark base cmd mark="${SVC_PIDCHECK:-}" [ -z "$mark" ] && mark="$(svc_get pidcheck)" [ -z "$mark" ] && mark="${ENV_BIN:-}" [ -n "$mark" ] || return 1 [ -r "/proc/$pid/cmdline" ] || return 1 cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1 case "$cmd" in *"$mark"*) return 0 ;; esac base="$(basename "$mark" 2>/dev/null || true)" [ -n "$base" ] && [ "$base" != "$mark" ] || return 1 case "$cmd" in *"$base"*) [ -n "${ENV_DIR:-}" ] || return 1 case "$cmd" in *"$ENV_DIR"*) return 0 ;; esac ;; esac return 1 } # find_own_in_ancestry PID:从占口 PID 沿 PPid 向上找本服务进程(stdout 打印命中 PID)。 # 真实 agent-system 单进程自听;桩用 bash+python http.server 时 ss 看到的是子进程。 find_own_in_ancestry() { local cur="$1" i=0 next while [ -n "$cur" ] && [ "$cur" -gt 1 ] 2>/dev/null && [ "$i" -lt 8 ]; do if cmdline_matches_own "$cur"; then printf '%s' "$cur" return 0 fi next="$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)" [ -n "$next" ] && [ "$next" != "$cur" ] || return 1 cur="$next" i=$((i + 1)) done return 1 } # reclaim_own_port:若 ENV_ADDR 端口被本服务残留占用则停掉并清 pidfile。 # 返回 0=端口已空闲(本就空或回收成功);1=仍被外部/未知占用或回收未释放端口。 # 修复:update/deploy 误清 pidfile 后 cp 覆盖二进制留下 (deleted) 孤儿, # check_port_free 一律报「非本服务」导致无法重启(2026-10-12 genui@8973)。 reclaim_own_port() { local port="${ENV_ADDR##*:}" listener target i [ -n "$port" ] || return 0 port_listening "$port" || return 0 listener="$(port_owner_pid 2>/dev/null || true)" [ -n "$listener" ] || return 1 target="$(find_own_in_ancestry "$listener" 2>/dev/null || true)" if [ -n "$target" ]; then c_warn "[$ENV_NAME] 端口 $port 被本服务残留进程占用(listener=$listener svc=$target),正在回收" proc_stop "$target" rm -f "$ENV_PID" hist_event stop ok "$target" "reclaim_own_port" for i in $(seq 1 50); do port_listening "$port" || return 0 sleep 0.1 done c_err "[$ENV_NAME] 本服务残留已发终止信号,但端口 $port 仍被占用" return 1 fi return 1 } # ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)---- # svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s), # MODE=-n 非阻塞(抢不到返回 1)。flock 缺失或等待超时 → 拒绝(不得直通冲突操作)。 # 锁随进程退出自动释放(fd 关闭);长驻循环(recover)用 svc_unlock 逐服务显式释放。 SVC_LOCK_HELD=0 svc_lock() { [ "$SVC_LOCK_HELD" = 1 ] && return 0 if ! command -v flock >/dev/null 2>&1; then c_err "[$ENV_NAME] 缺少 flock:拒绝生命周期/账户冲突操作(安装 util-linux 后重试)" return 1 fi mkdir -p "$ENV_DIR" 2>/dev/null || return 1 exec 9>>"$ENV_DIR/.start.lock" || return 1 if [ "$1" = "-n" ]; then flock -n 9 || { exec 9>&-; return 1; } else if ! flock -w 300 9; then exec 9>&- c_err "[$ENV_NAME] 生命周期锁等待超时:拒绝继续(避免并发冲突)" return 1 fi fi SVC_LOCK_HELD=1 return 0 } svc_unlock() { [ "$SVC_LOCK_HELD" = 1 ] || return 0 exec 9>&- || true SVC_LOCK_HELD=0 return 0 } # ---- U1 实例身份 manifest(设计:docs/recovery/u1-identity-manifest.md,已批准)---- # manifest.json 为启动权威(identity 锚点冻结数据根/数据库身份;kv 为权威键值; # desired 为期望态),svc.env 降为派生只读视图,未迁移实例沿用旧路径。 MANIFEST_PY="$ROOT/scripts/svc_manifest.py" manifest_ok() { [ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] } # svc_manifest_migrate:锁内一次性迁移(幂等)。已有数据视为"领养"——按当前数据根 # 建立 marker;数据根不存在或携带他实例 marker 时拒绝(不新建空系统掩盖丢数据), # 失败保持 legacy 治理继续运行。 svc_manifest_migrate() { [ -f "$ENV_DIR/svc.env" ] && [ ! -f "$ENV_DIR/manifest.json" ] || return 0 command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || { c_warn "[$ENV_NAME] 跳过 manifest 迁移:需 python3 与 scripts/svc_manifest.py" return 0 } local out rc=0 out="$(python3 "$MANIFEST_PY" "$ENV_DIR" migrate 2>&1)" || rc=$? if [ "$rc" -eq 0 ]; then c_ok "[$ENV_NAME] manifest:$(printf '%s' "$out" | tail -1)" # fresh:库文件已在时用当前制品补行并升为 established;无能力则记 pending。 # 库还不存在时保持 fresh,等启动建库后再 ensure。不在这里为缺库建文件。 resolve_db_config 2>/dev/null || true local marker data_root marker="$(svc_db_marker)" if [ "$marker" = fresh ]; then data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")" if ! svc_bin_has_identity; then svc_db_marker_set pending || return 1 elif [ -f "$data_root/agent-system.db" ]; then svc_db_identity_ensure || return 1 fi fi return 0 fi if [ "$rc" -eq 4 ]; then # 数据根携带他实例 marker:身份治理冲突(如 manifest 丢失后试图重建)—— # 中止而非降级 legacy(I4:丢身份是事件,禁止新建空系统掩盖)。 c_err "[$ENV_NAME] 身份治理冲突,中止:$(printf '%s' "$out" | tail -1)" return 1 fi c_err "[$ENV_NAME] manifest 迁移失败(保持旧治理):$(printf '%s' "$out" | tail -1)" return 0 } # svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配; # 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。 # 顺序:文件 marker → DB verify(缺/错即拦)。ensure 只在领养(svc_manifest_migrate) # 与显式 init——普通启动不建身份,缺行即事件(修复路径:svc init,或换具备 # identity 能力的产物更新时由候选门禁补齐)。 svc_manifest_verify() { manifest_ok || return 0 local out if ! out="$(python3 "$MANIFEST_PY" "$ENV_DIR" verify \ --data "$ENV_DATA" --db-mode "${DB_MODE:-}" --db-ref "${DB_REF:-}" 2>&1)"; then c_err "[$ENV_NAME] 身份校验失败,拒绝启动:$(printf '%s' "$out" | tail -1)" c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)" return 1 fi # 文件校验通过后再碰库。fresh 允许库尚未建立;established 只验证; # pending 仅当库文件已在且制品有能力时补齐。无能力制品不得启动已治理实例。 local kind marker data_root kind="$(svc_get kind)" [ "$kind" = agent ] || return 0 marker="$(svc_db_marker)" case "$marker" in fresh) return 0 ;; pending) if ! svc_bin_has_identity; then c_err "[$ENV_NAME] 身份尚未建立(pending),当前制品不能验证身份,拒绝启动" return 1 fi data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")" if [ ! -f "$data_root/agent-system.db" ] && [ "${DB_MODE:-}" != pg ]; then c_err "[$ENV_NAME] 身份 pending 但库文件不存在,拒绝启动(不新建空库)" return 1 fi svc_db_identity_ensure || return 1 svc_db_identity_verify || return 1 ;; *) if ! svc_bin_has_identity; then c_err "[$ENV_NAME] 已建立身份的实例只接受能验证身份的制品,拒绝启动" return 1 fi svc_db_identity_verify || return 1 ;; esac return 0 } # svc_db_identity_*:DB marker(设计 §5.2)。与 --instance(runs 进程标识)无关。 # dbref 稳定串:pg 用托管绑定或 "pg";其余归一为 "sqlite"。 svc_db_identity_args() { # 输出:INSTANCE_ID\tDBREF\tDATA(供 ensure/verify 共用) manifest_ok || return 1 python3 - "$ENV_DIR" "${ENV_DATA:-}" <<'PY' import json, os, sys env_dir, data_fallback = sys.argv[1], sys.argv[2] m = json.load(open(os.path.join(env_dir, "manifest.json"), encoding="utf-8")) ident = m.get("identity") or {} mode = ident.get("db_mode") or "" ref = ident.get("db_ref") or "" if mode == "pg": dbref = ref or "pg" else: dbref = "sqlite" data = ident.get("data_root") or data_fallback print("%s\t%s\t%s" % (m["instance_id"], dbref, data)) PY } # svc_bin_caps [BIN]:只执行 version --json,在空目录、清空的环境里。非 JSON 或 # protocol<1 一律无能力。旧制品对 version 立即退出,不会进入服务路径。 # 按二进制摘要缓存;缓存目录默认在临时目录,不写进实例运行时。 svc_bin_caps() { local bin="${1:-${ENV_BIN:-}}" [ -x "$bin" ] || return 1 local digest cache tmp out digest="$(sha256sum "$bin" 2>/dev/null | awk '{print $1}')" [ -n "$digest" ] || return 1 cache="${SVC_CAPS_CACHE_DIR:-${TMPDIR:-/tmp}}/svc-caps-${digest}" if [ -f "$cache" ]; then cat "$cache" return 0 fi tmp="$(mktemp -d)" out="$(cd "$tmp" && timeout -k 2 5 env -i PATH=/usr/bin:/bin HOME="$tmp" "$bin" version --json 2>/dev/null)" || true rm -rf "$tmp" out="$(printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert int(d.get("protocol",0))>=1; print(json.dumps(d,separators=(",",":")))' 2>/dev/null)" || return 1 printf '%s\n' "$out" > "$cache" printf '%s\n' "$out" } # svc_bin_has_identity [BIN]:能力文档声明 identity.ops 含 ensure 与 verify。 svc_bin_has_identity() { local bin="${1:-${ENV_BIN:-}}" doc doc="$(svc_bin_caps "$bin")" || return 1 printf '%s' "$doc" | python3 -c 'import json,sys; d=json.load(sys.stdin); ops=(d.get("identity") or {}).get("ops") or []; raise SystemExit(0 if "ensure" in ops and "verify" in ops else 1)' } # svc_db_marker:manifest.identity.db_marker。缺字段的旧 manifest 视为 established #(已在治理下跑过的实例,缺行即事件,不再自动补)。 svc_db_marker() { manifest_ok || { printf '%s' established; return 0; } python3 - "$ENV_DIR" <<'PY' import json, os, sys p = os.path.join(sys.argv[1], "manifest.json") m = json.load(open(p, encoding="utf-8")) print((m.get("identity") or {}).get("db_marker") or "established") PY } svc_db_marker_set() { local marker="$1" manifest_ok || return 0 python3 - "$ENV_DIR" "$marker" <<'PY' import json, os, sys env_dir, marker = sys.argv[1], sys.argv[2] path = os.path.join(env_dir, "manifest.json") m = json.load(open(path, encoding="utf-8")) ident = dict(m.get("identity") or {}) if ident.get("db_marker") == marker: sys.exit(0) ident["db_marker"] = marker m["identity"] = ident m["revision"] = int(m.get("revision") or 0) + 1 tmp = path + ".new" with open(tmp, "w", encoding="utf-8") as f: json.dump(m, f, ensure_ascii=False, indent=2) f.write("\n") os.replace(tmp, path) PY } # svc_identity_run BIN OP:身份子命令。DSN 只经环境(AGENT_SYSTEM_DSN),不进 argv。 svc_identity_run() { local bin="$1" op="$2" iid="$3" dbref="$4" data="$5" local -a args=(env -i "PATH=$PATH" "HOME=${HOME:-/tmp}" "TMPDIR=${TMPDIR:-/tmp}" "AGENT_SYSTEM_INSTANCE_ID=$iid" "AGENT_SYSTEM_DBREF=$dbref" "AGENT_SYSTEM_DATA=$data") if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then args+=("AGENT_SYSTEM_DSN=$DB_DSN") fi args+=("$bin" identity "$op" --instance-id "$iid" --dbref "$dbref" --data "$data") "${args[@]}" } svc_db_identity_ensure() { # 仅 fresh/pending 或显式 adopt 调用。established 缺行不得走这里自动补。 # 非 agent 或无能力:跳过。可选参数 BIN:部署候选。 local bin="${1:-${ENV_BIN:-}}" manifest_ok || return 0 local kind; kind="$(svc_get kind)" [ "$kind" = agent ] || return 0 svc_bin_has_identity "$bin" || return 0 local line iid dbref data line="$(svc_db_identity_args)" || return 1 IFS=$'\t' read -r iid dbref data <<<"$line" local out rc=0 out="$(svc_identity_run "$bin" ensure "$iid" "$dbref" "$data" 2>&1)" || rc=$? if [ "$rc" -ne 0 ]; then c_err "[$ENV_NAME] DB 身份 ensure 失败:$out" return 1 fi svc_db_marker_set established || return 1 return 0 } svc_db_identity_verify() { manifest_ok || return 0 local kind; kind="$(svc_get kind)" [ "$kind" = agent ] || return 0 local bin="${1:-${ENV_BIN:-}}" svc_bin_has_identity "$bin" || return 0 local line iid dbref data line="$(svc_db_identity_args)" || return 1 IFS=$'\t' read -r iid dbref data <<<"$line" local out rc=0 out="$(svc_identity_run "$bin" verify "$iid" "$dbref" "$data" 2>&1)" || rc=$? if [ "$rc" -ne 0 ]; then c_err "[$ENV_NAME] DB 身份校验失败,拒绝启动:$out" c_err " 先调查库文件与身份行;确认归属后 scripts/svc.sh $ENV_NAME adopt --confirm $iid" return 1 fi AGENT_SYSTEM_INSTANCE_ID="$iid" AGENT_SYSTEM_DBREF="$dbref" export AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF return 0 } # svc_deploy_identity_gate SRCBIN:部署候选的身份门禁——在停旧服务、覆盖制品之前 # 执行(2026-10-12 rdm1/rdmcand 事故:停服→覆盖→启动即被身份门禁拒绝,只剩 # 「登记版本≠磁盘制品」的混合状态且无恢复路径)。规则: # 1) 未入 manifest 治理(全新实例,无 manifest.json):放行——首次部署无旧状态可保; # 2) 非 agent 类型:放行; # 3) 候选产物无 identity 管理能力:拒绝——装上即启动被基座身份门禁拒绝, # 登记与旧制品/旧进程保持不变(下游组合仓接齐 identity 接口前不可更新); # 4) 候选有能力:先以候选幂等补齐 DB 身份行(领养时旧产物可能无能力而未补; # 外宾行/不一致 fail-closed),成功才放行——把「缺行」在停服前治好, # 避免换完制品才发现 start 起不来。 svc_deploy_identity_gate() { local srcbin="$1" manifest_ok || return 0 local kind; kind="$(svc_get kind)" [ "$kind" = agent ] || return 0 if ! svc_bin_has_identity "$srcbin"; then c_err "[$ENV_NAME] 候选产物不支持 identity 管理:$srcbin" c_err " manifest 治理实例拒绝安装(装上即被启动身份校验拒绝);旧服务/旧制品/登记保持不变" c_err " 处理:换具备 identity ensure/verify 的版本部署,或 scripts/svc.sh rm $ENV_NAME --purge 后放弃治理重新部署" return 1 fi resolve_db_config 2>/dev/null || true local marker; marker="$(svc_db_marker)" case "$marker" in fresh|pending) # 库文件还不存在时不补行、不建库;留给启动后的受控 ensure。 local data_root data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")" if [ -f "$data_root/agent-system.db" ] || { [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; }; then svc_db_identity_ensure "$srcbin" || { c_err "[$ENV_NAME] 候选补齐 DB 身份失败(外宾行/不一致 fail-closed);旧服务/旧制品/登记保持不变" return 1 } fi ;; *) # established:只验证,不补行。缺行或不匹配在停旧服务前拒绝。 svc_db_identity_verify "$srcbin" || { c_err "[$ENV_NAME] 候选无法验证已建立的 DB 身份;旧服务/旧制品/登记保持不变" return 1 } ;; esac svc_schema_compatible "$ENV_BIN" "$srcbin" || return 1 return 0 } # svc_schema_compatible INSTALLED CANDIDATE:候选 compatFloor 高于已装制品的 # schema.target 时,迁移会让旧制品读不了库。停旧之前拒绝。缺 schema 字段视为兼容。 svc_schema_compatible() { local installed="$1" candidate="$2" local icaps ccaps icaps="$(svc_bin_caps "$installed" 2>/dev/null || true)" ccaps="$(svc_bin_caps "$candidate" 2>/dev/null || true)" python3 - "$icaps" "$ccaps" <<'PY' import json, sys def load(raw): try: return json.loads(raw) if raw else {} except Exception: return {} old, new = load(sys.argv[1]), load(sys.argv[2]) osch, nsch = old.get("schema") or {}, new.get("schema") or {} if "target" not in osch or "compatFloor" not in nsch: sys.exit(0) if int(osch["target"]) < int(nsch["compatFloor"]): sys.exit(1) sys.exit(0) PY local rc=$? if [ "$rc" -eq 0 ]; then return 0 fi if [ "${CLI_MIGRATE_SCHEMA:-0}" = 1 ]; then local iid backup iid="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["instance_id"])' "$ENV_DIR/manifest.json")" [ "$CLI_CONFIRM" = "$iid" ] || { c_err "[$ENV_NAME] --migrate-schema 需要 --confirm $iid"; return 1; } backup="$(agent_backup_root)" if ! find "$backup" -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then c_err "[$ENV_NAME] forward-only 迁移必须先 backup" return 1 fi c_warn "[$ENV_NAME] forward-only schema 迁移已确认,回滚不会覆盖业务库" return 0 fi c_err "[$ENV_NAME] 候选 schema 与已装制品不兼容,停旧服务前拒绝" c_err " 若必须前向迁移:scripts/svc.sh $ENV_NAME deploy --migrate-schema --confirm (会先 backup)" return 1 } # svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 running, # 显式 stop 置 stopped;进程自行死亡不动——recover 据 svc.env 派生的 autostart 判断)。 # svc_write_serve_env:首次显式启动把白名单环境(不含凭据、不含 AGENT_SYSTEM_*) # 写入 0600 的 serve.env。裸启动与 recover 复用它,避免交互壳与 watchdog 环境不同。 svc_write_serve_env() { local f="$ENV_DIR/serve.env" [ -f "$f" ] && return 0 ( umask 077 { printf 'PATH=%s\n' "${PATH:-/usr/bin:/bin}" printf 'HOME=%s\n' "${HOME:-/tmp}" printf 'USER=%s\n' "${USER:-}" printf 'LOGNAME=%s\n' "${LOGNAME:-${USER:-}}" printf 'LANG=%s\n' "${LANG:-C.UTF-8}" printf 'TZ=%s\n' "${TZ:-UTC}" printf 'TMPDIR=%s\n' "${TMPDIR:-/tmp}" } > "$f" ) chmod 0600 "$f" 2>/dev/null || true } # svc_launch_serve ARGS:env -i 只带 serve.env 与本次构造的 guard_env。 svc_launch_serve() { local exec_args="$1" local -a args=(env -i) local line if [ -f "$ENV_DIR/serve.env" ]; then while IFS= read -r line || [ -n "$line" ]; do [ -z "$line" ] && continue case "$line" in AGENT_SYSTEM_*|*' '*) continue ;; *=*) args+=("$line") ;; esac done < "$ENV_DIR/serve.env" fi if [ "${#guard_env[@]}" -gt 0 ]; then args+=("${guard_env[@]}") fi # 桩只靠 SVC_STUB_* 找到记录目录。这些变量不写入 serve.env,避免 recover # 把某次测试路径固化进实例;生产调用方没有它们。 # RDM_DISABLE_EXEC_TOOLS 是产品兜底开关,必须到达 serve,否则无 bwrap 时拒启。 local k while IFS= read -r k; do [ -n "$k" ] || continue args+=("$k=${!k}") done < <(compgen -e | grep -E '^SVC_STUB_' || true) if [ -n "${RDM_DISABLE_EXEC_TOOLS:-}" ]; then args+=("RDM_DISABLE_EXEC_TOOLS=$RDM_DISABLE_EXEC_TOOLS") fi # shellcheck disable=SC2086 nohup "${args[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- & } # svc_lifecycle_child ACTION [extra args...]:用清空的环境拉起本脚本的 # __lifecycle。白名单:PATH/HOME/USER/LOGNAME/LANG/LC_*/TZ/TMPDIR、代理、SVC_*、 # GIT_CONFIG_*(运行器把 SSH pin 改写成受信 HTTPS,token 只在环境里)。 # 从不传入 AGENT_SYSTEM_*。serve 仍是 env -i,不继承这些变量。 svc_lifecycle_child() { local -a args=(env -i "PATH=${PATH:-/usr/bin:/bin}" "HOME=${HOME:-/tmp}" "USER=${USER:-}" "LOGNAME=${LOGNAME:-${USER:-}}" "LANG=${LANG:-C.UTF-8}" "TZ=${TZ:-UTC}" "TMPDIR=${TMPDIR:-/tmp}") local k for k in http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy NO_PROXY; do if [ -n "${!k:-}" ]; then args+=("$k=${!k}") fi done while IFS= read -r k; do [ -n "$k" ] || continue args+=("$k=${!k}") done < <(compgen -e | grep -E '^(SVC_|LC_|GIT_CONFIG_)' || true) if [ -n "${RDM_DISABLE_EXEC_TOOLS:-}" ]; then args+=("RDM_DISABLE_EXEC_TOOLS=$RDM_DISABLE_EXEC_TOOLS") fi args+=(bash "$0" __lifecycle "$@") "${args[@]}" } svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired running >/dev/null 2>&1 && return 0; svc_set_key autostart 1 >/dev/null 2>&1 || true; } svc_clear_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired stopped >/dev/null 2>&1 && return 0; svc_del_key autostart 2>/dev/null || true; } check_port_free() { # 先尝试回收本服务残留(无/坏 pidfile 的孤儿);外部占用仍拒绝。 reclaim_own_port || true local owner pid own owner="$(port_owner)" if [ -n "$owner" ]; then pid="$(port_owner_pid 2>/dev/null || true)" own="" [ -n "$pid" ] && own="$(find_own_in_ancestry "$pid" 2>/dev/null || true)" if [ -n "$own" ]; then c_err "[$ENV_NAME] 端口被本服务残留占用且回收失败:$owner" else c_err "[$ENV_NAME] 端口被占用(非本服务):$owner" fi c_err "处理:停掉该进程,或换端口(--port / --addr)" return 1 fi # ss 不可用时 port_owner 为空——再经 port_listening(含 /dev/tcp 兜底)确认, # 否则无 iproute2 的环境里启动守卫完全失明(CI 容器实测)。 if port_listening "${ENV_ADDR##*:}"; then c_err "[$ENV_NAME] 端口 ${ENV_ADDR##*:} 正被监听(未能识别占用者;无 ss 时仅能探测不能归因)" c_err "处理:停掉该进程,或换端口(--port / --addr)" return 1 fi } # ---- 服务配置解析(CLI > 配置文件/环境变量 > svc.env 持久化 > 默认)---- env_config() { local name="$1" valid_env "$name" || { c_err "非法服务名:$name(^[a-z][a-z0-9-]{0,15}$;保留字 status/list/db/rm 等不可用)" exit 2 } ENV_NAME="$name" ENV_DIR="$RUNTIME/$name" ENV_PID="$ENV_DIR/svc.pid" ENV_LOG="$ENV_DIR/svc.log" # U2:releases/ 不可变版本目录 + current 原子指针。已切换实例经 current 解析 # 产物(进程 cmdline 保留未解析路径——指针切换不破坏 pidcheck 匹配,也彻底 # 消除 exe=(deleted) 覆盖孤儿);legacy 实例沿用原路径(首次 deploy 领养, # svc_release_adopt_legacy)。 if [ -L "$ENV_DIR/current" ]; then ENV_BIN_DIR="$ENV_DIR/current/bin" ENV_PLUGINS="$ENV_DIR/current/plugins" else ENV_BIN_DIR="$ENV_DIR/bin" ENV_PLUGINS="$ENV_DIR/plugins" fi ENV_BIN="$ENV_BIN_DIR/agent-system" # 按实例重置内存态——批量 update/restart 逐服务解析时不串用上一服务。 # DB_*:防跨服务串库;SVC_PIDCHECK/EXEC/VERSION/SOURCE:防 pid_alive 与 # reclaim_own_port 用上一服务标记误判本服务进程为「已死/非本服务」(2026-10-12 # update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。 SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE="" SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE="" # U1:身份环境同样按实例重置——上一服务 verify 成功后导出的 # AGENT_SYSTEM_INSTANCE_ID/DBREF 不得串给下一服务(批量 update 单进程逐服务解析; # 2026-10-12 实况:dev 的身份漏给 rdm1/rdmcand,叠加 RDM 产物无 identity 子命令 # 跳过校验,serve 带外宾身份即被基座门禁拒绝)。serve 只允许拿到本服务 verify # 写入的身份。 unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF local registered="" [ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")" SVC_KIND="${registered:-agent}" [ -n "$CLI_KIND" ] && SVC_KIND="$CLI_KIND" # 类型与既有登记冲突防护:同名服务换类型必须先 rm。 if [ -n "$registered" ] && [ -n "$CLI_KIND" ] && [ "$CLI_KIND" != "$registered" ]; then c_err "[$ENV_NAME] 已登记为 kind=$registered,不能改类型;先 scripts/svc.sh rm $ENV_NAME" exit 2 fi [ "$SVC_KIND" = postgres ] && ENV_LOG="$ENV_DIR/pg.log" local base_addr base_data persisted_addr base_addr="$(env_var "$name" ADDR)" base_data="$(env_var "$name" DATA)" persisted_addr="" [ -f "$ENV_DIR/svc.env" ] && persisted_addr="$(svc_get addr)" if [ -z "$base_addr" ]; then if [ -n "$persisted_addr" ]; then base_addr="$persisted_addr" else case "$name" in dev) base_addr="127.0.0.1:8788" ;; prod) base_addr="127.0.0.1:8787" ;; *) local p p="$(derive_port "$name")" || { c_err "[$name] 无空闲派生端口(8800-9099),请用 --port/--addr 或 SVC_$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')_ADDR 显式指定" exit 1 } base_addr="127.0.0.1:$p" ;; esac fi fi if [ -z "$base_data" ]; then case "$SVC_KIND" in postgres) base_data="$ENV_DIR/pgdata" ;; *) base_data="$ENV_DIR/data" ;; esac fi ENV_ADDR="$(addr_compose "$base_addr")" ENV_DATA="${CLI_DATA:-$base_data}" return 0 } # agent_has_explicit_cfg:用户是否显式给了运行参数(决定是否复用持久化 exec=)。 agent_has_explicit_cfg() { [ -n "$CLI_ADDR$CLI_HOST$CLI_PORT$CLI_DSN$CLI_AUTH$CLI_PLUGINS$CLI_EXTRA" ] && return 0 [ "$CLI_MOCK" = 1 ] && return 0 [ -n "$SVC_ENVFILE" ] && return 0 [ -n "$(env_var "$ENV_NAME" ADDR)$(env_var "$ENV_NAME" DATA)$(env_var "$ENV_NAME" DSN)$(env_var "$ENV_NAME" AUTH)$(env_var "$ENV_NAME" MOCK)" ] && return 0 return 1 } # 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() { local bin="$1" args="$2" out # timeout 护栏:组合二进制若把 serve 当位置参数忽略而直接进入服务态,探测会挂死。 out="$(timeout 5 "$bin" serve -h 2>&1 || true)" local filtered="" w drop_next=0 flag for w in $args; do case "$w" in --auth|--config) flag="${w#--}" if printf '%s\n' "$out" | grep -Eq -- "(^|[[:space:]])-{1,2}${flag}([[:space:]]|=|$)"; then filtered="$filtered $w" else drop_next=1 fi ;; *) if [ "$drop_next" = 1 ]; then drop_next=0; continue; fi filtered="$filtered $w" ;; esac done printf '%s' "${filtered# }" } # ---- 提交列举与选择(dev / release 两源)---- releases_root() { [ -n "${REL_ROOT:-}" ] && return 0 REL_ROOT="${SVC_RELEASES_DIR:-$ROOT/../Agent-System-Releases}" if [ ! -d "$REL_ROOT/.git" ]; then c_err "Releases 仓不存在:$REL_ROOT(可用 SVC_RELEASES_DIR 指定路径)" exit 2 fi } # svc_src_ready:确保独立部署源仓就绪(不存在则从 $ROOT 的 origin 首次克隆)并 fetch # 远端;--no-fetch 跳过网络。同一次命令运行内幂等(首个调用定下 fetch 策略)。 # 失败 return 1(无 origin 可克隆/克隆失败),调用方自行决定报错或降级。 SRC_READY_DONE=0 svc_src_ready() { [ "$SRC_READY_DONE" = 1 ] && return 0 if [ ! -d "$SRC_DIR/.git" ]; then local url url="$(git -C "$ROOT" remote get-url origin 2>/dev/null || true)" [ -n "$url" ] || { c_err "无法确定 origin 地址($ROOT 无 origin 远端);可用 SVC_SRC_DIR 指向已有仓"; return 1; } c_info "初始化独立部署源仓:$SRC_DIR(← origin;与当前工作区无关,可随时删除重建)" mkdir -p "$(dirname "$SRC_DIR")" if ! git clone --quiet "$url" "$SRC_DIR" >&2; then c_err "部署源仓克隆失败:$url → $SRC_DIR" return 1 fi fi if [ "${1:-}" != "--no-fetch" ]; then c_info "git fetch(独立部署源仓:$SRC_DIR)" git -C "$SRC_DIR" fetch origin --quiet 2>/dev/null || c_warn "部署源仓 fetch 失败——基于本地缓存($SRC_DIR)" fi SRC_READY_DONE=1 return 0 } # rdm_src_ready:确保独立 RDM 源仓就绪(缺省从 RDM 远端克隆,SVC_RDM_ORIGIN 覆盖来源、 # SVC_RDM_SRC_DIR 覆盖路径)并 fetch;--no-fetch 跳过网络。与 svc_src_ready 同构, # 面向下游组合仓 Agent-System-RDM(构建组合二进制 agent-system-rdm)。 RDM_SRC_READY_DONE=0 rdm_src_ready() { [ "$RDM_SRC_READY_DONE" = 1 ] && return 0 if [ ! -d "$RDM_SRC_DIR/.git" ]; then local url="${SVC_RDM_ORIGIN:-$RDM_ORIGIN_DEFAULT}" c_info "初始化独立 RDM 源仓:$RDM_SRC_DIR(← $url;与当前工作区无关,可随时删除重建)" mkdir -p "$(dirname "$RDM_SRC_DIR")" if ! git clone --quiet "$url" "$RDM_SRC_DIR" >&2; then c_err "RDM 源仓克隆失败:$url → $RDM_SRC_DIR(可用 SVC_RDM_ORIGIN 指定克隆来源)" return 1 fi fi if [ "${1:-}" != "--no-fetch" ]; then c_info "git fetch(独立 RDM 源仓:$RDM_SRC_DIR)" git -C "$RDM_SRC_DIR" fetch origin --quiet 2>/dev/null || c_warn "RDM 源仓 fetch 失败——基于本地缓存($RDM_SRC_DIR)" fi RDM_SRC_READY_DONE=1 return 0 } deployed_envs() { local key="$1" d e s out="" for d in "$RUNTIME"/*/; do [ -f "${d}svc.env" ] || continue e="$(basename "$d")" valid_env "$e" || continue s="$(sed -n 's/^source=//p' "${d}svc.env")" [ "$s" = "$key" ] && out="${out:+$out,}$e" done printf '%s' "$out" } print_commit_list() { # SRC N PICK:提交表到 stdout;PICK=1 时填充 PICK_OPTS local src="$1" count="$2" pick="$3" local sep=$'\037' if [ "$src" = dev ]; then svc_src_ready || { c_err "部署源仓不可用,无法列出可部署提交"; return 1; } c_info "Dev 仓提交(独立部署源仓 $SRC_DIR 的 origin/main;倒序取 $count 条)——部署:scripts/svc.sh deploy --from dev@" printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题' local latest_short; latest_short="$(git -C "$SRC_DIR" rev-parse --short origin/main 2>/dev/null || true)" local n=0 h date subject marks envs while IFS="$sep" read -r h date subject; do [ -z "$h" ] && continue n=$((n + 1)) marks="" [ "$h" = "$latest_short" ] && marks="${marks}[最新]" envs="$(deployed_envs "dev@$h")" [ -n "$envs" ] && marks="${marks}[已部署:$envs]" printf ' %3d %-9s %-10s %s %s\n' "$n" "$h" "$date" "$subject" "$marks" [ "$pick" = 1 ] && PICK_OPTS+=("$h") done < <(git -C "$SRC_DIR" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short origin/main) elif [ "$src" = rdm ]; then rdm_src_ready || { c_err "RDM 源仓不可用,无法列出可部署提交"; return 1; } c_info "RDM 仓提交(独立 RDM 源仓 $RDM_SRC_DIR 的 origin/main;倒序取 $count 条)——部署:scripts/svc.sh deploy --from rdm@" printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题' local latest_short; latest_short="$(git -C "$RDM_SRC_DIR" rev-parse --short origin/main 2>/dev/null || true)" local n=0 h date subject marks envs while IFS="$sep" read -r h date subject; do [ -z "$h" ] && continue n=$((n + 1)) marks="" [ "$h" = "$latest_short" ] && marks="${marks}[最新]" envs="$(deployed_envs "rdm@$h")" [ -n "$envs" ] && marks="${marks}[已部署:$envs]" printf ' %3d %-9s %-10s %s %s\n' "$n" "$h" "$date" "$subject" "$marks" [ "$pick" = 1 ] && PICK_OPTS+=("$h") done < <(git -C "$RDM_SRC_DIR" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short origin/main) else c_info "Releases 仓提交($REL_ROOT;倒序取 $count 条)——部署:scripts/svc.sh deploy --from release@<版本>" printf ' %3s %-9s %-10s %-8s %-9s %s\n' '#' 'sha' '日期' '版本' 'dev提交' '主题' local head_full; head_full="$(git -C "$REL_ROOT" rev-parse HEAD)" local n=0 full h date subject ver dc marks envs while IFS="$sep" read -r full h date subject; do [ -z "$full" ] && continue n=$((n + 1)) ver="$(git -C "$REL_ROOT" ls-tree --name-only "$full:versions" 2>/dev/null | sort -V | tail -1 || true)" dc="" [ -n "$ver" ] && dc="$(git -C "$REL_ROOT" show "$full:versions/$ver/manifest.json" 2>/dev/null | sed -n 's/.*"devCommit": *"\([0-9a-f]\{8,\}\)".*/\1/p' || true)" [ -z "$ver" ] && ver="-" marks="" [ "$full" = "$head_full" ] && marks="${marks}[HEAD]" envs="$(deployed_envs "release@$ver")" [ -n "$envs" ] && marks="${marks}[已部署:$envs]" printf ' %3d %-9s %-10s %-8s %-9s %s %s\n' "$n" "$h" "$date" "$ver" "${dc:0:8}" "$subject" "$marks" [ "$pick" = 1 ] && PICK_OPTS+=("$ver") done < <(git -C "$REL_ROOT" log -n "$count" --format="%H${sep}%h${sep}%ad${sep}%s" --date=short) fi return 0 } cmd_commits() { local src="${1:-dev}" count="${2:-20}" case "$src" in dev|release|rdm) ;; *) c_err "用法:scripts/svc.sh commits [dev|release|rdm] [N]"; exit 2 ;; esac case "$count" in ''|*[!0-9]*|0) c_err "N 须为正整数"; exit 2 ;; esac [ "$src" = release ] && releases_root print_commit_list "$src" "$count" 0 } pick_commit() { # SRC → stdout 只回显 "dev@"/"release@<版本>"(列表走 stderr) local src="$1" [ -t 0 ] || { c_err "--pick 需要交互终端;非交互环境请用 --from $src@"; exit 2; } PICK_OPTS=() print_commit_list "$src" 20 1 >&2 local sel printf '\n输入序号选择要部署的提交(1-%s,回车取消):' "${#PICK_OPTS[@]}" >&2 read -r sel || { printf '\n' >&2; c_info "已取消"; exit 0; } case "$sel" in ''|*[!0-9]*) c_info "已取消(未选择)"; exit 0 ;; *) [ "$sel" -ge 1 ] && [ "$sel" -le "${#PICK_OPTS[@]}" ] || { c_err "序号超范围:$sel"; exit 2; } ;; esac echo "$src@${PICK_OPTS[$((sel - 1))]}" } # ---- 部署来源解析与物化 ---- resolve_release() { # REF(空=最新) → REL_COMMIT/REL_VERSION/SRC_DEVCOMMIT local ref="${1:-}" commit ver if [ -z "$ref" ]; then commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet HEAD)" [ -n "$commit" ] || { c_err "Releases 仓无法解析 HEAD:$REL_ROOT"; exit 2; } elif commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet "${ref}^{commit}")"; then : elif git -C "$REL_ROOT" cat-file -e "HEAD:versions/$ref/manifest.json" 2>/dev/null; then commit="$(git -C "$REL_ROOT" rev-parse HEAD)" else c_err "Releases 仓无此引用:$ref(可用版本号或提交号;先看可用项:scripts/svc.sh commits release)" exit 2 fi ver="$(git -C "$REL_ROOT" ls-tree --name-only "$commit:versions" 2>/dev/null | sort -V | tail -1 || true)" [ -n "$ver" ] || { c_err "提交 $(git -C "$REL_ROOT" rev-parse --short "$commit") 下无 versions/ 快照,无法确定版本号"; exit 2; } REL_COMMIT="$commit"; REL_VERSION="$ver" SRC_DEVCOMMIT="$(git -C "$REL_ROOT" show "$commit:versions/$ver/manifest.json" 2>/dev/null | sed -n 's/.*"devCommit": *"\([0-9a-f]\{8,\}\)".*/\1/p' || true)" } resolve_source() { # → SRC_KINDSRC(local|dev|release) + SRC_LABEL(+SRC_FULL/SRC_SHA/REL_*) local spec="${CLI_FROM:-dev}" if [ -n "$CLI_PICK" ]; then [ "$CLI_PICK" = release ] && releases_root spec="$(pick_commit "$CLI_PICK")" fi case "$spec" in local) SRC_KINDSRC=local SRC_LABEL="local@$(git -C "$ROOT" rev-parse --short HEAD)" ;; dev|dev@*) # 缺省 ref=origin/main(独立部署源仓的远端最新);显式 @ 亦可。 local ref="origin/main" case "$spec" in dev@*) ref="${spec#dev@}" ;; esac svc_src_ready || { c_err "部署源仓不可用——dev 源依赖它;或改用 --from local 构建当前工作区"; exit 2; } local full full="$(git -C "$SRC_DIR" rev-parse --verify --quiet "${ref}^{commit}")" || { c_err "部署源仓无此提交:$ref(已自动 fetch;可用项:scripts/svc.sh commits dev)" exit 2 } SRC_KINDSRC=dev SRC_FULL="$full" SRC_SHA="$(git -C "$SRC_DIR" rev-parse --short "$full")" SRC_LABEL="dev@$SRC_SHA" ;; release|release@*) releases_root local ref="" case "$spec" in release@*) ref="${spec#release@}" ;; esac resolve_release "$ref" SRC_KINDSRC=release SRC_LABEL="release@$REL_VERSION" ;; rdm|rdm@*) # 缺省 ref=origin/main(独立 RDM 源仓远端最新);显式 @ 亦可。 local ref="origin/main" case "$spec" in rdm@*) ref="${spec#rdm@}" ;; esac rdm_src_ready || { c_err "独立 RDM 源仓不可用——rdm 源依赖它(可用 SVC_RDM_ORIGIN 指定克隆来源、SVC_RDM_SRC_DIR 指定路径)"; exit 2; } local full full="$(git -C "$RDM_SRC_DIR" rev-parse --verify --quiet "${ref}^{commit}")" || { c_err "RDM 源仓无此提交:$ref(已自动 fetch;可用项:scripts/svc.sh commits rdm)" exit 2 } SRC_KINDSRC=rdm SRC_FULL="$full" SRC_SHA="$(git -C "$RDM_SRC_DIR" rev-parse --short "$full")" SRC_LABEL="rdm@$SRC_SHA" ;; *) c_err "无法解析 --from $spec(可用:local | dev[@] | release[@<版本|sha>] | rdm[@])" exit 2 ;; esac } # svc_fault_check PHASE:仅当 SVC_TEST_FAULTS=1 且 ROOT 在临时目录时,于该阶段退出。 svc_fault_check() { local phase="$1" [ "${SVC_TEST_FAULTS:-}" = 1 ] || return 0 [ "${SVC_FAULT_AT:-}" = "$phase" ] || return 0 case "$ROOT" in /tmp/*|/var/tmp/*) ;; *) c_err "[$ENV_NAME] 忽略故障注入:ROOT 不在临时目录"; return 0 ;; esac c_err "[$ENV_NAME] 故障注入于 $phase" exit 86 } svc_journal_write() { local phase="$1" SVC_OP_ID="${SVC_OP_ID:-op-$(date -u +%Y%m%dT%H%M%SZ)-$$}" python3 - "$ENV_DIR/deploy-journal.json" "$phase" "$SVC_OP_ID" \ "${SVC_FROM_RID:-}" "${SVC_TO_RID:-}" "${SVC_FROM_DESIRED:-running}" \ "${SVC_WAS_RUNNING:-0}" <<'PY' import json, os, sys path, phase, op, fr, to, desired, running = sys.argv[1:] doc = { "op_id": op, "from_rid": fr, "to_rid": to, "from_desired": desired, "was_running": running == "1", "phase": phase, } os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".new" with open(tmp, "w", encoding="utf-8") as f: json.dump(doc, f, ensure_ascii=False) f.write("\n") os.chmod(tmp, 0o600) os.replace(tmp, path) os.chmod(path, 0o600) PY svc_fault_check "$phase" } svc_journal_phase() { [ -f "$ENV_DIR/deploy-journal.json" ] || return 0 python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("phase",""))' "$ENV_DIR/deploy-journal.json" } # svc_release_switch RID:current 用 ln -s + mv -T 原子切换,bin/plugins 保持稳定链接。 svc_release_switch() { local rid="$1" local rel="$ENV_DIR/releases/$rid" [ -d "$rel/bin" ] || { c_err "[$ENV_NAME] 版本目录缺少 bin:$rel"; return 1; } ln -s "$rid" "$ENV_DIR/current.new" mv -Tf "$ENV_DIR/current.new" "$ENV_DIR/current" ln -sfn "current/bin" "$ENV_DIR/bin" ln -sfn "current/plugins" "$ENV_DIR/plugins" if [ -d "$rel/htdocs" ]; then ln -sfn "current/htdocs" "$ENV_DIR/htdocs" fi } svc_release_stage() { # SRC BIN PLUGINS → 打印 rid local srcbin="$1" srcplug="$2" local digest sha rid dest digest="$(sha256sum "$srcbin" | awk '{print substr($1,1,12)}')" sha="$(printf '%s' "${SRC_SHA:-inplace}" | cut -c1-12)" rid="${SRC_KINDSRC:-inplace}-${sha}-${digest}" dest="$ENV_DIR/releases/$rid" if [ ! -d "$dest/bin" ]; then rm -rf "${dest}.partial" mkdir -p "${dest}.partial/bin" "${dest}.partial/plugins" cp -f "$srcbin" "${dest}.partial/bin/agent-system" || return 1 chmod 755 "${dest}.partial/bin/agent-system" if [ -d "$srcplug" ]; then cp -a "$srcplug/." "${dest}.partial/plugins/" || return 1 fi python3 - "${dest}.partial/release.json" "$rid" "${SRC_LABEL:-}" "$digest" <<'PY' import json, sys path, rid, label, digest = sys.argv[1:] with open(path, "w", encoding="utf-8") as f: json.dump({"rid": rid, "source": label, "bin_digest12": digest}, f) f.write("\n") PY mv "${dest}.partial" "$dest" fi printf '%s' "$rid" } svc_release_rollback() { local from from="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("from_rid",""))' "$ENV_DIR/deploy-journal.json" 2>/dev/null || true)" svc_journal_write rolling_back || true if [ -z "$from" ] || [ ! -d "$ENV_DIR/releases/$from/bin" ]; then svc_journal_write aborted || true c_err "[$ENV_NAME] 无法回滚:没有上一版本" return 3 fi svc_release_switch "$from" || { svc_journal_write aborted || true; return 3; } local was was="$(python3 -c 'import json,sys; print("1" if json.load(open(sys.argv[1])).get("was_running") else "0")' "$ENV_DIR/deploy-journal.json")" if [ "$was" = 1 ]; then if agent_do_start; then svc_journal_write rolled_back return 0 fi svc_journal_write aborted || true c_err "[$ENV_NAME] 回滚后的旧版本也未能启动,期望态保持" return 3 fi svc_journal_write rolled_back return 0 } svc_reconcile_journal() { [ -f "$ENV_DIR/deploy-journal.json" ] || return 0 local phase phase="$(svc_journal_phase)" case "$phase" in ""|committed|rolled_back|aborted) return 0 ;; esac c_warn "[$ENV_NAME] 发现未完成的切换(phase=$phase),回滚到上一版本" svc_release_rollback } install_artifacts() { # SRCBIN SRCPLUGINS:写入不可变版本目录并原子切换 current。 local srcbin="$1" srcplug="$2" # 覆盖前必须无本服务占口进程——否则留下 exe=(deleted) 孤儿,后续 start 误报非本服务。 if ! reclaim_own_port; then c_err "[$ENV_NAME] 安装前端口仍被占用,拒绝覆盖二进制(避免 exe deleted 孤儿)" return 1 fi if [ -d "$ENV_BIN_DIR" ] && [ ! -L "$ENV_BIN_DIR" ] && [ -x "$ENV_BIN" ]; then local old_digest old_rid old_digest="$(sha256sum "$ENV_BIN" | awk '{print substr($1,1,12)}')" old_rid="inplace-${old_digest}" if [ ! -d "$ENV_DIR/releases/$old_rid/bin" ]; then mkdir -p "$ENV_DIR/releases/$old_rid" mv "$ENV_BIN_DIR" "$ENV_DIR/releases/$old_rid/bin" if [ -d "$ENV_DIR/plugins" ] && [ ! -L "$ENV_DIR/plugins" ]; then mv "$ENV_DIR/plugins" "$ENV_DIR/releases/$old_rid/plugins" else mkdir -p "$ENV_DIR/releases/$old_rid/plugins" fi fi SVC_FROM_RID="$old_rid" svc_release_switch "$old_rid" elif [ -L "$ENV_DIR/current" ]; then SVC_FROM_RID="$(readlink "$ENV_DIR/current")" fi local rid rid="$(svc_release_stage "$srcbin" "$srcplug")" || return 1 SVC_TO_RID="$rid" svc_journal_write switched || return 1 svc_release_switch "$rid" || return 1 c_ok "[$ENV_NAME] 产物就位:$ENV_BIN(release $rid)" return 0 } # ---- U2:不可变版本目录(releases/)+ current 原子指针 + 切换 journal + 失败回退 ---- # 布局:releases//{bin/agent-system, plugins/, htdocs/, release.json}; # current → releases/(相对符号链接,temp+rename 原子切换);deploy.journal # 记录切换过程(staging→switched→done/rolledback/blocked),任意步骤中断后由 # svc_deploy_journal_resolve 判定并恢复实际状态。legacy 目录永不删除:旧进程 # cmdline 指向 legacy 路径,pidcheck 兼容到自然换版。 svc_release_cur() { [ -L "$ENV_DIR/current" ] && readlink "$ENV_DIR/current" || true; } svc_release_cur_id() { local c; c="$(svc_release_cur)"; printf '%s' "${c##*/}"; } # svc_release_adopt_legacy:legacy 布局(bin/ 直接落服务目录)→ 领养为首个 # release(幂等:已有 current 即跳过;无产物即跳过——全新部署无需领养)。 svc_release_adopt_legacy() { [ -L "$ENV_DIR/current" ] && return 0 [ -x "$ENV_DIR/bin/agent-system" ] || return 0 # 注意:bash 5.3 set -u 下同一 local 行不得引用本行先声明变量(拆行)。 local id="legacy-$(date -u +%Y%m%dT%H%M%SZ)" local dir="$ENV_DIR/releases/$id" mkdir -p "$dir/bin" || return 1 cp -f "$ENV_DIR/bin/agent-system" "$dir/bin/agent-system" || return 1 if [ -d "$ENV_DIR/plugins" ]; then cp -r "$ENV_DIR/plugins" "$dir/plugins" || return 1 else mkdir -p "$dir/plugins" fi [ -d "$ENV_DIR/htdocs" ] && cp -r "$ENV_DIR/htdocs" "$dir/htdocs" || true printf '{"id":"%s","source":"legacy","created":"%s"}\n' "$id" "$(date -u +%FT%TZ)" > "$dir/release.json" ln -s "releases/$id" "$ENV_DIR/.current.new" && mv -Tf "$ENV_DIR/.current.new" "$ENV_DIR/current" || { rm -f "$ENV_DIR/.current.new"; return 1; } c_info "[$ENV_NAME] 领养 legacy 制品为首个 release:releases/$id" } # svc_release_id:源标签 + 时钟 + 调用进程身份,同源同秒部署仍使用不同目录。 svc_release_id() { local label label="$(printf '%s' "${SRC_LABEL:-manual}" | tr '@' '-' | tr -cd 'a-zA-Z0-9._-' | cut -c1-40)" printf '%s-%s-%s' "$label" "$(date -u +%Y%m%dT%H%M%SZ)" "$BASHPID" } # svc_release_stage ID SRCBIN SRCPLUGINS [HTDOCS]:候选产物入不可变版本目录。 # stdout 回显 release 目录(供门禁/探测引用)。 svc_release_stage() { local id="$1" srcbin="$2" srcplug="$3" htdocs="${4:-}" local dir="$ENV_DIR/releases/$id" mkdir -p "$ENV_DIR/releases" || return 1 # 排他创建:重复 id 必须拒绝,不能覆盖仍用于运行或回退的历史制品。 mkdir "$dir" || { c_err "[$ENV_NAME] release 目录已存在或无法创建:$dir"; return 1; } mkdir "$dir/bin" || return 1 cp -f "$srcbin" "$dir/bin/agent-system" || return 1 if [ -d "$srcplug" ]; then cp -r "$srcplug" "$dir/plugins" || return 1 else mkdir -p "$dir/plugins" fi if [ -n "$htdocs" ] && [ -d "$htdocs" ]; then cp -r "$htdocs" "$dir/htdocs" || return 1 fi printf '{"id":"%s","source":"%s","created":"%s"}\n' \ "$id" "${SRC_LABEL:-}" "$(date -u +%FT%TZ)" > "$dir/release.json" printf '%s' "$dir" } # svc_release_switch ID:原子切换 current(temp 符号链接 + rename)。 svc_release_switch() { ln -s "releases/$1" "$ENV_DIR/.current.new" || return 1 mv -Tf "$ENV_DIR/.current.new" "$ENV_DIR/current" || { rm -f "$ENV_DIR/.current.new"; return 1; } } # svc_release_gc [KEEP]:裁剪历史 release(保留最近 KEEP 个,缺省 5;current 与 # legacy 永不删除)。失败静默——目录非关键路径。 svc_release_gc() { local keep="${1:-5}" cur id dirs=() [ -d "$ENV_DIR/releases" ] || return 0 cur="$(svc_release_cur_id)" while IFS= read -r d; do dirs+=("$(basename "$d")"); done \ < <(ls -1dt "$ENV_DIR/releases"/*/ 2>/dev/null) local n=${#dirs[@]} i rmv for ((i = keep; i < n; i++)); do rmv="${dirs[$i]}" [ "$rmv" = "$cur" ] && continue case "$rmv" in legacy-*) continue ;; esac rm -rf "$ENV_DIR/releases/$rmv" done return 0 } # ---- 切换 journal(deploy.journal):python3 原子写;缺 python3 时降级为无 journal ---- svc_journal_path() { printf '%s' "$ENV_DIR/deploy.journal"; } svc_journal_write() { # PHASE [k=v ...] local phase="$1"; shift command -v python3 >/dev/null 2>&1 || return 0 python3 - "$phase" "$(svc_journal_path)" "$@" <<'PYJ' import json, os, sys, time phase, out, args = sys.argv[1], sys.argv[2], sys.argv[3:] j = {"phase": phase, "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} for kv in args: if "=" in kv: k, v = kv.split("=", 1) j[k] = v tmp = out + ".new" with open(tmp, "w") as f: f.write(json.dumps(j, ensure_ascii=False) + "\n") os.replace(tmp, out) PYJ svc_fault_check "$phase" } svc_journal_get() { # KEY → 值(journal 缺失/损坏 → 空) local f; f="$(svc_journal_path)" [ -f "$f" ] || return 0 python3 -c 'import json,sys try: d = json.load(open(sys.argv[1])); v = d.get(sys.argv[2], "") print(v if isinstance(v, str) else json.dumps(v)) except Exception: pass' "$f" "$1" 2>/dev/null } svc_journal_phase() { svc_journal_get phase; } svc_journal_clear() { rm -f "$(svc_journal_path)"; } # svc_db_schema_version:SQLite 档当前 schema(schema_migrations 最大 id; # 无库/无表/pg → 空)。只读连接,绝不写库。 svc_db_schema_version() { local db="$ENV_DATA/agent-system.db" [ -f "$db" ] || return 0 python3 -c 'import sqlite3,sys try: con = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True) print(con.execute("select max(id) from schema_migrations").fetchone()[0]) con.close() except Exception: pass' "$db" 2>/dev/null } # svc_release_schema_supported ID:release.json 的 schema_at_commit(无则空)。 svc_release_schema_supported() { local f="$ENV_DIR/releases/$1/release.json" [ -f "$f" ] || return 0 python3 -c 'import json,sys try: print(json.load(open(sys.argv[1])).get("schema_at_commit") or "") except Exception: pass' "$f" 2>/dev/null } # svc_release_commit_schema ID:成功提交时把当前库 schema 记入 release.json # (回退时据此判定旧制品可否运行;pg/无库静默跳过)。 svc_release_commit_schema() { local f="$ENV_DIR/releases/$1/release.json" v v="$(svc_db_schema_version)" [ -n "$v" ] || return 0 python3 -c 'import json,sys try: d = json.load(open(sys.argv[1])); d["schema_at_commit"] = sys.argv[2] open(sys.argv[1], "w").write(json.dumps(d, ensure_ascii=False) + "\n") except Exception: pass' "$f" "$v" 2>/dev/null || true } # svc_rollback_deploy REASON:切换失败 → 回退上一完整版本并恢复期望运行态。 # schema 阻断(库 schema 超过上一 release 提交时点)不自动回退启动、绝不触碰 # 业务数据库——登记 blocked 待受控迁移(U2 约束:不能靠覆盖数据库「回滚」)。 svc_rollback_deploy() { local reason="$1" local prev new desired pid cur_schema prev_schema prev="$(svc_journal_get prev)" new="$(svc_journal_get new)" desired="$(svc_journal_get desired)" pid="$(read_pid)" if [ -n "$pid" ] && pid_alive "$pid"; then c_warn "[$ENV_NAME] 回退前停止新制品进程 pid=$pid" proc_stop "$pid" || true rm -f "$ENV_PID" fi if [ -z "$prev" ] || [ ! -d "$ENV_DIR/releases/$prev" ]; then c_err "[$ENV_NAME] 无可回退版本(prev 缺失,$reason)——保持停止;清理:rm 前先取证" svc_journal_write blocked reason="无 prev release:$reason" hist_event deploy fail "$pid" "回退无 prev($reason)" return 1 fi cur_schema="$(svc_db_schema_version)" prev_schema="$(svc_release_schema_supported "$prev")" if [ -n "$cur_schema" ] && [ -n "$prev_schema" ] \ && [ "$cur_schema" -gt "$prev_schema" ] 2>/dev/null; then c_err "[$ENV_NAME] schema 已前进(库 v$cur_schema > 上一 release 提交 v$prev_schema):旧制品无法运行,禁止自动回退" c_err " 不触碰业务数据库。处理:deploy 换可运行制品(受控迁移后),或人工处置;登记 blocked" svc_journal_write blocked reason="schema v$cur_schema > prev v$prev_schema($reason)" hist_event deploy fail "$pid" "回退阻断:schema 前进($reason)" return 1 fi svc_release_switch "$prev" || { svc_journal_write blocked reason="指针回退失败:$reason" return 1 } c_ok "[$ENV_NAME] 已回退上一版本:releases/$prev($reason)" # 指针已切回:重解析产物路径到回退后的 release。 ENV_BIN_DIR="$ENV_DIR/current/bin" ENV_PLUGINS="$ENV_DIR/current/plugins" ENV_BIN="$ENV_BIN_DIR/agent-system" hist_event deploy rollback "$pid" "new=${new:-?} prev=$prev($reason)" svc_journal_write rolledback "prev=$prev" "new=${new:-}" "reason=$reason" if [ "$desired" = "running" ]; then c_info "[$ENV_NAME] 按原期望态恢复运行(上一版本)" # 登记一致性:清掉新部署的源标签——回退后的 record_state 须回落到 svc.env # 里上一版的 source(否则实际跑旧制品、登记却写新标签)。 SRC_LABEL="" SVC_SOURCE="$(svc_get source)" agent_do_start || { svc_journal_clear; return 1; } fi svc_journal_clear return 0 } # svc_deploy_journal_resolve [ACTION]:生命周期入口处理中断的切换。 # switched/verifying:指针已切但未经完整验证 → 回退上一完整版本(无 prev 的 # 首次部署中断 → 保持停止)。blocked:拒绝除 deploy 外的动作(显式换制品即 # 解除路径)。staging:未切换,清记录(孤儿 release 目录无害,随 gc 裁剪)。 svc_deploy_journal_resolve() { local action="${1:-}" phase [ -f "$(svc_journal_path)" ] || return 0 phase="$(svc_journal_phase)" case "$phase" in ""|done|rolledback) svc_journal_clear; return 0 ;; staging) c_warn "[$ENV_NAME] 发现中断的暂存(staging,未切换)——忽略" svc_journal_clear; return 0 ;; blocked) if [ "$action" = "deploy" ]; then c_warn "[$ENV_NAME] 存在 blocked 切换记录,本次 deploy 尝试跨越(换可运行制品)" return 0 fi c_err "[$ENV_NAME] 切换被阻断($(svc_journal_get reason)):先 deploy 换可运行制品,或受控迁移后再操作" return 1 ;; switched|verifying) c_warn "[$ENV_NAME] 发现中断的切换($phase,new=$(svc_journal_get new))——回退上一完整版本" svc_rollback_deploy "中断的切换(journal=$phase)" || return 1 return 0 ;; esac return 0 } # svc_deploy_recycle_wt:deploy 各失败/成功出口统一回收构建 worktree(尽力而为)。 svc_deploy_recycle_wt() { case "${SRC_KINDSRC:-}" in dev) [ -n "${wt:-}" ] && recycle_worktree "$SRC_SHA" ;; rdm) [ -n "${wt:-}" ] && recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR" ;; esac return 0 } # materialize_dev_commit FULL SHA TARGET:隔离 worktree 物化+构建;stdout 只回显 worktree # 路径(提示信息走 stderr)。TARGET=build(agent)| frontend(static)。 materialize_dev_commit() { local full="$1" sha="$2" target="$3" local wt="$RUNTIME/.worktrees/$sha" if [ -d "$wt" ]; then c_info "[$ENV_NAME] 复用已有构建 worktree:$wt" else c_info "[$ENV_NAME] 物化 Dev 提交 $sha → 隔离 worktree(源:独立部署源仓 $SRC_DIR,不触碰当前工作区)" git -C "$SRC_DIR" worktree add --detach "$wt" "$full" >/dev/null || return 1 fi c_info "[$ENV_NAME] 物化子模块组合(git submodule update --init --recursive;缺对象时需网络)" # stdout 只留给最后的 worktree 路径(调用方 $() 捕获)——子模块/构建输出全部走 stderr。 git -C "$wt" submodule update --init --recursive >&2 || { c_err "[$ENV_NAME] 子模块物化失败(网络或对象缺失;重试会自动 fetch 部署源仓)" return 1 } c_info "[$ENV_NAME] 构建(make $target:npm ci + 构建,需网络)" make -C "$wt" "$target" >&2 || return 1 echo "$wt" } # recycle_worktree SHA [REPO]:回收构建 worktree(尽力而为)。REPO 缺省为 Dev 源仓。 recycle_worktree() { local repo="${2:-$SRC_DIR}" local wt="$RUNTIME/.worktrees/$1" c_info "[$ENV_NAME] 回收构建 worktree" git -C "$repo" worktree remove --force "$wt" >/dev/null 2>&1 \ || c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $repo worktree remove --force $wt" } # materialize_rdm_commit FULL SHA:RDM 源的隔离 worktree 物化+构建;stdout 只回显 # worktree 路径。构建 = make build(纯 Go:pins/ 子模块经 go.mod replace 就位后无需 # npm 与网络);产物固定为 bin/agent-system-rdm(缺失即失败,绝不装半成品)。 materialize_rdm_commit() { local full="$1" sha="$2" local wt="$RUNTIME/.worktrees/rdm-$sha" if [ -d "$wt" ]; then c_info "[$ENV_NAME] 复用已有构建 worktree:$wt" else c_info "[$ENV_NAME] 物化 RDM 提交 $sha → 隔离 worktree(源:独立 RDM 源仓 $RDM_SRC_DIR,不触碰当前工作区)" git -C "$RDM_SRC_DIR" worktree add --detach "$wt" "$full" >/dev/null || return 1 fi c_info "[$ENV_NAME] 物化基座 pins(git submodule update --init --recursive;缺对象时需网络)" git -C "$wt" submodule update --init --recursive >&2 || { c_err "[$ENV_NAME] pins 子模块物化失败(网络或对象缺失;重试会自动 fetch RDM 源仓)" return 1 } c_info "[$ENV_NAME] 构建(make build:纯 Go,无需 npm)" make -C "$wt" build >&2 || return 1 [ -x "$wt/bin/agent-system-rdm" ] || { c_err "[$ENV_NAME] 构建产物缺失:$wt/bin/agent-system-rdm(RDM Makefile 的 build 目标应产出该名称)" return 1 } echo "$wt" } materialize_release() { local art="$REL_ROOT/build/$REL_VERSION" bin="$REL_ROOT/build/$REL_VERSION/agent-system" local mf="$REL_ROOT/versions/$REL_VERSION/manifest.json" if [ ! -f "$bin" ]; then c_err "[$ENV_NAME] Releases 仓无制品:$bin" c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)" return 1 fi if [ -f "$mf" ]; then local want have want="$(sed -n 's/.*"agent-system": *"\([0-9a-f]\{64\}\)".*/\1/p' "$mf" | head -1)" have="$(sha256sum "$bin" | cut -d' ' -f1)" if [ -n "$want" ] && [ "$want" != "$have" ]; then c_err "[$ENV_NAME] sha256 校验失败:制品与 manifest 不符($bin)——拒绝部署" return 1 fi [ -n "$want" ] && c_ok "[$ENV_NAME] sha256 校验通过(manifest:$REL_VERSION)" else c_warn "[$ENV_NAME] 无 manifest 可校验:$mf" fi [ -n "${SRC_DEVCOMMIT:-}" ] && c_info "[$ENV_NAME] 版本对应 Dev 提交:$SRC_DEVCOMMIT" install_artifacts "$bin" "$art/plugins" || return 1 } # ---- agent:账户 CLI(绑定实例存储;禁止回落默认 ./data)---- # 用法:scripts/svc.sh user [--owner …] # 从实例最终配置 / manifest identity 解析 data/DSN,强制 --open-existing, # 调用 ENV_BIN 对应版本。拒绝调用方覆盖 --data/--dsn/--open-existing。 agent_do_user() { local a for a in "$@"; do case "$a" in --data|--dsn|--open-existing|--data=*|--dsn=*|--open-existing=*) c_err "[$ENV_NAME] 账户操作禁止覆盖存储绑定(勿传 --data/--dsn/--open-existing;由实例 manifest 决定)" return 2 ;; esac done svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁(账户操作与启停互斥)"; return 1; } # 有 manifest 时以 identity.data_root 为权威,拒绝 ENV_DATA 漂移到默认 ./data。 if [ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1; then local mf_root mf_root="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m["identity"]["data_root"])' \ "$ENV_DIR/manifest.json" 2>/dev/null || true)" if [ -n "$mf_root" ]; then ENV_DATA="$mf_root" fi fi case "${ENV_DATA:-}" in ""|./data|data) c_err "[$ENV_NAME] 未绑定数据目录:拒绝账户操作(不回落默认 ./data)" return 1 ;; esac resolve_db_config || { c_err "[$ENV_NAME] 数据库绑定无效:拒绝账户操作(不回落默认 ./data)" return 1 } if [ "$DB_MODE" = mock ]; then c_err "[$ENV_NAME] mock 档不支持账户操作" return 1 fi if [ ! -x "${ENV_BIN:-}" ]; then c_err "[$ENV_NAME] 实例二进制不存在:$ENV_BIN(先 deploy)" return 1 fi if [ "$DB_MODE" = pg ] && [ -z "${DB_DSN:-}" ] && [ "${DB_SOURCE:-}" != conffile ]; then c_err "[$ENV_NAME] PostgreSQL 档缺少 DSN:拒绝账户操作" return 1 fi # 嵌入式档:数据文件必须已存在(OpenExisting);PG 档由 --open-existing 在后端核验。 if [ "$DB_MODE" != pg ]; then if [ ! -d "$ENV_DATA" ] || [ ! -f "$ENV_DATA/agent-system.db" ]; then c_err "[$ENV_NAME] 既有库不存在:$ENV_DATA/agent-system.db(OpenExisting:拒绝创建空库)" return 1 fi fi local bin_args=(--open-existing --data "$ENV_DATA") if [ "$DB_MODE" = pg ] && [ -n "${DB_DSN:-}" ]; then bin_args+=(--dsn "$DB_DSN") fi c_info "[$ENV_NAME] 账户 CLI → $ENV_BIN user $* (data=$ENV_DATA open-existing)" # 子命令参数在前,绑定 flags 在后(Go flag 不区分顺序)。 AGENT_SYSTEM_DSN="${DB_DSN:-}" "$ENV_BIN" user "$@" "${bin_args[@]}" } # ---- agent:RDM 域管理员台账(rdm_admins 单行;受信运维通道,见 help rdm-admin) ---- # rdm_admin_read DB:只读台账行 "owner|added_by|added_at"(无库/无表/空表 → 空串)。 rdm_admin_read() { python3 -c 'import sqlite3,sys try: con = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True) row = con.execute("select owner,added_by,added_at from rdm_admins where id=1").fetchone() con.close() print("|".join(row) if row else "") except Exception: pass' "$1" 2>/dev/null } # rdm_admin_base_owners DB:基座 users 表 owner 逗号清单(无库/无表 → 空串)。 # 台账核验与 transfer typo 防线共用。 rdm_admin_base_owners() { python3 -c 'import sqlite3,sys try: con = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True) rows = con.execute("select owner from users").fetchall() con.close() print(",".join(r[0] for r in rows)) except Exception: pass' "$1" 2>/dev/null } # rdm_admin_transfer_write DB TO OLD:受信单行改写(BEGIN IMMEDIATE + 回读核验; # added_by 记 svc:rdm-admin: 保留前任可追溯)。失败非零退出。 rdm_admin_transfer_write() { python3 -c 'import sqlite3,sys db, to, old = sys.argv[1], sys.argv[2], sys.argv[3] con = sqlite3.connect(db, timeout=10, isolation_level=None) con.execute("BEGIN IMMEDIATE") con.execute("update rdm_admins set owner=?, added_by=?, added_at=strftime(\"%Y-%m-%dT%H:%M:%fZ\",\"now\") where id=1", (to, "svc:rdm-admin:" + old)) row = con.execute("select owner from rdm_admins where id=1").fetchone() ok = row is not None and row[0] == to con.execute("COMMIT") con.close() sys.exit(0 if ok else 1)' "$1" "$2" "$3" 2>/dev/null } # agent_do_rdm_admin:show(默认)/transfer --to 。绑定合同同 agent_do_user #(flock、manifest data_root 权威、禁回落 ./data、mock 拒绝);仅内嵌 SQLite 档。 agent_do_rdm_admin() { local sub="${1:-show}" case "$sub" in show|transfer) if [ $# -ge 1 ]; then shift; fi ;; *) c_err "[$ENV_NAME] rdm-admin 用法:show | transfer --to (help rdm-admin)" return 2 ;; esac svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁(与启停互斥)"; return 1; } # 有 manifest 时以 identity.data_root 为权威,拒绝 ENV_DATA 漂移到默认 ./data。 if [ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1; then local mf_root mf_root="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m["identity"]["data_root"])' \ "$ENV_DIR/manifest.json" 2>/dev/null || true)" if [ -n "$mf_root" ]; then ENV_DATA="$mf_root" fi fi case "${ENV_DATA:-}" in ""|./data|data) c_err "[$ENV_NAME] 未绑定数据目录:拒绝 RDM 台账操作(不回落默认 ./data)" return 1 ;; esac resolve_db_config || { c_err "[$ENV_NAME] 数据库绑定无效:拒绝 RDM 台账操作(不回落默认 ./data)" return 1 } if [ "$DB_MODE" = mock ]; then c_err "[$ENV_NAME] mock 档不支持 RDM 台账操作" return 1 fi if [ "$DB_MODE" = pg ]; then c_err "[$ENV_NAME] PostgreSQL 档不走受信直写:以现任管理员经 rdm.perm.transfer 移交" return 1 fi local rdm_db="$ENV_DATA/rdm.db" base_db="$ENV_DATA/agent-system.db" if [ ! -f "$base_db" ]; then c_err "[$ENV_NAME] 既有库不存在:$base_db(先 deploy/启动初始化)" return 1 fi if [ ! -f "$rdm_db" ]; then c_err "[$ENV_NAME] 无 RDM 域库:$rdm_db(未装 RDM 插件或域未初始化)" return 1 fi local row owner added_by added_at owners row="$(rdm_admin_read "$rdm_db")" owners="$(rdm_admin_base_owners "$base_db")" case "$sub" in show) if [ -z "$row" ]; then c_info "[$ENV_NAME] RDM 域管理员台账为空(空或未迁移)——经 serve 二进制 -bootstrap-admin 受信引导" else IFS='|' read -r owner added_by added_at <<<"$row" printf ' RDM 域管理员 %s(由 %s 于 %s 登记)\n' "$owner" "$added_by" "$added_at" fi if [ -n "$row" ] && [ -n "$owner" ]; then case ",$owners," in *",$owner,"*) ;; *) c_warn "[$ENV_NAME] 台账管理员 $owner 不在基座用户表——验收/引导残留占用共享台账的典型特征;恢复:scripts/svc.sh $ENV_NAME rdm-admin transfer --to <操作者账号>" ;; esac fi ;; transfer) local to="" while [ $# -gt 0 ]; do case "$1" in --to) [ -n "${2:-}" ] || { c_err "transfer --to 需要账号参数"; return 2; }; to="$2"; shift 2 ;; *) c_err "[$ENV_NAME] rdm-admin transfer 用法:--to (help rdm-admin)"; return 2 ;; esac done [ -n "$to" ] || { c_err "[$ENV_NAME] rdm-admin transfer 用法:--to "; return 2; } if [ -z "$row" ]; then c_err "[$ENV_NAME] 台账为空:不适用 transfer(经 serve 二进制 -bootstrap-admin $to 受信引导)" return 1 fi IFS='|' read -r owner added_by added_at <<<"$row" if [ "$to" = "$owner" ]; then c_info "[$ENV_NAME] 台账管理员已是 $to(无需移交)" return 0 fi case ",$owners," in *",$to,"*) ;; *) c_err "[$ENV_NAME] 目标账号 $to 不在基座用户表($base_db users):拒绝——防 typo 锁死;先 user create 建号" return 1 ;; esac c_warn "[$ENV_NAME] 受信部署通道:改写 rdm_admins 单行 $owner → $to(与 -bootstrap-admin 同级信任;正式移交建议经 rdm.perm.transfer 保留审计)" printf ' 移交前 %s(由 %s 于 %s 登记)\n' "$owner" "$added_by" "$added_at" if ! rdm_admin_transfer_write "$rdm_db" "$to" "$owner"; then c_err "[$ENV_NAME] 台账写入或回读核验失败(服务占用锁时稍后重试)" return 1 fi row="$(rdm_admin_read "$rdm_db")" IFS='|' read -r owner added_by added_at <<<"$row" printf ' 移交后 %s(由 %s 于 %s 登记)\n' "$owner" "$added_by" "$added_at" c_info "[$ENV_NAME] RDM 域管理员移交完成:$to(服务下一命令即生效,无需重启)" ;; esac return 0 } # ---- agent:部署与启动 ---- # source_kind_prefix LABEL:取 source= 的种类前缀(dev@sha → dev;空 → 空)。 source_kind_prefix() { case "$1" in *@*) printf '%s' "${1%%@*}" ;; *) printf '%s' "$1" ;; esac } # require_allow_source_switch:同名 agent 换源种类(dev↔rdm↔release↔local)须显式确认; # 同种类换 sha(含 update)不要求。与 service kind 冲突门禁正交。 require_allow_source_switch() { local prev prev_kind new_kind prev="$(svc_get source)" [ -n "$prev" ] || return 0 prev_kind="$(source_kind_prefix "$prev")" new_kind="$(source_kind_prefix "$SRC_LABEL")" [ -n "$prev_kind" ] && [ -n "$new_kind" ] || return 0 [ "$prev_kind" = "$new_kind" ] && return 0 if [ "${CLI_ALLOW_SOURCE_SWITCH:-0}" = 1 ]; then c_warn "[$ENV_NAME] 换源已确认:$prev → $SRC_LABEL(--allow-source-switch)" return 0 fi c_err "[$ENV_NAME] 换源种类 $prev_kind → $new_kind($prev → $SRC_LABEL)需显式确认" c_err " 加 --allow-source-switch 后重试(会改变环境是否自带 UI)" return 1 } rdm_companion_name() { printf '%sweb' "$1"; } # http_code URL:返回 HTTP 状态码(连接失败 → 000)。 http_code() { curl -s -o /dev/null -m 2 -w '%{http_code}' "$1" 2>/dev/null || printf '000' } # rdm_workbench_missing NAME:rdm@* agent 且伴生未登记或未运行 → 0(真)。 rdm_workbench_missing() { local name="$1" src cname st src="$(env_kv_of "$name" source)" case "$src" in rdm@*) ;; *) return 1 ;; esac cname="$(rdm_companion_name "$name")" [ -f "$RUNTIME/$cname/svc.env" ] || return 0 st="$(svc_pid_state "$cname")" [ "${st%% *}" = alive ] && return 1 return 0 } # deploy_rdm_companion_ui:子进程部署 ${ENV_NAME}web --kind static --dir htdocs。 deploy_rdm_companion_ui() { local cname="$1" htdocs="$2" if ! valid_env "$cname"; then c_err "[$ENV_NAME] 伴生名非法:$cname(服务名 ≤16 字符;ui-missing)" return 1 fi if [ ! -d "$htdocs" ] || [ ! -f "$htdocs/index.html" ]; then c_err "[$ENV_NAME] RDM 工作台缺失:$htdocs(ui-missing)" return 1 fi c_info "[$ENV_NAME] 自动部署伴生工作台:$cname --kind static --dir $htdocs" if ! "$0" "$cname" deploy --kind static --dir "$htdocs"; then c_err "[$ENV_NAME] 伴生工作台部署失败(ui-missing)" return 1 fi return 0 } # assert_ui_ready:按源种类断言页面入口 HTTP 200(与进程探活分层)。 # rdm@* → 伴生 ${name}web;dev@*/local@*/release@* → agent 自身 GET /。 assert_ui_ready() { local src="${SRC_LABEL:-$(svc_get source)}" local code url host port addr cname probe case "$src" in rdm@*) cname="$(rdm_companion_name "$ENV_NAME")" addr="$(env_kv_of "$cname" addr)" if [ -z "$addr" ]; then c_err "[$ENV_NAME] UI 探活失败:伴生 $cname 未登记(ui-missing)" return 1 fi host="${addr%:*}"; port="${addr##*:}" [ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1" url="http://$host:$port/" code="$(http_code "$url")" if [ "$code" != 200 ]; then c_err "[$ENV_NAME] UI 探活失败:伴生 $url → HTTP $code(期望 200;ui-missing)" return 1 fi c_ok "[$ENV_NAME] UI 就绪:伴生 $cname → $url" return 0 ;; dev@*|local@*|release@*) probe="${ENV_ADDR}" case "$(svc_get exec)" in *--addr\ *) probe="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" ;; esac [ -n "$probe" ] || probe="$ENV_ADDR" host="${probe%:*}"; port="${probe##*:}" [ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1" url="http://$host:$port/" code="$(http_code "$url")" if [ "$code" != 200 ]; then c_err "[$ENV_NAME] UI 探活失败:$url → HTTP $code(期望 200;ui-missing)" return 1 fi c_ok "[$ENV_NAME] UI 就绪:$url" return 0 ;; *) return 0 ;; esac } agent_do_deploy() { resolve_source if ! require_allow_source_switch; then hist_event deploy fail "$(read_pid)" "换源种类需 --allow-source-switch($SRC_LABEL)" return 1 fi 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" # U2(不可变版本目录 + 失败回退):构建 → 暂存 release → 门禁(身份/环境 DSN) # → 停旧 → current 原子切换 → 启动与 UI 验证 → 成功提交(schema 记录 + journal # 清理 + release 裁剪);任一步失败回退上一完整版本并恢复期望运行态 # (svc_rollback_deploy;库 schema 已前进时阻断,绝不覆盖业务数据库)。 local wt relid staged srcbin srcplug srchtdocs="" prev_id desired svc_release_adopt_legacy \ || { hist_event deploy fail "$(read_pid)" "legacy 领养失败($SRC_LABEL)"; return 1; } case "$SRC_KINDSRC" in local) c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径" make -C "$ROOT" build || { c_err "[$ENV_NAME] 构建失败($SRC_LABEL)——旧服务保持运行、制品与登记不变" hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)" return 1 } srcbin="$BIN"; srcplug="$ROOT/plugins" ;; dev) wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \ || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } srcbin="$wt/bin/agent-system"; srcplug="$wt/plugins" ;; release) local relbin="$REL_ROOT/build/$REL_VERSION/agent-system" if [ ! -f "$relbin" ]; then c_err "[$ENV_NAME] Releases 仓无制品:$relbin" c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)" hist_event deploy fail "$(read_pid)" "制品缺失($SRC_LABEL)" return 1 fi local mf="$REL_ROOT/versions/$REL_VERSION/manifest.json" want have if [ -f "$mf" ]; then want="$(sed -n 's/.*"agent-system": *"\([0-9a-f]\{64\}\)".*/\1/p' "$mf" | head -1)" have="$(sha256sum "$relbin" | cut -d' ' -f1)" if [ -n "$want" ] && [ "$want" != "$have" ]; then c_err "[$ENV_NAME] sha256 校验失败:制品与 manifest 不符($relbin)——拒绝部署" hist_event deploy fail "$(read_pid)" "sha256 校验失败($SRC_LABEL)" return 1 fi [ -n "$want" ] && c_ok "[$ENV_NAME] sha256 校验通过(manifest:$REL_VERSION)" else c_warn "[$ENV_NAME] 无 manifest 可校验:$mf" fi srcbin="$relbin"; srcplug="$REL_ROOT/build/$REL_VERSION/plugins" ;; rdm) wt="$(materialize_rdm_commit "$SRC_FULL" "$SRC_SHA")" \ || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } # RDM 组合插件编译进二进制(provides 无声明式资产)——插件目录按空集。 mkdir -p "$wt/.svc-empty-plugins" srcbin="$wt/bin/agent-system-rdm"; srcplug="$wt/.svc-empty-plugins" [ -d "$wt/web" ] && srchtdocs="$wt/web" ;; esac relid="$(svc_release_id)" staged="$(svc_release_stage "$relid" "$srcbin" "$srcplug" ${srchtdocs:+"$srchtdocs"})" \ || { hist_event deploy fail "$(read_pid)" "产物暂存失败($SRC_LABEL)"; svc_deploy_recycle_wt; return 1; } if ! svc_deploy_identity_gate "$staged/bin/agent-system"; then hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)" rm -rf "$staged" svc_deploy_recycle_wt return 1 fi # 新产物承接能力复核(环境 DSN 交付;候选上、停服前):不支持即失败——绑定 # 保持可恢复,不降级启动。 if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ] && ! bin_env_dsn_supported "$staged/bin/agent-system"; then c_err "[$ENV_NAME] 新产物不消费环境变量 DSN(AGENT_SYSTEM_DSN)——部署中止;旧绑定保留在 svc.env" c_err " 恢复:换部署版本,或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config )" hist_event deploy fail "$(read_pid)" "新产物不支持环境 DSN($SRC_LABEL)" rm -rf "$staged" return 1 fi prev_id="$(svc_release_cur_id)" desired="stopped"; pid_alive "$(read_pid)" 2>/dev/null && desired="running" svc_journal_write staging "new=$relid" "prev=$prev_id" "desired=$desired" do_stop_quiet svc_release_switch "$relid" || { svc_journal_write blocked reason="current 指针切换失败" hist_event deploy fail "$(read_pid)" "current 指针切换失败($SRC_LABEL)" return 1 } svc_journal_write switched "new=$relid" "prev=$prev_id" "desired=$desired" c_ok "[$ENV_NAME] 已切换 release:releases/$relid(prev:${prev_id:-无})" # 指针已切:重解析产物路径(env_config 解析时 current 可能尚不存在)。 ENV_BIN_DIR="$ENV_DIR/current/bin" ENV_PLUGINS="$ENV_DIR/current/plugins" ENV_BIN="$ENV_BIN_DIR/agent-system" if ! agent_do_start; then svc_rollback_deploy "新制品启动失败($SRC_LABEL)" || true hist_event deploy fail "$(read_pid)" "启动失败已回退($SRC_LABEL)" return 1 fi case "$SRC_KINDSRC" in rdm) # 顶层 htdocs → current/htdocs 符号链接:伴生经它伺服,指针切换自动换内容 # (legacy 实体目录内容已随领养保入 release,替换不丢)。 if [ -n "$srchtdocs" ]; then if [ ! -L "$ENV_DIR/htdocs" ]; then rm -rf "$ENV_DIR/htdocs" ln -s current/htdocs "$ENV_DIR/htdocs" fi c_ok "[$ENV_NAME] RDM 工作台就位:releases/$relid/htdocs → $ENV_DIR/htdocs(伴生 $(rdm_companion_name "$ENV_NAME"))" if ! deploy_rdm_companion_ui "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR/htdocs"; then svc_rollback_deploy "RDM 伴生工作台部署失败(ui-missing)" || true hist_event deploy fail "$(read_pid)" "ui-missing(伴生部署失败,已回退)" return 1 fi else svc_rollback_deploy "RDM 源无 web/ 工作台(ui-missing)" || true hist_event deploy fail "$(read_pid)" "ui-missing(源无 web/,已回退)" return 1 fi ;; esac if ! assert_ui_ready; then svc_rollback_deploy "UI 探活失败(ui-missing)" || true hist_event deploy fail "$(read_pid)" "ui-missing(已回退)" return 1 fi # 首次部署:agent_do_start 的 record_state 建立 svc.env 后,同轮补走 manifest # 领养(ensure 身份行)——否则身份治理要到下一次生命周期动作才生效 #(U3 全轨实测:全新 rdm 部署当轮无 manifest)。 if ! svc_manifest_migrate; then svc_rollback_deploy "manifest 领养失败($SRC_LABEL)" || true hist_event deploy fail "$(read_pid)" "manifest 领养失败(已回退)" return 1 fi svc_release_commit_schema "$relid" svc_journal_clear svc_release_gc svc_deploy_recycle_wt hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL release=$relid" return 0 } print_agent_plan() { c_info "[$ENV_NAME] 部署计划(dry-run,不执行)" printf ' 服务:%s(kind=agent)→ %s(addr %s,data %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR" "$ENV_DATA" case "$SRC_KINDSRC" in local) printf ' 来源:%s(make build 当前工作区;唯一写主检出构建产物的路径)\n' "$SRC_LABEL" printf ' 产物:%s ← %s + %s/plugins\n' "$ENV_BIN" "$BIN" "$ROOT" ;; dev) printf ' 来源:%s(完整提交 %s;独立部署源仓 %s)\n' "$SRC_LABEL" "$SRC_FULL" "$SRC_DIR" printf ' 物化:worktree %s(submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/$SRC_SHA" ;; release) printf ' 来源:%s(Releases 提交 %s)\n' "$SRC_LABEL" "$(git -C "$REL_ROOT" rev-parse --short "$REL_COMMIT")" [ -n "${SRC_DEVCOMMIT:-}" ] && printf ' 对应 Dev 提交:%s\n' "$SRC_DEVCOMMIT" local art="$REL_ROOT/build/$REL_VERSION/agent-system" if [ -f "$art" ]; then printf ' 制品:%s(存在,将做 sha256 校验)\n' "$art" else printf ' 制品:%s(缺失!需先在 Releases 仓构建,或换版本)\n' "$art"; fi ;; rdm) printf ' 来源:%s(完整提交 %s;独立 RDM 源仓 %s)\n' "$SRC_LABEL" "$SRC_FULL" "$RDM_SRC_DIR" printf ' 物化:worktree %s(pins submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/rdm-$SRC_SHA" printf ' 产物:%s ← bin/agent-system-rdm + 空插件目录(组合插件编译进二进制)\n' "$ENV_BIN" printf ' 工作台:web/ → %s/htdocs;自动伴生:scripts/svc.sh %s deploy --kind static --dir 该目录\n' \ "$ENV_DIR" "$(rdm_companion_name "$ENV_NAME")" ;; esac return 0 } # seed_settings:把 CLI/环境变量给出的模型与搜索配置写入系统设置(幂等)。 # P2 受管(提案 2026-10-13 rdm-settings-members-duties-perms-increment):models.* # 与 tools.tavily.* 是系统受管项,user 层写入整笔拒绝,播种改走系统管理面 # PUT /api/settings/system。认证开启(PG 档默认)时该面要求管理员凭证:自动铸一把 # 临时 API key(name=svc-seed)完成写入后立即回收;owner 缺省取库内首个管理员 #(系统面必须 isAdmin,不再回落普通账号),--seed-owner / SVC_SEED_OWNER 可指定。 # 铸 key 失败维持无头 PUT(仅无认证单机档可成)。 seed_settings() { local host="$1" port="$2" local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}" local lb="${CLI_LBASE:-${SVC_LIGHT_BASE:-}}" lk="${CLI_LKEY:-${SVC_LIGHT_KEY:-}}" ln="${CLI_LNAME:-${SVC_LIGHT_NAME:-}}" local tk="${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}" [ -z "$cb$ck$cn$lb$lk$ln$tk" ] && return 0 command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; } local payload payload="$(python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" <<'PY' import json, sys cb, ck, cn, lb, lk, ln, tk = sys.argv[1:8] out = {} def sub(base, key, name): d = {} if base: d["baseUrl"] = base if key: d["apiKey"] = key if name: d["model"] = name return d chat, light = sub(cb, ck, cn), sub(lb, lk, ln) models = {} if chat: models["chat"] = chat if light: models["lightweight"] = light if models: out["models"] = models if tk: out["tools"] = {"tavily": {"apiKeys": tk}} print(json.dumps(out)) PY )" # 认证开启时的播种凭证:临时 key,明文只存在于本函数局部变量,不回显、不落盘。 # apikey 子命令不读环境 DSN,只能经 CLI 传递(进程列表短暂可见;serve 走 # AGENT_SYSTEM_DSN 环境注入,此路对 CLI 子命令不可用)。 local seed_key="" seed_owner="${CLI_SEED_OWNER:-${SVC_SEED_OWNER:-}}" local -a seed_hdr=() seed_dbf=() if db_auth_enabled; then if [ "$DB_MODE" = pg ]; then seed_dbf=(--dsn "$DB_DSN"); else seed_dbf=(--data "$ENV_DATA"); fi if [ -z "$seed_owner" ]; then seed_owner="$("$ENV_BIN" user list ${seed_dbf[@]+"${seed_dbf[@]}"} 2>/dev/null | python3 -c 'import json, sys try: users = json.load(sys.stdin) except Exception: sys.exit(0) for u in users: if u.get("isAdmin"): print(u["owner"]); break' 2>/dev/null || true)" fi if [ -z "$seed_owner" ]; then c_warn "[$ENV_NAME] 跳过配置播种:系统设置面需要管理员账号(user list 未见 isAdmin)" return 0 fi seed_key="$("$ENV_BIN" apikey create ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-seed 2>/dev/null || true)" [ -n "$seed_key" ] && seed_hdr=(-H "Authorization: Bearer $seed_key") if [ -z "$seed_key" ]; then c_warn "[$ENV_NAME] 跳过配置播种:铸临时管理员 key 失败(认证档系统面拒绝无头写入)" return 0 fi fi local resp resp="$(curl -s -m 5 -X PUT "http://$host:$port/api/settings/system" -H 'Content-Type: application/json' ${seed_hdr[@]+"${seed_hdr[@]}"} -d "$payload")" # 用后即回收;失败只提示吊销途径,不回显任何密钥材料。 if [ -n "$seed_key" ]; then "$ENV_BIN" apikey revoke ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-seed >/dev/null 2>&1 \ || c_warn "[$ENV_NAME] 临时播种密钥回收失败:请手动吊销 owner=$seed_owner name=svc-seed" fi if echo "$resp" | grep -q '"ok": *true'; then local did="" [ -n "$cb$ck$cn" ] && did="主模型" [ -n "$lb$lk$ln" ] && did="${did:+$did、}轻量模型" [ -n "$tk" ] && did="${did:+$did、}Tavily" c_ok "[$ENV_NAME] 已播种设置:$did(持久化于 $ENV_DATA,后续启动无需再传)" else c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(认证开启时需库内已有账号以铸临时密钥,或用 --seed-owner 指定;也可在设置页手动配置)" fi } # dsn_from_dbref DBREF:由 dbref(pg实例/库名)从该实例登记重建连接串。 # 实例未登记 / 非 postgres / 缺 addr·user·password 时返回非零且不输出。 dsn_from_dbref() { local dbref="$1" inst tdb taddr tuser tpass inst="${dbref%%/*}"; tdb="${dbref#*/}" { [ -n "$inst" ] && [ -n "$tdb" ] && [ "$inst" != "$dbref" ]; } || return 1 [ -f "$RUNTIME/$inst/svc.env" ] || return 1 [ "$(svc_kind "$inst")" = postgres ] || return 1 taddr="$(env_kv_of "$inst" addr)" tuser="$(env_kv_of "$inst" user)" tpass="$(env_kv_of "$inst" password)" { [ -n "$taddr" ] && [ -n "$tuser" ] && [ -n "$tpass" ]; } || return 1 printf 'postgresql://%s:%s@%s:%s/%s' "$tuser" "$tpass" "${taddr%:*}" "${taddr##*:}" "$tdb" } # addr_is_loopback HOST:回环写法白名单判定(127.x / ::1 / localhost)。 # 只服务启动预检的快速拦截:其余形态一律按非回环处理,权威判定在二进制 listen guard。 addr_is_loopback() { case "$1" in localhost|127.*|::1|[::1]) return 0 ;; *) return 1 ;; esac } # 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" } # dsn_ref_match DSN:DSN 的 host:port/db 命中已登记 postgres 实例时回显 /(无匹配回空)。 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 /(命中/保持托管绑定时;否则空) # 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 )或 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 );不回退内嵌 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 )或 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 db_auth_enabled && return 1 return 0 } # 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 # 无 config explain 子命令的组合二进制(下游仓产物):静态证据兜底——ELF 二进制内 # 引用 AGENT_SYSTEM_DSN 即视为消费环境层 DSN(下游经基座 extension.Main 的 env # 回退生效)。仅对 ELF 生效:脚本文本必然含该字符串字样,会把「不支持」误判为 # 「支持」(回归教训:test_svc 的 legacy 桩即脚本形态)。 [ "$(head -c 4 "$1" 2>/dev/null)" = $'\x7fELF' ] || return 1 command -v strings >/dev/null 2>&1 || return 1 strings "$1" 2>/dev/null | grep -q 'AGENT_SYSTEM_DSN' && return 0 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 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 )" return 1 fi return 0 } agent_do_start() { mkdir -p "$ENV_DATA" "$ENV_DIR" check_port_free || return 1 if [ ! -x "$ENV_BIN" ]; then c_err "[$ENV_NAME] 环境产物缺失:$ENV_BIN" c_err "请先部署:scripts/svc.sh $ENV_NAME deploy [--from local|dev[@]|release[@<版本>]|rdm[@]]" return 1 fi # timeout 护栏:组合二进制若不识别子命令可能直接进入服务态阻塞探测(旧产物形态)。 SVC_VERSION="$(timeout 5 "$ENV_BIN" version 2>/dev/null || true)" # 统一解析数据库配置(复用与重建两路共用;失败拒绝启动,绝不静默回退 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 exec_args="$(svc_get exec)" # U2:release 治理实例的持久化 exec 里 legacy plugins 路径跟随 current #(指针切换后插件目录随 release 解析)。 [ -L "$ENV_DIR/current" ] && exec_args="${exec_args//$ENV_DIR\/plugins/$ENV_PLUGINS}" c_info "[$ENV_NAME] 复用上次配置(svc.env exec=;显式参数可覆盖):$(mask_dsn "$exec_args")" else local args="" explicit_auth="${CLI_AUTH:-$(env_var "$ENV_NAME" AUTH)}" if [ "$DB_MODE" = mock ]; then args="--mock" 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 同样归一,否则每次带 # --auth/--config 的重建都会误报"旧版本二进制已自动省略"(参数实际全保留)。 if [ "$adjusted" != "${args# }" ]; then c_warn "[$ENV_NAME] 该产物不支持部分启动参数(旧版本二进制),已自动省略:${args:-<无>} → ${adjusted:-<无>}" fi args="$adjusted" exec_args="--addr $ENV_ADDR --data $ENV_DATA --plugins ${CLI_PLUGINS:-$ENV_PLUGINS} $args $CLI_EXTRA" exec_args="$(printf '%s' "$exec_args" | sed 's/[[:space:]]*$//')" fi SVC_EXEC="$exec_args" SVC_PIDCHECK="$ENV_BIN" local guard_env=() insecure hosts insecure="$(env_var "$ENV_NAME" INSECURE_LISTEN)" hosts="$(env_var "$ENV_NAME" ALLOWED_HOSTS)" [ -n "$insecure" ] && guard_env+=("AGENT_SYSTEM_INSECURE_LISTEN=$insecure") [ -n "$hosts" ] && guard_env+=("AGENT_SYSTEM_ALLOWED_HOSTS=$hosts") # U1 身份校验:文件 marker + DB verify(不一致 fail-closed;ensure 只在领养与 # 显式 init,普通启动不补行——缺行即事件,见 svc_manifest_verify)。 svc_manifest_verify || { hist_event start fail "" "manifest 身份校验失败"; return 1; } # 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作—— # 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。 # 认证判定依据统一解析的最终有效 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 ;" c_err " 显式 --auth on;或环境 SVC_$(printf '%s' "$ENV_NAME" | tr 'a-z-' 'A-Z_')_INSECURE_LISTEN=1 显式放行。" c_err " 登记未变更(svc.env 保留上次配置);二进制侧 listen guard 同样会拒绝。" hist_event start fail "" "预检拒绝:非回环且未开认证" return 1 fi c_info "[$ENV_NAME] 启动:$ENV_BIN serve $(mask_dsn "$exec_args")" if [ -n "$insecure" ] || [ -n "$hosts" ]; then c_info "[$ENV_NAME] 监听守卫:insecureListen=${insecure:-0} allowedHosts=${hosts:-无}" fi # 直接 nohup 简单命令:$! 即服务进程本身,pidfile 语义才成立。 # 守卫变量走环境,不写进 exec=;旧二进制不认识这两个变量,会忽略。 # G1-7:DSN 经环境变量传入(AGENT_SYSTEM_DSN),不进命令行——命令行对同机任何 # 进程可见(含智能体生成脚本的 /proc//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 )" hist_event start fail "" "产物不支持环境 DSN,拒绝启动" return 1 fi guard_env+=("AGENT_SYSTEM_DSN=$DB_DSN") fi # U1 I3:把服务实例身份交给 serve 二次门禁(与 --instance 进程标识无关)。 if [ -n "${AGENT_SYSTEM_INSTANCE_ID:-}" ]; then guard_env+=("AGENT_SYSTEM_INSTANCE_ID=$AGENT_SYSTEM_INSTANCE_ID") guard_env+=("AGENT_SYSTEM_DBREF=${AGENT_SYSTEM_DBREF:-sqlite}") fi # 9>&-:显式关闭锁 fd——服务进程绝不能继承 .start.lock(长驻持有会令后续 # 生命周期操作在锁上自锁死等;2026-10-11 自恢复任务实测发现)。 svc_write_serve_env svc_launch_serve "$exec_args" echo $! > "$ENV_PID" # 健康探测:取 exec 里的 --addr(复用持久化配置时可能与当前派生值不同)。 local probe="${ENV_ADDR}" case "$exec_args" in *--addr\ *) probe="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" ;; esac local phost="${probe%:*}" [ "$phost" = "0.0.0.0" ] || [ "$phost" = "::" ] && phost="127.0.0.1" for _ in $(seq 1 100); do if curl -s -o /dev/null -m 2 "http://$phost:${probe##*:}/" 2>/dev/null; then # 健康通过才写 svc.env——启动失败不再用新参数覆盖上次的可用配置 #(2026-10-05 dev 事故:失败后坏 exec= 持久化,裸 start 复用导致二次失败)。 # fresh:库由本次启动建立后再 ensure,升为 established。失败则停掉进程, # 不把半套身份登记成已启动。 if [ "$(svc_db_marker 2>/dev/null || true)" = fresh ]; then if ! svc_db_identity_ensure; then kill "$(read_pid)" 2>/dev/null || true c_err "[$ENV_NAME] 启动后建立 DB 身份失败,已停进程" hist_event start fail "$(read_pid)" "fresh ensure 失败" return 1 fi fi record_state svc_mark_autostart c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)" hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}" # rdm 组合产物无 user/apikey CLI 子命令:设置播种(--model-*/--tavily-keys)仅 # 基座产物支持;记录态 svc.env 的 source=rdm@* 跨裸 start/restart 稳定标识形态。 case "${SVC_SOURCE:-}" in rdm@*) ;; *) seed_settings "$phost" "${probe##*:}" ;; esac return 0 fi pid_alive "$(read_pid)" || { c_err "[$ENV_NAME] 进程启动后即退出(登记未变更,svc.env 保留上次 exec=),查看日志:tail -40 $ENV_DIR/svc.log" hist_event start fail "$(read_pid)" "进程即退(日志 svc.log)" return 1 } sleep 0.2 done # 进程存活但未响应:按实际运行参数登记(进程确以新参数在跑,登记与现实一致)。 record_state svc_mark_autostart c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_DIR/svc.log" hist_event start fail "$(read_pid)" "健康检查超时" return 1 } # ---- static:前端静态服务 ---- static_do_deploy() { # --dir:登记既有目录(不构建);否则 dev@sha/local 构建 frontend dist → htdocs/。 if [ -n "$CLI_DIR" ]; then [ -d "$CLI_DIR" ] || { c_err "[$ENV_NAME] --dir 目录不存在:$CLI_DIR"; return 1; } resolve_dir_deploy return 0 fi resolve_source if [ "$CLI_DRYRUN" = 1 ]; then print_static_plan; return 0; fi hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL" do_stop_quiet local src_dist wt case "$SRC_KINDSRC" in local) c_info "[$ENV_NAME] 构建当前工作区前端(make frontend)——写主检出构建产物(gitignore 目录)" make -C "$ROOT" frontend || { hist_event deploy fail "$(read_pid)" "make frontend 失败"; return 1; } src_dist="$ROOT/modules/frontend/dist" ;; dev) wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" frontend)" \ || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } src_dist="$wt/modules/frontend/dist" ;; release) c_err "[$ENV_NAME] release 制品内嵌前端(无独立 dist);static 请用 --from dev|local 或 --dir" hist_event deploy fail "$(read_pid)" "release 源不支持 static" return 1 ;; rdm) c_err "[$ENV_NAME] rdm 源不支持 static 构建(RDM 工作台为 agent 环境内 htdocs/ 的零构建资产)" c_err " 用法:先 deploy --from rdm,再 scripts/svc.sh deploy --kind static --dir /htdocs" hist_event deploy fail "$(read_pid)" "rdm 源不支持 static" return 1 ;; esac if [ ! -d "$src_dist" ]; then [ "$SRC_KINDSRC" = dev ] && recycle_worktree "$SRC_SHA" c_err "[$ENV_NAME] 前端产物缺失:$src_dist" hist_event deploy fail "$(read_pid)" "dist 缺失" return 1 fi mkdir -p "$ENV_DIR" rm -rf "${ENV_DIR:?}/htdocs" cp -r "$src_dist" "$ENV_DIR/htdocs" || { [ "$SRC_KINDSRC" = dev ] && recycle_worktree "$SRC_SHA" hist_event deploy fail "$(read_pid)" "dist 复制失败" return 1 } [ "$SRC_KINDSRC" = dev ] && recycle_worktree "$SRC_SHA" write_static_reg "$ENV_DIR/htdocs" c_ok "[$ENV_NAME] 前端产物就位:$ENV_DIR/htdocs(源 $SRC_LABEL)" hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL" return 0 } resolve_dir_deploy() { # --dir 路径:登记(含 dry-run 计划)后交由 start 提供服务 local dir; dir="$(cd "$CLI_DIR" && pwd)" SRC_LABEL="dir@${dir}" if [ "$CLI_DRYRUN" = 1 ]; then print_static_plan return 0 fi do_stop_quiet write_static_reg "$dir" c_ok "[$ENV_NAME] 静态目录登记:$dir(source=$SRC_LABEL)" hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL" return 0 } # write_static_reg HTDOCS:登记/更新 static 服务(保留 pid 等当前态由 start 覆写)。 # 同步写入 pidcheck,避免首启在 record_state 前 pid_alive 误回退到 agent-system。 write_static_reg() { local htdocs="$1" local old_pid=""; [ -f "$ENV_PID" ] && old_pid="$(read_pid)" local merged_tags; merged_tags="$(svc_merge_tags)" # 先读旧文件再截断 mkdir -p "$ENV_DIR" SVC_PIDCHECK="--directory $htdocs" { printf 'kind=static\n' printf 'env=%s\n' "$ENV_NAME" printf 'pid=%s\n' "${old_pid:-}" printf 'addr=%s\n' "$ENV_ADDR" printf 'tags=%s\n' "$merged_tags" printf 'source=%s\n' "$SRC_LABEL" printf 'started=%s\n' "$(date -Is)" printf 'htdocs=%s\n' "$htdocs" printf 'pidcheck=%s\n' "$SVC_PIDCHECK" } > "$ENV_DIR/svc.env" chmod 600 "$ENV_DIR/svc.env" } print_static_plan() { c_info "[$ENV_NAME] 部署计划(dry-run,不执行)" printf ' 服务:%s(kind=static)→ %s(addr %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR" if [ -n "$CLI_DIR" ]; then printf ' 目录:%s(--dir 直接服务,不构建)\n' "$CLI_DIR" else case "$SRC_KINDSRC" in local) printf ' 来源:%s(make frontend → htdocs/)\n' "$SRC_LABEL" ;; dev) printf ' 来源:%s(完整提交 %s;独立部署源仓 %s)\n 物化:worktree %s(make frontend → htdocs/)\n' \ "$SRC_LABEL" "$SRC_FULL" "$SRC_DIR" "$RUNTIME/.worktrees/$SRC_SHA" ;; release) printf ' 来源:release —— 不支持(制品内嵌前端);请用 dev|local 或 --dir\n' ;; esac fi return 0 } static_do_start() { mkdir -p "$ENV_DIR" check_port_free || return 1 command -v python3 >/dev/null 2>&1 || { c_err "[$ENV_NAME] static 需要 python3"; return 1; } local htdocs; htdocs="$(svc_get htdocs)" [ -n "$htdocs" ] || htdocs="$ENV_DIR/htdocs" [ -d "$htdocs" ] || { c_err "[$ENV_NAME] 静态目录缺失:$htdocs(先 deploy 或 --dir)"; return 1; } local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}" SVC_EXEC="--directory $htdocs" SVC_PIDCHECK="--directory $htdocs" SVC_VERSION="" c_info "[$ENV_NAME] 启动静态服务:python3 http.server $port --bind $host --directory $htdocs" nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 9>&- & echo $! > "$ENV_PID" local probe_host="$host" code [ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1" for _ in $(seq 1 50); do code="$(http_code "http://$probe_host:$port/")" if [ "$code" = 200 ]; then record_state "htdocs=$htdocs" # 健康通过才写 svc.env(与 agent 路径同规) svc_mark_autostart c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$probe_host:$port/(目录 $htdocs)" hist_event start ok "$(read_pid)" "http://$probe_host:$port/" return 0 fi pid_alive "$(read_pid)" || { c_err "[$ENV_NAME] 进程启动后即退出(登记未变更,svc.env 保留上次配置),查看日志:tail -40 $ENV_DIR/svc.log" hist_event start fail "$(read_pid)" "进程即退" return 1 } sleep 0.2 done record_state "htdocs=$htdocs" svc_mark_autostart c_err "[$ENV_NAME] 10s 健康检查未通过(末次 HTTP ${code:-?})" hist_event start fail "$(read_pid)" "健康检查超时" return 1 } # ---- postgres:数据库服务 ---- pg_bindir() { if [ -n "${SVC_PG_BIN:-}" ] && [ -x "${SVC_PG_BIN}/initdb" ]; then printf '%s' "$SVC_PG_BIN"; return 0; fi local d d="$(pg_config --bindir 2>/dev/null || true)" [ -n "$d" ] && [ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; } for d in /usr/lib/postgresql/*/bin /usr/local/pgsql/bin; do [ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; } done return 1 } cmd_db() { local sub="${1:-}" [ $# -gt 0 ] && shift case "$sub" in create) db_create "$@" ;; dsn) db_dsn "$@" ;; backup) db_backup "$@" ;; backups) db_backups "$@" ;; restore) db_restore "$@" ;; clone) db_clone "$@" ;; copy) db_copy "$@" ;; ls) db_ls "$@" ;; set) cmd_set "$@" ;; *) c_err "用法:scripts/svc.sh db create|dsn|backup|backups|restore|clone|copy|ls|set [...] db create [--port --user --password --db --auth-mode --tag] db dsn [--reveal] [--db X] db backup [--db X] [--out F] | db backups db restore --file F [--db X] [--force] db clone [--port P] [--password PW] [--tag T] [--online] db copy [--db X] [--force] db ls | db set [--port N --apply]"; exit 2 ;; esac } db_create() { local name="${1:-}" [ -n "$name" ] || { c_err "用法:scripts/svc.sh db create [...]"; exit 2; } shift parse_args "$@" env_config "$name" if [ -f "$ENV_DIR/svc.env" ]; then c_err "[$ENV_NAME] 已有登记(kind=$(svc_kind "$name"));重建先 scripts/svc.sh rm $ENV_NAME [--purge]" exit 2 fi local pgbin pgbin="$(pg_bindir)" || { c_err "找不到 postgres 工具链(initdb/pg_ctl);安装 postgresql 或设 SVC_PG_BIN="; exit 2; } local user="${CLI_DB_USER:-agent}" dbname="${CLI_DB_NAME:-agent}" authmode="${CLI_DB_AUTH:-scram-sha-256}" local password="${CLI_DB_PASSWORD:-$(head -c 18 /dev/urandom | od -An -tx1 | tr -d ' \n')}" local pgdata="$ENV_DIR/pgdata" if [ -e "$pgdata" ]; then c_err "[$ENV_NAME] 数据目录已存在:$pgdata(rm --purge 后重建,或换名)" exit 2 fi check_port_free || exit 1 mkdir -p "$ENV_DIR" chmod 700 "$ENV_DIR" local pwf="$ENV_DIR/.pwfile" ( umask 077; printf '%s\n' "$password" > "$pwf" ) c_info "[$ENV_NAME] initdb(用户 $user,认证 $authmode,数据 $pgdata)" if ! "$pgbin/initdb" -D "$pgdata" -U "$user" -A "$authmode" --pwfile="$pwf" -E UTF8 >/dev/null; then rm -f "$pwf" c_err "[$ENV_NAME] initdb 失败(上方输出)" exit 1 fi rm -f "$pwf" SVC_KIND=postgres SRC_LABEL="postgres@local" local pgver merged_tags pgver="$("$pgbin/postgres" --version 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+(\.[0-9]+)+$/) {print $i; exit}}' || true)" merged_tags="$(svc_merge_tags)" { printf 'kind=postgres\n' printf 'env=%s\n' "$ENV_NAME" printf 'addr=%s\n' "$ENV_ADDR" printf 'tags=%s\n' "$merged_tags" printf 'source=%s\n' "$SRC_LABEL" printf 'version=%s\n' "$pgver" printf 'user=%s\n' "$user" printf 'password=%s\n' "$password" printf 'dbname=%s\n' "$dbname" printf 'authmode=%s\n' "$authmode" printf 'pgdata=%s\n' "$pgdata" } > "$ENV_DIR/svc.env" chmod 600 "$ENV_DIR/svc.env" rm -f "$ENV_PID" hist_event create ok "" "user=$user db=$dbname authmode=$authmode port=${ENV_ADDR##*:}" c_ok "[$ENV_NAME] 数据库已创建(未启动):scripts/svc.sh $ENV_NAME start" c_info "[$ENV_NAME] 连接串:scripts/svc.sh db dsn $ENV_NAME --reveal(密码存 svc.env,权限 600)" c_info "[$ENV_NAME] 服务绑定:scripts/svc.sh db $ENV_NAME [--db X](运行中自动重启生效)" return 0 } # pg_psql SQL [tA]:以登记的管理员+密码走 TCP 执行;tA=取纯值输出。 pg_psql() { local sql="$1" ta="${2:-}" pgbin pgbin="$(pg_bindir)" PGPASSWORD="$(svc_get password)" "$pgbin/psql" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" \ -U "$(svc_get user)" -d postgres ${ta:+-tA} -c "$sql" 2>/dev/null } pg_do_start() { local pgbin pgbin="$(pg_bindir)" || { c_err "[$ENV_NAME] 找不到 postgres 工具链;设 SVC_PG_BIN"; return 1; } local pgdata; pgdata="$(svc_get pgdata)" [ -n "$pgdata" ] && [ -d "$pgdata" ] || { c_err "[$ENV_NAME] 无数据目录(先 db create)"; return 1; } check_port_free || return 1 local listen="${ENV_ADDR%:*}" [ "$listen" = "0.0.0.0" ] || [ "$listen" = "::" ] && listen="'*'" c_info "[$ENV_NAME] 启动 postgres:$pgdata(addr $ENV_ADDR)" if ! "$pgbin/pg_ctl" -D "$pgdata" -w -t 30 -l "$ENV_DIR/pg.log" \ -o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1 9>&-; then c_err "[$ENV_NAME] pg_ctl start 失败,查看日志:tail -40 $ENV_DIR/pg.log" hist_event start fail "" "pg_ctl start 失败" return 1 fi local pid; pid="$(head -1 "$pgdata/postmaster.pid" 2>/dev/null || true)" [ -n "$pid" ] && echo "$pid" > "$ENV_PID" SVC_PIDCHECK="$pgdata" SVC_EXEC="pg_ctl -D $pgdata -p ${ENV_ADDR##*:}" SVC_VERSION="$(svc_get version)" # 首次启动:建默认库(已存在则跳过)。 local dbname; dbname="$(svc_get dbname)" if [ -n "$dbname" ]; then if ! pg_psql "SELECT 1 FROM pg_database WHERE datname='$dbname'" tA | grep -q 1; then if pg_psql "CREATE DATABASE \"$dbname\""; then c_ok "[$ENV_NAME] 已建库:$dbname" else c_warn "[$ENV_NAME] 建库 $dbname 未成功(可手动创建)" fi fi fi record_state "user=$(svc_get user)" "password=$(svc_get password)" "dbname=$(svc_get dbname)" \ "authmode=$(svc_get authmode)" "pgdata=$pgdata" svc_mark_autostart if "$pgbin/pg_isready" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" >/dev/null 2>&1; then c_ok "[$ENV_NAME] postgres 就绪 pid=$(read_pid) → $ENV_ADDR(库 $(svc_get dbname),用户 $(svc_get user))" hist_event start ok "$(read_pid)" "pg_isready ok" return 0 fi c_warn "[$ENV_NAME] pg_isready 未通过(进程已起,查看 $ENV_DIR/pg.log)" hist_event start warn "$(read_pid)" "进程起但 pg_isready 未过" return 0 } pg_do_stop() { local pgbin pgdata pid pgbin="$(pg_bindir)" || { c_err "[$ENV_NAME] 找不到 postgres 工具链"; return 1; } pgdata="$(svc_get pgdata)" pid="$(read_pid)" if [ -z "$pid" ] || ! pid_alive "$pid"; then c_info "[$ENV_NAME] postgres 未在运行" rm -f "$ENV_PID" svc_clear_autostart return 0 fi c_info "[$ENV_NAME] 停止 postgres pid=$pid(pg_ctl -m fast,优雅回写)" if [ -n "$pgdata" ] && "$pgbin/pg_ctl" -D "$pgdata" -m fast -w -t 30 stop >/dev/null 2>&1; then rm -f "$ENV_PID" svc_clear_autostart c_ok "[$ENV_NAME] postgres 已停止" hist_event stop ok "$pid" "pg_ctl fast" return 0 fi c_warn "[$ENV_NAME] pg_ctl stop 失败,尝试 SIGTERM(绝不 SIGKILL postmaster)" kill -TERM "$pid" 2>/dev/null || true for _ in $(seq 1 100); do pid_alive "$pid" || { rm -f "$ENV_PID"; svc_clear_autostart; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; } sleep 0.1 done c_err "[$ENV_NAME] postgres 未能停止(保留现场;查 $pgdata/postmaster.pid 与 $ENV_DIR/pg.log)" hist_event stop fail "$pid" "未能停止" return 1 } db_dsn() { local name="${1:-}" [ -n "$name" ] || { c_err "用法:scripts/svc.sh db dsn [--reveal] [--db X]"; exit 2; } shift parse_args "$@" env_config "$name" [ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; } local user dbname pass host port user="$(svc_get user)" dbname="${CLI_DB_NAME:-$(svc_get dbname)}" pass="$(svc_get password)" host="${ENV_ADDR%:*}"; port="${ENV_ADDR##*:}" if [ "$CLI_REVEAL" = 1 ]; then printf 'postgresql://%s:%s@%s:%s/%s\n' "$user" "$pass" "$host" "$port" "$dbname" else printf 'postgresql://%s:***@%s:%s/%s\n' "$user" "$host" "$port" "$dbname" c_info "密码已登记于 $ENV_DIR/svc.env(--reveal 显示)" fi return 0 } # ---- 数据库运维:换端口 / 备份 / 恢复 / 克隆 / 搬库 ---- # check_port_available PORT SKIP_NAME:显式选端口时校验(未被其他登记占用且未在监听)。 check_port_available() { local p="$1" skip="$2" if ports_recorded "$p" "$skip"; then c_err "[$ENV_NAME] 端口 $p 已被其他服务登记占用" return 1 fi if port_listening "$p"; then c_err "[$ENV_NAME] 端口 $p 正被监听(非本脚本登记的进程)" return 1 fi return 0 } # svc_set_key KEY VALUE:改写当前服务 svc.env 的单键。manifest 治理下经 manifest # 整份提交(原子 + revision);legacy 路径保持先读后写 + 临时文件原子替换。 svc_set_key() { local key="$1" val="$2" rest tmp if manifest_ok; then python3 "$MANIFEST_PY" "$ENV_DIR" set "$key" "$val" && return 0 c_warn "[$ENV_NAME] manifest set 失败,回退 legacy 单键写" fi [ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; } rest="$(grep -v "^$key=" "$ENV_DIR/svc.env" || true)" 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 } # svc_del_key KEY:从当前服务 svc.env 删除单键(键不存在则无操作)。manifest 治理下 # 同样经 manifest 整份提交。 svc_del_key() { local key="$1" if manifest_ok; then python3 "$MANIFEST_PY" "$ENV_DIR" del "$key" && return 0 c_warn "[$ENV_NAME] manifest del 失败,回退 legacy 单键写" fi [ -f "$ENV_DIR/svc.env" ] || return 0 { grep -v "^$key=" "$ENV_DIR/svc.env" || true; } > "$ENV_DIR/svc.env.tmp" mv "$ENV_DIR/svc.env.tmp" "$ENV_DIR/svc.env" chmod 600 "$ENV_DIR/svc.env" return 0 } # 显式参数版 psql/dump/restore:clone/copy 需要同时操作两个实例,不依赖全局 ENV_*。 pg_run() { # HOST PORT USER PASS DB SQL [tA] local host="$1" port="$2" user="$3" pass="$4" db="$5" sql="$6" ta="${7:-}" pgbin pgbin="$(pg_bindir)" PGPASSWORD="$pass" "$pgbin/psql" -h "$host" -p "$port" -U "$user" -d "$db" ${ta:+-tA} -c "$sql" 2>/dev/null } pg_dump_run() { # HOST PORT USER PASS DB OUT local host="$1" port="$2" user="$3" pass="$4" db="$5" out="$6" pgbin pgbin="$(pg_bindir)" PGPASSWORD="$pass" "$pgbin/pg_dump" -h "$host" -p "$port" -U "$user" -d "$db" -Fc -f "$out" } pg_restore_list() { # FILE:exit 0 = 合法 pg_dump 归档 "$(pg_bindir)/pg_restore" --list "$1" >/dev/null 2>&1 } pg_restore_run() { # HOST PORT USER PASS DB FILE:出错回显 stderr 尾部 local host="$1" port="$2" user="$3" pass="$4" db="$5" file="$6" pgbin err pgbin="$(pg_bindir)" if ! err="$(PGPASSWORD="$pass" "$pgbin/pg_restore" -h "$host" -p "$port" -U "$user" -d "$db" \ --no-owner --no-privileges "$file" 2>&1)"; then printf '%s\n' "$err" | tail -5 >&2 return 1 fi return 0 } # copy_database SH SP SU SPASS SDB DH DP DU DPASS DDB [FORCE]:单库逻辑复制 # (dump → 建库/覆盖 → restore);目标库非空且无 FORCE 时拒绝。返回 0 = 成功。 copy_database() { local sh="$1" sp="$2" su="$3" spass="$4" sdb="$5" local dh="$6" dp="$7" du="$8" dpass="$9" ddb="${10}" force="${11:-0}" local tmp exists objects tmp="$(mktemp /tmp/svc-copy-XXXXXXXX.dump)" if ! pg_dump_run "$sh" "$sp" "$su" "$spass" "$sdb" "$tmp"; then rm -f "$tmp"; c_err "pg_dump $sdb 失败(源 $sh:$sp)"; return 1 fi if ! pg_restore_list "$tmp"; then rm -f "$tmp"; c_err "备份归档自检失败"; return 1 fi exists="$(pg_run "$dh" "$dp" "$du" "$dpass" postgres "SELECT 1 FROM pg_database WHERE datname='$ddb'" tA || true)" if [ -n "$exists" ]; then objects="$(pg_run "$dh" "$dp" "$du" "$dpass" "$ddb" "SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')" tA || true)" case "$objects" in ''|*[!0-9]*) objects=0 ;; esac if [ "$objects" -gt 0 ] && [ "$force" != 1 ]; then rm -f "$tmp" c_err "目标库 $ddb 已有 $objects 张表——拒绝覆盖(--force 覆盖)" return 1 fi if [ "$force" = 1 ]; then pg_run "$dh" "$dp" "$du" "$dpass" postgres "DROP DATABASE IF EXISTS \"$ddb\" WITH (FORCE)" >/dev/null \ || { rm -f "$tmp"; c_err "drop $ddb 失败"; return 1; } pg_run "$dh" "$dp" "$du" "$dpass" postgres "CREATE DATABASE \"$ddb\"" >/dev/null \ || { rm -f "$tmp"; c_err "create $ddb 失败"; return 1; } fi else pg_run "$dh" "$dp" "$du" "$dpass" postgres "CREATE DATABASE \"$ddb\"" >/dev/null \ || { rm -f "$tmp"; c_err "create $ddb 失败"; return 1; } fi if ! pg_restore_run "$dh" "$dp" "$du" "$dpass" "$ddb" "$tmp"; then rm -f "$tmp"; return 1 fi rm -f "$tmp" return 0 } db_backup() { local name="${1:-}" [ -n "$name" ] || { c_err "用法:scripts/svc.sh db backup [--db X] [--out FILE]"; exit 2; } shift parse_args "$@" env_config "$name" [ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; } if [ -z "$(read_pid)" ] || ! pid_alive "$(read_pid)"; then c_err "[$ENV_NAME] 未运行——备份走 TCP,请先 scripts/svc.sh $ENV_NAME start" exit 1 fi local db="${CLI_DB_NAME:-$(svc_get dbname)}" local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}" local user pass; user="$(svc_get user)"; pass="$(svc_get password)" local out="${CLI_OUT:-$ENV_DIR/backups/$(date -u +%Y%m%dT%H%M%SZ)-$db.dump}" mkdir -p "$(dirname "$out")"; chmod 700 "$(dirname "$out")" ( umask 077; : > "$out" ) c_info "[$ENV_NAME] pg_dump $db → $out" if ! pg_dump_run "$host" "$port" "$user" "$pass" "$db" "$out"; then rm -f "$out" hist_event backup fail "$(read_pid)" "db=$db" c_err "[$ENV_NAME] 备份失败(上方输出)" exit 1 fi if ! pg_restore_list "$out"; then rm -f "$out" hist_event backup fail "$(read_pid)" "归档自检失败 db=$db" c_err "[$ENV_NAME] 备份归档自检失败(pg_restore --list 不通过),已删除" exit 1 fi hist_event backup ok "$(read_pid)" "db=$db file=$(basename "$out") size=$(du -h "$out" | cut -f1)" c_ok "[$ENV_NAME] 备份完成:$out($(du -h "$out" | cut -f1))" } db_backups() { local name="${1:-}" [ -n "$name" ] || { c_err "用法:scripts/svc.sh db backups "; exit 2; } env_config "$name" [ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; } local dir="$ENV_DIR/backups" f if [ ! -d "$dir" ] || [ -z "$(ls "$dir"/*.dump 2>/dev/null || true)" ]; then c_info "[$ENV_NAME] 暂无备份(scripts/svc.sh db backup $ENV_NAME)" return 0 fi c_info "[$ENV_NAME] 备份列表($dir)" for f in "$dir"/*.dump; do [ -f "$f" ] || continue printf ' %s %6s %s\n' "$(stat -c %y "$f" | cut -d. -f1)" "$(du -h "$f" | cut -f1)" "$(basename "$f")" done return 0 } db_restore() { local name="${1:-}" [ -n "$name" ] || { c_err "用法:scripts/svc.sh db restore --file F [--db X] [--force]"; exit 2; } shift parse_args "$@" [ -n "$CLI_FILE" ] || { c_err "需要 --file <备份文件>"; exit 2; } [ -f "$CLI_FILE" ] || { c_err "备份文件不存在:$CLI_FILE"; exit 2; } env_config "$name" [ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; } if [ -z "$(read_pid)" ] || ! pid_alive "$(read_pid)"; then c_err "[$ENV_NAME] 未运行——恢复走 TCP,请先 scripts/svc.sh $ENV_NAME start" exit 1 fi pg_restore_list "$CLI_FILE" || { c_err "不是有效的 pg_dump 归档:$CLI_FILE"; exit 2; } local db="${CLI_DB_NAME:-$(svc_get dbname)}" local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}" local user pass; user="$(svc_get user)"; pass="$(svc_get password)" local exists objects exists="$(pg_run "$host" "$port" "$user" "$pass" postgres "SELECT 1 FROM pg_database WHERE datname='$db'" tA || true)" if [ -n "$exists" ]; then objects="$(pg_run "$host" "$port" "$user" "$pass" "$db" "SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')" tA || true)" case "$objects" in ''|*[!0-9]*) objects=0 ;; esac if [ "$objects" -gt 0 ] && [ "$CLI_FORCE" != 1 ]; then c_err "[$ENV_NAME] 目标库 $db 已存在且含 $objects 张表——拒绝覆盖;确认覆盖加 --force" exit 1 fi if [ "$CLI_FORCE" = 1 ]; then c_warn "[$ENV_NAME] --force:删除并重建库 $db" pg_run "$host" "$port" "$user" "$pass" postgres "DROP DATABASE IF EXISTS \"$db\" WITH (FORCE)" >/dev/null \ || { c_err "[$ENV_NAME] drop $db 失败"; exit 1; } pg_run "$host" "$port" "$user" "$pass" postgres "CREATE DATABASE \"$db\"" >/dev/null \ || { c_err "[$ENV_NAME] create $db 失败"; exit 1; } fi else pg_run "$host" "$port" "$user" "$pass" postgres "CREATE DATABASE \"$db\"" >/dev/null \ || { c_err "[$ENV_NAME] 建库 $db 失败"; exit 1; } fi c_info "[$ENV_NAME] 恢复 $db ← $CLI_FILE" if ! pg_restore_run "$host" "$port" "$user" "$pass" "$db" "$CLI_FILE"; then hist_event restore fail "$(read_pid)" "db=$db file=$(basename "$CLI_FILE")" c_err "[$ENV_NAME] 恢复失败(上方输出)" exit 1 fi local tables tables="$(pg_run "$host" "$port" "$user" "$pass" "$db" "SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')" tA || true)" hist_event restore ok "$(read_pid)" "db=$db file=$(basename "$CLI_FILE") tables=${tables:-?}" c_ok "[$ENV_NAME] 恢复完成:库 $db(${tables:-?} 张表)← $(basename "$CLI_FILE")" } db_clone() { local src="${1:-}" dst="${2:-}" [ -n "$src" ] && [ -n "$dst" ] || { c_err "用法:scripts/svc.sh db clone [--port P] [--host H] [--password PW] [--tag T] [--online]" exit 2 } shift 2 parse_args "$@" local req_port="${CLI_PORT:-}" req_host="${CLI_HOST:-}" CLI_PORT=""; CLI_HOST=""; CLI_ADDR="" # 克隆不消费地址覆盖——防泄漏到源复启与目标解析 valid_env "$dst" || { c_err "非法目标服务名:$dst"; exit 2; } [ "$dst" != "$src" ] || { c_err "目标不能与源相同"; exit 2; } env_config "$src" [ "$SVC_KIND" = postgres ] || { c_err "[$src] kind=$SVC_KIND 不是 postgres"; exit 2; } local src_name="$ENV_NAME" src_addr="$ENV_ADDR" src_dir="$ENV_DIR" local src_user pass db auth pgdata ver tags src_user="$(svc_get user)"; pass="$(svc_get password)"; db="$(svc_get dbname)" auth="$(svc_get authmode)"; pgdata="$(svc_get pgdata)"; ver="$(svc_get version)"; tags="$(svc_get tags)" [ -n "$pgdata" ] && [ -d "$pgdata" ] || { c_err "[$src] 无数据目录"; exit 1; } if [ "$CLI_ONLINE" = 1 ]; then # 逻辑克隆:不停源;目标须已 create+start,复制源登记库。 [ -f "$RUNTIME/$dst/svc.env" ] || { c_err "[$dst] 未登记——--online 要求目标先 db create + start(或省略 --online 走物理克隆)" exit 2 } env_config "$dst" [ "$SVC_KIND" = postgres ] || { c_err "[$dst] kind=$SVC_KIND 不是 postgres"; exit 2; } [ -n "$(read_pid)" ] && pid_alive "$(read_pid)" || { c_err "[$dst] 未运行——先 start"; exit 1; } local dh="${ENV_ADDR%:*}" dp="${ENV_ADDR##*:}" du dpass dname du="$(svc_get user)"; dpass="$(svc_get password)"; dname="$ENV_NAME" c_info "[$src].$db → [$dst].$db 在线克隆(不停源)" if copy_database "${src_addr%:*}" "${src_addr##*:}" "$src_user" "$pass" "$db" \ "$dh" "$dp" "$du" "$dpass" "$db" "$CLI_FORCE"; then ENV_NAME="$dname"; ENV_DIR="$RUNTIME/$dname"; ENV_ADDR="$dh:$dp"; SVC_KIND=postgres; SRC_LABEL="clone@$src_name" hist_event clone ok "" "online db=$db from $src_name" ENV_NAME="$src_name"; ENV_DIR="$src_dir"; ENV_ADDR="$src_addr" hist_event clone ok "" "online db=$db to $dst" c_ok "[$src] → [$dst] 在线克隆完成(库 $db)" return 0 fi ENV_NAME="$src_name"; ENV_DIR="$src_dir"; ENV_ADDR="$src_addr" hist_event clone fail "" "online db=$db to $dst" c_err "在线克隆失败(上方输出)" return 1 fi # 物理克隆(默认):短暂停源 → cp pgdata → 登记目标 → 复启源;目标不自动启动。 [ -e "$RUNTIME/$dst" ] && { c_err "[$dst] 目录已存在:$RUNTIME/$dst(换名或 rm)"; exit 2; } local saved_name="$ENV_NAME" ENV_NAME="$dst" # 端口校验以目标视角提示 local dst_port="${req_port:-}" if [ -n "$dst_port" ]; then check_port_available "$dst_port" "$dst" || exit 1 else dst_port="$(derive_port "$dst")" || { c_err "[$dst] 无空闲派生端口,请 --port 指定"; exit 1; } fi ENV_NAME="$saved_name" local dst_host="${req_host:-127.0.0.1}" local dst_pass="${CLI_DB_PASSWORD:-$pass}" local was_running=0 if [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then was_running=1; fi if [ "$was_running" = 1 ]; then c_info "[$src] 物理克隆需短暂停止源实例(一致性),完成后自动复启" pg_do_stop || { c_err "[$src] 停止失败,克隆中止(数据未动)"; return 1; } fi mkdir -p "$RUNTIME/$dst"; chmod 700 "$RUNTIME/$dst" c_info "[$dst] 复制数据目录($pgdata → $RUNTIME/$dst/pgdata)" if ! cp -a "$pgdata" "$RUNTIME/$dst/pgdata"; then c_err "[$dst] 数据目录复制失败" [ "$was_running" = 1 ] && { env_config "$src_name"; pg_do_start; } return 1 fi rm -f "$RUNTIME/$dst/pgdata/postmaster.pid" "$RUNTIME/$dst/pgdata/postmaster.opts" local t out_tags="" seen=" " for t in $(printf '%s' "$tags" | tr ',' ' ') ${CLI_TAGS[@]+"${CLI_TAGS[@]}"}; do [ -n "$t" ] || continue case "$seen" in *" $t "*) continue ;; esac seen="$seen $t "; out_tags="${out_tags:+$out_tags,}$t" done { printf 'kind=postgres\n' printf 'env=%s\n' "$dst" printf 'pid=\n' printf 'addr=%s:%s\n' "$dst_host" "$dst_port" printf 'tags=%s\n' "$out_tags" printf 'source=clone@%s\n' "$src_name" printf 'version=%s\n' "$ver" printf 'pidcheck=%s\n' "$RUNTIME/$dst/pgdata" printf 'user=%s\n' "$src_user" printf 'password=%s\n' "$dst_pass" printf 'dbname=%s\n' "$db" printf 'authmode=%s\n' "$auth" printf 'pgdata=%s\n' "$RUNTIME/$dst/pgdata" } > "$RUNTIME/$dst/svc.env" chmod 600 "$RUNTIME/$dst/svc.env" ENV_NAME="$dst"; ENV_DIR="$RUNTIME/$dst"; ENV_ADDR="$dst_host:$dst_port"; SVC_KIND=postgres SRC_LABEL="clone@$src_name" hist_event create ok "" "physical clone from $src_name port=$dst_port" if [ "$was_running" = 1 ]; then env_config "$src_name" pg_do_start || c_warn "[$src] 复启失败(克隆已成功;查看 $ENV_DIR/pg.log)" fi c_ok "[$dst] 克隆完成(未启动):scripts/svc.sh $dst start;DSN:scripts/svc.sh db dsn $dst --reveal" return 0 } db_copy() { local src="${1:-}" dst="${2:-}" [ -n "$src" ] && [ -n "$dst" ] || { c_err "用法:scripts/svc.sh db copy [--db X] [--force]"; exit 2; } shift 2 parse_args "$@" CLI_PORT=""; CLI_HOST=""; CLI_ADDR="" # copy 不消费地址覆盖 [ "$src" != "$dst" ] || { c_err "源与目标不能相同"; exit 2; } env_config "$src" [ "$SVC_KIND" = postgres ] || { c_err "[$src] kind=$SVC_KIND 不是 postgres"; exit 2; } [ -n "$(read_pid)" ] && pid_alive "$(read_pid)" || { c_err "[$src] 未运行——copy 走 TCP,双方都需 start"; exit 1; } local sh="${ENV_ADDR%:*}" sp="${ENV_ADDR##*:}" su pass sdb sname sdir su="$(svc_get user)"; pass="$(svc_get password)"; sname="$ENV_NAME"; sdir="$ENV_DIR" sdb="${CLI_DB_NAME:-$(svc_get dbname)}" env_config "$dst" [ "$SVC_KIND" = postgres ] || { c_err "[$dst] kind=$SVC_KIND 不是 postgres"; exit 2; } [ -n "$(read_pid)" ] && pid_alive "$(read_pid)" || { c_err "[$dst] 未运行——copy 走 TCP,双方都需 start"; exit 1; } local dh="${ENV_ADDR%:*}" dp="${ENV_ADDR##*:}" du dpass dname du="$(svc_get user)"; dpass="$(svc_get password)"; dname="$ENV_NAME" c_info "[$sname].$sdb → [$dname].$sdb(逻辑复制)" if copy_database "$sh" "$sp" "$su" "$pass" "$sdb" "$dh" "$dp" "$du" "$dpass" "$sdb" "$CLI_FORCE"; then ENV_NAME="$dname"; ENV_DIR="$RUNTIME/$dname"; ENV_ADDR="$dh:$dp"; SVC_KIND=postgres hist_event copy ok "" "from $sname db=$sdb" ENV_NAME="$sname"; ENV_DIR="$sdir"; ENV_ADDR="$sh:$sp" hist_event copy ok "" "to $dname db=$sdb" c_ok "复制完成:$sname/$sdb → $dname/$sdb" return 0 fi ENV_NAME="$sname"; ENV_DIR="$sdir"; ENV_ADDR="$sh:$sp" hist_event copy fail "" "to $dname db=$sdb" c_err "复制失败(上方输出)" return 1 } db_ls() { local name="${1:-}" [ -n "$name" ] || { c_err "用法:scripts/svc.sh db ls "; exit 2; } env_config "$name" [ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; } [ -n "$(read_pid)" ] && pid_alive "$(read_pid)" || { c_err "[$ENV_NAME] 未运行——先 start"; exit 1; } pg_run "${ENV_ADDR%:*}" "${ENV_ADDR##*:}" "$(svc_get user)" "$(svc_get password)" postgres \ "SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY 1" tA || true return 0 } # mask_json TEXT:JSON 文本里键名含 key/pass/secret/token/dsn(不区分大小写)的 # 字符串值掩为 ***(配置文件内容快照用)。输出带尾换行。 mask_json() { printf '%s\n' "$1" | sed -E 's/("[^"]*(key|pass|secret|token|dsn)[^"]*"[[:space:]]*:[[:space:]]*")[^"]*"/\1***"/Ig' } # ---- agent 服务的配置文件( config ...)---- # svc_config [FILE|--clear] [--no-restart]:查看 / 更新 / 移除 agent 服务使用的 # 配置文件(agent 二进制的 --config,JSON,schema 严格校验)。更新 = 校验通过后 # 把 --config <绝对路径> 写入持久化 exec= 并登记 conffile=,运行中自动重启生效 # (agent 无 SIGHUP 热重载,重启即"及时生效",秒级);--clear 移除回命令行参数形态。 svc_config() { [ "$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; } local target="" clear=0 restart=1 while [ $# -gt 0 ]; do case "$1" in --clear) clear=1; shift ;; --no-restart) restart=0; shift ;; -h|--help) cat >&2 < [--no-restart] 更新使用的配置文件 (schema 校验通过才切换;运行中自动重启生效) scripts/svc.sh $ENV_NAME config --clear [--no-restart] 移除(回命令行参数形态) 提示:配置文件绑定后,改文件内容 + restart 即生效;config <同一文件> 也会校验并重启。 EOF return 0 ;; -*) c_err "[$ENV_NAME] config 未知参数:$1(可用:<文件> | --clear,--no-restart)"; return 2 ;; *) [ -z "$target" ] || { c_err "[$ENV_NAME] config 只接受一个文件路径"; return 2; } target="$1"; shift ;; esac done # 当前配置文件(conffile= 优先;旧服务回退 exec= 的 --config)。 local cur="" cur="$(svc_get conffile)" [ -z "$cur" ] && cur="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')" # 无参:查看(文件状态 + 掩码内容 + 二进制视角的生效值)。 if [ -z "$target" ] && [ "$clear" = 0 ]; then if [ -n "$cur" ]; then printf ' [%s] 配置文件:%s\n' "$ENV_NAME" "$cur" if [ -f "$cur" ]; then printf ' 权限 %s;内容(敏感值掩码):\n' "$(info_file_mode "$cur")" mask_json "$(cat "$cur")" | sed 's/^/ /' if [ -x "$ENV_BIN" ] && "$ENV_BIN" config explain -config "$cur" >/dev/null 2>&1; then printf ' 生效值(agent 视角,敏感值掩码):\n' "$ENV_BIN" config explain -config "$cur" 2>/dev/null \ | sed -E 's#(postgres(ql)?://[^:/@[:space:]]+:)[^@[:space:]]+@#\1***@#g; s/^/ /' else printf ' (当前产物无法校验该文件:config explain 不可用或文件不合法)\n' fi else printf ' 文件不存在!重启将失败——请恢复文件或 config --clear\n' fi else printf ' [%s] 配置文件:无(命令行参数形态;绑定:config )\n' "$ENV_NAME" fi return 0 fi # 移除。 if [ "$clear" = 1 ]; then [ -n "$target" ] && { c_err "--clear 不接受文件参数"; return 2; } local exec_old new_exec exec_old="$(svc_get exec)" if printf '%s' "$exec_old" | grep -q -- '--config '; then new_exec="$(printf '%s' "$exec_old" | sed -E 's/(^| )--config [^ ]+//; s/ +/ /g; s/^ //; s/ $//')" svc_set_key exec "$new_exec" fi svc_del_key conffile hist_event set ok "$(read_pid)" "config unbind" c_ok "[$ENV_NAME] 已移除配置文件(回命令行参数形态)" if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then do_stop; do_start || { c_err "[$ENV_NAME] 重启失败——移除已登记,排查后 start"; return 1; } else c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效" fi return 0 fi # 更新。 [ -f "$target" ] || { c_err "配置文件不存在:$target"; return 2; } local abs abs="$(cd "$(dirname "$target")" && pwd)/$(basename "$target")" if [ -x "$ENV_BIN" ]; then if ! "$ENV_BIN" config explain -config "$abs" >/dev/null 2>&1; then c_err "[$ENV_NAME] 配置文件未通过 schema 校验:$abs" "$ENV_BIN" config explain -config "$abs" >&2 || true c_err "(键与取值以 config explain 为准;先修正文件再切换)" return 2 fi if ! "$ENV_BIN" serve -h 2>&1 | grep -Eq -- "(^|[[:space:]])-{1,2}config([[:space:]]|=|$)"; then c_warn "[$ENV_NAME] 当前产物不识别 --config(旧版本二进制)——绑定已登记,但重启将忽略该参数" fi fi local exec_old new_exec exec_old="$(svc_get exec)" if printf '%s' "$exec_old" | grep -q -- '--config '; then new_exec="$(printf '%s' "$exec_old" | sed -E "s|--config [^ ]+|--config $abs|")" else new_exec="${exec_old:+$exec_old }--config $abs" fi svc_set_key exec "$new_exec" svc_set_key conffile "$abs" hist_event set ok "$(read_pid)" "config → $abs" c_ok "[$ENV_NAME] 配置文件已更新:$abs(schema 校验通过;svc.env 600 保存)" if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then c_info "[$ENV_NAME] 重启以应用新配置……" do_stop do_start || { c_err "[$ENV_NAME] 重启失败——绑定已登记,排查日志后 scripts/svc.sh $ENV_NAME start"; return 1; } c_ok "[$ENV_NAME] 新配置已生效:$abs" else c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效" fi return 0 } # ---- agent 服务的数据库绑定( db ...)---- # svc_db_bind [pg名] [--db X] [--clear] [--no-restart]:查看 / 绑定切换 / 解除 # agent 服务的数据库。绑定 = dbref=/<库> + 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; } local target="" clear=0 restart=1 while [ $# -gt 0 ]; do case "$1" in --clear) clear=1; shift ;; --no-restart) restart=0; shift ;; --db) need_value "$@"; CLI_DB_NAME="$2"; shift 2 ;; -h|--help) cat >&2 < [--db X] [--no-restart] 绑定/切换(运行中自动重启生效) scripts/svc.sh $ENV_NAME db --clear [--no-restart] 解除绑定(显式回内嵌 SQLite 档) EOF return 0 ;; -*) c_err "[$ENV_NAME] db 未知参数:$1(可用: | --clear,--db X,--no-restart)"; return 2 ;; *) [ -z "$target" ] || { c_err "[$ENV_NAME] db 只接受一个 postgres 服务名"; return 2; } target="$1"; shift ;; esac done # 无参:查看当前绑定 + 可用实例。 if [ -z "$target" ] && [ "$clear" = 0 ]; then 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="$(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 printf ' 可用实例:\n' local found=0 while IFS= read -r pg; do st="$(svc_pid_state "$pg")" printf ' %-14s %-7s %s\n' "$pg" \ "$(if [ "${st%% *}" = alive ]; then printf 运行中; else printf 未运行; fi)" \ "$(env_kv_of "$pg" addr)" found=1 done < <(registered_names | while IFS= read -r n; do [ "$(svc_kind "$n")" = postgres ] && printf '%s\n' "$n"; done) [ "$found" = 0 ] && printf ' (无;scripts/svc.sh db create 创建)\n' printf ' 切换:scripts/svc.sh %s db [--db X];解除:--clear\n' "$ENV_NAME" return 0 fi # 解除绑定(显式选择 SQLite:清除 dbref 与直接 dsn,环境残留不再重新选库)。 if [ "$clear" = 1 ]; then [ -n "$target" ] && { c_err "--clear 不接受服务名参数"; return 2; } 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 [^ ]+//; s/ +/ /g; s/^ //; s/ $//')" 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] 已解除数据库绑定(显式 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 档)" else c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效" fi return 0 fi # 绑定/切换。 valid_env "$target" || { c_err "非法服务名:$target"; return 2; } [ -f "$RUNTIME/$target/svc.env" ] || { c_err "[$target] 未登记(scripts/svc.sh db create $target)"; return 2; } [ "$(svc_kind "$target")" = postgres ] || { c_err "[$target] kind=$(svc_kind "$target") 不是 postgres 实例"; return 2; } local taddr thost tport tuser tpass regdb tdb dsn st taddr="$(env_kv_of "$target" addr)" thost="${taddr%:*}"; tport="${taddr##*:}" tuser="$(env_kv_of "$target" user)" tpass="$(env_kv_of "$target" password)" regdb="$(env_kv_of "$target" dbname)" tdb="${CLI_DB_NAME:-$regdb}" { [ -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 if ! pg_run "$thost" "$tport" "$tuser" "$tpass" postgres "SELECT 1 FROM pg_database WHERE datname='$tdb'" tA | grep -q 1; then if pg_run "$thost" "$tport" "$tuser" "$tpass" postgres "CREATE DATABASE \"$tdb\"" >/dev/null; then c_ok "[$target] 已建库:$tdb" else c_warn "[$target] 建库 $tdb 未成功——绑定继续,请手动建库后重启服务" fi fi elif [ "$tdb" != "$regdb" ]; then c_warn "[$target] 未运行——库 $tdb 暂未创建(先 start $target 再重跑,或绑定默认库 $regdb)" fi dsn="postgresql://$tuser:$tpass@$thost:$tport/$tdb" # 写持久化配置(托管绑定形态):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 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 按新登记重建 DSN 交付)。 if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then c_info "[$ENV_NAME] 重启以切换数据库……" do_stop do_start || { c_err "[$ENV_NAME] 重启失败——绑定已登记,排查日志后 scripts/svc.sh $ENV_NAME start"; return 1; } c_ok "[$ENV_NAME] 已切换数据库:$target/$tdb(生效中)" else c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效" fi return 0 } # ---- agent 数据目录快照(SQLite 一致性备份;2026-10-12)---- # 快照根在 runtime-backups//(runtime/ 之外):2026-10-11 runtime/ 被整体 # 误删的事故里,放在 runtime//backups/ 的任何东西都会一起消失。每个 *.db # 经 sqlite3 .backup 导出一致性副本(并入 WAL 内容)并 integrity_check 自检, # 不裸拷 .db(约束见 modules/documents/docs/proposals/ # 2026-10-12-service-restart-data-recovery.md §6);-wal/-shm 不入快照。 BACKUP_KEEP_DEFAULT=7 BACKUP_TS_GLOB='[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z' agent_backup_root() { printf '%s/runtime-backups/%s' "$ROOT" "$ENV_NAME"; } agent_backup_guard() { # 公共前置:agent 已登记(backups 列表也走这里) if [ "$SVC_KIND" = postgres ]; then c_err "[$ENV_NAME] postgres 用 db backup(pg_dump);backup 是 agent 数据目录快照" exit 2 elif [ "$SVC_KIND" != agent ]; then c_err "[$ENV_NAME] kind=$SVC_KIND 无数据目录,不适用 backup" exit 2 fi [ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 未登记(无 svc.env);先 deploy"; exit 2; } } agent_backup_snapshot() { # $1=dest:svc.env + history.jsonl + data/(*.db 一致性化) local dest="$1" rel cp -p "$ENV_DIR/svc.env" "$dest/svc.env" || return 1 [ -f "$ENV_DIR/history.jsonl" ] && cp -p "$ENV_DIR/history.jsonl" "$dest/history.jsonl" mkdir -p "$dest/data" || return 1 # 先整树拷贝(非 db 内容的原样副本),再对 *.db 用一致性导出覆盖:cp 得到的 # 撕裂 db/-wal 副本在下一步被丢弃替换,不影响最终一致性。 cp -a "$ENV_DATA/." "$dest/data/" || return 1 while IFS= read -r rel; do [ -f "$ENV_DATA/$rel" ] || continue case "$rel" in *"'"*|*'"'*) if head -c 15 "$ENV_DATA/$rel" | grep -qa '^SQLite format 3$'; then c_err "[$ENV_NAME] 路径含引号,无法走 sqlite 一致性导出:$rel" return 1 fi continue ;; esac if sqlite3 "$ENV_DATA/$rel" ".backup '$dest/data/$rel.bk'" 2>/dev/null \ && [ "$(sqlite3 "$dest/data/$rel.bk" 'PRAGMA integrity_check;' 2>/dev/null)" = ok ]; then mv -f "$dest/data/$rel.bk" "$dest/data/$rel" || return 1 chmod 600 "$dest/data/$rel" rm -f "$dest/data/$rel-wal" "$dest/data/$rel-shm" # .backup 已并入 WAL 内容 else rm -f "$dest/data/$rel.bk" # 失败尝试的半成品不留在快照里 if head -c 15 "$ENV_DATA/$rel" | grep -qa '^SQLite format 3$'; then c_err "[$ENV_NAME] SQLite 一致性备份/自检失败:$rel" return 1 fi c_warn "[$ENV_NAME] $rel 不是 SQLite 库(无文件头),保留原样副本" fi done < <(cd "$dest/data" 2>/dev/null && find . -type f -name '*.db' | sort) return 0 } agent_backup_manifest() { # $1=dest:快照自描述(600)——逐文件 sha256 供恢复时校验 local dest="$1" { printf 'service=%s\nkind=%s\ncreated=%s\nsource=%s\nversion=%s\ndata_dir=%s\n' \ "$ENV_NAME" "$SVC_KIND" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ "$(svc_get source)" "$(svc_get version)" "$ENV_DATA" ( cd "$dest" && find . -type f ! -name MANIFEST -print0 | sort -z | xargs -0 -r sha256sum ) } > "$dest/MANIFEST" chmod 600 "$dest/MANIFEST" } agent_backup_prune() { # $1=root $2=keep:只清理符合时间戳命名的快照目录(最新在前保留 keep 份) local root="$1" keep="$2" n=0 d [ -d "$root" ] || return 0 while IFS= read -r d; do n=$((n + 1)) [ "$n" -le "$keep" ] && continue rm -rf "$root/$d" c_info "[$ENV_NAME] 清理旧快照:$d(保留最近 $keep 份)" done < <(cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB" | sort -r) return 0 } svc_agent_backup() { # [--out DIR] [--keep N] local out="" keep="$BACKUP_KEEP_DEFAULT" while [ $# -gt 0 ]; do case "$1" in --out) need_value "$@"; out="$2"; shift 2 ;; --keep) need_value "$@"; keep="$2"; shift 2 case "$keep" in ''|*[!0-9]*) c_err "--keep 须为非负整数"; exit 2 ;; esac ;; *) c_err "用法:scripts/svc.sh backup [--out DIR] [--keep N](未知参数:$1)"; exit 2 ;; esac done agent_backup_guard [ -d "$ENV_DATA" ] || { c_err "[$ENV_NAME] 数据目录不存在:$ENV_DATA"; exit 2; } command -v sqlite3 >/dev/null 2>&1 \ || { c_err "缺少 sqlite3——一致性备份必需,不回退裸拷贝(会漏 WAL 得到撕裂副本)"; exit 1; } local ts dest size ts="$(date -u +%Y%m%dT%H%M%SZ)" dest="${out:-$(agent_backup_root)/$ts}" case "$dest" in /*) ;; *) dest="$PWD/$dest" ;; esac [ ! -e "$dest" ] || { c_err "备份目标已存在:$dest"; exit 1; } mkdir -p "$dest" || { c_err "无法创建备份目录:$dest"; exit 1; } if ! agent_backup_snapshot "$dest" || ! agent_backup_manifest "$dest"; then rm -rf "$dest" hist_event backup fail "$(read_pid)" "dir=$dest" c_err "[$ENV_NAME] 备份失败,半成品已清理:$dest" exit 1 fi chmod 700 "$dest" 2>/dev/null || true size="$(du -sh "$dest" 2>/dev/null | cut -f1)" hist_event backup ok "$(read_pid)" "dir=$dest size=$size" [ -n "$out" ] || agent_backup_prune "$(agent_backup_root)" "$keep" c_ok "[$ENV_NAME] 数据目录快照完成:$dest($size;svc.env+history+data,db 已一致性自检)" } svc_agent_backups() { # [--prune] [--keep N] local prune=0 keep="$BACKUP_KEEP_DEFAULT" while [ $# -gt 0 ]; do case "$1" in --prune) prune=1; shift ;; --keep) need_value "$@"; keep="$2"; shift 2 case "$keep" in ''|*[!0-9]*) c_err "--keep 须为非负整数"; exit 2 ;; esac ;; *) c_err "用法:scripts/svc.sh backups [--prune] [--keep N](未知参数:$1)"; exit 2 ;; esac done agent_backup_guard local root n=0 root="$(agent_backup_root)" if [ -d "$root" ]; then while IFS= read -r _; do n=$((n + 1)); done \ < <(cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB") fi if [ "$n" -eq 0 ]; then c_info "[$ENV_NAME] 暂无快照(root=$root;scripts/svc.sh $ENV_NAME backup)" return 0 fi c_info "[$ENV_NAME] 快照列表($root,最新在前)" ( cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB" | sort -r ) \ | while IFS= read -r d; do printf ' %s %6s %s\n' "${d#./}" "$(du -sh "$root/$d" | cut -f1)" \ "$(find "$root/$d/data" -type f 2>/dev/null | wc -l) 个文件" done if [ "$prune" = 1 ]; then agent_backup_prune "$root" "$keep"; fi return 0 } cmd_set() { local name="${1:-}" [ -n "$name" ] || { c_err "用法:scripts/svc.sh set [--port N] [--host H] [--apply] [--tag T...]"; exit 2; } shift parse_args "$@" env_config "$name" [ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 无登记(先 db create / deploy)"; exit 2; } # tags:给了 --tag 才改(合并去重;postgres/agent/static 通用)。 if [ "${#CLI_TAGS[@]}" -gt 0 ]; then local merged; merged="$(svc_merge_tags)" svc_set_key tags "$merged" c_ok "[$ENV_NAME] 标签更新:$merged" fi case "$SVC_KIND" in postgres) if [ -n "$CLI_ADDR$CLI_HOST$CLI_PORT" ]; then local old; old="$(svc_get addr)"; [ -n "$old" ] || old="$ENV_ADDR" local running=0 if [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then running=1; fi if [ "$running" = 1 ] && [ "$CLI_APPLY" != 1 ]; then c_err "[$ENV_NAME] 运行中——端口/地址变更需重启生效:加 --apply(停止→改登记→重启),或先 stop 再 set" exit 1 fi check_port_available "${ENV_ADDR##*:}" "$ENV_NAME" || exit 1 if [ "$running" = 1 ]; then pg_do_stop svc_set_key addr "$ENV_ADDR" pg_do_start || { c_err "[$ENV_NAME] 新地址重启失败(登记已是 $ENV_ADDR);查看 $ENV_DIR/pg.log"; return 1; } else svc_set_key addr "$ENV_ADDR" fi hist_event set ok "$(read_pid)" "addr $old → $ENV_ADDR" c_ok "[$ENV_NAME] 地址已更新:$old → $ENV_ADDR" elif [ "${#CLI_TAGS[@]}" -eq 0 ]; then c_warn "[$ENV_NAME] 未指定任何变更(--port/--host/--addr/--tag)" fi ;; agent|static) [ -n "$CLI_ADDR$CLI_HOST$CLI_PORT" ] && \ c_warn "[$ENV_NAME] agent/static 换端口请用 restart --port N(进程与登记同步更新);set 只改登记" [ "${#CLI_TAGS[@]}" -eq 0 ] && c_warn "[$ENV_NAME] 未指定任何变更(--tag)" ;; *) c_err "[$ENV_NAME] 未知 kind:$SVC_KIND"; return 1 ;; esac return 0 } # ---- 生命周期分派 ---- do_start() { if pid_alive "$(read_pid)"; then c_warn "[$ENV_NAME] 已在运行 pid=$(read_pid)(如需换参数/新版本请用 deploy/restart)" return 0 fi case "$SVC_KIND" in agent) agent_do_start ;; static) static_do_start ;; postgres) pg_do_start ;; *) c_err "[$ENV_NAME] 未知 kind:$SVC_KIND"; return 1 ;; esac } do_stop() { local pid; pid="$(read_pid)" if [ -z "$pid" ]; then c_info "[$ENV_NAME] 无 pidfile——没有由本脚本启动的实例" # 仍回收本服务残留占口(update 误清 pidfile 后的孤儿)。 reclaim_own_port || true svc_clear_autostart return 0 fi if ! pid_alive "$pid"; then c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile" rm -f "$ENV_PID" reclaim_own_port || true svc_clear_autostart # 显式 stop 对已死进程 = 期望保持停止 return 0 fi case "$SVC_KIND" in postgres) pg_do_stop ;; *) c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)" proc_stop "$pid" rm -f "$ENV_PID" svc_clear_autostart svc_wait_port_released c_ok "[$ENV_NAME] 已停止" hist_event stop ok "$pid" "SIGTERM" ;; esac return 0 } # svc_wait_port_released:停机后等子进程释放端口(最多 3s)。桩/组合产物的 http # 子进程经 ppid-watch 自杀(≤0.5s 语义),慢机上略晚;不等会让紧跟的 start 把 # 已无父的孤儿占口判成「非本服务」拒启(U2 回退实测,CI 慢机 run 3774)。 svc_wait_port_released() { local port="${ENV_ADDR##*:}" i=0 [ -n "$port" ] || return 0 while [ "$i" -lt 30 ] && port_listening "$port"; do sleep 0.1 i=$((i + 1)) done return 0 } # do_stop_quiet [--keep-desired]:deploy 前置。切换事务必须保留期望态, # 失败回滚后仍按原期望拉起。显式 stop 仍清除期望态。未运行时静默返回。 do_stop_quiet() { local keep=0 [ "${1:-}" = --keep-desired ] && keep=1 local pid; pid="$(read_pid)" if [ -z "$pid" ]; then reclaim_own_port || true [ "$keep" = 1 ] || svc_clear_autostart return 0 fi if ! pid_alive "$pid"; then rm -f "$ENV_PID" reclaim_own_port || true [ "$keep" = 1 ] || svc_clear_autostart return 0 fi case "$SVC_KIND" in postgres) pg_do_stop ;; *) c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)" proc_stop "$pid" rm -f "$ENV_PID" [ "$keep" = 1 ] || svc_clear_autostart svc_wait_port_released c_ok "[$ENV_NAME] 已停止" hist_event stop ok "$pid" "SIGTERM" ;; esac 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 [--purge]"; exit 2; } shift parse_args "$@" env_config "$name" if [ ! -f "$ENV_DIR/svc.env" ]; then c_err "[$ENV_NAME] 无登记(runtime/$name/svc.env 不存在);rm 只管理本脚本登记的服务" exit 2 fi do_stop hist_event rm ok "$(read_pid)" "${CLI_PURGE:+purge}" rm -f "$ENV_DIR/svc.env" "$ENV_DIR/svc.pid" if [ "$CLI_PURGE" = 1 ]; then rm -rf "${ENV_DIR:?}" local bkroot="$ROOT/runtime-backups/$name" if [ -d "$bkroot" ]; then rm -rf "$bkroot" c_ok "[$ENV_NAME] 数据目录快照已删除:$bkroot" fi c_ok "[$ENV_NAME] 已注销并删除整个目录:$ENV_DIR(含数据与历史)" else c_ok "[$ENV_NAME] 已注销登记;目录保留(数据/历史在 $ENV_DIR;彻底删除用 --purge)" fi return 0 } # ---- 总览与历史 ---- svc_pid_state() { # NAME → "alive " | "dead" local d="$RUNTIME/$1" pid mark [ -f "$d/svc.pid" ] || { printf dead; return 0; } pid="$(cat "$d/svc.pid" 2>/dev/null || true)" [ -n "$pid" ] || { printf dead; return 0; } mark="$(sed -n 's/^pidcheck=//p' "$d/svc.env" 2>/dev/null | head -1)" [ -z "$mark" ] && mark="agent-system" if pid_alive_mark "$pid" "$mark"; then printf 'alive %s' "$pid"; else printf dead; fi return 0 } # registered_names:runtime 下已登记服务名(stdout 每行一个;list/info 共用)。 registered_names() { local d e for d in "$RUNTIME"/*/; do [ -f "${d}svc.env" ] || continue e="$(basename "$d")" [ "$e" = ".worktrees" ] && continue valid_env "$e" || continue printf '%s\n' "$e" done return 0 } # db_ref_of NAME:agent 服务的数据库绑定标识(list/status/info 共用)。 # dbref= 优先;否则从 exec= 的 --dsn 或配置文件(conffile=)的 dsn 键提取 host:port # 匹配已登记 postgres 实例(兼容直接 --dsn/--config 部署、尚未登记 dbref 的服务); # 都匹配不上则回 外部@host:port。 db_ref_of() { local name="$1" ref dsn hostport pg addr cf 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')" if [ -n "$cf" ] && [ -f "$cf" ]; then dsn="$(grep -oE '"dsn"[[:space:]]*:[[:space:]]*"[^"]*"' "$cf" 2>/dev/null | head -1 | sed -E 's/.*"([^"]*)"$/\1/')" fi fi [ -n "$dsn" ] || return 0 hostport="$(printf '%s' "$dsn" | sed -n 's#.*@\([^/?]*\).*#\1#p')" while IFS= read -r pg; do [ "$(svc_kind "$pg")" = postgres ] || continue addr="$(env_kv_of "$pg" addr)" if [ "$addr" = "$hostport" ]; then printf '%s/%s' "$pg" "${dsn##*/}" return 0 fi done < <(registered_names) printf '外部@%s' "$hostport" return 0 } global_list() { c_info "服务总览(root: $ROOT)" printf ' %-12s %-9s %-7s %-8s %-20s %-16s %-24s %s\n' NAME KIND STATE PID ADDR DB SOURCE TAGS local names=() d name kind addr src tags st dbref while IFS= read -r name; do names+=("$name"); done < <(registered_names) for name in ${names[@]+"${names[@]}"}; do d="$RUNTIME/$name" kind="$(sed -n 's/^kind=//p' "$d/svc.env" | head -1)"; [ -n "$kind" ] || kind=agent addr="$(sed -n 's/^addr=//p' "$d/svc.env" | head -1)" src="$(sed -n 's/^source=//p' "$d/svc.env" | head -1)" tags="$(sed -n 's/^tags=//p' "$d/svc.env" | head -1)" if [ -n "${LIST_TAG:-}" ]; then case ",$tags," in *",${LIST_TAG},"*) ;; *) continue ;; esac fi st="$(svc_pid_state "$name")" dbref="-" [ "$kind" = agent ] && dbref="$(db_ref_of "$name")" [ -n "$dbref" ] || dbref="-" if [ "${st%% *}" = alive ]; then printf ' %-12s %-9s %-7s %-8s %-20s %-16s %-24s %s\n' "$name" "$kind" "运行中" "${st#alive }" "$addr" "$dbref" "${src:--}" "${tags:--}" else printf ' %-12s %-9s %-7s %-8s %-20s %-16s %-24s %s\n' "$name" "$kind" "未运行" "-" "$addr" "$dbref" "${src:--}" "${tags:--}" fi done [ "${#names[@]}" -eq 0 ] && c_info "(暂无已登记服务;db create / deploy 会登记)" for name in ${names[@]+"${names[@]}"}; do if rdm_workbench_missing "$name"; then c_warn " [$name] 无工作台入口(伴生 $(rdm_companion_name "$name") 未登记或未运行)" fi done return 0 } env_status_detail() { local pid; pid="$(read_pid)" local addr="$ENV_ADDR" a src="" ver="" tags="" local kind="$SVC_KIND" if [ -f "$ENV_DIR/svc.env" ]; then a="$(svc_get addr)"; [ -n "$a" ] && addr="$a" src="$(svc_get source)"; ver="$(svc_get version)"; tags="$(svc_get tags)" fi if pid_alive "$pid"; then printf ' [%s] %s 运行中 pid=%s\n' "$ENV_NAME" "$kind" "$pid" else printf ' [%s] %s 未运行\n' "$ENV_NAME" "$kind" fi printf ' 地址 %s 数据 %s\n' "$addr" "$ENV_DATA" [ -n "$src" ] && printf ' 来源 %s 版本 %s\n' "$src" "${ver:--}" [ -n "$tags" ] && printf ' 标签 %s\n' "$tags" case "$kind" in agent) local dbref cfile dbref="$(db_ref_of "$ENV_NAME")" printf ' 数据库 %s\n' "${dbref:-无(内嵌 SQLite 档;绑定: db )}" cfile="$(svc_get conffile)" [ -z "$cfile" ] && cfile="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')" printf ' 配置文件 %s\n' "${cfile:-无(命令行参数形态; config 绑定)}" printf ' 监听守卫 insecureListen=%s allowedHosts=%s\n' \ "$(env_var "$ENV_NAME" INSECURE_LISTEN)" \ "$(env_var "$ENV_NAME" ALLOWED_HOSTS)" if rdm_workbench_missing "$ENV_NAME"; then printf ' 工作台 无入口(伴生 %s 未登记或未运行;scripts/svc.sh %s deploy --kind static --dir %s/htdocs)\n' \ "$(rdm_companion_name "$ENV_NAME")" "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR" fi # RDM 域管理员台账行(rdm.db 存在才显示):验收/引导残留占用共享台账时, # status 即刻可见(2026-10-13 8788 dev 锁死复现后的巡检要求)。 if [ -f "$ENV_DATA/rdm.db" ]; then local rdm_row rdm_owner _rb _ra rdm_row="$(rdm_admin_read "$ENV_DATA/rdm.db")" if [ -n "$rdm_row" ]; then IFS='|' read -r rdm_owner _rb _ra <<<"$rdm_row" printf ' RDM 域管理员 %s\n' "$rdm_owner" local rdm_owners rdm_owners="$(rdm_admin_base_owners "$ENV_DATA/agent-system.db")" case ",$rdm_owners," in *",$rdm_owner,"*) ;; *) c_warn " [$ENV_NAME] RDM 域管理员 $rdm_owner 不在基座用户表(验收/引导残留?);scripts/svc.sh $ENV_NAME rdm-admin 查看与移交" ;; esac else printf ' RDM 域管理员 无(台账为空;-bootstrap-admin 受信引导)\n' fi fi ;; postgres) printf ' 库 %s 用户 %s\n' "$(svc_get dbname)" "$(svc_get user)" printf ' DSN postgresql://%s:***@%s:%s/%s(db dsn %s --reveal 看密码)\n' \ "$(svc_get user)" "${addr%%:*}" "${addr##*:}" "$(svc_get dbname)" "$ENV_NAME" ;; static) printf ' 目录 %s\n' "$(svc_get htdocs)" ;; esac return 0 } jfield() { printf '%s' "$2" | sed -n "s/.*\"$1\": *\"\([^\"]*\)\".*/\1/p"; } cmd_history() { local env="${1:-}" n="${2:-20}" files=() f # 首参为纯数字 → 视为条数(svc.sh history 4 == history "" 4)。 case "$env" in ''|*[!0-9]*) ;; *) n="$env"; env=""; shift ;; esac case "$n" in ''|*[!0-9]*|0) c_err "N 须为正整数"; exit 2 ;; esac if [ -n "$env" ]; then valid_env "$env" || { c_err "非法服务名:$env"; exit 2; } if [ -f "$RUNTIME/$env/history.jsonl" ]; then files=("$RUNTIME/$env/history.jsonl") else c_info "[$env] 暂无历史(未登记过任何事件)" exit 0 fi else for f in "$RUNTIME"/*/history.jsonl; do [ -f "$f" ] && files+=("$f") done if [ "${#files[@]}" -eq 0 ]; then c_info "暂无历史(任何服务都未登记过事件)" exit 0 fi fi c_info "事件历史($([ -n "$env" ] && echo "服务 $env" || echo "全部服务"),最近 $n 条)" history_print_rows "$n" "${files[@]}" } # history_print_rows N FILES...:合并多个 history.jsonl、按时间排序、取最近 N 条打印 # (cmd_history 与 info 共用;行格式:时间/服务/kind/事件/结果/来源/pid/详情)。 history_print_rows() { local n="$1"; shift cat "$@" | sort | tail -n "$n" | while IFS= read -r l; do [ -n "$l" ] || continue printf ' %s %-10s %-9s %-8s %-6s %-24s %-7s %s\n' \ "$(jfield ts "$l")" "$(jfield env "$l")" "$(jfield kind "$l")" "$(jfield event "$l")" \ "$(jfield result "$l")" "$(jfield source "$l")" "$(jfield pid "$l")" "$(jfield detail "$l")" done return 0 } # ---- info:综合信息报告(只读) ---- # 报告整体走 stdout(区别于提示走 stderr 的惯例)——info 的产物就是报告本身, # 便于整页重定向/保存为快照;敏感值默认掩码,--reveal 才显示。 # mask_dsn STRING:把 postgres DSN 里的密码掩为 ***(postgresql://user:pass@ → user:***@)。 mask_dsn() { printf '%s' "$1" | sed -E 's#(postgres(ql)?://[^:/@[:space:]]+:)[^@[:space:]]+@#\1***@#g' } # 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|dsn|*KEY*|*PASS*|*SECRET*|*TOKEN*|*DSN*) printf '%s=***' "$k"; return 0 ;; esac fi mask_dsn "$line" } # info_section TITLE:stdout 分节标题。 info_section() { printf '\n────── %s ──────\n' "$*"; } # info_du PATH:目录/文件大小(du -sh;不存在或失败回 '-')。 info_du() { local s="" [ -e "$1" ] && s="$(du -sh "$1" 2>/dev/null | cut -f1)" printf '%s' "${s:--}" } # info_file_mode PATH:八进制权限(不存在回 '-')。 info_file_mode() { local m="" [ -f "$1" ] && m="$(stat -c %a "$1" 2>/dev/null || true)" printf '%s' "${m:--}" } # env_kv_of NAME KEY:读 runtime//svc.env 单键首个值(不依赖 ENV_* 全局, # info 遍历多服务时使用;无则空)。 env_kv_of() { [ -f "$RUNTIME/$1/svc.env" ] || return 0 sed -n "s/^$2=//p" "$RUNTIME/$1/svc.env" | head -1 } # info_snapshot_lines FILE:逐行打印 KEY=VALUE 内容快照(按 CLI_REVEAL 决定掩码)。 info_snapshot_lines() { local line while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue if [ "$CLI_REVEAL" = 1 ]; then printf ' %s\n' "$line" else printf ' %s\n' "$(mask_kv "$line")"; fi done < "$1" return 0 } # info_pg_block NAME:postgres 实例详情块——登记信息、数据目录与备份体量; # 运行中则列出实例内部数据库(含大小,按大小降序)。 info_pg_block() { local name="$1" d="$RUNTIME/$1" local addr user db auth ver pgdata src st pass rows addr="$(env_kv_of "$name" addr)" user="$(env_kv_of "$name" user)" db="$(env_kv_of "$name" dbname)" auth="$(env_kv_of "$name" authmode)" ver="$(env_kv_of "$name" version)" pgdata="$(env_kv_of "$name" pgdata)" src="$(env_kv_of "$name" source)" st="$(svc_pid_state "$name")" if [ "${st%% *}" = alive ]; then printf ' [%s] postgres 运行中 pid=%s → %s\n' "$name" "${st#alive }" "$addr" else printf ' [%s] postgres 未运行(内部库不可见,start 后可列出) → %s\n' "$name" "${addr:--}" fi printf ' 版本 %s 用户 %s 认证 %s 默认库 %s 来源 %s\n' \ "${ver:--}" "${user:--}" "${auth:--}" "${db:--}" "${src:--}" # 反向绑定:哪些 agent 服务当前指向本实例。 local bound="" bn bref while IFS= read -r bn; do [ "$(svc_kind "$bn")" = agent ] || continue bref="$(db_ref_of "$bn")" case "$bref" in "$name/"*) bound="${bound:+$bound、}$bn(${bref#*/})" ;; esac done < <(registered_names) printf ' 绑定服务 %s\n' "${bound:-无(绑定:scripts/svc.sh db $name)}" [ -n "$pgdata" ] && printf ' 数据目录 %s(%s)\n' "$pgdata" "$(info_du "$pgdata")" local nb=0 bsize="" latest="" f if [ -d "$d/backups" ]; then for f in "$d/backups"/*.dump; do [ -f "$f" ] || continue nb=$((nb + 1)) latest="$f" done [ "$nb" -gt 0 ] && bsize="$(info_du "$d/backups")" fi if [ "$nb" -gt 0 ]; then printf ' 备份 %d 份(合计 %s,最近 %s)\n' "$nb" "$bsize" "$(basename "$latest")" else printf ' 备份 0 份\n' fi if [ "${st%% *}" = alive ]; then pass="$(env_kv_of "$name" password)" rows="$(pg_run "${addr%:*}" "${addr##*:}" "$user" "$pass" postgres \ "SELECT datname || '|' || pg_size_pretty(pg_database_size(datname)) FROM pg_database WHERE NOT datistemplate ORDER BY pg_database_size(datname) DESC" tA)" || rows="" if [ -n "$rows" ]; then printf ' 内部库:\n' printf '%s\n' "$rows" | while IFS='|' read -r dn ds; do printf ' %-24s %s\n' "$dn" "$ds" done else printf ' 内部库:查询失败(psql 连接异常)\n' fi fi return 0 } # info_config_block NAME:单服务 svc.env 内容快照(文件路径+权限+逐行内容), # agent 服务另附其配置文件(conffile)的掩码内容快照。 info_config_block() { local name="$1" f="$RUNTIME/$1/svc.env" printf ' [%s] %s(权限 %s,kind=%s)\n' "$name" "$f" "$(info_file_mode "$f")" "$(svc_kind "$name")" info_snapshot_lines "$f" local cf cf="$(env_kv_of "$name" conffile)" [ -z "$cf" ] && cf="$(printf '%s' "$(env_kv_of "$name" exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')" if [ -n "$cf" ]; then if [ -f "$cf" ]; then printf ' ── 配置文件 %s(权限 %s)──\n' "$cf" "$(info_file_mode "$cf")" if [ "$CLI_REVEAL" = 1 ]; then cat "$cf" | sed 's/^/ /' else mask_json "$(cat "$cf")" | sed 's/^/ /' fi else printf ' ── 配置文件 %s:文件不存在(重启将失败,恢复或 %s config --clear)──\n' "$cf" "$name" fi fi return 0 } # info_global_config:全局配置层快照——$ROOT/.svc.env(自动加载层)、本次 --config # 文件、shell 层已导出的 SVC_* 变量;值同掩码规则。 info_global_config() { local f="$ROOT/.svc.env" exported="" if [ -f "$f" ]; then printf ' %s(自动加载层,权限 %s)\n' "$f" "$(info_file_mode "$f")" info_snapshot_lines "$f" else printf ' .svc.env(自动加载层):不存在\n' fi [ -n "${SVC_ENVFILE:-}" ] && printf ' 本次 --config/--env 文件:%s(值已并入生效配置)\n' "$SVC_ENVFILE" exported="$(env | grep '^SVC_' | sort || true)" if [ -n "$exported" ]; then printf ' shell 层已导出的 SVC_* 变量:\n' if [ "$CLI_REVEAL" = 1 ]; then printf '%s\n' "$exported" | sed 's/^/ /' else printf '%s\n' "$exported" | while IFS= read -r line; do printf ' %s\n' "$(mask_kv "$line")"; done fi else printf ' shell 层 SVC_* 变量:无(.svc.env 里的值是脚本内层变量,不出现在 env)\n' fi return 0 } # info_names_filtered:LIST_TAG 过滤后的已登记服务名(stdout 每行一个;与 # global_list 的 --tag 过滤同一语义)。 info_names_filtered() { local name tags while IFS= read -r name; do if [ -n "${LIST_TAG:-}" ]; then tags="$(env_kv_of "$name" tags)" case ",$tags," in *",${LIST_TAG},"*) ;; *) continue ;; esac fi printf '%s\n' "$name" done < <(registered_names) return 0 } # cmd_info [NAME] [--tag T] [--reveal]:综合信息报告(只读)。 # 无名 = 全景:服务表+运行汇总 → postgres 实例与内部库 → 各服务 svc.env 快照 + # 全局配置层 → 最近事件;有名 = 单服务深度:状态详情 +(postgres)实例 + 快照 + 事件。 cmd_info() { CLI_REVEAL=0; LIST_TAG="" local name="" while [ $# -gt 0 ]; do case "$1" in --reveal) CLI_REVEAL=1; shift ;; --tag) need_value "$@"; LIST_TAG="$2"; shift 2 ;; -h|--help) cmd_help lifecycle; return 0 ;; -*) c_err "info 未知参数:$1(可用:--tag T --reveal)"; return 2 ;; *) [ -z "$name" ] || { c_err "info 只接受一个服务名"; return 2; } name="$1"; shift ;; esac done if [ -n "$name" ]; then valid_env "$name" || { c_err "非法服务名:$name"; return 2; } [ -f "$RUNTIME/$name/svc.env" ] || { c_err "[$name] 无登记(scripts/svc.sh list 查看)"; return 2; } env_config "$name" info_section "服务 $name" env_status_detail if [ "$SVC_KIND" = postgres ]; then info_section "数据库实例 $name" info_pg_block "$name" fi info_section "配置快照 $name" info_config_block "$name" info_section "最近事件 $name" local hf="$RUNTIME/$name/history.jsonl" if [ -f "$hf" ]; then history_print_rows 5 "$hf" else printf ' (暂无事件)\n'; fi return 0 fi # 全景报告。 local names=() n kind st total=0 running=0 ka=0 kp=0 ks=0 while IFS= read -r n; do names+=("$n"); done < <(info_names_filtered) info_section "服务" global_list for n in ${names[@]+"${names[@]}"}; do total=$((total + 1)) st="$(svc_pid_state "$n")" [ "${st%% *}" = alive ] || continue running=$((running + 1)) case "$(svc_kind "$n")" in agent) ka=$((ka + 1)) ;; postgres) kp=$((kp + 1)) ;; static) ks=$((ks + 1)) ;; esac done [ "$total" -gt 0 ] && printf ' 汇总:%d 已登记 / %d 运行中(agent %d、postgres %d、static %d)\n' \ "$total" "$running" "$ka" "$kp" "$ks" info_section "数据库" local pgs=() for n in ${names[@]+"${names[@]}"}; do [ "$(svc_kind "$n")" = postgres ] && pgs+=("$n") done if [ "${#pgs[@]}" -eq 0 ]; then printf ' (无 postgres 实例;scripts/svc.sh db create 创建)\n' else for n in ${pgs[@]+"${pgs[@]}"}; do info_pg_block "$n"; done fi info_section "配置" if [ "${#names[@]}" -eq 0 ]; then printf ' (无已登记服务)\n' else for n in ${names[@]+"${names[@]}"}; do info_config_block "$n"; done fi info_global_config info_section "最近事件" local files=() f for n in ${names[@]+"${names[@]}"}; do f="$RUNTIME/$n/history.jsonl" [ -f "$f" ] && files+=("$f") done if [ "${#files[@]}" -eq 0 ]; then printf ' (暂无事件)\n' else history_print_rows 5 "${files[@]}" printf ' (更多:scripts/svc.sh history%s)\n' "${LIST_TAG:+ --tag 对应服务名}" fi return 0 } # ---- 自动检索与更新 ---- # cmd_update [NAME...] [--check] [--all] [--no-fetch]: # fetch 独立部署源仓 → 逐服务比较登记 source 与远端 main 最新(Releases 最新版本)→ # --check 只报告;否则更新"过时且运行中"的服务(点名或 --all 时含已停止)。 # local@/dev@ 源一律重新部署为 dev@<远端最新>(隔离 worktree 构建)——与主检出 # 工作树状态完全无关(脏树、任意分支均可);领先/分叉/提交不在远端不动作。 cmd_update() { local names=() a while [ $# -gt 0 ]; do case "$1" in --check) CLI_CHECK=1; shift ;; --all) CLI_ALL=1; shift ;; --quiet) CLI_QUIET=1; shift ;; --no-fetch) CLI_NOFETCH=1; shift ;; --) shift; break ;; -h|--help) usage; exit 0 ;; -*) c_err "update 未知参数:$1(可用:--check --all --no-fetch)"; return 2 ;; *) names+=("$1"); shift ;; esac done local targets=() d e if [ "${#names[@]}" -gt 0 ]; then for a in ${names[@]+"${names[@]}"}; do valid_env "$a" || { c_err "非法服务名:$a"; return 2; } [ -f "$RUNTIME/$a/svc.env" ] || { c_err "[$a] 未登记(scripts/svc.sh list 查看)"; return 2; } targets+=("$a") done else for d in "$RUNTIME"/*/; do [ -f "${d}svc.env" ] || continue e="$(basename "$d")" [ "$e" = ".worktrees" ] && continue valid_env "$e" || continue targets+=("$e") done fi [ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; } # 按目标来源决定访问哪些仓:local@/dev@ 走独立部署源仓;release@ 走 Releases 仓; # rdm@ 走独立 RDM 源仓。 local latest_sha="" latest_rel="" latest_rdm="" need_rel=0 need_dev=0 need_rdm=0 e_src for e in ${targets[@]+"${targets[@]}"}; do e_src="$(sed -n 's/^source=//p' "$RUNTIME/$e/svc.env" | head -1)" case "$e_src" in release@*) need_rel=1 ;; local@*|dev@*) need_dev=1 ;; rdm@*) need_rdm=1 ;; esac done local fetch_note="" src_args="" [ "$CLI_NOFETCH" = 1 ] && { fetch_note="--no-fetch,基于本地缓存引用"; src_args="--no-fetch"; } if [ "$need_dev" = 1 ]; then if svc_src_ready $src_args; then latest_sha="$(git -C "$SRC_DIR" rev-parse --verify --quiet origin/main || true)" else c_warn "部署源仓不可用——local/dev 源服务本轮无法检测与更新" fi fi if [ "$need_rel" = 1 ]; then releases_root if [ "$CLI_NOFETCH" != 1 ]; then git -C "$REL_ROOT" fetch --quiet 2>/dev/null || c_warn "Releases 仓 fetch 失败——基于本地缓存" fi local rel_head rel_head="$(git -C "$REL_ROOT" rev-parse --verify --quiet HEAD || true)" if [ -n "$rel_head" ]; then latest_rel="$(git -C "$REL_ROOT" ls-tree --name-only "$rel_head:versions" 2>/dev/null | sort -V | tail -1 || true)" fi fi if [ "$need_rdm" = 1 ]; then if rdm_src_ready $src_args; then latest_rdm="$(git -C "$RDM_SRC_DIR" rev-parse --verify --quiet origin/main || true)" else c_warn "独立 RDM 源仓不可用——rdm 源服务本轮无法检测与更新" fi fi # 逐服务分析:状态判定 + 收集可更新动作。 c_info "更新检测($fetch_note;root: $ROOT)" printf ' %-12s %-20s %-12s %s\n' 服务 当前来源 最新 状态 local acts=() state to latest_disp="" updated=0 failed=0 skipped=0 for e in ${targets[@]+"${targets[@]}"}; do local kind src cur st_running kind="$(svc_kind "$e")" src="$(sed -n 's/^source=//p' "$RUNTIME/$e/svc.env" | head -1)" to="" latest_disp="" case "$kind" in postgres) state="不适用(数据库)" ;; *) case "$src" in dir@*) state="不适用(外部目录)" ;; "") state="来源未知(重新 deploy 后可参与更新)" ;; local@*|dev@*) cur="${src##*@}" if [ -z "$latest_sha" ]; then state="远端 main 未知"; latest_disp="-" elif ! git -C "$SRC_DIR" cat-file -e "${cur}^{commit}" 2>/dev/null; then state="提交不在远端(不自动更新;重新 deploy 可纳入更新轨道)" latest_disp="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || echo "-")" elif [ "${latest_sha:0:${#cur}}" = "$cur" ]; then state="✓ 最新"; latest_disp="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || echo "-")" elif git -C "$SRC_DIR" merge-base --is-ancestor "$cur" "$latest_sha" 2>/dev/null; then to="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || true)" latest_disp="$to" state="可更新" elif git -C "$SRC_DIR" merge-base --is-ancestor "$latest_sha" "$cur" 2>/dev/null; then state="领先远端 main(不动作)"; latest_disp="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || echo "-")" else state="与远端 main 分叉(不自动更新)"; latest_disp="-" fi ;; release@*) cur="${src#release@}" latest_disp="${latest_rel:--}" if [ -z "$latest_rel" ]; then state="Releases 仓不可用" elif [ "$cur" = "$latest_rel" ]; then state="✓ 最新" else local ordered ordered="$(printf '%s\n%s\n' "$cur" "$latest_rel" | sort -V | tail -1)" if [ "$ordered" = "$latest_rel" ]; then state="可更新"; to="$latest_rel" else state="领先 Releases(不动作)" fi fi ;; rdm@*) cur="${src##*@}" if [ -z "$latest_rdm" ]; then state="RDM 远端 main 未知"; latest_disp="-" elif ! git -C "$RDM_SRC_DIR" cat-file -e "${cur}^{commit}" 2>/dev/null; then state="提交不在 RDM 远端(不自动更新;重新 deploy 可纳入更新轨道)" latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")" elif [ "${latest_rdm:0:${#cur}}" = "$cur" ]; then state="✓ 最新"; latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")" elif git -C "$RDM_SRC_DIR" merge-base --is-ancestor "$cur" "$latest_rdm" 2>/dev/null; then to="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || true)" latest_disp="$to" state="可更新" elif git -C "$RDM_SRC_DIR" merge-base --is-ancestor "$latest_rdm" "$cur" 2>/dev/null; then state="领先 RDM 远端 main(不动作)"; latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")" else state="与 RDM 远端 main 分叉(不自动更新)"; latest_disp="-" fi ;; *) state="来源未知(重新 deploy 后可参与更新)" ;; esac ;; esac printf ' %-12s %-20s %-12s %s\n' "$e" "${src:--}" "$latest_disp" "$state" if [ "$state" = "可更新" ]; then st_running="$(svc_pid_state "$e")" if [ "${st_running%% *}" = alive ] || [ "$CLI_ALL" = 1 ] || [ "${#names[@]}" -gt 0 ]; then acts+=("$e|$src|$to") else c_warn "[$e] 已停止且可更新——加 --all 或点名以更新(更新会启动服务)" fi fi done if [ "$CLI_CHECK" = 1 ]; then c_info "--check:仅报告,未做任何变更" return 0 fi [ ${#acts[@]} -gt 0 ] || { c_ok "无需更新(全部最新或不适用)"; return 0; } # 执行更新:逐服务走 deploy 全链路(复用持久化配置与标签)。 for a in ${acts[@]+"${acts[@]}"}; do local name="${a%%|*}" rest="${a#*|}" local from="${rest%%|*}" tov="${rest#*|}" local deploy_from="" env_config "$name" # local@/dev@ 源统一重新部署为 dev@<远端最新>:独立部署源仓 + 隔离 worktree 构建, # 不 pull 主检出、不看工作树状态(脏树、任意分支均可更新)。 case "$from" in local@*|dev@*) deploy_from="dev@$tov" ;; release@*) deploy_from="release@$tov" ;; rdm@*) deploy_from="rdm@$tov" ;; esac c_info "[$name] 更新:$from → $tov($deploy_from)" hist_event update begin "$(read_pid)" "from=$from to=$tov" # 每个服务独立子进程、清空环境,避免批内 AGENT_SYSTEM_* 串实例。 if svc_lifecycle_child deploy "$name" --from "$deploy_from"; then hist_event update ok "$(read_pid)" "from=$from to=$tov" c_ok "[$name] 已更新:$from → $tov" updated=$((updated + 1)) else hist_event update fail "$(read_pid)" "from=$from to=$tov(部署失败)" c_err "[$name] 更新失败($from → $tov),其余服务继续" failed=$((failed + 1)) fi done c_info "更新汇总:成功 $updated / 失败 $failed / 跳过 $skipped / 其余最新或不适用" [ "$failed" -eq 0 ] && return 0 || return 1 } # ---- 自恢复:recover 扫描拉回 + watchdog(systemd 用户 timer)---- # recover_targets [NAME...]:postgres 在前(绑定的 agent 依赖实例先起),其余按登记序。 recover_targets() { local n while IFS= read -r n; do [ "$(svc_kind "$n")" = postgres ] && printf '%s\n' "$n" done < <(registered_names) while IFS= read -r n; do [ "$(svc_kind "$n")" != postgres ] && printf '%s\n' "$n" done < <(registered_names) } # cmd_recover [NAME...]:把 autostart=1 但已死的服务重新拉起(裸 start 复用持久化 # exec= 与数据库绑定;逐服务非阻塞持锁,被 deploy/手动 start 持有时跳过本轮)。 # 退出码:0 = 全部成功或无需动作;1 = 有恢复尝试失败(端口被占/启动失败等)。 cmd_recover() { local names=() a while [ $# -gt 0 ]; do case "$1" in --quiet) CLI_QUIET=1; shift ;; --) shift; break ;; -h|--help) cat >&2 <<'EOF' 用法:scripts/svc.sh recover [name...] [--quiet] 自恢复:扫描已登记服务(缺省全部;postgres 优先),对 svc.env autostart=1 且进程已死(pidfile+cmdline 校验)的服务执行裸 start 拉回。 通常由 watchdog timer 每分钟调用;手动执行用于立即恢复。 EOF return 0 ;; -*) c_err "recover 未知参数:$1(可用:--quiet)"; return 2 ;; *) names+=("$1"); shift ;; esac done local targets=() n rc=0 recovered=0 failed=0 skipped=0 if [ "${#names[@]}" -gt 0 ]; then for a in ${names[@]+"${names[@]}"}; do valid_env "$a" || { c_err "非法服务名:$a"; return 2; } [ -f "$RUNTIME/$a/svc.env" ] || { c_err "[$a] 未登记(scripts/svc.sh list 查看)"; return 2; } done # 点名时保持用户顺序,但 postgres 仍前置(依赖顺序)。 local pgs=() others=() for a in ${names[@]+"${names[@]}"}; do [ "$(svc_kind "$a")" = postgres ] && pgs+=("$a") || others+=("$a") done targets=(${pgs[@]+"${pgs[@]}"} ${others[@]+"${others[@]}"}) else while IFS= read -r n; do targets+=("$n"); done < <(recover_targets) fi [ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; } for n in ${targets[@]+"${targets[@]}"}; do [ "$(env_kv_of "$n" autostart)" = "1" ] || { skipped=$((skipped + 1)); continue; } local st; st="$(svc_pid_state "$n")" if [ "${st%% *}" = alive ]; then skipped=$((skipped + 1)); continue; fi c_info "[$n] recover:autostart=1 且进程已死,重新拉起(复用持久化配置)" # 子进程自己抢锁;锁被占用时 __lifecycle 以 75 退出,本轮记跳过。 local child_rc=0 svc_lifecycle_child recover "$n" || child_rc=$? env_config "$n" if [ "$child_rc" -eq 0 ]; then hist_event recover ok "$(read_pid)" "autostart revival" recovered=$((recovered + 1)) elif [ "$child_rc" -eq 75 ]; then c_warn "[$n] 生命周期锁被占用(deploy/手动操作进行中),本轮跳过" skipped=$((skipped + 1)) else hist_event recover fail "" "autostart revival 失败(见 start fail 事件与 svc.log)" failed=$((failed + 1)); rc=1 fi done if [ "$recovered" -gt 0 ] || [ "$failed" -gt 0 ]; then c_event "recover 汇总:拉回 $recovered / 失败 $failed / 无需动作 $skipped" fi return "$rc" } # ---- watchdog:systemd 用户级 timer,每分钟执行 recover ---- WD_UNIT_BASE="agent-system-svc-recover" WD_UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" # wd_service_text ROOT / wd_timer_text:单元文件内容(纯函数,治理测试直接断言)。 wd_service_text() { printf '%s ' \ "# 由 scripts/svc.sh watchdog install 生成(ROOT=$(printf '%s' "$1" | sed 's/[[:space:]]/\\ /g'));修改请重装或手工维护" \ "[Unit]" \ "Description=agent-system svc auto-recovery (restart crashed/killed services)" \ "" \ "[Service]" \ "Type=oneshot" \ "# KillMode=process:oneshot 结束时只回收脚本自身——默认 control-group 会把" \ "# recover 刚拉起的服务一并杀掉(2026-10-11 实测:recover ok 后进程立即消失)。" \ "KillMode=process" \ "ExecStart=$1/scripts/svc.sh recover --quiet" \ "Nice=5" } wd_timer_text() { printf '%s\n' \ "[Unit]" \ "Description=Run agent-system svc recovery every minute" \ "" \ "[Timer]" \ "OnBootSec=1min" \ "OnUnitActiveSec=60s" \ "AccuracySec=10s" \ "Unit=${WD_UNIT_BASE}.service" \ "" \ "[Install]" \ "WantedBy=timers.target" } # wd_systemd_ok:systemd 用户会话是否可用(不可用环境给出 cron 降级指引)。 wd_systemd_ok() { command -v systemctl >/dev/null 2>&1 || return 1 systemctl --user show-environment >/dev/null 2>&1 } # cmd_watchdog install|remove|status [--print]:看护 timer 的安装/移除/状态。 cmd_watchdog() { local sub="${1:-status}"; [ $# -gt 0 ] && shift || true case "$sub" in install) local print=0 case "${1:-}" in --print) print=1 ;; "") ;; *) c_err "watchdog install 未知参数:$1(可用:--print)"; return 2 ;; esac local svc="$WD_UNIT_DIR/${WD_UNIT_BASE}.service" tmr="$WD_UNIT_DIR/${WD_UNIT_BASE}.timer" if [ "$print" = 1 ]; then c_info "watchdog install --print:只打印计划,不写文件、不碰 systemd" printf '── %s ──\n%s\n' "$svc" "$(wd_service_text "$ROOT")" printf '── %s ──\n%s\n' "$tmr" "$(wd_timer_text)" wd_systemd_ok || c_warn "当前环境 systemd 用户会话不可用——正式 install 需要它;或退化用 cron(help recovery)" return 0 fi wd_systemd_ok || { c_err "systemd 用户会话不可用(systemctl --user 失败)——无法安装 timer" c_err " 降级方案(cron,每分钟):" c_err " (crontab -l 2>/dev/null; echo '* * * * * $ROOT/scripts/svc.sh recover --quiet >/dev/null 2>&1') | crontab -" return 1 } mkdir -p "$WD_UNIT_DIR" { wd_service_text "$ROOT"; printf '\n'; } > "$svc" { wd_timer_text; printf '\n'; } > "$tmr" systemctl --user daemon-reload || { c_err "daemon-reload 失败"; return 1; } systemctl --user enable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || { c_err "timer enable 失败(systemctl --user status ${WD_UNIT_BASE}.timer 查原因)"; return 1; } c_ok "watchdog timer 已安装并启用:${WD_UNIT_BASE}.timer(每分钟 recover;remove 移除)" [ "$(loginctl show-user "$(id -un)" -p Linger 2>/dev/null)" = "Linger=yes" ] \ || c_warn "用户未开 linger(sudo loginctl enable-linger $(id -un))——开机后 timer 不自启,登出不受影响" c_info "立即自检:systemctl --user list-timers ${WD_UNIT_BASE}.timer;人工触发:systemctl --user start ${WD_UNIT_BASE}.service" return 0 ;; remove|uninstall) wd_systemd_ok || { c_info "systemd 用户会话不可用——只清理单元文件"; } if wd_systemd_ok; then systemctl --user disable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || true systemctl --user reset-failed "${WD_UNIT_BASE}.service" >/dev/null 2>&1 || true fi rm -f "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" "$WD_UNIT_DIR/${WD_UNIT_BASE}.timer" if wd_systemd_ok; then systemctl --user daemon-reload || true; fi c_ok "watchdog 已移除(单元文件与 timer;服务登记不受影响)" return 0 ;; status) local n auto_list="" if wd_systemd_ok; then systemctl --user list-timers --no-pager "${WD_UNIT_BASE}.timer" 2>/dev/null | sed 's/^/ /' >&2 || true local exec_line exec_line="$(sed -n 's/^ExecStart=//p' "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" 2>/dev/null || true)" if [ -n "$exec_line" ] && [ "$exec_line" != "$ROOT/scripts/svc.sh recover --quiet" ]; then c_warn "已安装 timer 指向其他检出:$exec_line(当前 ROOT=$ROOT;重装:watchdog install)" fi else c_warn "systemd 用户会话不可用——timer 未安装(watchdog install 或用 cron,见 help recovery)" fi while IFS= read -r n; do [ "$(env_kv_of "$n" autostart)" = "1" ] && auto_list="${auto_list:+$auto_list }$n" done < <(registered_names) c_info "autostart=1 的服务:${auto_list:-无(start 成功后自动登记)}" return 0 ;; -h|--help|help) cat >&2 <<'EOF' 用法:scripts/svc.sh watchdog install|remove|status [install --print] install 写入并启用 systemd 用户 timer(每分钟 svc.sh recover --quiet) install --print 只打印将写入的单元文件与计划(不落盘) remove 停用并删除 timer(服务登记与运行状态不受影响) status timer 状态 + autostart=1 的服务清单 EOF return 0 ;; *) c_err "未知子命令:$sub(可用:install|remove|status)"; return 2 ;; esac } # ---- main ---- # svc_lifecycle_main ACTION NAME [--from SPEC] [--quiet] # 锁内:迁移 → 身份策略(在各动作里)→ 动作 → 解锁。供 update/recover 子进程使用。 svc_lifecycle_main() { local action="${1:-}" name="${2:-}" [ -n "$action" ] && [ -n "$name" ] || { c_err "__lifecycle 需要动作与服务名"; exit 2; } shift 2 valid_env "$name" || exit 2 while [ $# -gt 0 ]; do case "$1" in --from) CLI_FROM="$2"; shift 2 ;; --quiet) CLI_QUIET=1; shift ;; *) c_err "__lifecycle 未知参数:$1"; exit 2 ;; esac done env_config "$name" if ! svc_lock -n; then exit 75 fi case "$action" in deploy|recover|start) svc_manifest_migrate || { svc_unlock; exit 1; } ;; esac # 对账必须发生在干净子进程里:父进程的调用方环境不得进入部署。 case "$action" in deploy|recover) svc_deploy_journal_resolve || { svc_unlock; exit 1; } ;; esac local rc=0 case "$action" in deploy) case "$SVC_KIND" in agent) agent_do_deploy || rc=$? ;; static) static_do_deploy && static_do_start || rc=$? ;; *) c_err "[$name] 该类型不支持 deploy"; rc=2 ;; esac ;; recover|start) do_start || rc=$? ;; *) c_err "未知生命周期动作:$action"; rc=2 ;; esac svc_unlock exit "$rc" } main() { if [ "${1:-}" = "__lifecycle" ]; then shift svc_lifecycle_main "$@" fi [ $# -ge 1 ] || { usage_short; exit 2; } # 预扫 --config/--env(FILE 与 =FILE 两种写法,可出现在任意位置)。 local svc_envfile="" pre=() while [ $# -gt 0 ]; do case "$1" in --config|--env) [ $# -ge 2 ] || { c_err "$1 需要文件路径"; exit 2; }; svc_envfile="$2"; shift 2 ;; --config=*|--env=*) svc_envfile="${1#*=}"; shift ;; *) pre+=("$1"); shift ;; esac done set -- ${pre[@]+"${pre[@]}"} SVC_ENVFILE="$svc_envfile" [ -f "$ROOT/.svc.env" ] && load_env_file "$ROOT/.svc.env" [ -n "$svc_envfile" ] && load_env_file "$svc_envfile" case "$1" in -h) usage_short; exit 0 ;; --help) usage; exit 0 ;; help) shift; cmd_help "$@"; exit $? ;; esac case "$1" in list|status) shift LIST_TAG="" while [ $# -gt 0 ]; do case "$1" in --tag) need_value "$@"; LIST_TAG="$2"; shift 2 ;; *) c_err "list/status 只接受 --tag(未知参数:$1)"; exit 2 ;; esac done global_list exit 0 ;; db) shift; cmd_db "$@"; exit 0 ;; set) shift; cmd_set "$@"; exit 0 ;; rm) shift; cmd_rm "$@"; exit 0 ;; info) shift; cmd_info "$@" || exit $?; exit 0 ;; commits) shift; cmd_commits "$@"; exit 0 ;; history) shift; cmd_history "$@"; exit 0 ;; update) shift; cmd_update "$@" || exit $?; exit 0 ;; recover) shift; cmd_recover "$@" || exit $?; exit 0 ;; watchdog) shift; cmd_watchdog "$@" || exit $?; exit 0 ;; esac ENV_NAME="$1"; shift # 动作词可省略(省略 = deploy);flags 可直接跟随服务名。 local action="deploy" case "${1:-}" in deploy|stop|start|restart|status|logs|db|config|init|adopt|user|rdm-admin|backup|backups) action="$1"; shift ;; esac if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then CLI_LOGS_N="$1"; shift fi # db / config / user / rdm-admin / backup / backups:自带参数解析(位置参数不走 # parse_args;未知 flags 由其自行拒绝)。 if [ "$action" = db ] || [ "$action" = config ] || [ "$action" = user ] || [ "$action" = rdm-admin ]; then env_config "$ENV_NAME" case "$action" in db) svc_db_bind "$@" || exit $? ;; config) svc_config "$@" || exit $? ;; user) agent_do_user "$@" || exit $? ;; rdm-admin) agent_do_rdm_admin "$@" || exit $? ;; esac exit 0 fi if [ "$action" = backup ] || [ "$action" = backups ]; then env_config "$ENV_NAME" case "$action" in backup) svc_agent_backup "$@" || exit $? ;; backups) svc_agent_backups "$@" || exit $? ;; esac exit 0 fi local adopt_confirm="" if [ "$action" = adopt ]; then while [ $# -gt 0 ]; do case "$1" in --confirm) adopt_confirm="$2"; shift 2 ;; *) c_err "adopt 用法:scripts/svc.sh $ENV_NAME adopt --confirm "; exit 2 ;; esac done fi parse_args "$@" if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ] || [ "$CLI_ALLOW_SOURCE_SWITCH" = 1 ]; } && [ "$action" != deploy ]; then c_err "--from/--pick/--dry-run/--dir/--allow-source-switch 仅用于 deploy 动作" exit 2 fi env_config "$ENV_NAME" # 生命周期动作持 .start.lock(wait 模式;进程退出自动释放)——deploy 全程含 # 构建窗口,防 recover/并发 start 在停机-换产物间隙抢跑。只读动作不加锁。 case "$action" in deploy|stop|start|restart|init|adopt) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;; esac # U1 身份:锁内一次性迁移(幂等;含既有数据领养 marker)。归属冲突(rc=4)中止; # 其余失败保持 legacy 治理继续运行。 case "$action" in start|restart|deploy|init|adopt) svc_manifest_migrate || exit 1 ;; esac # U2:入口处理中断的切换(deploy.journal)。blocked 拒绝除 deploy 外的动作 #(显式换制品即解除路径);start/restart 触发回退判定。 case "$action" in start|restart|deploy) svc_deploy_journal_resolve "$action" || exit 1 ;; esac case "$action" in deploy) case "$SVC_KIND" in agent) agent_do_deploy ;; static) static_do_deploy && static_do_start ;; postgres) c_err "[$ENV_NAME] postgres 无 deploy;db create 建库、start 启动"; exit 2 ;; esac ;; stop) do_stop ;; start) do_start ;; restart) do_restart ;; init) # 只用于 fresh/pending 的显式建立。established 缺行不得用 init 补,走 adopt。 command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || { c_err "[$ENV_NAME] init 需要 python3 与 scripts/svc_manifest.py"; exit 2; } if [ -f "$ENV_DIR/manifest.json" ]; then case "$(svc_db_marker)" in established) c_err "[$ENV_NAME] 身份已建立。缺行或不匹配时先调查,再 scripts/svc.sh $ENV_NAME adopt --confirm " exit 1 ;; esac c_ok "[$ENV_NAME] 已在 manifest 治理下(init 补齐 fresh/pending):" elif [ -f "$ENV_DIR/svc.env" ]; then python3 "$MANIFEST_PY" "$ENV_DIR" init \ || { c_err "[$ENV_NAME] init 失败(数据根冲突等,见上)"; exit 1; } else c_err "[$ENV_NAME] 尚无 svc.env:先用 start 完成首次登记;init 用于显式化已有实例"; exit 2 fi resolve_db_config 2>/dev/null || true svc_db_identity_ensure || { c_err "[$ENV_NAME] DB 身份 ensure 失败"; exit 1; } python3 "$MANIFEST_PY" "$ENV_DIR" status ;; adopt) # 显式重审已建立实例的 DB 身份。先打印库大小、行数、最近写入,再要求 --confirm。 [ -n "$adopt_confirm" ] || { c_err "adopt 必须 --confirm "; exit 2; } [ -f "$ENV_DIR/manifest.json" ] || { c_err "[$ENV_NAME] 无 manifest,不能 adopt"; exit 1; } local iid iid="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["instance_id"])' "$ENV_DIR/manifest.json")" [ "$adopt_confirm" = "$iid" ] || { c_err "[$ENV_NAME] --confirm 与 instance_id 不符($iid)"; exit 1; } resolve_db_config 2>/dev/null || true python3 - "$ENV_DIR" <<'PY' import json, os, sqlite3, sys, time env_dir = sys.argv[1] m = json.load(open(os.path.join(env_dir, "manifest.json"), encoding="utf-8")) data = (m.get("identity") or {}).get("data_root") or "" db = os.path.join(data, "agent-system.db") print("adopt 证据 instance_id=%s" % m["instance_id"]) if not os.path.isfile(db): print("库文件不存在:%s" % db) sys.exit(0) st = os.stat(db) print("库大小=%d 字节 最近写入=%s" % (st.st_size, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime)))) con = sqlite3.connect(db) try: for table in ("agent_system_identity", "conversations", "users"): try: n = con.execute("SELECT COUNT(*) FROM %s" % table).fetchone()[0] print("%s 行数=%s" % (table, n)) except sqlite3.Error: print("%s 行数=(无此表)" % table) finally: con.close() PY svc_db_marker_set pending || exit 1 svc_db_identity_ensure || { c_err "[$ENV_NAME] adopt ensure 失败"; exit 1; } c_ok "[$ENV_NAME] adopt 完成,db_marker=established" ;; status) env_status_detail ;; logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;; esac } # 仅作为脚本直接执行时进入主流程;被 source(治理单测 fixtures)时只定义函数。 if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@" fi