Files
Agent-System-Dev/scripts/svc.sh
T
weichen 6e4ebae2f6 Merge origin/main: supersede the PR #317 minimal fix with the unified resolver
Why: main landed fix-svc-dsn-reuse-dbref (reuse-path dbref restore, env-DSN
aware precheck, DSN_ENV scoping) for the same restart-DSN incident; the
approved recovery plan is its superset and replaces those mechanisms with
resolve_db_config + recoverable svc.env keys + refuse-on-invalid semantics.
Evidence: tests/test_svc_precheck.py ported to the new units (18 cases green,
same scenario matrix; broken-dbref expectation updated to refusal per the
approved plan); tests/test_svc.py 11 cases green; bash -n OK.
Impact: conflict hunks resolved toward the unified resolver; main's source
guard at the script tail restored; agent_listen_rejected kept as a pure
predicate (used by the start precheck and the governance tests).
Refs: main 7acf318 (PR #317); task card .task/feature-svc-db-config-recover.md.
2026-10-11 09:21:27 +00:00

3415 lines
156 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(postgres) 三类服务的
# 部署、生命周期与数据库运维。每个服务自包含于 runtime/<name>/,登记与配置持久化
# (svc.env + history.jsonl),全程不触碰主检出 git 状态与其他已启动服务。
#
# ════════════════════ 命令一览 ════════════════════
# scripts/svc.sh <name> [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 <name> [--port N --apply] [--tag T] 变更登记(换端口/标签)
# scripts/svc.sh rm <name> [--purge] 注销服务(--purge 连数据目录删除)
# scripts/svc.sh db create <name> [参数] 创建 PostgreSQL 实例(initdb)
# scripts/svc.sh db dsn <name> [--reveal] [--db X] 连接串(默认掩码密码)
# scripts/svc.sh db backup <name> [--db X] [--out F] 备份(pg_dump -Fc + 自检)
# scripts/svc.sh db backups <name> 备份列表
# scripts/svc.sh db restore <name> --file F [--db X] [--force] 恢复(覆盖非空库须 --force)
# scripts/svc.sh db clone <src> <dst> [--port P] [--password PW] [--online] 克隆实例
# scripts/svc.sh db copy <src> <dst> [--db X] [--force] 跨实例搬库(双方须运行)
# scripts/svc.sh db ls <name> 实例内数据库列表
# scripts/svc.sh db set <name> [...] 等价于 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[@<sha>] 同缺省;@<sha> 显式指定提交。物化与构建在独立部署源仓的
# 隔离 worktree(runtime/.worktrees/<sha>)进行,不触碰当前工作区
# --from local 构建当前工作区(唯一写主检出构建产物的路径,均 gitignore)
# --from release[@<版本|sha>] Releases 仓制品(sha256 对 versions/<v>/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 运行参数
# <name> db <pg名> [--db X] [--no-restart] agent 绑定/切换数据库(实例运行中缺库自动建;
# --clear 解绑回内嵌 SQLite;无参查看当前绑定与可用实例)
# <name> config <file.json> [--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=/dbref=/dsn=/db=)
# > SVC_<NAME>_* 环境变量(.svc.env 自动加载,同层)> 默认值。裸 start/restart 复用
# 上次启动配置;显式参数出现即覆盖。任意环境 X 的变量名:SVC_<X 大写,- → _>_
# ADDR/DATA/DSN/AUTH/MOCK,以及 INSECURE_LISTEN / ALLOWED_HOSTS(传给进程的
# AGENT_SYSTEM_*,旧二进制会忽略)。
# 数据库配置统一解析:托管绑定(dbref=)从实例登记重建 DSN,外部直连存受保护
# dsn= 字段;连接串不进 exec=/命令行,经 AGENT_SYSTEM_DSN 环境变量交付。绑定失效
# 或配置冲突(直接 DSN 与 dbref 不同库、mock 与绑定并存、db=pg 无可恢复 DSN)时
# 拒绝启动,绝不静默回退内嵌 SQLite;显式解绑(db=sqlite)后环境残留不再重新选库。
#
# ════════════════════ 服务模型与登记 ════════════════════
# - 服务名 ^[a-z][a-z0-9-]{0,15}$(保留字不可用);dev=8788 / prod=8787 默认端口,
# 其余按名字确定性派生(8800-9099,避让已登记/监听端口),SVC_<NAME>_ADDR 可覆盖
# - runtime/<name>/: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@<sha> --dsn "$(scripts/svc.sh db dsn pg1 --reveal)"
# scripts/svc.sh ui-r4 deploy --kind static --from dev@<sha> --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 <dump> --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/<name>/、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/<pid>/cmdline 标记双校验;postgres 走
# pg_ctl -m fast 优雅停机,SIGKILL 不用于 postmaster;
# - 端口被非本脚本进程占用时报错并给出占用者,不抢不杀;
# - svc.env/备份文件权限 600;DSN 默认掩码密码(--reveal 才显示);
# - 备份/克隆只增不删;restore/copy 覆盖非空库必须显式 --force(drop 重建再恢复);
# 备份文件在 runtime/<name>/backups/,随 rm --purge 一并删除;
# - 物理克隆期间源实例短暂停止(一致性要求),完成后自动复启;--online 不停源。
#
# 完整文档:docs/svc.md
# <<usage-end>>
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,/^# <<usage-end>>$/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 <name> deploy [--from dev@<sha>] 部署并启动(缺省=远端 main 最新)
scripts/svc.sh <name> start|stop|restart|logs [N] 生命周期与日志
scripts/svc.sh <name> status 服务详情(版本/来源/DSN 等)
scripts/svc.sh <name> db [pg名|--clear] agent 数据库绑定/切换(缺省查看)
scripts/svc.sh <name> 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 <<EOF
svc.sh 分主题帮助(scripts/svc.sh help <主题>;完整 --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/<name>/
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@<sha> | dev@<sha> | release@<版本> | dir@<路径> |
postgres@local | clone@<名>)
version 产物版本串(agent-system version 输出)
started 最近启动时间
pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory)
exec agent 上次完整启动参数(裸 start/restart 复用;显式参数出现即重建)。
只存非敏感参数——DSN 不在其中,数据库配置走 db=/dbref=/dsn= 专键
db agent 数据库模式:pg | sqlite(显式解绑)| mock;从未配置则无此键
dbref agent 托管数据库绑定 <pg名>/<库名>(启动时由实例登记重建 DSN)
dsn agent 外部直连 DSN(受保护,600;未命中本机实例时使用)
user/password/dbname/authmode/pgdata postgres 专属(密码 600 保存)
htdocs static 专属
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_<NAME>_ADDR 显式覆盖。
EOF
return 0 ;;
deploy)
cat >&2 <<'EOF'
【deploy】部署与启动(agent / static;省略 action 即 deploy)
语法:scripts/svc.sh <name> deploy [--kind agent|static] [--from 源] [参数...]
部署来源(--from,缺省=dev 远端最新):
(缺省)/ dev 部署独立部署源仓的 origin/main 最新提交——与当前工作区
状态无关(脏树、任意分支均可)。
dev@<sha> 显式指定提交,物化与构建同上。
两者统一流程:在独立部署源仓(SVC_SRC_DIR,默认 ~/.cache/agent-system-svc/src,
首次使用自动从 origin 克隆)上 git worktree add --detach 出
runtime/.worktrees/<sha>,递归物化子模块组合(精确落位 gitlink),
make build/frontend,产物复制后回收 worktree。全程不触碰当前工作区。
local 构建当前工作区(make build / make frontend),产物复制到
服务目录。注意:这是唯一会写主检出构建产物(bin/、
node_modules/、dist/——均 gitignore)的路径,也是唯一
依赖当前工作区状态的来源。
release[@<版本|sha>] 取 Releases 仓 build/<v>/agent-system(+plugins),
sha256 对 versions/<v>/manifest.json 校验,不符即拒绝。
--pick [dev|release] 交互式编号选择(需 TTY;列表与提示走 stderr)
--dry-run 只打印部署计划不执行
行为细节:
- deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程)
- agent 启动后等健康检查(20s),随后按 --model-*/--tavily-keys 播种设置(幂等)
- 旧版本产物不认识 --auth/--config 时自动省略(探测 serve 用法)并提示;
不消费环境变量 DSN(AGENT_SYSTEM_DSN)的旧产物在需要交付数据库配置时
拒绝部署/启动(不静默按内嵌 SQLite 运行)
- static 的 release 源不支持(制品内嵌前端);--dir PATH 可直接登记并服务
既有目录(不构建、不复制,source=dir@<路径>)
- deploy 全程事件入 history.jsonl(begin/ok/fail)
示例:
scripts/svc.sh prod deploy --from release@0.1.0 --tag stable
scripts/svc.sh prod deploy --from dev@73b2802 --dry-run
scripts/svc.sh 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 <name> start 启动(agent/static/postgres 分别分派;已在运行则提示)
scripts/svc.sh <name> stop 停止(agent/static: TERM→KILL;postgres: pg_ctl -m fast)
scripts/svc.sh <name> restart 停止 + 启动(裸 restart 复用上次配置)
scripts/svc.sh <name> db [pg名|--clear] [--db X] agent 数据库绑定:缺省查看当前
绑定与可用实例;给 pg名=绑定/切换(dbref= 登记,启动时由实例登记
重建 DSN 并经环境变量交付,实例运行中缺库自动建,服务运行中自动
重启生效);--clear 显式解绑(db=sqlite,清除直接 DSN,环境残留
SVC_*_DSN 不再重新选库);--no-restart 只改登记
scripts/svc.sh <name> config [file|--clear] [--no-restart] agent 配置文件:缺省查看当前
文件与生效值;给 file=更换(agent 二进制 --config,JSON schema 校验
通过才切换,运行中自动重启即时生效);--clear 移除回命令行参数形态。
绑定后改文件内容 + restart 即生效(agent 无热重载,重启秒级)
scripts/svc.sh <name> logs [N] 跟随日志(默认 50 行起;如 svc.sh pg1 logs 200)
scripts/svc.sh <name> 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 <name> --tag T... 三类服务通用,合并去重
scripts/svc.sh set <pg名> --port N [--host H] 未运行:直接改登记
scripts/svc.sh set <pg名> --port N --apply 运行中:停止→改登记→重启
- 运行中的 postgres 改端口必须 --apply(防止登记与实际监听不一致)
- agent/static 换端口用 restart --port N(进程与登记同步更新)
- 新端口会先校验:未被其他服务登记占用且未在监听
注销:
scripts/svc.sh rm <name> 停止并移除登记(目录/数据/历史保留)
scripts/svc.sh rm <name> --purge 连 runtime/<name>/ 整个目录一起删除
自动更新(一条命令把服务刷到远端最新;与当前工作区状态无关):
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 <name> [--port P] [--host H] [--user U] [--password P]
[--db NAME] [--auth-mode scram-sha-256|md5] [--tag T...]
initdb 到 runtime/<name>/pgdata(目录 700,密码文件即用即删);
默认用户/库均为 agent,缺省密码自动生成(db dsn --reveal 查看);
只登记不启动——scripts/svc.sh <name> start 启动。
scripts/svc.sh <name> start|stop|restart
pg_ctl 生命周期:start 带 -p <port> -k <环境目录>(socket 不落 pgdata)、
listen_addresses 随 --host;首次启动自动创建登记库(已存在则跳过);
就绪判定 pg_isready;stop 用 -m fast 优雅回写,失败退 SIGTERM,绝不 KILL。
scripts/svc.sh db dsn <name> [--reveal] [--db X]
连接串 postgresql://user:pass@host:port/db;默认掩码密码(stdout 纯净可组合)。
scripts/svc.sh db ls <name>
实例内数据库列表(须运行中)。
scripts/svc.sh db set <name> [...] / scripts/svc.sh set <name> [...]
换端口/主机/标签:见 help lifecycle(运行中改端口需 --apply)。
连接其他服务:DSN 可直接组合进 agent 部署——
scripts/svc.sh prod deploy --from dev@<sha> --dsn "$(scripts/svc.sh db dsn pg1 --reveal)"
(旧版本产物不识别 --dsn 时会自动省略并提示。)
更常用:让已部署的 agent 服务绑定/切换数据库(不用重新 deploy)——
scripts/svc.sh prod db pg1 # 绑定 pg1 默认库(运行中自动重启生效)
scripts/svc.sh prod db pg1 --db prod # 绑定 pg1/prod 库(缺库自动建)
scripts/svc.sh prod db pg2 --no-restart # 切换实例但暂不重启(下次 start 生效)
scripts/svc.sh prod db # 查看当前绑定 + 可用实例
scripts/svc.sh prod db --clear # 显式解绑(db=sqlite,回内嵌档)
绑定登记于 svc.env(dbref= + db=pg,600;DSN 启动时由实例登记重建,经环境变量
交付);info / list 的 DB 列与数据库节 "绑定服务" 行展示服务↔实例对应关系。
密码管理:密码明文存 runtime/<name>/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 <name> [--db X] [--out FILE]
pg_dump -Fc 走 TCP(实例须运行中)→ 默认 runtime/<name>/backups/<UTC时间戳>-<库>.dump
(目录 700、文件 600);完成后 pg_restore --list 自检归档,失败即删。
scripts/svc.sh db backups <name>
列出备份(时间 / 大小 / 文件名)。
恢复(唯一可能覆盖数据的操作,非空库必须 --force):
scripts/svc.sh db restore <name> --file F [--db X] [--force]
目标实例须运行中;F 须为合法 pg_dump 归档(先自检);
目标库不存在→建库后恢复;存在但空→直接恢复;存在且非空→拒绝(--force 才
DROP WITH (FORCE) + 重建 + 恢复);完成后回显表数。
克隆(整实例,绑定新端口):
scripts/svc.sh db clone <src> <dst> [--port P] [--host H] [--password PW] [--tag T] [--online]
默认物理克隆:短暂停止源(一致性)→ cp -a pgdata(剔除 postmaster.pid/.opts)→
登记目标(新端口:--port 或派生;用户/库/认证继承,密码可换;标签继承+可加;
source=clone@<src>)→ 自动复启源。目标不自动启动(提示 start 命令)。
--online:不停源,dump/restore 逻辑复制源登记库到目标——要求目标已
db create + start。
搬库(单库、跨实例):
scripts/svc.sh db copy <src> <dst> [--db X] [--force]
双方都须运行中;--db X 同时命名源与目标库(默认取源登记库);
目标库非空须 --force(语义同 restore)。
注意:备份/克隆只增不删;备份随 rm --purge 一并删除;所有操作入 history.jsonl。
EOF
return 0 ;;
config)
cat >&2 <<'EOF'
【config】配置分层与持久化
优先级(高 → 低):
1. 命令行参数(--port/--dsn/--tag/...)
2. --config FILE(或 --env FILE,兼容别名;=FILE 写法亦可)
3. svc.env 持久化(addr=、exec=、dbref=/dsn=/db=——数据库配置专键)
4. SVC_<NAME>_* 环境变量与 .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_* 变量)出现即整体重建参数;两种路径共用同一数据库配置解析——改端口/地址/
版本不改变数据库选择,conffile 引用随重建保留
- 数据库配置可恢复:托管绑定 dbref= 在启动时由实例登记重建 DSN(实例端口/密码
变更自动跟随);外部直连存受保护 dsn= 字段;显式解绑记 db=sqlite(环境残留
SVC_*_DSN 不再重新选库)。DSN 经 AGENT_SYSTEM_DSN 环境变量交付,不进 exec=
与命令行;存量 exec= 内的 --dsn 启动时自动迁移(幂等)
- 绑定失效或配置冲突时拒绝启动(不静默回退 SQLite):dbref 实例缺失/登记不全、
直接 DSN 与 dbref 指向不同库、mock 与绑定并存、db=pg 无可恢复 DSN、产物不
消费环境 DSN;restart/deploy 在停止旧进程前先做这些静态检查
- tags 随每次登记合并去重,跨重启保留
服务级配置文件(与上述 svc.sh dotenv 层不同——这是 agent 二进制自己的 JSON 配置):
scripts/svc.sh <name> config [file|--clear] 更换/移除/查看(见 help lifecycle);
切换前 schema 校验(agent-system config explain -config <file>),运行中自动重启
即时生效;登记于 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@<sha> \
--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@<shaA> --port 8801
scripts/svc.sh b deploy --from dev@<shaB> --port 8802
⑤ 前端静态服务:
scripts/svc.sh ui deploy --kind static --from dev@<sha> # 构建并服务 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@<sha> --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/<name>/、runtime/.worktrees/(构建 worktree,用后即收)与独立
部署源仓(SVC_SRC_DIR,默认 ~/.cache/agent-system-svc/src,可随时删除重建);
- 不改主检出 git 状态;--from local 是唯一写主检出构建产物(bin/、node_modules/、
dist/——均 gitignore)的路径,也是唯一依赖当前工作区状态的来源;
- rm --purge 只删 runtime/<name>/,且要求服务已登记。
进程边界:
- 只停"自己 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_<NAME 大写,- → _>_<SUFFIX> 变量值(无则空)。
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/<name>/history.jsonl 追加事件。
hist_event() {
mkdir -p "$ENV_DIR" 2>/dev/null || return 0
printf '{"ts":"%s","event":"%s","env":"%s","kind":"%s","source":"%s","result":"%s","pid":"%s","addr":"%s","detail":"%s"}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"$(jclean "$1")" "$(jclean "$ENV_NAME")" "$(jclean "$SVC_KIND")" "$(jclean "${SRC_LABEL:-}")" \
"$(jclean "$2")" "$(jclean "$3")" "$(jclean "$ENV_ADDR")" "$(jclean "$4")" \
>> "$ENV_DIR/history.jsonl" 2>/dev/null || true
}
# svc_merge_tags:合并已有 tags 与本次 --tag(去重,逗号连接)。
svc_merge_tags() {
local prev t out="" seen=" "
prev="$(svc_get tags)"
for t in $(printf '%s' "$prev" | tr ',' ' ') ${CLI_TAGS[@]+"${CLI_TAGS[@]}"}; do
[ -n "$t" ] || continue
case "$seen" in *" $t "*) continue ;; esac
seen="$seen $t "
out="${out:+$out,}$t"
done
printf '%s' "$out"
}
# record_state EXTRA_KEY=VALUE...:把当前态+持久化配置写入 svc.env(600,临时文件
# 原子替换,避免截断/半写)。基本键:kind/env/pid/addr/tags/source/version/started/
# pidcheck/exec + db/dbref/dsn/conffile(跨重启保留的数据库配置与配置文件引用);
# kind 特有键(user/password/dbname/authmode/pgdata/htdocs...)经参数追加。
# agent 服务经统一解析启动后按生效态写 db=(pg/mock;显式 sqlite)与 dbref=/dsn=
# (托管绑定不重复存 DSN,外部直连存受保护 dsn=);未走解析的调用保持旧值。
record_state() {
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn merged_tags
pid="$(read_pid)"
prev_src=""
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
prev_dbref="$(svc_get dbref)"
prev_conffile="$(svc_get conffile)"
prev_db="$(svc_get db)"
prev_dsn="$(svc_get dsn)"
SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}"
merged_tags="$(svc_merge_tags)"
local db_out="" dbref_out="" dsn_out=""
if [ "${SVC_KIND:-agent}" = agent ]; then
if [ -n "${SVC_DB_MODE:-}" ]; then
case "$SVC_DB_MODE" in
pg)
# 只在有可恢复来源(dbref=/dsn=/conffile)时记 pg 标记——纯环境层来源
# 不落标记,环境撤除后回到原语义,不产生"db=pg 却无可恢复 DSN"的死锁。
[ -n "$SVC_DB_REF" ] || [ -n "$SVC_DB_DSN_SAVE" ] || [ "$SVC_DB_SOURCE" = conffile ] && db_out=pg
;;
mock) db_out=mock ;;
sqlite) [ "$SVC_DB_SOURCE" != none ] && db_out=sqlite ;; # 显式解绑才记;从未配置不写
esac
dbref_out="${SVC_DB_REF:-}"
dsn_out="${SVC_DB_DSN_SAVE:-}"
else
db_out="$prev_db"; dbref_out="$prev_dbref"; dsn_out="$prev_dsn"
fi
fi
mkdir -p "$ENV_DIR"
local tmp="$ENV_DIR/svc.env.tmp.$$"
{
printf 'kind=%s\n' "${SVC_KIND:-agent}"
printf 'env=%s\n' "$ENV_NAME"
printf 'pid=%s\n' "${pid:-}"
printf 'addr=%s\n' "$ENV_ADDR"
printf 'tags=%s\n' "$merged_tags"
printf 'source=%s\n' "${SVC_SOURCE:-}"
printf 'version=%s\n' "${SVC_VERSION:-}"
printf 'started=%s\n' "$(date -Is)"
printf 'pidcheck=%s\n' "${SVC_PIDCHECK:-}"
printf 'exec=%s\n' "${SVC_EXEC:-}"
[ -n "$db_out" ] && printf 'db=%s\n' "$db_out"
[ -n "$dbref_out" ] && printf 'dbref=%s\n' "$dbref_out"
[ -n "$dsn_out" ] && printf 'dsn=%s\n' "$dsn_out"
[ -n "$prev_conffile" ] && printf 'conffile=%s\n' "$prev_conffile"
[ $# -gt 0 ] && printf '%s\n' "$@"
} > "$tmp"
chmod 600 "$tmp"
mv -f "$tmp" "$ENV_DIR/svc.env"
}
# ---- pid 管理(泛化:pidfile + cmdline 标记双校验)----
pid_alive_mark() { # PID MARKER:cmdline 含 MARKER 视为我们的进程
local pid="$1" mark="$2" cmd
[ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || return 1
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
case "$cmd" in *"$mark"*) return 0 ;; *) return 1 ;; esac
}
# pid_alive PID:当前服务的 pid 校验(svc.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"
# 数据库配置解析态按实例重置——批量 update/restart 逐服务解析时不串用上一服务的 DB_*。
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
local registered=""
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
SVC_KIND="${registered:-agent}"
[ -n "$CLI_KIND" ] && SVC_KIND="$CLI_KIND"
# 类型与既有登记冲突防护:同名服务换类型必须先 rm。
if [ -n "$registered" ] && [ -n "$CLI_KIND" ] && [ "$CLI_KIND" != "$registered" ]; then
c_err "[$ENV_NAME] 已登记为 kind=$registered,不能改类型;先 scripts/svc.sh rm $ENV_NAME"
exit 2
fi
[ "$SVC_KIND" = postgres ] && ENV_LOG="$ENV_DIR/pg.log"
local base_addr base_data persisted_addr
base_addr="$(env_var "$name" ADDR)"
base_data="$(env_var "$name" DATA)"
persisted_addr=""
[ -f "$ENV_DIR/svc.env" ] && persisted_addr="$(svc_get addr)"
if [ -z "$base_addr" ]; then
if [ -n "$persisted_addr" ]; then
base_addr="$persisted_addr"
else
case "$name" in
dev) base_addr="127.0.0.1:8788" ;;
prod) base_addr="127.0.0.1:8787" ;;
*)
local p
p="$(derive_port "$name")" || {
c_err "[$name] 无空闲派生端口(8800-9099),请用 --port/--addr 或 SVC_$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')_ADDR 显式指定"
exit 1
}
base_addr="127.0.0.1:$p"
;;
esac
fi
fi
if [ -z "$base_data" ]; then
case "$SVC_KIND" in
postgres) base_data="$ENV_DIR/pgdata" ;;
*) base_data="$ENV_DIR/data" ;;
esac
fi
ENV_ADDR="$(addr_compose "$base_addr")"
ENV_DATA="${CLI_DATA:-$base_data}"
return 0
}
# agent_has_explicit_cfg:用户是否显式给了运行参数(决定是否复用持久化 exec=)。
agent_has_explicit_cfg() {
[ -n "$CLI_ADDR$CLI_HOST$CLI_PORT$CLI_DSN$CLI_AUTH$CLI_PLUGINS$CLI_EXTRA" ] && return 0
[ "$CLI_MOCK" = 1 ] && return 0
[ -n "$SVC_ENVFILE" ] && return 0
[ -n "$(env_var "$ENV_NAME" ADDR)$(env_var "$ENV_NAME" DATA)$(env_var "$ENV_NAME" DSN)$(env_var "$ENV_NAME" AUTH)$(env_var "$ENV_NAME" MOCK)" ] && return 0
return 1
}
# adjust_args_for_bin BIN ARGS:按二进制 serve 用法过滤旧产物不认识的 --auth/--config。
# 匹配按"去横线的旗标名 + 1-2 个横线"(Go flag 帮助打印单横线 -dsn,2026-10-04 实测
# 旧实现 grep -- '--dsn' 永不命中,把现役二进制的 --dsn/--auth 误当不支持而丢弃)。
adjust_args_for_bin() {
local bin="$1" args="$2" out
out="$("$bin" serve -h 2>&1 || true)"
local filtered="" w drop_next=0 flag
for w in $args; do
case "$w" in
--auth|--config)
flag="${w#--}"
if printf '%s\n' "$out" | grep -Eq -- "(^|[[:space:]])-{1,2}${flag}([[:space:]]|=|$)"; then
filtered="$filtered $w"
else
drop_next=1
fi
;;
*)
if [ "$drop_next" = 1 ]; then drop_next=0; continue; fi
filtered="$filtered $w"
;;
esac
done
printf '%s' "${filtered# }"
}
# ---- 提交列举与选择(dev / release 两源)----
releases_root() {
[ -n "${REL_ROOT:-}" ] && return 0
REL_ROOT="${SVC_RELEASES_DIR:-$ROOT/../Agent-System-Releases}"
if [ ! -d "$REL_ROOT/.git" ]; then
c_err "Releases 仓不存在:$REL_ROOT(可用 SVC_RELEASES_DIR 指定路径)"
exit 2
fi
}
# svc_src_ready:确保独立部署源仓就绪(不存在则从 $ROOT 的 origin 首次克隆)并 fetch
# 远端;--no-fetch 跳过网络。同一次命令运行内幂等(首个调用定下 fetch 策略)。
# 失败 return 1(无 origin 可克隆/克隆失败),调用方自行决定报错或降级。
SRC_READY_DONE=0
svc_src_ready() {
[ "$SRC_READY_DONE" = 1 ] && return 0
if [ ! -d "$SRC_DIR/.git" ]; then
local url
url="$(git -C "$ROOT" remote get-url origin 2>/dev/null || true)"
[ -n "$url" ] || { c_err "无法确定 origin 地址($ROOT 无 origin 远端);可用 SVC_SRC_DIR 指向已有仓"; return 1; }
c_info "初始化独立部署源仓:$SRC_DIR(← origin;与当前工作区无关,可随时删除重建)"
mkdir -p "$(dirname "$SRC_DIR")"
if ! git clone --quiet "$url" "$SRC_DIR" >&2; then
c_err "部署源仓克隆失败:$url → $SRC_DIR"
return 1
fi
fi
if [ "${1:-}" != "--no-fetch" ]; then
c_info "git fetch(独立部署源仓:$SRC_DIR)"
git -C "$SRC_DIR" fetch origin --quiet 2>/dev/null || c_warn "部署源仓 fetch 失败——基于本地缓存($SRC_DIR)"
fi
SRC_READY_DONE=1
return 0
}
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 <name> deploy --from dev@<sha>"
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 <name> 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@<sha>"/"release@<版本>"(列表走 stderr)
local src="$1"
[ -t 0 ] || { c_err "--pick 需要交互终端;非交互环境请用 --from $src@<sha|版本>"; 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(独立部署源仓的远端最新);显式 @<sha> 亦可。
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[@<sha>] | 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
# 停旧之前的静态配置验证:数据库配置可解析(绑定有效/无冲突)、conffile 存在、
# 现产物承接能力——配置性问题不制造停机窗口(登记保持可恢复,失败直接返回)。
if ! agent_static_preflight; then
hist_event deploy fail "$(read_pid)" "预检拒绝:数据库配置/配置文件无效(登记未变更)"
return 1
fi
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
do_stop_quiet
local wt
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
# 新产物承接能力复核(环境 DSN 交付):不支持即失败——绑定保持可恢复,不降级启动。
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ] && ! bin_env_dsn_supported "$ENV_BIN"; then
c_err "[$ENV_NAME] 新产物不消费环境变量 DSN(AGENT_SYSTEM_DSN)——部署中止;旧绑定保留在 svc.env"
c_err " 恢复:换部署版本,或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <file.json>)"
hist_event deploy fail "$(read_pid)" "新产物不支持环境 DSN($SRC_LABEL)"
return 1
fi
agent_do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; return 1; }
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
return 0
}
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
}
# dsn_target DSN → host:port/db(配置一致性比较用;解析失败回空串)。
dsn_target() {
local t="${1%%\?*}"
case "$t" in *://*) t="${t#*://}" ;; esac
case "$t" in *@*) t="${t#*@}" ;; esac
local hostport="${t%%/*}" db="${t#*/}"
[ "$db" = "$t" ] && db=""
printf '%s/%s' "$hostport" "$db"
}
# dsn_ref_match DSN:DSN 的 host:port/db 命中已登记 postgres 实例时回显 <inst>/<db>(无匹配回空)。
dsn_ref_match() {
local tgt pg paddr
tgt="$(dsn_target "$1")"
[ -n "$tgt" ] || return 0
while IFS= read -r pg; do
[ "$(svc_kind "$pg")" = postgres ] || continue
paddr="$(env_kv_of "$pg" addr)"
[ -n "$paddr" ] || continue
if [ "$tgt" = "$paddr/${tgt#*/}" ]; then
printf '%s/%s' "$pg" "${tgt#*/}"
return 0
fi
done < <(registered_names)
return 0
}
# auth_pick CLI ENV PERSISTED:生效认证方式(缺省 auto)。
auth_pick() {
case "${1:-}" in ?*) printf '%s' "$1"; return ;; esac
case "${2:-}" in ?*) printf '%s' "$2"; return ;; esac
case "${3:-}" in ?*) printf '%s' "$3"; return ;; esac
printf auto
}
# migrate_legacy_db_config:存量 exec= 的 --dsn 迁移为可恢复配置(dbref=/dsn=)并从 exec
# 剥离——此后 exec= 只存非敏感启动参数,DSN 经环境变量传递(G1-7)。幂等:再次运行时
# exec= 已无 --dsn,直接返回。命中已登记实例 → dbref(密码经实例登记存放,不重复保存);
# 否则 → 受保护 dsn= 字段。已有 dbref= 时 exec 的 DSN 只作历史残留处理,直接剥离。
migrate_legacy_db_config() {
local exec_old dsn new_exec ref
[ -f "$ENV_DIR/svc.env" ] || return 0
exec_old="$(svc_get exec)"
case "$exec_old" in *--dsn\ *) ;; *) return 0 ;; esac
dsn="$(printf '%s' "$exec_old" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
new_exec="$(printf '%s' "$exec_old" | sed -E 's/(^| )--dsn [^ ]+//; s/ +/ /g; s/^ //; s/ $//')"
svc_set_key exec "$new_exec"
if [ -n "$(svc_get dbref)" ]; then
c_info "[$ENV_NAME] 存量 exec DSN 与 dbref=$(svc_get dbref) 并存,已剥离 exec 残留(绑定不受影响)"
return 0
fi
ref="$(dsn_ref_match "$dsn")"
if [ -n "$ref" ]; then
svc_set_key dbref "$ref"
svc_set_key db pg
c_info "[$ENV_NAME] 存量 exec DSN 已迁移为托管绑定 dbref=$ref(DSN 改由实例登记重建)"
else
svc_set_key dsn "$dsn"
svc_set_key db pg
c_info "[$ENV_NAME] 存量 exec DSN 已迁移至受保护 dsn= 字段(未命中本机实例登记)"
fi
return 0
}
# ---- 数据库配置统一解析(start/restart/deploy/update/db 共用单点)----
# 核心约束:修改端口、监听地址或部署版本不改变数据库选择;绑定失效或配置冲突时
# 拒绝启动(返回 1),绝不静默回退内嵌 SQLite。
# 结果全局变量(入口全部重置——批量 update 逐服务重解析,不串用):
# DB_MODE pg | sqlite | mock
# DB_SOURCE cli | env | dbref | dsn | conffile | db | mock | none
# DB_REF <inst>/<db>(命中/保持托管绑定时;否则空)
# DB_DSN 有效连接串(sqlite/mock 为空;conffile 来源=文件内值,仅用于判定不外传)
# DB_AUTH on | off | auto(生效值,对齐二进制 AuthEnabled 口径)
# DB_DSN_SAVE 需持久化到 dsn= 的外部直连 DSN(仅 cli/dsn 来源且未命中实例)
resolve_db_config() {
DB_MODE="sqlite"; DB_SOURCE="none"; DB_REF=""; DB_DSN=""; DB_AUTH="auto"; DB_DSN_SAVE=""
migrate_legacy_db_config
local env_auth env_mock env_dsn
env_auth="$(env_var "$ENV_NAME" AUTH)"
env_mock="$(env_var "$ENV_NAME" MOCK)"
env_dsn="$(env_var "$ENV_NAME" DSN)"
local p_dbref p_dsn p_db p_conf p_exec p_auth
p_dbref="$(svc_get dbref)"
p_dsn="$(svc_get dsn)"
p_db="$(svc_get db)"
p_conf="$(svc_get conffile)"
p_exec="$(svc_get exec)"
p_auth="$(printf '%s' "$p_exec" | sed -n 's/.*--auth \([^ ]*\).*/\1/p')"
[ -n "$p_auth" ] || p_auth="$(svc_get auth)"
DB_AUTH="$(auth_pick "$CLI_AUTH" "$env_auth" "$p_auth")"
case "$DB_AUTH" in on|off|auto) ;; *)
c_err "[$ENV_NAME] 拒绝启动:无效 auth 值「$DB_AUTH」(可用 on/off/auto)"
return 1 ;;
esac
local mock=0
{ [ "$CLI_MOCK" = 1 ] || [ "$env_mock" = "1" ]; } && mock=1
[ "$mock" = 0 ] && case "$p_exec" in *--mock*) mock=1 ;; esac
# conffile 的 dsn 键(二进制自行读文件,连接串不经环境重复传递)。
local cf_dsn=""
if [ -n "$p_conf" ] && [ -f "$p_conf" ]; then
cf_dsn="$(grep -oE '"dsn"[[:space:]]*:[[:space:]]*"[^"]*"' "$p_conf" 2>/dev/null | head -1 | sed -E 's/.*"([^"]*)"$/\1/')"
fi
# mock 档与数据库配置共存 = 冲突,拒绝(行为要明确,不隐式降级)。
if [ "$mock" = 1 ]; then
if [ -n "$p_dbref" ] || [ -n "$p_dsn" ] || [ -n "$CLI_DSN" ] || { [ -n "$env_dsn" ] && [ "$p_db" != "sqlite" ]; }; then
c_err "[$ENV_NAME] 拒绝启动:mock 档与数据库配置冲突(dbref=${p_dbref:-无}、dsn=${p_dsn:+已设、}CLI/环境 DSN=${CLI_DSN:+已设}${env_dsn:+已设})"
c_err " 先解绑(scripts/svc.sh $ENV_NAME db --clear)再用 mock,或去掉 --mock / SVC_$(printf '%s' "$ENV_NAME" | tr 'a-z-' 'A-Z_')_MOCK"
return 1
fi
DB_MODE=mock; DB_SOURCE=mock
return 0
fi
# 显式解绑(db=sqlite):残留 SVC_*_DSN 不再悄悄重新选库;CLI 显式 --dsn 仍可覆盖。
if [ -n "$env_dsn" ] && [ "$p_db" = "sqlite" ] && [ -z "$CLI_DSN" ]; then
c_info "[$ENV_NAME] 已显式解绑(db=sqlite),忽略环境层 SVC_*_DSN 残留(显式 --dsn 或重新绑定可覆盖)"
env_dsn=""
fi
# 有效 DSN 选择:CLI > 环境 > 持久化(dbref / dsn) > conffile > 无(SQLite)。
local eff_dsn="" eff_src="" bt
if [ -n "$CLI_DSN" ]; then
eff_dsn="$CLI_DSN"; eff_src=cli
elif [ -n "$env_dsn" ]; then
eff_dsn="$env_dsn"; eff_src=env
elif [ -n "$p_dbref" ] && [ -n "$p_dsn" ]; then
bt="$(dsn_from_dbref "$p_dbref" 2>/dev/null || true)"
if [ -n "$bt" ] && [ "$(dsn_target "$bt")" = "$(dsn_target "$p_dsn")" ]; then
eff_dsn="$p_dsn"; eff_src=dsn
else
c_err "[$ENV_NAME] 拒绝启动:配置冲突——dbref=$p_dbref 与 dsn= 指向不同数据库,不能静默取其一"
c_err " 保留其一:重新绑定(scripts/svc.sh $ENV_NAME db <pg实例>)或 db --clear 后重新直连"
return 1
fi
elif [ -n "$p_dbref" ]; then
if ! eff_dsn="$(dsn_from_dbref "$p_dbref")"; then
c_err "[$ENV_NAME] 拒绝启动:dbref=$p_dbref 无法重建 DSN(实例未登记 / 类型错误 / addr·user·password 登记不全)"
c_err " 恢复实例登记或重新绑定(scripts/svc.sh $ENV_NAME db <pg实例>);不回退内嵌 SQLite"
return 1
fi
eff_src=dbref
elif [ -n "$p_dsn" ]; then
eff_dsn="$p_dsn"; eff_src=dsn
elif [ -n "$cf_dsn" ]; then
eff_dsn="$cf_dsn"; eff_src=conffile
fi
# 直接 DSN(CLI/环境/持久化)与 dbref 并存:目标库必须一致,否则配置冲突。
if [ -n "$eff_dsn" ] && [ -n "$p_dbref" ] && [ "$eff_src" != dbref ]; then
if ! bt="$(dsn_from_dbref "$p_dbref")"; then
c_err "[$ENV_NAME] 拒绝启动:dbref=$p_dbref 无效(无法重建 DSN)且存在直接 DSN——先修复或解除绑定"
return 1
fi
if [ "$(dsn_target "$bt")" != "$(dsn_target "$eff_dsn")" ]; then
c_err "[$ENV_NAME] 拒绝启动:直接 DSN($(mask_dsn "$eff_dsn"))与 dbref=$p_dbref 指向不同数据库,不能静默取其一"
return 1
fi
fi
# conffile 一致性:文件内 dsn 与有效来源并存时必须同库。
if [ -n "$cf_dsn" ] && [ "$eff_src" != conffile ] && [ -n "$eff_dsn" ] \
&& [ "$(dsn_target "$cf_dsn")" != "$(dsn_target "$eff_dsn")" ]; then
c_err "[$ENV_NAME] 拒绝启动:配置文件 dsn($(mask_dsn "$cf_dsn"))与数据库绑定指向不同库——先对齐(改文件或换绑定)"
return 1
fi
# 已选 PostgreSQL 但拿不到任何有效 DSN → 拒绝(不降级 SQLite)。
if [ -z "$eff_dsn" ] && [ "$p_db" = "pg" ]; then
c_err "[$ENV_NAME] 拒绝启动:已登记 db=pg 但无可恢复 DSN(dbref/dsn/conffile 均缺失)"
c_err " 重新绑定(scripts/svc.sh $ENV_NAME db <pg实例>)或 db --clear 显式回内嵌 SQLite"
return 1
fi
if [ -n "$eff_dsn" ]; then
DB_MODE=pg; DB_DSN="$eff_dsn"; DB_SOURCE="$eff_src"
local ref=""
case "$eff_src" in
cli|dsn)
if [ -n "$p_dbref" ]; then
DB_REF="$p_dbref" # 一致性已验证:保持既有托管绑定
else
ref="$(dsn_ref_match "$eff_dsn")"
if [ -n "$ref" ]; then DB_REF="$ref" # 直连命中本机实例 → 升级托管绑定(密码不重复保存)
else DB_DSN_SAVE="$eff_dsn"
fi
fi
;;
env)
[ -n "$p_dbref" ] && DB_REF="$p_dbref" # 环境层本身不持久化;已有绑定/直连保持
[ -n "$p_dsn" ] && DB_DSN_SAVE="$p_dsn" # 环境覆盖不丢已持久化的直连配置
;;
dbref)
DB_REF="$p_dbref"
;;
esac
else
DB_MODE=sqlite
[ "$p_db" = "sqlite" ] && DB_SOURCE=db # 显式解绑的选择跨重启保留
fi
return 0
}
# db_auth_enabled:启动预检的认证判定,对齐二进制 AuthEnabled:显式 on → 开;
# 显式 off → 关;auto → 存在有效 DSN(含环境与配置文件来源)即开。
db_auth_enabled() {
case "$DB_AUTH" in
on) return 0 ;;
off) return 1 ;;
*) [ -n "$DB_DSN" ] ;;
esac
}
# agent_listen_rejected EXEC_ARGS DEFAULT_ADDR INSECURE:启动预检整体判定(0=放行,
# 1=拒绝)。与二进制 listen guard 同口径:回环或显式放行(insecure)不拦;认证开
# (db_auth_enabled,依赖解析出的 DB_AUTH/DB_DSN)不拦;仅非回环 + 未放行 + 认证关
# 才拦。抽出纯判定便于治理测试覆盖(tests/test_svc_precheck.py)。
agent_listen_rejected() {
local addr; addr="$(printf '%s' "$1" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
addr="${addr:-$2}"
addr_is_loopback "${addr%:*}" && return 1
[ -n "$3" ] && return 1
db_auth_enabled && return 1
return 0
}
# bin_env_dsn_supported BIN:二进制是否消费环境层 AGENT_SYSTEM_DSN(经 config explain
# 探测:explain 对 DSN 值脱敏但用户名保留,探测标记用户即可)。旧产物不支持环境层时
# 返回 1——调用方拒绝以环境传递 DSN(防静默按内嵌 SQLite 运行)。
bin_env_dsn_supported() {
local out
out="$(AGENT_SYSTEM_DSN='postgresql://svcprobe:svcprobe@127.0.0.1:9/svcprobe' "$1" config explain 2>/dev/null || true)"
case "$out" in *svcprobe*) return 0 ;; esac
return 1
}
# agent_static_preflight:停止旧进程 / 更换产物之前可静态检查的配置验证——数据库配置
# 可解析(含绑定有效性与冲突)、conffile 存在、现产物能承接环境 DSN。失败返回 1(输出
# 脱敏原因;除存量迁移外不写登记)。
agent_static_preflight() {
resolve_db_config || return 1
local cf
cf="$(svc_get conffile)"
if [ -n "$cf" ] && [ ! -f "$cf" ]; then
c_err "[$ENV_NAME] 拒绝启动:配置文件不存在:$cf(恢复文件或 config --clear)"
return 1
fi
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ] && [ -x "$ENV_BIN" ] \
&& ! bin_env_dsn_supported "$ENV_BIN"; then
c_err "[$ENV_NAME] 拒绝启动:当前产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),无法按绑定非降级交付数据库配置"
c_err " 更新产物(deploy 新版本),或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <file.json>)"
return 1
fi
return 0
}
agent_do_start() {
mkdir -p "$ENV_DATA" "$ENV_DIR"
check_port_free || return 1
if [ ! -x "$ENV_BIN" ]; then
c_err "[$ENV_NAME] 环境产物缺失:$ENV_BIN"
c_err "请先部署:scripts/svc.sh $ENV_NAME deploy [--from local|dev[@<sha>]|release[@<版本>]]"
return 1
fi
SVC_VERSION="$("$ENV_BIN" version 2>/dev/null || true)"
# 统一解析数据库配置(复用与重建两路共用;失败拒绝启动,绝不静默回退 SQLite)。
# 2026-10-11 修复:复用 exec= 分支此前不查 dbref——G1-7 迁移把 --dsn 剥入环境后
# record_state 写回不含 DSN 的 exec=,重新绑定后的第二次裸 restart 即丢库。
if ! resolve_db_config; then
hist_event start fail "" "数据库配置解析拒绝(绑定失效/配置冲突)"
return 1
fi
SVC_DB_MODE="$DB_MODE"; SVC_DB_SOURCE="$DB_SOURCE"; SVC_DB_REF="$DB_REF"; SVC_DB_DSN_SAVE="$DB_DSN_SAVE"
# 组装启动参数:显式配置 → 重建;否则复用上次持久化 exec=(DSN 已由解析迁移出 exec)。
local exec_args
if ! agent_has_explicit_cfg && [ -n "$(svc_get exec)" ]; then
exec_args="$(svc_get exec)"
c_info "[$ENV_NAME] 复用上次配置(svc.env exec=;显式参数可覆盖):$(mask_dsn "$exec_args")"
else
local args="" explicit_auth="${CLI_AUTH:-$(env_var "$ENV_NAME" AUTH)}"
if [ "$DB_MODE" = mock ]; then
args="--mock"
elif [ "$DB_MODE" = pg ] || [ -n "$explicit_auth" ] || [ "$ENV_NAME" != dev ]; then
args="--auth $DB_AUTH"
fi
# 保留既有配置文件引用:显式参数重建时不丢 --config(conffile= 持久化跨重启)。
local conf; conf="$(svc_get conffile)"
[ -n "$conf" ] && [ -f "$conf" ] && args="$args --config $conf"
local adjusted; adjusted="$(adjust_args_for_bin "$ENV_BIN" "$args")"
# adjust_args_for_bin 返回值已去首空格——比较前对 args 同样归一,否则每次带
# --auth/--config 的重建都会误报"旧版本二进制已自动省略"(参数实际全保留)。
if [ "$adjusted" != "${args# }" ]; then
c_warn "[$ENV_NAME] 该产物不支持部分启动参数(旧版本二进制),已自动省略:${args:-<无>} → ${adjusted:-<无>}"
fi
args="$adjusted"
exec_args="--addr $ENV_ADDR --data $ENV_DATA --plugins ${CLI_PLUGINS:-$ENV_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 以"进程即退"暴露;二进制守卫仍是权威。
# 认证判定依据统一解析的最终有效 DSN 与 auth(含环境变量与配置文件来源),
# 与二进制 AuthEnabled 口径一致(2026-10-11 修复:此前只看命令行 --dsn,
# DSN 迁入环境变量后 auto 被误判为未开认证;PR #317 同因,本方案为其超集)。
if agent_listen_rejected "$exec_args" "$ENV_ADDR" "$insecure"; then
local pf_addr; pf_addr="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
pf_addr="${pf_addr:-$ENV_ADDR}"
c_err "[$ENV_NAME] 拒绝启动:非回环地址 $pf_addr 且认证判定为关(auto 无 DSN 即关)。"
c_err " 任选其一:--addr 127.0.0.1:${pf_addr##*:};数据库绑定 scripts/svc.sh $ENV_NAME db <pg实例>;"
c_err " 显式 --auth on;或环境 SVC_$(printf '%s' "$ENV_NAME" | tr 'a-z-' 'A-Z_')_INSECURE_LISTEN=1 显式放行。"
c_err " 登记未变更(svc.env 保留上次配置);二进制侧 listen guard 同样会拒绝。"
hist_event start fail "" "预检拒绝:非回环且未开认证"
return 1
fi
c_info "[$ENV_NAME] 启动:$ENV_BIN serve $(mask_dsn "$exec_args")"
if [ -n "$insecure" ] || [ -n "$hosts" ]; then
c_info "[$ENV_NAME] 监听守卫:insecureListen=${insecure:-0} allowedHosts=${hosts:-无}"
fi
# 直接 nohup 简单命令:$! 即服务进程本身,pidfile 语义才成立。
# 守卫变量走环境,不写进 exec=;旧二进制不认识这两个变量,会忽略。
# G1-7:DSN 经环境变量传入(AGENT_SYSTEM_DSN),不进命令行——命令行对同机任何
# 进程可见(含智能体生成脚本的 /proc/<pid>/cmdline)。conffile 来源由二进制自行
# 读文件,不重复传递。环境层交付前再核一次产物能力(start 可能紧跟 deploy 换产物),
# 不支持即拒绝——绝不静默按内嵌 SQLite 运行。
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ]; then
if ! bin_env_dsn_supported "$ENV_BIN"; then
c_err "[$ENV_NAME] 拒绝启动:产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),无法按绑定交付数据库配置(不回退 SQLite)"
c_err " 更新产物(deploy 新版本),或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <file.json>)"
hist_event start fail "" "产物不支持环境 DSN,拒绝启动"
return 1
fi
guard_env+=("AGENT_SYSTEM_DSN=$DB_DSN")
fi
if [ "${#guard_env[@]}" -gt 0 ];then
nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 &
else
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 <name> [...]
db create <name> [--port --user --password --db --auth-mode --tag]
db dsn <name> [--reveal] [--db X]
db backup <name> [--db X] [--out F] | db backups <name>
db restore <name> --file F [--db X] [--force]
db clone <src> <dst> [--port P] [--password PW] [--tag T] [--online]
db copy <src> <dst> [--db X] [--force]
db ls <name> | db set <name> [--port N --apply]"; exit 2 ;;
esac
}
db_create() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db create <name> [...]"; 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=<bindir>"; 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 <agent名> 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 <name> [--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 tmp
[ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; }
rest="$(grep -v "^$key=" "$ENV_DIR/svc.env" || true)"
tmp="$ENV_DIR/svc.env.tmp.$$"
{ [ -n "$rest" ] && printf '%s\n' "$rest"; printf '%s=%s\n' "$key" "$val"; } > "$tmp"
chmod 600 "$tmp"
mv -f "$tmp" "$ENV_DIR/svc.env"
return 0
}
# svc_del_key KEY:从当前服务 svc.env 删除单键(键不存在则无操作)。
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 <name> [--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 <name>"; 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 <name> --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 <src> <dst> [--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 <src> <dst> [--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 <name>"; 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 服务的配置文件(<name> 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 <<EOF
[$ENV_NAME] 配置文件:
scripts/svc.sh $ENV_NAME config 查看当前配置文件与生效值
scripts/svc.sh $ENV_NAME config <file.json> [--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 <file.json>)\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 服务的数据库绑定(<name> db ...)----
# svc_db_bind [pg名] [--db X] [--clear] [--no-restart]:查看 / 绑定切换 / 解除
# agent 服务的数据库。绑定 = dbref=<pg>/<库> + db=pg 登记(600);DSN 不落 exec=,
# 启动时由实例登记重建并经 AGENT_SYSTEM_DSN 环境变量交付(实例运行中缺库自动建;
# 服务运行中自动重启生效,--no-restart 只改登记)。--clear 显式解绑:db=sqlite、
# 清除 dbref 与直接 dsn=,残留 SVC_*_DSN 不再悄悄重新选库。
svc_db_bind() {
[ "$SVC_KIND" = agent ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 无数据库绑定(仅 agent 服务)"; return 2; }
[ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 无登记(先 deploy)"; return 2; }
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 <<EOF
[$ENV_NAME] 数据库绑定:
scripts/svc.sh $ENV_NAME db 查看当前绑定与可用实例
scripts/svc.sh $ENV_NAME db <pg名> [--db X] [--no-restart] 绑定/切换(运行中自动重启生效)
scripts/svc.sh $ENV_NAME db --clear [--no-restart] 解除绑定(显式回内嵌 SQLite 档)
EOF
return 0 ;;
-*) c_err "[$ENV_NAME] db 未知参数:$1(可用:<pg名> | --clear,--db X,--no-restart)"; return 2 ;;
*) [ -z "$target" ] || { c_err "[$ENV_NAME] db 只接受一个 postgres 服务名"; return 2; }
target="$1"; shift ;;
esac
done
# 无参:查看当前绑定 + 可用实例。
if [ -z "$target" ] && [ "$clear" = 0 ]; then
local ref dsn pg st mode
mode="$(svc_get db)"
ref="$(db_ref_of "$ENV_NAME")"
if [ -n "$ref" ]; then
printf ' [%s] 数据库绑定:%s\n' "$ENV_NAME" "$ref"
dsn="$(svc_get dsn)"
[ -z "$dsn" ] && dsn="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
[ -z "$dsn" ] && [ -n "$(svc_get dbref)" ] && dsn="$(dsn_from_dbref "$(svc_get dbref)" 2>/dev/null || true)"
[ -n "$dsn" ] && printf ' DSN %s\n' "$(mask_dsn "$dsn")"
elif [ "$mode" = "sqlite" ]; then
printf ' [%s] 数据库绑定:已显式解绑(db=sqlite,data=%s;残留 SVC_*_DSN 不再自动选库)\n' "$ENV_NAME" "$ENV_DATA"
else
printf ' [%s] 数据库绑定:无(内嵌 SQLite 档,data=%s)\n' "$ENV_NAME" "$ENV_DATA"
fi
printf ' 可用实例:\n'
local found=0
while IFS= read -r pg; do
st="$(svc_pid_state "$pg")"
printf ' %-14s %-7s %s\n' "$pg" \
"$(if [ "${st%% *}" = alive ]; then printf 运行中; else printf 未运行; fi)" \
"$(env_kv_of "$pg" addr)"
found=1
done < <(registered_names | while IFS= read -r n; do [ "$(svc_kind "$n")" = postgres ] && printf '%s\n' "$n"; done)
[ "$found" = 0 ] && printf ' (无;scripts/svc.sh db create <name> 创建)\n'
printf ' 切换:scripts/svc.sh %s db <pg名> [--db X];解除:--clear\n' "$ENV_NAME"
return 0
fi
# 解除绑定(显式选择 SQLite:清除 dbref 与直接 dsn,环境残留不再重新选库)。
if [ "$clear" = 1 ]; then
[ -n "$target" ] && { c_err "--clear 不接受服务名参数"; return 2; }
local exec_old new_exec
exec_old="$(svc_get exec)"
if printf '%s' "$exec_old" | grep -q -- '--dsn '; then
new_exec="$(printf '%s' "$exec_old" | sed -E 's/(^| )--dsn [^ ]+//; s/ +/ /g; s/^ //; s/ $//')"
svc_set_key exec "$new_exec"
fi
svc_del_key dbref
svc_del_key dsn
svc_set_key db sqlite
hist_event set ok "$(read_pid)" "db unbind"
c_ok "[$ENV_NAME] 已解除数据库绑定(显式 db=sqlite;数据目录 $ENV_DATA)"
if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then
do_stop; do_start || { c_err "[$ENV_NAME] 重启失败——解绑已登记,排查后 start"; return 1; }
c_ok "[$ENV_NAME] 已重启(内嵌 SQLite 档)"
else
c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效"
fi
return 0
fi
# 绑定/切换。
valid_env "$target" || { c_err "非法服务名:$target"; return 2; }
[ -f "$RUNTIME/$target/svc.env" ] || { c_err "[$target] 未登记(scripts/svc.sh db create $target)"; return 2; }
[ "$(svc_kind "$target")" = postgres ] || { c_err "[$target] kind=$(svc_kind "$target") 不是 postgres 实例"; return 2; }
local taddr thost tport tuser tpass regdb tdb dsn st
taddr="$(env_kv_of "$target" addr)"
thost="${taddr%:*}"; tport="${taddr##*:}"
tuser="$(env_kv_of "$target" user)"
tpass="$(env_kv_of "$target" password)"
regdb="$(env_kv_of "$target" dbname)"
tdb="${CLI_DB_NAME:-$regdb}"
{ [ -n "$tuser" ] && [ -n "$tpass" ] && [ -n "$taddr" ]; } || { c_err "[$target] 登记缺 addr/user/password(重新 db create)"; return 2; }
# mock 档与绑定冲突:拒绝——绑定后按 mock 运行等于静默丢库。
local exec_now mock_now=0
exec_now="$(svc_get exec)"
{ [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$ENV_NAME" MOCK)" = "1" ]; } && mock_now=1
case "$exec_now" in *--mock*) mock_now=1 ;; esac
if [ "$mock_now" = 1 ]; then
c_err "[$ENV_NAME] 拒绝绑定:当前为 mock 档(--mock)与数据库配置冲突;先去 mock(重新 deploy 不带 --mock)或保持 mock 不绑定"
return 2
fi
# 产物承接能力预检:绑定后 DSN 经环境变量交付,产物须消费 AGENT_SYSTEM_DSN。
if [ -x "$ENV_BIN" ] && ! bin_env_dsn_supported "$ENV_BIN"; then
c_err "[$ENV_NAME] 拒绝绑定:当前产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),绑定后无法生效;先更新产物再绑定"
return 2
fi
# 实例运行中:确保目标库存在(缺则建,幂等)。
st="$(svc_pid_state "$target")"
if [ "${st%% *}" = alive ]; then
if ! pg_run "$thost" "$tport" "$tuser" "$tpass" postgres "SELECT 1 FROM pg_database WHERE datname='$tdb'" tA | grep -q 1; then
if pg_run "$thost" "$tport" "$tuser" "$tpass" postgres "CREATE DATABASE \"$tdb\"" >/dev/null; then
c_ok "[$target] 已建库:$tdb"
else
c_warn "[$target] 建库 $tdb 未成功——绑定继续,请手动建库后重启服务"
fi
fi
elif [ "$tdb" != "$regdb" ]; then
c_warn "[$target] 未运行——库 $tdb 暂未创建(先 start $target 再重跑,或绑定默认库 $regdb)"
fi
dsn="postgresql://$tuser:$tpass@$thost:$tport/$tdb"
# 写持久化配置(托管绑定形态):dbref= + db=pg;清除直接 dsn=;剥离 exec 残留 --dsn。
# DSN 不落 exec=——启动时由实例登记重建(实例端口/密码变更自动跟随,密码不重复保存)。
if printf '%s' "$exec_now" | grep -q -- '--dsn '; then
local new_exec
new_exec="$(printf '%s' "$exec_now" | sed -E 's/(^| )--dsn [^ ]+//; s/ +/ /g; s/^ //; s/ $//')"
svc_set_key exec "$new_exec"
fi
svc_set_key dbref "$target/$tdb"
svc_set_key db pg
svc_del_key dsn
hist_event set ok "$(read_pid)" "db bind → $target/$tdb"
c_ok "[$ENV_NAME] 数据库绑定:$target/$tdb(启动时由实例登记重建 DSN:$(mask_dsn "$dsn");svc.env 600 保存,info 可查)"
# 运行中:重启生效(start 按新登记重建 DSN 交付)。
if [ "$restart" = 1 ] && [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then
c_info "[$ENV_NAME] 重启以切换数据库……"
do_stop
do_start || { c_err "[$ENV_NAME] 重启失败——绑定已登记,排查日志后 scripts/svc.sh $ENV_NAME start"; return 1; }
c_ok "[$ENV_NAME] 已切换数据库:$target/$tdb(生效中)"
else
c_info "[$ENV_NAME] 未运行或 --no-restart——下次 start 生效"
fi
return 0
}
cmd_set() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh set <name> [--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
}
# do_restart:agent 先完成可静态检查的配置验证(数据库绑定可解析、conffile 存在、
# 产物承接能力)再停旧进程——配置性拒绝不制造停机窗口,登记保持可恢复;其余类型
# 直接停止后启动。
do_restart() {
if [ "$SVC_KIND" = agent ]; then
agent_static_preflight || { hist_event start fail "" "restart 预检拒绝(数据库配置/配置文件无效)"; return 1; }
fi
do_stop
do_start
}
cmd_rm() {
local name="${1:-}"
[ -n "$name" ] || { c_err "用法:scripts/svc.sh rm <name> [--purge]"; exit 2; }
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 <pid>" | "dead"
local d="$RUNTIME/$1" pid mark
[ -f "$d/svc.pid" ] || { printf dead; return 0; }
pid="$(cat "$d/svc.pid" 2>/dev/null || true)"
[ -n "$pid" ] || { printf dead; return 0; }
mark="$(sed -n 's/^pidcheck=//p' "$d/svc.env" 2>/dev/null | head -1)"
[ -z "$mark" ] && mark="agent-system"
if pid_alive_mark "$pid" "$mark"; then printf 'alive %s' "$pid"; else printf dead; fi
return 0
}
# registered_names:runtime 下已登记服务名(stdout 每行一个;list/info 共用)。
registered_names() {
local d e
for d in "$RUNTIME"/*/; do
[ -f "${d}svc.env" ] || continue
e="$(basename "$d")"
[ "$e" = ".worktrees" ] && continue
valid_env "$e" || continue
printf '%s\n' "$e"
done
return 0
}
# db_ref_of NAME:agent 服务的数据库绑定标识(list/status/info 共用)。
# dbref= 优先;否则从 exec= 的 --dsn 或配置文件(conffile=)的 dsn 键提取 host:port
# 匹配已登记 postgres 实例(兼容直接 --dsn/--config 部署、尚未登记 dbref 的服务);
# 都匹配不上则回 外部@host:port。
db_ref_of() {
local name="$1" ref dsn hostport pg addr cf
ref="$(env_kv_of "$name" dbref)"
if [ -n "$ref" ]; then printf '%s' "$ref"; return 0; fi
dsn="$(printf '%s' "$(env_kv_of "$name" exec)" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')"
[ -z "$dsn" ] && dsn="$(env_kv_of "$name" dsn)"
if [ -z "$dsn" ]; then
cf="$(env_kv_of "$name" conffile)"
[ -z "$cf" ] && cf="$(printf '%s' "$(env_kv_of "$name" exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')"
if [ -n "$cf" ] && [ -f "$cf" ]; then
dsn="$(grep -oE '"dsn"[[:space:]]*:[[:space:]]*"[^"]*"' "$cf" 2>/dev/null | head -1 | sed -E 's/.*"([^"]*)"$/\1/')"
fi
fi
[ -n "$dsn" ] || return 0
hostport="$(printf '%s' "$dsn" | sed -n 's#.*@\([^/?]*\).*#\1#p')"
while IFS= read -r pg; do
[ "$(svc_kind "$pg")" = postgres ] || continue
addr="$(env_kv_of "$pg" addr)"
if [ "$addr" = "$hostport" ]; then
printf '%s/%s' "$pg" "${dsn##*/}"
return 0
fi
done < <(registered_names)
printf '外部@%s' "$hostport"
return 0
}
global_list() {
c_info "服务总览(root: $ROOT)"
printf ' %-12s %-9s %-7s %-8s %-20s %-16s %-24s %s\n' NAME KIND STATE PID ADDR DB SOURCE TAGS
local names=() d name kind addr src tags st dbref
while IFS= read -r name; do names+=("$name"); done < <(registered_names)
for name in ${names[@]+"${names[@]}"}; do
d="$RUNTIME/$name"
kind="$(sed -n 's/^kind=//p' "$d/svc.env" | head -1)"; [ -n "$kind" ] || kind=agent
addr="$(sed -n 's/^addr=//p' "$d/svc.env" | head -1)"
src="$(sed -n 's/^source=//p' "$d/svc.env" | head -1)"
tags="$(sed -n 's/^tags=//p' "$d/svc.env" | head -1)"
if [ -n "${LIST_TAG:-}" ]; then
case ",$tags," in *",${LIST_TAG},"*) ;; *) continue ;; esac
fi
st="$(svc_pid_state "$name")"
dbref="-"
[ "$kind" = agent ] && dbref="$(db_ref_of "$name")"
[ -n "$dbref" ] || dbref="-"
if [ "${st%% *}" = alive ]; then
printf ' %-12s %-9s %-7s %-8s %-20s %-16s %-24s %s\n' "$name" "$kind" "运行中" "${st#alive }" "$addr" "$dbref" "${src:--}" "${tags:--}"
else
printf ' %-12s %-9s %-7s %-8s %-20s %-16s %-24s %s\n' "$name" "$kind" "未运行" "-" "$addr" "$dbref" "${src:--}" "${tags:--}"
fi
done
[ "${#names[@]}" -eq 0 ] && c_info "(暂无已登记服务;db create / deploy 会登记)"
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 档;绑定:<name> db <pg名>)}"
cfile="$(svc_get conffile)"
[ -z "$cfile" ] && cfile="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--config \([^ ]*\).*/\1/p')"
printf ' 配置文件 %s\n' "${cfile:-无(命令行参数形态;<name> config <file> 绑定)}"
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/dsn 键与键名含 KEY/PASS/SECRET/
# TOKEN/DSN 的值整段掩为 ***;其余行只掩值内 DSN 密码(exec= 里可能带完整连接串)。
mask_kv() {
local line="$1" k="${1%%=*}"
if [ "$k" != "$1" ]; then
case "$k" in
password|dsn|*KEY*|*PASS*|*SECRET*|*TOKEN*|*DSN*)
printf '%s=***' "$k"; return 0 ;;
esac
fi
mask_dsn "$line"
}
# info_section TITLE:stdout 分节标题。
info_section() { printf '\n────── %s ──────\n' "$*"; }
# info_du PATH:目录/文件大小(du -sh;不存在或失败回 '-')。
info_du() {
local s=""
[ -e "$1" ] && s="$(du -sh "$1" 2>/dev/null | cut -f1)"
printf '%s' "${s:--}"
}
# info_file_mode PATH:八进制权限(不存在回 '-')。
info_file_mode() {
local m=""
[ -f "$1" ] && m="$(stat -c %a "$1" 2>/dev/null || true)"
printf '%s' "${m:--}"
}
# env_kv_of NAME KEY:读 runtime/<name>/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 <agent名> 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 <name> 创建)\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_restart ;;
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