#!/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 配置文件更新/生效) # 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] [N] 可部署提交列表(默认 dev,N=20) # scripts/svc.sh history [name] [N] 事件历史(默认全部,最近 20 条) # scripts/svc.sh update [name] [--check] [--all] 检测远端最新并更新过时服务(与当前工作区状态无关) # 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 校验) # --pick [dev|release] 交互式编号选提交;--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 启动后播种设置 # -- <剩余参数> 原样透传给 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 # # ════════════════════ 配置优先级 ════════════════════ # 命令行 > --config/--env 文件 > svc.env 持久化(exec=/addr=)> SVC__* 环境变量 # (.svc.env 自动加载,同层)> 默认值。裸 start/restart 复用上次启动配置;显式参数 # 出现即覆盖。任意环境 X 的变量名:SVC__ADDR/DATA/DSN/AUTH/MOCK, # 以及 INSECURE_LISTEN / ALLOWED_HOSTS(传给进程的 AGENT_SYSTEM_*,旧二进制会忽略)。 # # ════════════════════ 服务模型与登记 ════════════════════ # - 服务名 ^[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(事件流) # - 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 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 pg1 restart && scripts/svc.sh rm tmp1 --purge # make svc ENV=dev ARGS="--env .env.dev --port 9000" # # ════════════════════ 安全约束 ════════════════════ # - 只写 runtime//、runtime/.worktrees/ 与独立部署源仓(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 才显示); # - 备份/克隆只增不删;restore/copy 覆盖非空库必须显式 --force(drop 重建再恢复); # 备份文件在 runtime//backups/,随 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}" # 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。 c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; } c_ok() { printf '\033[1;32m[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 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] 刷新过时服务到远端最新(不依赖工作区状态) 帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle db dbops config examples safety) 文档:docs/svc.md EOF } # HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。 HELP_TOPICS="model deploy lifecycle db dbops config 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 注销 db 数据库服务:create/start/dsn/ls/set(换端口) dbops 数据库运维:backup/backups/restore/clone/copy config 配置分层:优先级、--config 文件、SVC_* 环境变量、播种参数 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) 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 复用;显式参数出现即重建) 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 含原因:进程即退/健康检查超时) stop ok|fail set 登记变更(addr 旧 → 新) backup ok|fail(文件与大小) 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 校验,不符即拒绝。 --pick [dev|release] 交互式编号选择(需 TTY;列表与提示走 stderr) --dry-run 只打印部署计划不执行 行为细节: - deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程) - agent 启动后等健康检查(20s),随后按 --model-*/--tavily-keys 播种设置(幂等) - 旧版本产物不认识 --dsn/--auth 时自动省略(探测 serve 用法)并提示 - 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 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名=绑定/切换(DSN 写入 exec=,dbref= 登记, 实例运行中缺库自动建,服务运行中自动重启生效);--clear 解绑回内嵌 SQLite 档;--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] 事件历史(首参为纯数字时视为条数) 变更登记(不动进程,除 --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// 整个目录一起删除 自动更新(一条命令把服务刷到远端最新;与当前工作区状态无关): 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 ;; 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 # 解绑回内嵌 SQLite 档 绑定写入 svc.env(exec= 的 --dsn + dbref=,600);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=——上次启动的实际参数) 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 密钥池 播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。 持久化语义: - deploy/start 成功(健康探测通过)后把当前态写入 svc.env(600);启动失败不覆盖 上次配置——裸 start 仍按上次可用参数重试 - 裸 start/restart 复用 exec=(上次完整参数);任何显式参数(或 --config 文件、 SVC_* 变量)出现即整体重建参数——重建时 dbref 绑定的 --dsn 自动从实例登记 重建保留(mock 档或显式 --dsn 除外) - 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 ;; 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_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_CHECK=0; CLI_ALL=0; CLI_NOFETCH=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 ;; --from) need_value "$@"; CLI_FROM="$2"; shift 2 ;; --pick) CLI_PICK="dev" case "${2:-}" in dev|release) 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 ;; --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 " # 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() { local p="$1" command -v ss >/dev/null 2>&1 || return 1 ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}' } # 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 + dbref/ # conffile(跨重启保留的数据库绑定与配置文件);kind 特有键(user/password/dbname/ # authmode/pgdata/htdocs...)经参数追加。 record_state() { # 先读完旧文件再开重定向(块内 {} > svc.env 会先截断文件,读旧值必须在其之前)。 local pid prev_src prev_dbref prev_conffile 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)" SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}" merged_tags="$(svc_merge_tags)" mkdir -p "$ENV_DIR" { 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 "$prev_dbref" ] && printf 'dbref=%s\n' "$prev_dbref" [ -n "$prev_conffile" ] && printf 'conffile=%s\n' "$prev_conffile" [ $# -gt 0 ] && printf '%s\n' "$@" } > "$ENV_DIR/svc.env" chmod 600 "$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.env pidcheck 优先;旧登记回退 agent-system)。 pid_alive() { local pid="$1" 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 } check_port_free() { local owner; owner="$(port_owner)" if [ -n "$owner" ]; then c_err "[$ENV_NAME] 端口被占用(非本服务):$owner" 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" ENV_BIN_DIR="$ENV_DIR/bin" ENV_BIN="$ENV_BIN_DIR/agent-system" 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 用法过滤旧产物不认识的 --dsn/--auth。 # 匹配按"去横线的旗标名 + 1-2 个横线"(Go flag 帮助打印单横线 -dsn,2026-10-04 实测 # 旧实现 grep -- '--dsn' 永不命中,把现役二进制的 --dsn/--auth 误当不支持而丢弃)。 adjust_args_for_bin() { local bin="$1" args="$2" out out="$("$bin" serve -h 2>&1 || true)" local filtered="" w drop_next=0 flag for w in $args; do case "$w" in --dsn|--auth) 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 } 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) 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) ;; *) c_err "用法:scripts/svc.sh commits [dev|release] [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" ;; *) c_err "无法解析 --from $spec(可用:local | dev[@] | release[@<版本|sha>])" exit 2 ;; esac } install_artifacts() { # SRCBIN SRCPLUGINS:agent 产物复制进服务目录。 local srcbin="$1" srcplug="$2" mkdir -p "$ENV_BIN_DIR" rm -rf "$ENV_DIR/plugins" cp -f "$srcbin" "$ENV_BIN" || return 1 if [ -d "$srcplug" ]; then cp -r "$srcplug" "$ENV_DIR/plugins" || return 1 else c_warn "[$ENV_NAME] 源插件目录缺失:$srcplug(按空插件集部署)" mkdir -p "$ENV_DIR/plugins" fi c_ok "[$ENV_NAME] 产物就位:$ENV_BIN(plugins:$ENV_DIR/plugins)" } # 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:回收构建 worktree(尽力而为)。 recycle_worktree() { local wt="$RUNTIME/.worktrees/$1" c_info "[$ENV_NAME] 回收构建 worktree" git -C "$SRC_DIR" worktree remove --force "$wt" >/dev/null 2>&1 \ || c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $SRC_DIR worktree remove --force $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:部署与启动 ---- agent_do_deploy() { resolve_source if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL" do_stop_quiet local wt case "$SRC_KINDSRC" in local) c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径" make -C "$ROOT" build || { hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"; return 1; } install_artifacts "$BIN" "$ROOT/plugins" \ || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } ;; dev) wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \ || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } install_artifacts "$wt/bin/agent-system" "$wt/plugins" \ || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } recycle_worktree "$SRC_SHA" ;; release) materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; } ;; esac agent_do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; return 1; } hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL" 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 ;; esac return 0 } # seed_settings:把 CLI/环境变量给出的模型与搜索配置写入 settings(幂等)。 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 )" local resp resp="$(curl -s -m 5 -X PUT "http://$host:$port/api/settings/user" -H 'Content-Type: application/json' -d "$payload")" 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:-<无响应>}(若开启了 API key 认证,需在设置页手动配置)" 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 } # agent_args_authed ARGS [DSN_ENV]:按二进制 AuthEnabled 口径(config.go: # 显式 on;或非显式 off 且 StorageDSN 非空)判断认证是否开启。DSN 两个来源等价: # 命令行 --dsn(历史兼容)与 AGENT_SYSTEM_DSN 环境变量(G1-7 起的标准通道, # 第二参数传其值)——只看参数串会把「非回环 + auto + env DSN」误判为认证关 #(2026-10-10 dev 实测误拒)。--mock 只影响存储档不影响认证。 agent_args_authed() { case "$1" in *--auth[=\ ]on*) return 0 ;; *--auth[=\ ]off*) return 1 ;; esac [ -n "${2:-}" ] && return 0 case "$1" in *--dsn\ *) return 0 ;; esac return 1 } # agent_listen_rejected EXEC_ARGS DEFAULT_ADDR INSECURE DSN_ENV:启动预检判定(0=拒绝)。 # 与二进制 listen guard 同口径:回环或显式放行(insecure)不拦;认证开不拦; # 仅非回环 + 未放行 + 认证关才拦。 agent_listen_rejected() { local addr; addr="$(printf '%s' "$1" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" addr="${addr:-$2}" addr_is_loopback "${addr%:*}" && return 1 [ -n "$3" ] && return 1 agent_args_authed "$1" "$4" && return 1 return 0 } # agent_reuse_exec EXEC_ARGS OUT_VAR:复用启动(无显式参数,复用 svc.env exec=) # 时的 exec 参数与 DSN 解析。处理后的 exec 参数经 nameref OUT_VAR 回写;DSN 经 # 全局 DSN_ENV 传递(agent_do_start 内已 local 化)。必须直接调用而非放进命令 # 替换——$() 子壳里的 DSN_ENV 赋值回不到调用方。两步:先做 G1-7 迁移(存量 # --dsn 剥到环境变量);exec= 不含 DSN 且 dbref= 登记在案时从实例登记重建—— # 否则 G1-7 后 exec= 永不含 DSN,纯 start/restart 复用会静默回落 SQLite,数据库 # 绑定丢失。 agent_reuse_exec() { local -n out_var="$2" # 内部变量不得叫 exec_args:与 nameref 目标同名会在函数作用域自我遮蔽, # 回写落到局部、调用方拿不到值。 local cur="$1" bound_dsn case "$cur" in *--dsn\ *) DSN_ENV="$(printf '%s' "$cur" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')" cur="$(printf '%s' "$cur" | sed 's/--dsn [^ ]*//' | sed 's/[[:space:]]\{2,\}/ /g; s/^ //; s/ $//')" c_info "[$ENV_NAME] DSN 已从命令行迁移到环境变量(G1-7)" ;; esac if [ -z "${DSN_ENV:-}" ] && [ -n "$(svc_get dbref)" ]; then if bound_dsn="$(dsn_from_dbref "$(svc_get dbref)")"; then DSN_ENV="$bound_dsn" c_info "[$ENV_NAME] 数据库绑定 $(svc_get dbref) 随复用启动保留(AGENT_SYSTEM_DSN)" else c_warn "[$ENV_NAME] dbref=$(svc_get dbref) 无法重建 DSN(实例缺失或登记不全),按无数据库绑定启动" fi fi out_var="$cur" } 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[@<版本>]]" return 1 fi SVC_VERSION="$("$ENV_BIN" version 2>/dev/null || true)" # 组装启动参数:显式配置 → 重建;否则复用上次持久化 exec=。 # DSN_ENV 必须 local 并清零:G1-7 起 DSN 经它传给启动守卫环境,全局残留会让 # update --all 同进程连续启动时前一个服务的 DSN 泄漏进后一个的 AGENT_SYSTEM_DSN。 local exec_args DSN_ENV="" if ! agent_has_explicit_cfg && [ -n "$(svc_get exec)" ]; then agent_reuse_exec "$(svc_get exec)" exec_args c_info "[$ENV_NAME] 复用上次配置(svc.env exec=;显式参数可覆盖):$(mask_dsn "$exec_args")" else local args="" if [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$ENV_NAME" MOCK)" = "1" ]; then args="--mock" else local dsn="${CLI_DSN:-$(env_var "$ENV_NAME" DSN)}" local auth="${CLI_AUTH:-$(env_var "$ENV_NAME" AUTH)}" # dbref 兜底:显式参数重建启动参数时,持久化的数据库绑定不再静默丢失 #(2026-10-05 dev 事故:restart --addr 走重建分支丢 --dsn → auto 判认证关 → 守卫拒启)。 if [ -z "$dsn" ] && [ -n "$(svc_get dbref)" ]; then local bound_dsn if bound_dsn="$(dsn_from_dbref "$(svc_get dbref)")"; then dsn="$bound_dsn" c_info "[$ENV_NAME] 数据库绑定 $(svc_get dbref) 随参数重建保留" else c_warn "[$ENV_NAME] dbref=$(svc_get dbref) 无法重建 DSN(实例缺失或登记不全),按无数据库绑定启动" fi fi # G1-7:DSN 经环境变量传入(AGENT_SYSTEM_DSN),不再进命令行——命令行对 # 同机任何进程可见(含智能体生成脚本的 /proc//cmdline),2026-10-09 # 方案 §3.4 实测。基座 config 优先级 CLI>env,旧 exec= 里的 --dsn 仍生效 # (向后兼容),但新建/重建的启动配置从此不带 DSN 明文。 DSN_ENV="$dsn" if [ -n "$dsn" ] || [ -n "$auth" ] || [ "$ENV_NAME" != dev ]; then [ -z "$auth" ] && auth=auto args="$args --auth $auth" fi fi local adjusted; adjusted="$(adjust_args_for_bin "$ENV_BIN" "$args")" # adjust_args_for_bin 返回值已去首空格——比较前对 args 同样归一,否则每次 # 带 --dsn/--auth 的重建都会误报"旧版本二进制已自动省略"(参数实际全保留)。 if [ "$adjusted" != "${args# }" ]; then c_warn "[$ENV_NAME] 该产物不支持 --dsn/--auth(旧版本二进制),已自动省略:$(mask_dsn "${args:-<无>}") → $(mask_dsn "${adjusted:-<无>}")" fi args="$adjusted" exec_args="--addr $ENV_ADDR --data $ENV_DATA --plugins ${CLI_PLUGINS:-$ENV_DIR/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") # 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作—— # 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。 # 认证判定与二进制同口径(agent_listen_rejected → agent_args_authed): # env DSN(AGENT_SYSTEM_DSN)同样算「已配置 DSN」,不再因命令行不见 --dsn # 而把非回环+auto 误判为认证关(2026-10-10 dev 实测误拒)。 local pf_addr; pf_addr="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" pf_addr="${pf_addr:-$ENV_ADDR}" if agent_listen_rejected "$exec_args" "$ENV_ADDR" "$insecure" "${DSN_ENV:-}"; then 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;基座 config 环境档已支持)。 [ -n "${DSN_ENV:-}" ] && guard_env+=("AGENT_SYSTEM_DSN=$DSN_ENV") if [ "${#guard_env[@]}" -gt 0 ];then nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 & else nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 & fi 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 复用导致二次失败)。 record_state 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##*:}" seed_settings "$phost" "${probe##*:}" 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 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" recycle_worktree "$SRC_SHA" ;; release) c_err "[$ENV_NAME] release 制品内嵌前端(无独立 dist);static 请用 --from dev|local 或 --dir" hist_event deploy fail "$(read_pid)" "release 源不支持 static" return 1 ;; esac [ -d "$src_dist" ] || { c_err "[$ENV_NAME] 前端产物缺失:$src_dist"; hist_event deploy fail "$(read_pid)" "dist 缺失"; return 1; } mkdir -p "$ENV_DIR" rm -rf "${ENV_DIR:?}/htdocs" cp -r "$src_dist" "$ENV_DIR/htdocs" || { hist_event deploy fail "$(read_pid)" "dist 复制失败"; return 1; } 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 覆写)。 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" { 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" } > "$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 & echo $! > "$ENV_PID" local probe_host="$host" [ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1" for _ in $(seq 1 50); do if curl -s -o /dev/null -m 2 "http://$probe_host:$port/" 2>/dev/null; then record_state "htdocs=$htdocs" # 健康通过才写 svc.env(与 agent 路径同规) 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" c_err "[$ENV_NAME] 10s 健康检查未通过" 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; 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" 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" 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" 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"; 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 的单键(先读后写,避开重定向截断陷阱)。 svc_set_key() { local key="$1" val="$2" rest [ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; } rest="$(grep -v "^$key=" "$ENV_DIR/svc.env" || true)" { [ -n "$rest" ] && printf '%s\n' "$rest"; printf '%s=%s\n' "$key" "$val"; } > "$ENV_DIR/svc.env" chmod 600 "$ENV_DIR/svc.env" return 0 } # svc_del_key KEY:从当前服务 svc.env 删除单键(键不存在则无操作)。 svc_del_key() { local key="$1" [ -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 服务的数据库。绑定 = 目标实例的 DSN 写入持久化 exec=(--dsn,密码 600 存放) # 并登记 dbref=/<库>;实例运行中时确保目标库存在(缺则建);服务运行中则自动 # 重启生效(--no-restart 只改登记)。--clear 解除绑定(回内嵌 SQLite 档)。 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 ref="$(db_ref_of "$ENV_NAME")" if [ -n "$ref" ]; then printf ' [%s] 数据库绑定:%s\n' "$ENV_NAME" "$ref" dsn="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')" [ -n "$dsn" ] && printf ' DSN %s\n' "$(mask_dsn "$dsn")" 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 # 解除绑定。 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 hist_event set ok "$(read_pid)" "db unbind" c_ok "[$ENV_NAME] 已解除数据库绑定(内嵌 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" ]; } || { c_err "[$target] 登记缺 user/password(重新 db create)"; return 2; } # 实例运行中:确保目标库存在(缺则建,幂等)。 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" # 产物支持预检(旧二进制不识别 --dsn 时提示;重启复用 exec 不会自动过滤)。 if [ -x "$ENV_BIN" ]; then local adj adj="$(adjust_args_for_bin "$ENV_BIN" "--dsn $dsn")" [ "$adj" != "--dsn $dsn" ] && \ c_warn "[$ENV_NAME] 当前产物不识别 --dsn(旧版本二进制)——绑定已登记,但重启后将按内嵌档运行" fi # 写持久化配置:exec= 的 --dsn 替换或追加;dbref= 登记。 local exec_old new_exec exec_old="$(svc_get exec)" if printf '%s' "$exec_old" | grep -q -- '--dsn '; then new_exec="$(printf '%s' "$exec_old" | sed -E "s|--dsn [^ ]+|--dsn $dsn|")" else new_exec="${exec_old:+$exec_old }--dsn $dsn" fi svc_set_key exec "$new_exec" svc_set_key dbref "$target/$tdb" hist_event set ok "$(read_pid)" "db bind → $target/$tdb" c_ok "[$ENV_NAME] 数据库绑定:$target/$tdb(DSN $(mask_dsn "$dsn");svc.env 600 保存,info 可查)" # 运行中:重启生效(start 复用新 exec=)。 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 } 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——没有由本脚本启动的实例" return 0 fi if ! pid_alive "$pid"; then c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile" rm -f "$ENV_PID" 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" c_ok "[$ENV_NAME] 已停止" hist_event stop ok "$pid" "SIGTERM" ;; esac return 0 } # do_stop_quiet:deploy 前置——未运行时静默返回。 do_stop_quiet() { local pid; pid="$(read_pid)" if [ -z "$pid" ]; then return 0; fi if ! pid_alive "$pid"; then rm -f "$ENV_PID" 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" c_ok "[$ENV_NAME] 已停止" hist_event stop ok "$pid" "SIGTERM" ;; esac return 0 } 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:?}" 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')" 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 会登记)" 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)" ;; 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 键与键名含 KEY/PASS/SECRET/ # TOKEN/DSN 的值整段掩为 ***;其余行只掩值内 DSN 密码(exec= 里可能带完整连接串)。 mask_kv() { local line="$1" k="${1%%=*}" if [ "$k" != "$1" ]; then case "$k" in password|*KEY*|*PASS*|*SECRET*|*TOKEN*|*DSN*) 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 ;; --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 仓。 local latest_sha="" latest_rel="" need_rel=0 need_dev=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 ;; 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 # 逐服务分析:状态判定 + 收集可更新动作。 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 ;; *) 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" ;; esac c_info "[$name] 更新:$from → $tov($deploy_from)" hist_event update begin "$(read_pid)" "from=$from to=$tov" CLI_FROM="$deploy_from" if agent_do_deploy; 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 CLI_FROM="" done c_info "更新汇总:成功 $updated / 失败 $failed / 跳过 $skipped / 其余最新或不适用" [ "$failed" -eq 0 ] && return 0 || return 1 } # ---- main ---- main() { [ $# -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 ;; esac ENV_NAME="$1"; shift # 动作词可省略(省略 = deploy);flags 可直接跟随服务名。 local action="deploy" case "${1:-}" in deploy|stop|start|restart|status|logs|db|config) action="$1"; shift ;; esac if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then CLI_LOGS_N="$1"; shift fi # db / config 子命令:自带参数解析(位置参数不走 parse_args;未知 flags 由其自行拒绝)。 if [ "$action" = db ] || [ "$action" = config ]; then env_config "$ENV_NAME" case "$action" in db) svc_db_bind "$@" || exit $? ;; config) svc_config "$@" || exit $? ;; esac exit 0 fi parse_args "$@" if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ]; } && [ "$action" != deploy ]; then c_err "--from/--pick/--dry-run/--dir 仅用于 deploy 动作" exit 2 fi env_config "$ENV_NAME" 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_stop; do_start ;; 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