Why: probing identity by running the subcommand still launched old RDM builds, and ensure on a missing database created an empty one. A batch update also inherited the caller's AGENT_SYSTEM_* variables. Evidence: python3 -m unittest tests.test_svc_identity tests.test_svc_recover passed (41 tests, 1 skipped). tests.test_svc_lifecycle_isolation caller-env and restart cases passed. Impact: capability probes use version --json only. Established instances refuse a missing identity row until adopt --confirm. update and recover run each service in a clean environment. serve reads a 0600 serve.env and does not inherit the caller environment. Backend and documents pins move to the D1 identity and recovery-plan commits. Refs: recovery plan r3.1 D1; Backend feature/d1-identity-open; RDM feature/d1-version-json Co-authored-by: Cursor <cursoragent@cursor.com>
4981 lines
230 KiB
Bash
Executable File
4981 lines
230 KiB
Bash
Executable File
#!/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 配置文件更新/生效)
|
||
# |backup [--out D] [--keep N](agent 数据目录快照)
|
||
# |backups [--prune](快照列表 / 按保留数清理)
|
||
# scripts/svc.sh list [--tag T] 服务总览(status 等价)
|
||
# scripts/svc.sh info [name] [--tag T] [--reveal] 综合报告(只读):服务/数据库/配置快照/最近事件
|
||
# scripts/svc.sh set <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|rdm] [N] 可部署提交列表(默认 dev,N=20)
|
||
# scripts/svc.sh history [name] [N] 事件历史(默认全部,最近 20 条)
|
||
# scripts/svc.sh update [name] [--check] [--all] 检测远端最新并更新过时服务(与当前工作区状态无关)
|
||
# scripts/svc.sh recover [name] [--quiet] 自恢复:把 autostart=1 但已死的服务重新拉起
|
||
# (崩溃/OOM 被杀/重启后由 watchdog 定时调用)
|
||
# scripts/svc.sh watchdog install|remove|status 自恢复看护:systemd 用户级 timer(每分钟 recover)
|
||
# scripts/svc.sh <name> init 显式建立/查看实例身份(manifest+数据 marker;U1)
|
||
# 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 校验)
|
||
# --from rdm[@<sha>] 下游组合仓 Agent-System-RDM(独立 RDM 源仓物化 pins/ 后
|
||
# make build 出 agent-system-rdm 组合二进制;web/ 工作台静态
|
||
# 资产随环境落 htdocs/,供伴生 static 服务 --dir 使用)
|
||
# --pick [dev|release|rdm] 交互式编号选提交;--dry-run 只打印部署计划
|
||
#
|
||
# ════════════════════ 常用参数(服务级)════════════════════
|
||
# --config FILE / --env FILE dotenv 配置文件(KEY=VALUE;--env 为兼容别名)
|
||
# --addr host:port / --host H / --port P 监听地址(pg=TCP,static=HTTP)
|
||
# --tag T 标签(可重复;登记于 svc.env,list --tag 过滤)
|
||
# --kind agent|static deploy 时指定类型(postgres 用 db create 建)
|
||
# --dir PATH static 直接服务既有目录(不构建)
|
||
# --plugins/--dsn/--auth/--mock/--data agent 运行参数
|
||
# <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 启动后播种设置
|
||
# (认证开启时自动铸临时密钥完成写入,--seed-owner 指定目标账号)
|
||
# -- <剩余参数> 原样透传给 agent-system serve(escape hatch)
|
||
# db create:--user U(默认 agent)--password P(缺省自动生成)--db NAME --auth-mode scram-sha-256|md5
|
||
# db backup:--db X --out F db restore:--file F --db X --force
|
||
# db clone:--port P --host H --password PW --tag T --online
|
||
# db copy:--db X --force set:--port N --host H --apply --tag T
|
||
# backup:--out DIR --keep N backups:--prune --keep N(agent 数据目录快照)
|
||
#
|
||
# ════════════════════ 配置优先级 ════════════════════
|
||
# 命令行 > --config/--env 文件 > svc.env 持久化(exec=/addr=/dbref=/dsn=/db=)
|
||
# > SVC_<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(事件流)
|
||
# + .start.lock(生命周期互斥锁,recover 与 deploy/手动操作防竞态)
|
||
# - runtime-backups/<name>/<UTC时间戳>/:agent 数据目录快照(svc.env+history+data;
|
||
# *.db 经 sqlite3 .backup 一致性导出并自检,MANIFEST 记逐文件 sha256。放 runtime/
|
||
# 之外——runtime 被整体误删时快照仍在,rm --purge 随服务一并删除)
|
||
# - kind:agent(后端单二进制,内嵌前端)/ static(前端静态服务)/ postgres(数据库);
|
||
# 无 kind= 的旧登记自动视为 agent;start/restart 使用环境内产物
|
||
#
|
||
# ════════════════════ 示例 ════════════════════
|
||
# scripts/svc.sh db create pg1 --port 8890 --tag core # 建库
|
||
# scripts/svc.sh pg1 start && scripts/svc.sh db dsn pg1 # 起库 + 看 DSN
|
||
# scripts/svc.sh prod deploy --from release@0.1.0 --tag stable
|
||
# scripts/svc.sh prod deploy --from dev@<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 dev backup && scripts/svc.sh dev backups # agent 数据目录快照 + 列表
|
||
# scripts/svc.sh db clone pg1 pg2 --port 8896 # 物理克隆一套到新端口
|
||
# scripts/svc.sh dev db pg1 --db dev # 把 dev 服务绑定到 pg1/dev 库(自动重启)
|
||
# scripts/svc.sh dev db pg2 --no-restart # 切换但不重启(下次 start 生效)
|
||
# scripts/svc.sh dev db --clear # 解绑回内嵌 SQLite 档
|
||
# scripts/svc.sh dev config .svc/dev.json # 更换配置文件(校验+重启即时生效)
|
||
# scripts/svc.sh dev config # 查看当前配置文件与生效值
|
||
# scripts/svc.sh db copy pg1 pg2 --db app # 把 pg1 的 app 库搬到 pg2
|
||
# scripts/svc.sh db restore pg2 --file <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 watchdog install # 装看护 timer:崩溃/OOM 被杀后自动拉回
|
||
# kill -9 <pid> && sleep 65 && scripts/svc.sh list # start 成功记 autostart=1,一分钟内自动恢复
|
||
# scripts/svc.sh pg1 restart && scripts/svc.sh rm tmp1 --purge
|
||
# make svc ENV=dev ARGS="--env .env.dev --port 9000"
|
||
#
|
||
# ════════════════════ 安全约束 ════════════════════
|
||
# - 只写 runtime/<name>/、runtime/.worktrees/、runtime-backups/<name>/ 与独立部署源仓(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 才显示);
|
||
# - 备份/克隆只增不删(backup --keep / backups --prune 的显式保留策略除外);
|
||
# restore/copy 覆盖非空库必须显式 --force(drop 重建再恢复);
|
||
# pg 备份在 runtime/<name>/backups/,agent 快照在 runtime-backups/<name>/(在
|
||
# runtime/ 之外,防整删事故连带),均随 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}"
|
||
# 独立 RDM 源仓(下游组合仓 Agent-System-RDM 的干净克隆;SVC_RDM_SRC_DIR 覆盖路径,
|
||
# SVC_RDM_ORIGIN 覆盖克隆来源)。rdm 源的解析/列举/物化全部基于它,语义与 SRC_DIR 一致。
|
||
RDM_SRC_DIR="${SVC_RDM_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc/rdm-src}"
|
||
RDM_ORIGIN_DEFAULT="ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-RDM.git"
|
||
|
||
# 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。
|
||
c_info() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; }
|
||
c_ok() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; }
|
||
# c_event:事件级输出,不受 --quiet 抑制——watchdog 静默周期里有实际拉回/失败
|
||
# 仍要在 journal 留痕(跳过类的噪声才被 --quiet 吞掉)。
|
||
c_event() { printf '\033[1;36m[svc]\033[0m %s\n' "$*" >&2; }
|
||
c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*" >&2; }
|
||
c_err() { printf '\033[1;31m[svc]\033[0m %s\n' "$*" >&2; }
|
||
|
||
usage() {
|
||
sed -n '2,/^# <<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> backup [--out D] [--keep N] agent 数据目录快照(SQLite 一致性备份)
|
||
scripts/svc.sh <name> backups [--prune] 快照列表 / 按保留数清理
|
||
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] 刷新过时服务到远端最新(不依赖工作区状态)
|
||
scripts/svc.sh recover [name] [--quiet] 自恢复:拉回 autostart=1 但已死的服务
|
||
scripts/svc.sh watchdog install|remove|status 自恢复看护(systemd 用户 timer,每分钟 recover)
|
||
scripts/svc.sh <name> user <subcommand> [...] 账户 CLI(绑定实例;见 help accounts)
|
||
|
||
帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle accounts db dbops config recovery examples safety)
|
||
文档:docs/svc.md
|
||
EOF
|
||
}
|
||
|
||
# HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。
|
||
HELP_TOPICS="model deploy lifecycle accounts db dbops config recovery 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 注销
|
||
accounts 账户 CLI:create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin
|
||
db 数据库服务:create/start/dsn/ls/set(换端口)
|
||
dbops 数据库运维:backup/backups/restore/clone/copy
|
||
config 配置分层:优先级、--config 文件、SVC_* 环境变量、播种参数
|
||
recovery 自恢复:recover、watchdog、autostart
|
||
examples 配方:常见场景的完整命令序列
|
||
safety 安全约束与边界
|
||
EOF
|
||
return 0 ;;
|
||
model)
|
||
cat >&2 <<'EOF'
|
||
【model】服务模型与登记
|
||
|
||
服务名:^[a-z][a-z0-9-]{0,15}$(小写字母开头,可含数字与连字符,≤16 位)。
|
||
保留字不可用作服务名:status list add set db rm create dsn commits history
|
||
update deploy stop start restart logs help info version。
|
||
|
||
目录布局(每个服务自包含,互不串版本):
|
||
runtime/<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)
|
||
.start.lock 生命周期互斥锁(recover 与 deploy/手动 start 防竞态)
|
||
|
||
runtime-backups/<name>/<UTC时间戳>/
|
||
agent 数据目录快照:svc.env + history.jsonl + data/(*.db 经 sqlite3 .backup
|
||
导出一致性副本并入 WAL 内容,integrity_check 自检;-wal/-shm 不入快照;
|
||
MANIFEST 记逐文件 sha256)。位于 runtime/ 之外——runtime 被整体误删时快照
|
||
仍在;rm --purge 随服务一并删除。
|
||
|
||
svc.env 字段:
|
||
kind agent|static|postgres(旧登记无此键视为 agent)
|
||
env 服务名
|
||
pid 最近启动的 pid
|
||
addr 监听地址 host:port(持久化——下次 start 复用)
|
||
tags 标签(逗号分隔)
|
||
source 部署来源(local@<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= 专键
|
||
autostart 期望运行态(成功 start 置 1;显式 stop 移除;进程死亡保留——
|
||
recover/watchdog 据此拉回,见 help recovery)
|
||
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 含原因:进程即退/健康检查超时)
|
||
recover ok|fail(watchdog/手动 recover 拉回死亡服务)
|
||
stop ok|fail
|
||
set 登记变更(addr 旧 → 新)
|
||
backup ok|fail(pg:库/文件/大小;agent:快照目录/大小)
|
||
restore ok|fail(库、文件、表数)
|
||
clone ok|fail(物理/在线)
|
||
copy ok|fail
|
||
rm 注销(purge 与否)
|
||
|
||
端口规则:dev 默认 127.0.0.1:8788、prod 127.0.0.1:8787;其余按名字确定性派生
|
||
(8800-9099,与已登记端口/监听端口冲突时递增扫描);SVC_<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 校验,不符即拒绝。
|
||
rdm[@<sha>] 部署下游组合仓 Agent-System-RDM:独立 RDM 源仓
|
||
(SVC_RDM_SRC_DIR,默认 ~/.cache/agent-system-svc/rdm-src,
|
||
首次按 SVC_RDM_ORIGIN——缺省 RDM 远端——克隆)worktree 物化
|
||
pins/ 子模块后 make build(纯 Go,无需 npm),产物
|
||
bin/agent-system-rdm 复制为环境二进制;插件目录按空集安装
|
||
(组合插件编译进二进制),web/ 工作台静态资产随环境落
|
||
htdocs/,并自动部署伴生 <name>web(--kind static --dir)。
|
||
注意:rdm 源不支持 --mock 与 --config;模型设置播种
|
||
(--model-*/--tavily-keys)仅基座产物支持。
|
||
--pick [dev|release|rdm] 交互式编号选择(需 TTY;列表与提示走 stderr)
|
||
--dry-run 只打印部署计划不执行
|
||
--allow-source-switch 同名服务换源种类(dev↔rdm↔release↔local)须显式确认;
|
||
同种类换 sha(含 update)不需要
|
||
|
||
行为细节:
|
||
- deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程)
|
||
- agent 启动后等健康检查(20s),随后按 --model-*/--tavily-keys 播种设置(幂等)
|
||
- 部署收尾按源种类做 UI 探活:dev/local/release 要求本服务 GET /=200;
|
||
rdm 要求伴生 <name>web GET /=200(失败记 ui-missing,deploy 非 ok)
|
||
- 旧版本产物不认识 --auth/--config 时自动省略(探测 serve 用法)并提示;
|
||
不消费环境变量 DSN(AGENT_SYSTEM_DSN)的旧产物在需要交付数据库配置时
|
||
拒绝部署/启动(不静默按内嵌 SQLite 运行)
|
||
- static 的 release 源不支持(制品内嵌前端);--dir PATH 可直接登记并服务
|
||
既有目录(不构建、不复制,source=dir@<路径>)
|
||
- deploy 全程事件入 history.jsonl(begin/ok/fail)
|
||
|
||
示例:
|
||
scripts/svc.sh prod deploy --from release@0.1.0 --tag stable
|
||
scripts/svc.sh prod deploy --from dev@73b2802 --dry-run
|
||
scripts/svc.sh dev deploy --from rdm@310c3fe --allow-source-switch
|
||
scripts/svc.sh ui-r4 deploy --kind static --from dev@73b2802
|
||
scripts/svc.sh ui-qa deploy --kind static --dir ../frontend/dist
|
||
EOF
|
||
return 0 ;;
|
||
lifecycle)
|
||
cat >&2 <<'EOF'
|
||
【lifecycle】生命周期与登记变更
|
||
|
||
scripts/svc.sh <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] 事件历史(首参为纯数字时视为条数)
|
||
scripts/svc.sh <name> user … 账户 CLI(绑定实例 manifest/OpenExisting;详见 help accounts)
|
||
|
||
变更登记(不动进程,除 --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>/ 整个目录一起删除
|
||
|
||
数据目录快照(agent;SQLite 一致性导出,详见 docs/svc.md):
|
||
scripts/svc.sh <name> backup [--out DIR] [--keep N] 快照到 runtime-backups/<name>/
|
||
scripts/svc.sh <name> backups [--prune] [--keep N] 列表 / 按保留数清理旧快照
|
||
|
||
自动恢复(崩溃 / OOM 被杀 / 机器重启后自动拉回;详见 help recovery):
|
||
scripts/svc.sh recover [name] [--quiet] 把 autostart=1 但已死的服务重新拉起
|
||
scripts/svc.sh watchdog install systemd 用户 timer 每分钟执行 recover
|
||
|
||
自动更新(一条命令把服务刷到远端最新;与当前工作区状态无关):
|
||
scripts/svc.sh update --check 只报告:每服务 当前来源 vs 最新(fetch 后比较)
|
||
scripts/svc.sh update 更新所有"过时且运行中"的服务
|
||
scripts/svc.sh update dev prod 点名更新(已停止的过时服务也会更新并启动)
|
||
scripts/svc.sh update --all 含已停止服务;--no-fetch 跳过网络
|
||
- local@/dev@ 源:一律重新部署为 dev@<远端 main 最新>(独立部署源仓 + 隔离
|
||
worktree 构建;主检出脏净、所在分支均不影响,也不 pull 主检出)
|
||
- 领先远端 main / 与远端分叉 / 提交不在远端:报告但不动作(不盲目降级)
|
||
- release@源:部署 Releases 仓最新版本(sha256 校验)
|
||
- postgres / static --dir 不参与更新;每次更新写 history(update 事件)
|
||
EOF
|
||
return 0 ;;
|
||
accounts|account|user)
|
||
cat >&2 <<'EOF'
|
||
【accounts】实例绑定的账户 CLI(U1 已接线;帮助可发现性)
|
||
|
||
绑定合同(禁止回落默认 ./data):
|
||
- 必须先有实例登记与 data 根(manifest identity.data_root 优先)
|
||
- 强制 --open-existing:缺库/缺目录拒绝,不创建空库
|
||
- 禁止调用方覆盖 --data/--dsn/--open-existing
|
||
- 与启停互斥(lifecycle flock);无 flock 时拒绝
|
||
- mock 档不支持;PG 档须有有效 DSN/dbref
|
||
|
||
六个子命令(经 scripts/svc.sh <env> user … 转发到实例二进制):
|
||
scripts/svc.sh <env> user create --owner <id> [--role user|admin] [--password …]
|
||
scripts/svc.sh <env> user list
|
||
scripts/svc.sh <env> user reset-password --owner <id> [--password …]
|
||
scripts/svc.sh <env> user issue-login-key --owner <id> [--purpose activate|recover]
|
||
scripts/svc.sh <env> user revoke-login-key --id <key-id>
|
||
scripts/svc.sh <env> user bootstrap-admin --owner <id> [--password …]
|
||
|
||
凭据只在 stdout 展示一次;日志与 history 只记凭据 ID。后端原子开户/改密/审计
|
||
补齐见 Documents 方案 U7(本帮助主题不替代该增量)。
|
||
更完整说明:docs/svc.md、docs/svc-deploy/u1-instance-account-binding.md
|
||
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 密钥池
|
||
SVC_SEED_OWNER 播种目标账号(缺省取库内首个管理员)
|
||
播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。
|
||
认证开启(PG 档默认)时播种自动铸临时密钥(name=svc-seed,用后即回收),
|
||
不依赖手动到设置页配置。
|
||
|
||
持久化语义:
|
||
- deploy/start 成功(健康探测通过)后把当前态写入 svc.env(600,临时文件原子替换);
|
||
启动失败不覆盖上次配置——裸 start 仍按上次可用参数重试
|
||
- 裸 start/restart 复用 exec=(上次完整参数);任何显式参数(或 --config 文件、
|
||
SVC_* 变量)出现即整体重建参数;两种路径共用同一数据库配置解析——改端口/地址/
|
||
版本不改变数据库选择,conffile 引用随重建保留
|
||
- 数据库配置可恢复:托管绑定 dbref= 在启动时由实例登记重建 DSN(实例端口/密码
|
||
变更自动跟随);外部直连存受保护 dsn= 字段;显式解绑记 db=sqlite(环境残留
|
||
SVC_*_DSN 不再重新选库)。DSN 经 AGENT_SYSTEM_DSN 环境变量交付,不进 exec=
|
||
与命令行;存量 exec= 内的 --dsn 启动时自动迁移(幂等)
|
||
- 绑定失效或配置冲突时拒绝启动(不静默回退 SQLite):dbref 实例缺失/登记不全、
|
||
直接 DSN 与 dbref 指向不同库、mock 与绑定并存、db=pg 无可恢复 DSN、产物不
|
||
消费环境 DSN;restart/deploy 在停止旧进程前先做这些静态检查
|
||
- tags 随每次登记合并去重,跨重启保留
|
||
|
||
服务级配置文件(与上述 svc.sh dotenv 层不同——这是 agent 二进制自己的 JSON 配置):
|
||
scripts/svc.sh <name> config [file|--clear] 更换/移除/查看(见 help lifecycle);
|
||
切换前 schema 校验(agent-system config explain -config <file>),运行中自动重启
|
||
即时生效;登记于 svc.env conffile=,info/status 可见。
|
||
EOF
|
||
return 0 ;;
|
||
recovery)
|
||
cat >&2 <<'EOF'
|
||
【recovery】自恢复:autostart 期望态 + recover + watchdog
|
||
|
||
期望运行态(autostart 键,svc.env):
|
||
- start / restart / deploy / update 成功启动(健康检查通过)→ autostart=1
|
||
- 显式 stop(含 deploy/update 前置停机)→ 移除 autostart(保持停止是明确意图)
|
||
- 进程自行死亡(崩溃、OOM 被杀、机器重启后)→ 键保留,等待恢复
|
||
- rm 注销后无 svc.env,recover 不再触碰
|
||
|
||
scripts/svc.sh recover [name] [--quiet]
|
||
扫描已登记服务(postgres 优先于 agent/static,保证绑定实例先起),
|
||
对 autostart=1 且 pidfile+cmdline 校验已死的服务执行裸 start
|
||
(复用持久化 exec= 与数据库绑定);每次尝试写 recover ok/fail 事件。
|
||
逐服务持 .start.lock(非阻塞,被 deploy/手动 start 持有时跳过本轮)。
|
||
进程反复即退时自然限频为约 1 次/分钟(健康探测 20s + timer 周期)。
|
||
|
||
scripts/svc.sh watchdog install 安装 systemd 用户级 timer(每分钟 recover):
|
||
~/.config/systemd/user/agent-system-svc-recover.{service,timer}
|
||
(OnBootSec=1min + OnUnitActiveSec=60s;开机自启依赖
|
||
loginctl enable-linger,未开时安装会提示)
|
||
scripts/svc.sh watchdog remove 停用并移除 timer
|
||
scripts/svc.sh watchdog status timer 状态 + autostart=1 的服务清单
|
||
无 systemd 用户会话的环境可退化为 cron:
|
||
* * * * * <ROOT>/scripts/svc.sh recover --quiet >/dev/null 2>&1
|
||
|
||
语义边界:
|
||
- recover 只用既有登记拉起,不重建产物、不改变任何配置;
|
||
- deploy/update 失败(构建失败、预检拒绝)→ 服务保持停止等人工介入,
|
||
watchdog 不盲目复活(前置停机已清 autostart);
|
||
- 端口被外部进程占用时 start 报错放弃(不抢不杀,watchdog 每轮重试无害)。
|
||
EOF
|
||
return 0 ;;
|
||
examples)
|
||
cat >&2 <<'EOF'
|
||
【examples】常见配方
|
||
|
||
① 起一套演示后端(缺省部署远端 main 最新;--from local 才构建当前工作区):
|
||
scripts/svc.sh dev deploy --mock
|
||
scripts/svc.sh dev deploy --from local --mock
|
||
② 建库 + 后端连库:
|
||
scripts/svc.sh db create pg1 --port 8890
|
||
scripts/svc.sh pg1 start
|
||
scripts/svc.sh prod deploy --from dev@<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_SEED_OWNER=""
|
||
CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0
|
||
CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0
|
||
CLI_APPLY=0; CLI_FORCE=0; CLI_ONLINE=0; CLI_FILE=""; CLI_OUT=""
|
||
CLI_ALLOW_SOURCE_SWITCH=0
|
||
CLI_CHECK=0; CLI_ALL=0; CLI_NOFETCH=0; CLI_QUIET=0
|
||
CLI_DB_USER=""; CLI_DB_PASSWORD=""; CLI_DB_NAME=""; CLI_DB_AUTH=""
|
||
CLI_TAGS=()
|
||
|
||
need_value() { [ $# -ge 2 ] || { c_err "参数 $1 需要值"; exit 2; }; }
|
||
|
||
parse_args() {
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--addr) need_value "$@"; CLI_ADDR="$2"; shift 2 ;;
|
||
--host) need_value "$@"; CLI_HOST="$2"; shift 2 ;;
|
||
--port) need_value "$@"; CLI_PORT="$2"; shift 2 ;;
|
||
--data) need_value "$@"; CLI_DATA="$2"; shift 2 ;;
|
||
--dsn) need_value "$@"; CLI_DSN="$2"; shift 2 ;;
|
||
--auth) need_value "$@"; CLI_AUTH="$2"; shift 2 ;;
|
||
--plugins) need_value "$@"; CLI_PLUGINS="$2"; shift 2 ;;
|
||
--mock) CLI_MOCK=1; shift ;;
|
||
--model-base) need_value "$@"; CLI_MBASE="$2"; shift 2 ;;
|
||
--model-key) need_value "$@"; CLI_MKEY="$2"; shift 2 ;;
|
||
--model-name) need_value "$@"; CLI_MNAME="$2"; shift 2 ;;
|
||
--light-base) need_value "$@"; CLI_LBASE="$2"; shift 2 ;;
|
||
--light-key) need_value "$@"; CLI_LKEY="$2"; shift 2 ;;
|
||
--light-name) need_value "$@"; CLI_LNAME="$2"; shift 2 ;;
|
||
--tavily-keys) need_value "$@"; CLI_TAVILY="$2"; shift 2 ;;
|
||
--seed-owner) need_value "$@"; CLI_SEED_OWNER="$2"; shift 2 ;;
|
||
--from) need_value "$@"; CLI_FROM="$2"; shift 2 ;;
|
||
--pick) CLI_PICK="dev"
|
||
case "${2:-}" in dev|release|rdm) CLI_PICK="$2"; shift 2 ;; *) shift ;; esac ;;
|
||
--dry-run) CLI_DRYRUN=1; shift ;;
|
||
--kind) need_value "$@"; CLI_KIND="$2"; shift 2 ;;
|
||
--dir) need_value "$@"; CLI_DIR="$2"; shift 2 ;;
|
||
--tag) need_value "$@"; CLI_TAGS+=("$2"); shift 2 ;;
|
||
--purge) CLI_PURGE=1; shift ;;
|
||
--reveal) CLI_REVEAL=1; shift ;;
|
||
--apply) CLI_APPLY=1; shift ;;
|
||
--force) CLI_FORCE=1; shift ;;
|
||
--allow-source-switch) CLI_ALLOW_SOURCE_SWITCH=1; shift ;;
|
||
--online) CLI_ONLINE=1; shift ;;
|
||
--file) need_value "$@"; CLI_FILE="$2"; shift 2 ;;
|
||
--out) need_value "$@"; CLI_OUT="$2"; shift 2 ;;
|
||
--user) need_value "$@"; CLI_DB_USER="$2"; shift 2 ;;
|
||
--password) need_value "$@"; CLI_DB_PASSWORD="$2"; shift 2 ;;
|
||
--db) need_value "$@"; CLI_DB_NAME="$2"; shift 2 ;;
|
||
--auth-mode) need_value "$@"; CLI_DB_AUTH="$2"; shift 2 ;;
|
||
--) shift; CLI_EXTRA="$*"; break ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
*) c_err "未知参数:$1"; usage_short; exit 2 ;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
# addr_compose 基础值(host:port)按 CLI 的 --addr/--host/--port 组合出最终地址。
|
||
addr_compose() {
|
||
local base="$1" host port
|
||
host="${base%:*}"; port="${base##*:}"
|
||
[ -n "$CLI_ADDR" ] && { echo "$CLI_ADDR"; return 0; }
|
||
[ -n "$CLI_HOST" ] && host="$CLI_HOST"
|
||
[ -n "$CLI_PORT" ] && port="$CLI_PORT"
|
||
echo "$host:$port"
|
||
}
|
||
|
||
# ---- 名称与端口 ----
|
||
RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version recover watchdog backup backups "
|
||
|
||
# valid_env NAME:服务名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用)。
|
||
valid_env() {
|
||
case "$RESERVED_ENVS" in *" $1 "*) return 1 ;; esac
|
||
[[ "$1" =~ ^[a-z][a-z0-9-]{0,15}$ ]]
|
||
}
|
||
|
||
# env_var NAME SUFFIX:取 SVC_<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 PORT:端口是否被监听。优先 ss;不可用或无命中时退化为 bash 内建
|
||
# /dev/tcp 连接探测(CI 最小容器无 iproute2 时端口检查会失明——2026-10-11 实测:
|
||
# thief 占口,recover 未识别照常启动,健康探测打到 thief 上误报已启动)。
|
||
port_listening() {
|
||
local p="$1"
|
||
if command -v ss >/dev/null 2>&1; then
|
||
ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}' && return 0
|
||
fi
|
||
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0
|
||
return 1
|
||
}
|
||
|
||
# derive_port NAME:由服务名确定性派生端口(8800+hash%300),冲突时在 8800..9099 递增。
|
||
derive_port() {
|
||
local name="$1" p i=0
|
||
p=$(( 8800 + ($(printf '%s' "$name" | cksum | cut -d' ' -f1) % 300) ))
|
||
while [ "$i" -le 300 ]; do
|
||
if ! ports_recorded "$p" "$name" && ! port_listening "$p"; then
|
||
echo "$p"; return 0
|
||
fi
|
||
p=$(( 8800 + (p - 8800 + 1) % 300 ))
|
||
i=$((i + 1))
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# svc_get KEY:读当前服务 svc.env 的键值(无则空)。
|
||
svc_get() {
|
||
[ -f "$ENV_DIR/svc.env" ] || return 0
|
||
sed -n "s/^$1=//p" "$ENV_DIR/svc.env" | head -1
|
||
}
|
||
|
||
# svc_kind NAME:服务类型(svc.env kind=,无登记或无 kind 键 → agent,兼容旧登记)。
|
||
svc_kind() {
|
||
local k=""
|
||
[ -f "$RUNTIME/$1/svc.env" ] && k="$(sed -n 's/^kind=//p' "$RUNTIME/$1/svc.env" | head -1)"
|
||
[ -n "$k" ] && printf '%s' "$k" || printf agent
|
||
}
|
||
|
||
# ---- 登记与事件 ----
|
||
|
||
jclean() { printf '%s' "$1" | tr -d '"\001-\037'; }
|
||
|
||
# hist_event EVENT RESULT PID DETAIL:向 runtime/<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"
|
||
# U1 身份:manifest 治理下的整份提交(bulk-set 走 temp→fsync→rename 原子链,
|
||
# revision 递增,svc.env 由 manifest 派生);失败保留旧配置,不产生半写态。
|
||
if manifest_ok; then
|
||
if python3 "$MANIFEST_PY" "$ENV_DIR" bulk-set < "$tmp"; then
|
||
rm -f "$tmp"
|
||
return 0
|
||
fi
|
||
c_err "[$ENV_NAME] manifest 提交失败,保留旧配置(详见上方错误)"
|
||
rm -f "$tmp"
|
||
return 1
|
||
fi
|
||
mv -f "$tmp" "$ENV_DIR/svc.env"
|
||
}
|
||
|
||
# ---- pid 管理(泛化:pidfile + cmdline 标记双校验)----
|
||
|
||
pid_alive_mark() { # PID MARKER:cmdline 含 MARKER 视为我们的进程
|
||
local pid="$1" mark="$2" cmd
|
||
[ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || return 1
|
||
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
|
||
case "$cmd" in *"$mark"*) return 0 ;; *) return 1 ;; esac
|
||
}
|
||
|
||
# pid_alive PID:当前服务的 pid 校验(内存 SVC_PIDCHECK → svc.env pidcheck → agent-system)。
|
||
# 启动路径在 record_state 之前就会调本函数;static 首启若只读 svc.env 会误回退到
|
||
# agent-system,把 python http.server 判成「进程即退」。
|
||
pid_alive() {
|
||
local pid="$1" mark
|
||
mark="${SVC_PIDCHECK:-}"
|
||
[ -z "$mark" ] && mark="$(svc_get pidcheck)"
|
||
[ -z "$mark" ] && mark="agent-system"
|
||
pid_alive_mark "$pid" "$mark"
|
||
}
|
||
|
||
read_pid() { [ -f "$ENV_PID" ] && cat "$ENV_PID" 2>/dev/null || true; }
|
||
|
||
# proc_stop PID(agent/static):TERM 优雅 → KILL 兜底(postgres 不走此路径)。
|
||
proc_stop() {
|
||
local pid="$1"
|
||
kill -TERM "$pid"
|
||
for _ in $(seq 1 100); do
|
||
pid_alive "$pid" || return 0
|
||
sleep 0.1
|
||
done
|
||
c_warn "[$ENV_NAME] 10s 未退出,SIGKILL 兜底"
|
||
kill -KILL "$pid" 2>/dev/null || true
|
||
return 0
|
||
}
|
||
|
||
port_owner() {
|
||
local port="${ENV_ADDR##*:}"
|
||
if command -v ss >/dev/null 2>&1; then
|
||
ss -ltnp 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {print $0; exit}'
|
||
fi
|
||
}
|
||
|
||
# port_owner_pid:从 ss 行解析占口 PID(无 ss / 无 pid= 字段则空失败)。
|
||
port_owner_pid() {
|
||
local line pid
|
||
line="$(port_owner)"
|
||
[ -n "$line" ] || return 1
|
||
pid="$(printf '%s' "$line" | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | head -1)"
|
||
[ -n "$pid" ] || return 1
|
||
printf '%s' "$pid"
|
||
}
|
||
|
||
# cmdline_matches_own PID:进程 cmdline 是否含本服务标记(pidcheck/ENV_BIN;
|
||
# 路径被 cp 覆盖后 argv 仍常保留原路径;basename + ENV_DIR 作收紧兜底)。
|
||
cmdline_matches_own() {
|
||
local pid="$1" mark base cmd
|
||
mark="${SVC_PIDCHECK:-}"
|
||
[ -z "$mark" ] && mark="$(svc_get pidcheck)"
|
||
[ -z "$mark" ] && mark="${ENV_BIN:-}"
|
||
[ -n "$mark" ] || return 1
|
||
[ -r "/proc/$pid/cmdline" ] || return 1
|
||
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
|
||
case "$cmd" in *"$mark"*) return 0 ;; esac
|
||
base="$(basename "$mark" 2>/dev/null || true)"
|
||
[ -n "$base" ] && [ "$base" != "$mark" ] || return 1
|
||
case "$cmd" in
|
||
*"$base"*)
|
||
[ -n "${ENV_DIR:-}" ] || return 1
|
||
case "$cmd" in *"$ENV_DIR"*) return 0 ;; esac
|
||
;;
|
||
esac
|
||
return 1
|
||
}
|
||
|
||
# find_own_in_ancestry PID:从占口 PID 沿 PPid 向上找本服务进程(stdout 打印命中 PID)。
|
||
# 真实 agent-system 单进程自听;桩用 bash+python http.server 时 ss 看到的是子进程。
|
||
find_own_in_ancestry() {
|
||
local cur="$1" i=0 next
|
||
while [ -n "$cur" ] && [ "$cur" -gt 1 ] 2>/dev/null && [ "$i" -lt 8 ]; do
|
||
if cmdline_matches_own "$cur"; then
|
||
printf '%s' "$cur"
|
||
return 0
|
||
fi
|
||
next="$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)"
|
||
[ -n "$next" ] && [ "$next" != "$cur" ] || return 1
|
||
cur="$next"
|
||
i=$((i + 1))
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# reclaim_own_port:若 ENV_ADDR 端口被本服务残留占用则停掉并清 pidfile。
|
||
# 返回 0=端口已空闲(本就空或回收成功);1=仍被外部/未知占用或回收未释放端口。
|
||
# 修复:update/deploy 误清 pidfile 后 cp 覆盖二进制留下 (deleted) 孤儿,
|
||
# check_port_free 一律报「非本服务」导致无法重启(2026-10-12 genui@8973)。
|
||
reclaim_own_port() {
|
||
local port="${ENV_ADDR##*:}" listener target i
|
||
[ -n "$port" ] || return 0
|
||
port_listening "$port" || return 0
|
||
listener="$(port_owner_pid 2>/dev/null || true)"
|
||
[ -n "$listener" ] || return 1
|
||
target="$(find_own_in_ancestry "$listener" 2>/dev/null || true)"
|
||
if [ -n "$target" ]; then
|
||
c_warn "[$ENV_NAME] 端口 $port 被本服务残留进程占用(listener=$listener svc=$target),正在回收"
|
||
proc_stop "$target"
|
||
rm -f "$ENV_PID"
|
||
hist_event stop ok "$target" "reclaim_own_port"
|
||
for i in $(seq 1 50); do
|
||
port_listening "$port" || return 0
|
||
sleep 0.1
|
||
done
|
||
c_err "[$ENV_NAME] 本服务残留已发终止信号,但端口 $port 仍被占用"
|
||
return 1
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
|
||
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
|
||
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失或等待超时 → 拒绝(不得直通冲突操作)。
|
||
# 锁随进程退出自动释放(fd 关闭);长驻循环(recover)用 svc_unlock 逐服务显式释放。
|
||
SVC_LOCK_HELD=0
|
||
svc_lock() {
|
||
[ "$SVC_LOCK_HELD" = 1 ] && return 0
|
||
if ! command -v flock >/dev/null 2>&1; then
|
||
c_err "[$ENV_NAME] 缺少 flock:拒绝生命周期/账户冲突操作(安装 util-linux 后重试)"
|
||
return 1
|
||
fi
|
||
mkdir -p "$ENV_DIR" 2>/dev/null || return 1
|
||
exec 9>>"$ENV_DIR/.start.lock" || return 1
|
||
if [ "$1" = "-n" ]; then
|
||
flock -n 9 || { exec 9>&-; return 1; }
|
||
else
|
||
if ! flock -w 300 9; then
|
||
exec 9>&-
|
||
c_err "[$ENV_NAME] 生命周期锁等待超时:拒绝继续(避免并发冲突)"
|
||
return 1
|
||
fi
|
||
fi
|
||
SVC_LOCK_HELD=1
|
||
return 0
|
||
}
|
||
svc_unlock() {
|
||
[ "$SVC_LOCK_HELD" = 1 ] || return 0
|
||
exec 9>&- || true
|
||
SVC_LOCK_HELD=0
|
||
return 0
|
||
}
|
||
|
||
# ---- U1 实例身份 manifest(设计:docs/recovery/u1-identity-manifest.md,已批准)----
|
||
# manifest.json 为启动权威(identity 锚点冻结数据根/数据库身份;kv 为权威键值;
|
||
# desired 为期望态),svc.env 降为派生只读视图,未迁移实例沿用旧路径。
|
||
MANIFEST_PY="$ROOT/scripts/svc_manifest.py"
|
||
manifest_ok() {
|
||
[ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ]
|
||
}
|
||
# svc_manifest_migrate:锁内一次性迁移(幂等)。已有数据视为"领养"——按当前数据根
|
||
# 建立 marker;数据根不存在或携带他实例 marker 时拒绝(不新建空系统掩盖丢数据),
|
||
# 失败保持 legacy 治理继续运行。
|
||
svc_manifest_migrate() {
|
||
[ -f "$ENV_DIR/svc.env" ] && [ ! -f "$ENV_DIR/manifest.json" ] || return 0
|
||
command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || {
|
||
c_warn "[$ENV_NAME] 跳过 manifest 迁移:需 python3 与 scripts/svc_manifest.py"
|
||
return 0
|
||
}
|
||
local out rc=0
|
||
out="$(python3 "$MANIFEST_PY" "$ENV_DIR" migrate 2>&1)" || rc=$?
|
||
if [ "$rc" -eq 0 ]; then
|
||
c_ok "[$ENV_NAME] manifest:$(printf '%s' "$out" | tail -1)"
|
||
# fresh:库文件已在时用当前制品补行并升为 established;无能力则记 pending。
|
||
# 库还不存在时保持 fresh,等启动建库后再 ensure。不在这里为缺库建文件。
|
||
resolve_db_config 2>/dev/null || true
|
||
local marker data_root
|
||
marker="$(svc_db_marker)"
|
||
if [ "$marker" = fresh ]; then
|
||
data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")"
|
||
if ! svc_bin_has_identity; then
|
||
svc_db_marker_set pending || return 1
|
||
elif [ -f "$data_root/agent-system.db" ]; then
|
||
svc_db_identity_ensure || return 1
|
||
fi
|
||
fi
|
||
return 0
|
||
fi
|
||
if [ "$rc" -eq 4 ]; then
|
||
# 数据根携带他实例 marker:身份治理冲突(如 manifest 丢失后试图重建)——
|
||
# 中止而非降级 legacy(I4:丢身份是事件,禁止新建空系统掩盖)。
|
||
c_err "[$ENV_NAME] 身份治理冲突,中止:$(printf '%s' "$out" | tail -1)"
|
||
return 1
|
||
fi
|
||
c_err "[$ENV_NAME] manifest 迁移失败(保持旧治理):$(printf '%s' "$out" | tail -1)"
|
||
return 0
|
||
}
|
||
# svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配;
|
||
# 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。
|
||
# 顺序:文件 marker → DB verify(缺/错即拦)。ensure 只在领养(svc_manifest_migrate)
|
||
# 与显式 init——普通启动不建身份,缺行即事件(修复路径:svc init,或换具备
|
||
# identity 能力的产物更新时由候选门禁补齐)。
|
||
svc_manifest_verify() {
|
||
manifest_ok || return 0
|
||
local out
|
||
if ! out="$(python3 "$MANIFEST_PY" "$ENV_DIR" verify \
|
||
--data "$ENV_DATA" --db-mode "${DB_MODE:-}" --db-ref "${DB_REF:-}" 2>&1)"; then
|
||
c_err "[$ENV_NAME] 身份校验失败,拒绝启动:$(printf '%s' "$out" | tail -1)"
|
||
c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)"
|
||
return 1
|
||
fi
|
||
# 文件校验通过后再碰库。fresh 允许库尚未建立;established 只验证;
|
||
# pending 仅当库文件已在且制品有能力时补齐。无能力制品不得启动已治理实例。
|
||
local kind marker data_root
|
||
kind="$(svc_get kind)"
|
||
[ "$kind" = agent ] || return 0
|
||
marker="$(svc_db_marker)"
|
||
case "$marker" in
|
||
fresh) return 0 ;;
|
||
pending)
|
||
if ! svc_bin_has_identity; then
|
||
c_err "[$ENV_NAME] 身份尚未建立(pending),当前制品不能验证身份,拒绝启动"
|
||
return 1
|
||
fi
|
||
data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")"
|
||
if [ ! -f "$data_root/agent-system.db" ] && [ "${DB_MODE:-}" != pg ]; then
|
||
c_err "[$ENV_NAME] 身份 pending 但库文件不存在,拒绝启动(不新建空库)"
|
||
return 1
|
||
fi
|
||
svc_db_identity_ensure || return 1
|
||
svc_db_identity_verify || return 1
|
||
;;
|
||
*)
|
||
if ! svc_bin_has_identity; then
|
||
c_err "[$ENV_NAME] 已建立身份的实例只接受能验证身份的制品,拒绝启动"
|
||
return 1
|
||
fi
|
||
svc_db_identity_verify || return 1
|
||
;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# svc_db_identity_*:DB marker(设计 §5.2)。与 --instance(runs 进程标识)无关。
|
||
# dbref 稳定串:pg 用托管绑定或 "pg";其余归一为 "sqlite"。
|
||
svc_db_identity_args() {
|
||
# 输出:INSTANCE_ID\tDBREF\tDATA(供 ensure/verify 共用)
|
||
manifest_ok || return 1
|
||
python3 - "$ENV_DIR" "${ENV_DATA:-}" <<'PY'
|
||
import json, os, sys
|
||
env_dir, data_fallback = sys.argv[1], sys.argv[2]
|
||
m = json.load(open(os.path.join(env_dir, "manifest.json"), encoding="utf-8"))
|
||
ident = m.get("identity") or {}
|
||
mode = ident.get("db_mode") or ""
|
||
ref = ident.get("db_ref") or ""
|
||
if mode == "pg":
|
||
dbref = ref or "pg"
|
||
else:
|
||
dbref = "sqlite"
|
||
data = ident.get("data_root") or data_fallback
|
||
print("%s\t%s\t%s" % (m["instance_id"], dbref, data))
|
||
PY
|
||
}
|
||
|
||
# svc_bin_caps [BIN]:只执行 version --json,在空目录、清空的环境里。非 JSON 或
|
||
# protocol<1 一律无能力。旧制品对 version 立即退出,不会进入服务路径。
|
||
# 按二进制摘要缓存;缓存目录默认在临时目录,不写进实例运行时。
|
||
svc_bin_caps() {
|
||
local bin="${1:-${ENV_BIN:-}}"
|
||
[ -x "$bin" ] || return 1
|
||
local digest cache tmp out
|
||
digest="$(sha256sum "$bin" 2>/dev/null | awk '{print $1}')"
|
||
[ -n "$digest" ] || return 1
|
||
cache="${SVC_CAPS_CACHE_DIR:-${TMPDIR:-/tmp}}/svc-caps-${digest}"
|
||
if [ -f "$cache" ]; then
|
||
cat "$cache"
|
||
return 0
|
||
fi
|
||
tmp="$(mktemp -d)"
|
||
out="$(cd "$tmp" && timeout -k 2 5 env -i PATH=/usr/bin:/bin HOME="$tmp" "$bin" version --json 2>/dev/null)" || true
|
||
rm -rf "$tmp"
|
||
out="$(printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert int(d.get("protocol",0))>=1; print(json.dumps(d,separators=(",",":")))' 2>/dev/null)" || return 1
|
||
printf '%s\n' "$out" > "$cache"
|
||
printf '%s\n' "$out"
|
||
}
|
||
|
||
# svc_bin_has_identity [BIN]:能力文档声明 identity.ops 含 ensure 与 verify。
|
||
svc_bin_has_identity() {
|
||
local bin="${1:-${ENV_BIN:-}}" doc
|
||
doc="$(svc_bin_caps "$bin")" || return 1
|
||
printf '%s' "$doc" | python3 -c 'import json,sys; d=json.load(sys.stdin); ops=(d.get("identity") or {}).get("ops") or []; raise SystemExit(0 if "ensure" in ops and "verify" in ops else 1)'
|
||
}
|
||
|
||
# svc_db_marker:manifest.identity.db_marker。缺字段的旧 manifest 视为 established
|
||
#(已在治理下跑过的实例,缺行即事件,不再自动补)。
|
||
svc_db_marker() {
|
||
manifest_ok || { printf '%s' established; return 0; }
|
||
python3 - "$ENV_DIR" <<'PY'
|
||
import json, os, sys
|
||
p = os.path.join(sys.argv[1], "manifest.json")
|
||
m = json.load(open(p, encoding="utf-8"))
|
||
print((m.get("identity") or {}).get("db_marker") or "established")
|
||
PY
|
||
}
|
||
|
||
svc_db_marker_set() {
|
||
local marker="$1"
|
||
manifest_ok || return 0
|
||
python3 - "$ENV_DIR" "$marker" <<'PY'
|
||
import json, os, sys
|
||
env_dir, marker = sys.argv[1], sys.argv[2]
|
||
path = os.path.join(env_dir, "manifest.json")
|
||
m = json.load(open(path, encoding="utf-8"))
|
||
ident = dict(m.get("identity") or {})
|
||
if ident.get("db_marker") == marker:
|
||
sys.exit(0)
|
||
ident["db_marker"] = marker
|
||
m["identity"] = ident
|
||
m["revision"] = int(m.get("revision") or 0) + 1
|
||
tmp = path + ".new"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(m, f, ensure_ascii=False, indent=2)
|
||
f.write("\n")
|
||
os.replace(tmp, path)
|
||
PY
|
||
}
|
||
|
||
# svc_identity_run BIN OP:身份子命令。DSN 只经环境(AGENT_SYSTEM_DSN),不进 argv。
|
||
svc_identity_run() {
|
||
local bin="$1" op="$2" iid="$3" dbref="$4" data="$5"
|
||
local -a args=(env -i "PATH=$PATH" "HOME=${HOME:-/tmp}" "TMPDIR=${TMPDIR:-/tmp}"
|
||
"AGENT_SYSTEM_INSTANCE_ID=$iid" "AGENT_SYSTEM_DBREF=$dbref" "AGENT_SYSTEM_DATA=$data")
|
||
if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then
|
||
args+=("AGENT_SYSTEM_DSN=$DB_DSN")
|
||
fi
|
||
args+=("$bin" identity "$op" --instance-id "$iid" --dbref "$dbref" --data "$data")
|
||
"${args[@]}"
|
||
}
|
||
|
||
svc_db_identity_ensure() {
|
||
# 仅 fresh/pending 或显式 adopt 调用。established 缺行不得走这里自动补。
|
||
# 非 agent 或无能力:跳过。可选参数 BIN:部署候选。
|
||
local bin="${1:-${ENV_BIN:-}}"
|
||
manifest_ok || return 0
|
||
local kind; kind="$(svc_get kind)"
|
||
[ "$kind" = agent ] || return 0
|
||
svc_bin_has_identity "$bin" || return 0
|
||
local line iid dbref data
|
||
line="$(svc_db_identity_args)" || return 1
|
||
IFS=$'\t' read -r iid dbref data <<<"$line"
|
||
local out rc=0
|
||
out="$(svc_identity_run "$bin" ensure "$iid" "$dbref" "$data" 2>&1)" || rc=$?
|
||
if [ "$rc" -ne 0 ]; then
|
||
c_err "[$ENV_NAME] DB 身份 ensure 失败:$out"
|
||
return 1
|
||
fi
|
||
svc_db_marker_set established || return 1
|
||
return 0
|
||
}
|
||
|
||
svc_db_identity_verify() {
|
||
manifest_ok || return 0
|
||
local kind; kind="$(svc_get kind)"
|
||
[ "$kind" = agent ] || return 0
|
||
local bin="${1:-${ENV_BIN:-}}"
|
||
svc_bin_has_identity "$bin" || return 0
|
||
local line iid dbref data
|
||
line="$(svc_db_identity_args)" || return 1
|
||
IFS=$'\t' read -r iid dbref data <<<"$line"
|
||
local out rc=0
|
||
out="$(svc_identity_run "$bin" verify "$iid" "$dbref" "$data" 2>&1)" || rc=$?
|
||
if [ "$rc" -ne 0 ]; then
|
||
c_err "[$ENV_NAME] DB 身份校验失败,拒绝启动:$out"
|
||
c_err " 先调查库文件与身份行;确认归属后 scripts/svc.sh $ENV_NAME adopt --confirm $iid"
|
||
return 1
|
||
fi
|
||
AGENT_SYSTEM_INSTANCE_ID="$iid"
|
||
AGENT_SYSTEM_DBREF="$dbref"
|
||
export AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF
|
||
return 0
|
||
}
|
||
|
||
# svc_deploy_identity_gate SRCBIN:部署候选的身份门禁——在停旧服务、覆盖制品之前
|
||
# 执行(2026-10-12 rdm1/rdmcand 事故:停服→覆盖→启动即被身份门禁拒绝,只剩
|
||
# 「登记版本≠磁盘制品」的混合状态且无恢复路径)。规则:
|
||
# 1) 未入 manifest 治理(全新实例,无 manifest.json):放行——首次部署无旧状态可保;
|
||
# 2) 非 agent 类型:放行;
|
||
# 3) 候选产物无 identity 管理能力:拒绝——装上即启动被基座身份门禁拒绝,
|
||
# 登记与旧制品/旧进程保持不变(下游组合仓接齐 identity 接口前不可更新);
|
||
# 4) 候选有能力:先以候选幂等补齐 DB 身份行(领养时旧产物可能无能力而未补;
|
||
# 外宾行/不一致 fail-closed),成功才放行——把「缺行」在停服前治好,
|
||
# 避免换完制品才发现 start 起不来。
|
||
svc_deploy_identity_gate() {
|
||
local srcbin="$1"
|
||
manifest_ok || return 0
|
||
local kind; kind="$(svc_get kind)"
|
||
[ "$kind" = agent ] || return 0
|
||
if ! svc_bin_has_identity "$srcbin"; then
|
||
c_err "[$ENV_NAME] 候选产物不支持 identity 管理:$srcbin"
|
||
c_err " manifest 治理实例拒绝安装(装上即被启动身份校验拒绝);旧服务/旧制品/登记保持不变"
|
||
c_err " 处理:换具备 identity ensure/verify 的版本部署,或 scripts/svc.sh rm $ENV_NAME --purge 后放弃治理重新部署"
|
||
return 1
|
||
fi
|
||
resolve_db_config 2>/dev/null || true
|
||
local marker; marker="$(svc_db_marker)"
|
||
case "$marker" in
|
||
fresh|pending)
|
||
# 库文件还不存在时不补行、不建库;留给启动后的受控 ensure。
|
||
local data_root
|
||
data_root="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identity"]["data_root"])' "$ENV_DIR/manifest.json")"
|
||
if [ -f "$data_root/agent-system.db" ] || { [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; }; then
|
||
svc_db_identity_ensure "$srcbin" || {
|
||
c_err "[$ENV_NAME] 候选补齐 DB 身份失败(外宾行/不一致 fail-closed);旧服务/旧制品/登记保持不变"
|
||
return 1
|
||
}
|
||
fi
|
||
;;
|
||
*)
|
||
# established:只验证,不补行。缺行或不匹配在停旧服务前拒绝。
|
||
svc_db_identity_verify "$srcbin" || {
|
||
c_err "[$ENV_NAME] 候选无法验证已建立的 DB 身份;旧服务/旧制品/登记保持不变"
|
||
return 1
|
||
}
|
||
;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 running,
|
||
# 显式 stop 置 stopped;进程自行死亡不动——recover 据 svc.env 派生的 autostart 判断)。
|
||
# svc_write_serve_env:首次显式启动把白名单环境(不含凭据、不含 AGENT_SYSTEM_*)
|
||
# 写入 0600 的 serve.env。裸启动与 recover 复用它,避免交互壳与 watchdog 环境不同。
|
||
svc_write_serve_env() {
|
||
local f="$ENV_DIR/serve.env"
|
||
[ -f "$f" ] && return 0
|
||
( umask 077
|
||
{
|
||
printf 'PATH=%s\n' "${PATH:-/usr/bin:/bin}"
|
||
printf 'HOME=%s\n' "${HOME:-/tmp}"
|
||
printf 'USER=%s\n' "${USER:-}"
|
||
printf 'LOGNAME=%s\n' "${LOGNAME:-${USER:-}}"
|
||
printf 'LANG=%s\n' "${LANG:-C.UTF-8}"
|
||
printf 'TZ=%s\n' "${TZ:-UTC}"
|
||
printf 'TMPDIR=%s\n' "${TMPDIR:-/tmp}"
|
||
} > "$f"
|
||
)
|
||
chmod 0600 "$f" 2>/dev/null || true
|
||
}
|
||
|
||
# svc_launch_serve ARGS:env -i 只带 serve.env 与本次构造的 guard_env。
|
||
svc_launch_serve() {
|
||
local exec_args="$1"
|
||
local -a args=(env -i)
|
||
local line
|
||
if [ -f "$ENV_DIR/serve.env" ]; then
|
||
while IFS= read -r line || [ -n "$line" ]; do
|
||
[ -z "$line" ] && continue
|
||
case "$line" in
|
||
AGENT_SYSTEM_*|*' '*) continue ;;
|
||
*=*) args+=("$line") ;;
|
||
esac
|
||
done < "$ENV_DIR/serve.env"
|
||
fi
|
||
if [ "${#guard_env[@]}" -gt 0 ]; then
|
||
args+=("${guard_env[@]}")
|
||
fi
|
||
# shellcheck disable=SC2086
|
||
nohup "${args[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
|
||
}
|
||
|
||
# svc_lifecycle_child ACTION [extra args...]:用清空的环境拉起本脚本的
|
||
# __lifecycle。白名单:PATH/HOME/USER/LOGNAME/LANG/LC_*/TZ/TMPDIR、代理、SVC_*。
|
||
# 从不传入 AGENT_SYSTEM_*。
|
||
svc_lifecycle_child() {
|
||
local -a args=(env -i "PATH=${PATH:-/usr/bin:/bin}" "HOME=${HOME:-/tmp}"
|
||
"USER=${USER:-}" "LOGNAME=${LOGNAME:-${USER:-}}" "LANG=${LANG:-C.UTF-8}"
|
||
"TZ=${TZ:-UTC}" "TMPDIR=${TMPDIR:-/tmp}")
|
||
local k
|
||
for k in http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy NO_PROXY; do
|
||
if [ -n "${!k:-}" ]; then
|
||
args+=("$k=${!k}")
|
||
fi
|
||
done
|
||
while IFS= read -r k; do
|
||
[ -n "$k" ] || continue
|
||
args+=("$k=${!k}")
|
||
done < <(compgen -e | grep -E '^(SVC_|LC_)' || true)
|
||
args+=(bash "$0" __lifecycle "$@")
|
||
"${args[@]}"
|
||
}
|
||
|
||
svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired running >/dev/null 2>&1 && return 0; svc_set_key autostart 1 >/dev/null 2>&1 || true; }
|
||
svc_clear_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired stopped >/dev/null 2>&1 && return 0; svc_del_key autostart 2>/dev/null || true; }
|
||
|
||
check_port_free() {
|
||
# 先尝试回收本服务残留(无/坏 pidfile 的孤儿);外部占用仍拒绝。
|
||
reclaim_own_port || true
|
||
local owner pid own
|
||
owner="$(port_owner)"
|
||
if [ -n "$owner" ]; then
|
||
pid="$(port_owner_pid 2>/dev/null || true)"
|
||
own=""
|
||
[ -n "$pid" ] && own="$(find_own_in_ancestry "$pid" 2>/dev/null || true)"
|
||
if [ -n "$own" ]; then
|
||
c_err "[$ENV_NAME] 端口被本服务残留占用且回收失败:$owner"
|
||
else
|
||
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
|
||
fi
|
||
c_err "处理:停掉该进程,或换端口(--port / --addr)"
|
||
return 1
|
||
fi
|
||
# ss 不可用时 port_owner 为空——再经 port_listening(含 /dev/tcp 兜底)确认,
|
||
# 否则无 iproute2 的环境里启动守卫完全失明(CI 容器实测)。
|
||
if port_listening "${ENV_ADDR##*:}"; then
|
||
c_err "[$ENV_NAME] 端口 ${ENV_ADDR##*:} 正被监听(未能识别占用者;无 ss 时仅能探测不能归因)"
|
||
c_err "处理:停掉该进程,或换端口(--port / --addr)"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# ---- 服务配置解析(CLI > 配置文件/环境变量 > svc.env 持久化 > 默认)----
|
||
|
||
env_config() {
|
||
local name="$1"
|
||
valid_env "$name" || {
|
||
c_err "非法服务名:$name(^[a-z][a-z0-9-]{0,15}$;保留字 status/list/db/rm 等不可用)"
|
||
exit 2
|
||
}
|
||
ENV_NAME="$name"
|
||
ENV_DIR="$RUNTIME/$name"
|
||
ENV_PID="$ENV_DIR/svc.pid"
|
||
ENV_LOG="$ENV_DIR/svc.log"
|
||
ENV_BIN_DIR="$ENV_DIR/bin"
|
||
ENV_BIN="$ENV_BIN_DIR/agent-system"
|
||
# 按实例重置内存态——批量 update/restart 逐服务解析时不串用上一服务。
|
||
# DB_*:防跨服务串库;SVC_PIDCHECK/EXEC/VERSION/SOURCE:防 pid_alive 与
|
||
# reclaim_own_port 用上一服务标记误判本服务进程为「已死/非本服务」(2026-10-12
|
||
# update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。
|
||
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
|
||
SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE=""
|
||
# U1:身份环境同样按实例重置——上一服务 verify 成功后导出的
|
||
# AGENT_SYSTEM_INSTANCE_ID/DBREF 不得串给下一服务(批量 update 单进程逐服务解析;
|
||
# 2026-10-12 实况:dev 的身份漏给 rdm1/rdmcand,叠加 RDM 产物无 identity 子命令
|
||
# 跳过校验,serve 带外宾身份即被基座门禁拒绝)。serve 只允许拿到本服务 verify
|
||
# 写入的身份。
|
||
unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF
|
||
local registered=""
|
||
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
|
||
SVC_KIND="${registered:-agent}"
|
||
[ -n "$CLI_KIND" ] && SVC_KIND="$CLI_KIND"
|
||
# 类型与既有登记冲突防护:同名服务换类型必须先 rm。
|
||
if [ -n "$registered" ] && [ -n "$CLI_KIND" ] && [ "$CLI_KIND" != "$registered" ]; then
|
||
c_err "[$ENV_NAME] 已登记为 kind=$registered,不能改类型;先 scripts/svc.sh rm $ENV_NAME"
|
||
exit 2
|
||
fi
|
||
[ "$SVC_KIND" = postgres ] && ENV_LOG="$ENV_DIR/pg.log"
|
||
|
||
local base_addr base_data persisted_addr
|
||
base_addr="$(env_var "$name" ADDR)"
|
||
base_data="$(env_var "$name" DATA)"
|
||
persisted_addr=""
|
||
[ -f "$ENV_DIR/svc.env" ] && persisted_addr="$(svc_get addr)"
|
||
if [ -z "$base_addr" ]; then
|
||
if [ -n "$persisted_addr" ]; then
|
||
base_addr="$persisted_addr"
|
||
else
|
||
case "$name" in
|
||
dev) base_addr="127.0.0.1:8788" ;;
|
||
prod) base_addr="127.0.0.1:8787" ;;
|
||
*)
|
||
local p
|
||
p="$(derive_port "$name")" || {
|
||
c_err "[$name] 无空闲派生端口(8800-9099),请用 --port/--addr 或 SVC_$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')_ADDR 显式指定"
|
||
exit 1
|
||
}
|
||
base_addr="127.0.0.1:$p"
|
||
;;
|
||
esac
|
||
fi
|
||
fi
|
||
if [ -z "$base_data" ]; then
|
||
case "$SVC_KIND" in
|
||
postgres) base_data="$ENV_DIR/pgdata" ;;
|
||
*) base_data="$ENV_DIR/data" ;;
|
||
esac
|
||
fi
|
||
ENV_ADDR="$(addr_compose "$base_addr")"
|
||
ENV_DATA="${CLI_DATA:-$base_data}"
|
||
return 0
|
||
}
|
||
|
||
# agent_has_explicit_cfg:用户是否显式给了运行参数(决定是否复用持久化 exec=)。
|
||
agent_has_explicit_cfg() {
|
||
[ -n "$CLI_ADDR$CLI_HOST$CLI_PORT$CLI_DSN$CLI_AUTH$CLI_PLUGINS$CLI_EXTRA" ] && return 0
|
||
[ "$CLI_MOCK" = 1 ] && return 0
|
||
[ -n "$SVC_ENVFILE" ] && return 0
|
||
[ -n "$(env_var "$ENV_NAME" ADDR)$(env_var "$ENV_NAME" DATA)$(env_var "$ENV_NAME" DSN)$(env_var "$ENV_NAME" AUTH)$(env_var "$ENV_NAME" MOCK)" ] && return 0
|
||
return 1
|
||
}
|
||
|
||
# adjust_args_for_bin BIN ARGS:按二进制 serve 用法过滤旧产物不认识的 --auth/--config。
|
||
# 匹配按"去横线的旗标名 + 1-2 个横线"(Go flag 帮助打印单横线 -dsn,2026-10-04 实测
|
||
# 旧实现 grep -- '--dsn' 永不命中,把现役二进制的 --dsn/--auth 误当不支持而丢弃)。
|
||
adjust_args_for_bin() {
|
||
local bin="$1" args="$2" out
|
||
# timeout 护栏:组合二进制若把 serve 当位置参数忽略而直接进入服务态,探测会挂死。
|
||
out="$(timeout 5 "$bin" serve -h 2>&1 || true)"
|
||
local filtered="" w drop_next=0 flag
|
||
for w in $args; do
|
||
case "$w" in
|
||
--auth|--config)
|
||
flag="${w#--}"
|
||
if printf '%s\n' "$out" | grep -Eq -- "(^|[[:space:]])-{1,2}${flag}([[:space:]]|=|$)"; then
|
||
filtered="$filtered $w"
|
||
else
|
||
drop_next=1
|
||
fi
|
||
;;
|
||
*)
|
||
if [ "$drop_next" = 1 ]; then drop_next=0; continue; fi
|
||
filtered="$filtered $w"
|
||
;;
|
||
esac
|
||
done
|
||
printf '%s' "${filtered# }"
|
||
}
|
||
|
||
# ---- 提交列举与选择(dev / release 两源)----
|
||
|
||
releases_root() {
|
||
[ -n "${REL_ROOT:-}" ] && return 0
|
||
REL_ROOT="${SVC_RELEASES_DIR:-$ROOT/../Agent-System-Releases}"
|
||
if [ ! -d "$REL_ROOT/.git" ]; then
|
||
c_err "Releases 仓不存在:$REL_ROOT(可用 SVC_RELEASES_DIR 指定路径)"
|
||
exit 2
|
||
fi
|
||
}
|
||
|
||
# svc_src_ready:确保独立部署源仓就绪(不存在则从 $ROOT 的 origin 首次克隆)并 fetch
|
||
# 远端;--no-fetch 跳过网络。同一次命令运行内幂等(首个调用定下 fetch 策略)。
|
||
# 失败 return 1(无 origin 可克隆/克隆失败),调用方自行决定报错或降级。
|
||
SRC_READY_DONE=0
|
||
svc_src_ready() {
|
||
[ "$SRC_READY_DONE" = 1 ] && return 0
|
||
if [ ! -d "$SRC_DIR/.git" ]; then
|
||
local url
|
||
url="$(git -C "$ROOT" remote get-url origin 2>/dev/null || true)"
|
||
[ -n "$url" ] || { c_err "无法确定 origin 地址($ROOT 无 origin 远端);可用 SVC_SRC_DIR 指向已有仓"; return 1; }
|
||
c_info "初始化独立部署源仓:$SRC_DIR(← origin;与当前工作区无关,可随时删除重建)"
|
||
mkdir -p "$(dirname "$SRC_DIR")"
|
||
if ! git clone --quiet "$url" "$SRC_DIR" >&2; then
|
||
c_err "部署源仓克隆失败:$url → $SRC_DIR"
|
||
return 1
|
||
fi
|
||
fi
|
||
if [ "${1:-}" != "--no-fetch" ]; then
|
||
c_info "git fetch(独立部署源仓:$SRC_DIR)"
|
||
git -C "$SRC_DIR" fetch origin --quiet 2>/dev/null || c_warn "部署源仓 fetch 失败——基于本地缓存($SRC_DIR)"
|
||
fi
|
||
SRC_READY_DONE=1
|
||
return 0
|
||
}
|
||
|
||
# rdm_src_ready:确保独立 RDM 源仓就绪(缺省从 RDM 远端克隆,SVC_RDM_ORIGIN 覆盖来源、
|
||
# SVC_RDM_SRC_DIR 覆盖路径)并 fetch;--no-fetch 跳过网络。与 svc_src_ready 同构,
|
||
# 面向下游组合仓 Agent-System-RDM(构建组合二进制 agent-system-rdm)。
|
||
RDM_SRC_READY_DONE=0
|
||
rdm_src_ready() {
|
||
[ "$RDM_SRC_READY_DONE" = 1 ] && return 0
|
||
if [ ! -d "$RDM_SRC_DIR/.git" ]; then
|
||
local url="${SVC_RDM_ORIGIN:-$RDM_ORIGIN_DEFAULT}"
|
||
c_info "初始化独立 RDM 源仓:$RDM_SRC_DIR(← $url;与当前工作区无关,可随时删除重建)"
|
||
mkdir -p "$(dirname "$RDM_SRC_DIR")"
|
||
if ! git clone --quiet "$url" "$RDM_SRC_DIR" >&2; then
|
||
c_err "RDM 源仓克隆失败:$url → $RDM_SRC_DIR(可用 SVC_RDM_ORIGIN 指定克隆来源)"
|
||
return 1
|
||
fi
|
||
fi
|
||
if [ "${1:-}" != "--no-fetch" ]; then
|
||
c_info "git fetch(独立 RDM 源仓:$RDM_SRC_DIR)"
|
||
git -C "$RDM_SRC_DIR" fetch origin --quiet 2>/dev/null || c_warn "RDM 源仓 fetch 失败——基于本地缓存($RDM_SRC_DIR)"
|
||
fi
|
||
RDM_SRC_READY_DONE=1
|
||
return 0
|
||
}
|
||
|
||
deployed_envs() {
|
||
local key="$1" d e s out=""
|
||
for d in "$RUNTIME"/*/; do
|
||
[ -f "${d}svc.env" ] || continue
|
||
e="$(basename "$d")"
|
||
valid_env "$e" || continue
|
||
s="$(sed -n 's/^source=//p' "${d}svc.env")"
|
||
[ "$s" = "$key" ] && out="${out:+$out,}$e"
|
||
done
|
||
printf '%s' "$out"
|
||
}
|
||
|
||
print_commit_list() { # SRC N PICK:提交表到 stdout;PICK=1 时填充 PICK_OPTS
|
||
local src="$1" count="$2" pick="$3"
|
||
local sep=$'\037'
|
||
if [ "$src" = dev ]; then
|
||
svc_src_ready || { c_err "部署源仓不可用,无法列出可部署提交"; return 1; }
|
||
c_info "Dev 仓提交(独立部署源仓 $SRC_DIR 的 origin/main;倒序取 $count 条)——部署:scripts/svc.sh <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)
|
||
elif [ "$src" = rdm ]; then
|
||
rdm_src_ready || { c_err "RDM 源仓不可用,无法列出可部署提交"; return 1; }
|
||
c_info "RDM 仓提交(独立 RDM 源仓 $RDM_SRC_DIR 的 origin/main;倒序取 $count 条)——部署:scripts/svc.sh <name> deploy --from rdm@<sha>"
|
||
printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题'
|
||
local latest_short; latest_short="$(git -C "$RDM_SRC_DIR" rev-parse --short origin/main 2>/dev/null || true)"
|
||
local n=0 h date subject marks envs
|
||
while IFS="$sep" read -r h date subject; do
|
||
[ -z "$h" ] && continue
|
||
n=$((n + 1))
|
||
marks=""
|
||
[ "$h" = "$latest_short" ] && marks="${marks}[最新]"
|
||
envs="$(deployed_envs "rdm@$h")"
|
||
[ -n "$envs" ] && marks="${marks}[已部署:$envs]"
|
||
printf ' %3d %-9s %-10s %s %s\n' "$n" "$h" "$date" "$subject" "$marks"
|
||
[ "$pick" = 1 ] && PICK_OPTS+=("$h")
|
||
done < <(git -C "$RDM_SRC_DIR" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short origin/main)
|
||
else
|
||
c_info "Releases 仓提交($REL_ROOT;倒序取 $count 条)——部署:scripts/svc.sh <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|rdm) ;;
|
||
*) c_err "用法:scripts/svc.sh commits [dev|release|rdm] [N]"; exit 2 ;;
|
||
esac
|
||
case "$count" in ''|*[!0-9]*|0) c_err "N 须为正整数"; exit 2 ;; esac
|
||
[ "$src" = release ] && releases_root
|
||
print_commit_list "$src" "$count" 0
|
||
}
|
||
|
||
pick_commit() { # SRC → stdout 只回显 "dev@<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"
|
||
;;
|
||
rdm|rdm@*)
|
||
# 缺省 ref=origin/main(独立 RDM 源仓远端最新);显式 @<sha> 亦可。
|
||
local ref="origin/main"
|
||
case "$spec" in rdm@*) ref="${spec#rdm@}" ;; esac
|
||
rdm_src_ready || { c_err "独立 RDM 源仓不可用——rdm 源依赖它(可用 SVC_RDM_ORIGIN 指定克隆来源、SVC_RDM_SRC_DIR 指定路径)"; exit 2; }
|
||
local full
|
||
full="$(git -C "$RDM_SRC_DIR" rev-parse --verify --quiet "${ref}^{commit}")" || {
|
||
c_err "RDM 源仓无此提交:$ref(已自动 fetch;可用项:scripts/svc.sh commits rdm)"
|
||
exit 2
|
||
}
|
||
SRC_KINDSRC=rdm
|
||
SRC_FULL="$full"
|
||
SRC_SHA="$(git -C "$RDM_SRC_DIR" rev-parse --short "$full")"
|
||
SRC_LABEL="rdm@$SRC_SHA"
|
||
;;
|
||
*)
|
||
c_err "无法解析 --from $spec(可用:local | dev[@<sha>] | release[@<版本|sha>] | rdm[@<sha>])"
|
||
exit 2
|
||
;;
|
||
esac
|
||
}
|
||
|
||
install_artifacts() { # SRCBIN SRCPLUGINS:agent 产物复制进服务目录。
|
||
local srcbin="$1" srcplug="$2"
|
||
# 覆盖前必须无本服务占口进程——否则留下 exe=(deleted) 孤儿,后续 start 误报非本服务。
|
||
if ! reclaim_own_port; then
|
||
c_err "[$ENV_NAME] 安装前端口仍被占用,拒绝覆盖二进制(避免 exe deleted 孤儿)"
|
||
return 1
|
||
fi
|
||
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 [REPO]:回收构建 worktree(尽力而为)。REPO 缺省为 Dev 源仓。
|
||
recycle_worktree() {
|
||
local repo="${2:-$SRC_DIR}"
|
||
local wt="$RUNTIME/.worktrees/$1"
|
||
c_info "[$ENV_NAME] 回收构建 worktree"
|
||
git -C "$repo" worktree remove --force "$wt" >/dev/null 2>&1 \
|
||
|| c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $repo worktree remove --force $wt"
|
||
}
|
||
|
||
# materialize_rdm_commit FULL SHA:RDM 源的隔离 worktree 物化+构建;stdout 只回显
|
||
# worktree 路径。构建 = make build(纯 Go:pins/ 子模块经 go.mod replace 就位后无需
|
||
# npm 与网络);产物固定为 bin/agent-system-rdm(缺失即失败,绝不装半成品)。
|
||
materialize_rdm_commit() {
|
||
local full="$1" sha="$2"
|
||
local wt="$RUNTIME/.worktrees/rdm-$sha"
|
||
if [ -d "$wt" ]; then
|
||
c_info "[$ENV_NAME] 复用已有构建 worktree:$wt"
|
||
else
|
||
c_info "[$ENV_NAME] 物化 RDM 提交 $sha → 隔离 worktree(源:独立 RDM 源仓 $RDM_SRC_DIR,不触碰当前工作区)"
|
||
git -C "$RDM_SRC_DIR" worktree add --detach "$wt" "$full" >/dev/null || return 1
|
||
fi
|
||
c_info "[$ENV_NAME] 物化基座 pins(git submodule update --init --recursive;缺对象时需网络)"
|
||
git -C "$wt" submodule update --init --recursive >&2 || {
|
||
c_err "[$ENV_NAME] pins 子模块物化失败(网络或对象缺失;重试会自动 fetch RDM 源仓)"
|
||
return 1
|
||
}
|
||
c_info "[$ENV_NAME] 构建(make build:纯 Go,无需 npm)"
|
||
make -C "$wt" build >&2 || return 1
|
||
[ -x "$wt/bin/agent-system-rdm" ] || {
|
||
c_err "[$ENV_NAME] 构建产物缺失:$wt/bin/agent-system-rdm(RDM Makefile 的 build 目标应产出该名称)"
|
||
return 1
|
||
}
|
||
echo "$wt"
|
||
}
|
||
|
||
materialize_release() {
|
||
local art="$REL_ROOT/build/$REL_VERSION" bin="$REL_ROOT/build/$REL_VERSION/agent-system"
|
||
local mf="$REL_ROOT/versions/$REL_VERSION/manifest.json"
|
||
if [ ! -f "$bin" ]; then
|
||
c_err "[$ENV_NAME] Releases 仓无制品:$bin"
|
||
c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)"
|
||
return 1
|
||
fi
|
||
if [ -f "$mf" ]; then
|
||
local want have
|
||
want="$(sed -n 's/.*"agent-system": *"\([0-9a-f]\{64\}\)".*/\1/p' "$mf" | head -1)"
|
||
have="$(sha256sum "$bin" | cut -d' ' -f1)"
|
||
if [ -n "$want" ] && [ "$want" != "$have" ]; then
|
||
c_err "[$ENV_NAME] sha256 校验失败:制品与 manifest 不符($bin)——拒绝部署"
|
||
return 1
|
||
fi
|
||
[ -n "$want" ] && c_ok "[$ENV_NAME] sha256 校验通过(manifest:$REL_VERSION)"
|
||
else
|
||
c_warn "[$ENV_NAME] 无 manifest 可校验:$mf"
|
||
fi
|
||
[ -n "${SRC_DEVCOMMIT:-}" ] && c_info "[$ENV_NAME] 版本对应 Dev 提交:$SRC_DEVCOMMIT"
|
||
install_artifacts "$bin" "$art/plugins" || return 1
|
||
}
|
||
|
||
# ---- agent:账户 CLI(绑定实例存储;禁止回落默认 ./data)----
|
||
# 用法:scripts/svc.sh <env> user <create|list|...> [--owner …]
|
||
# 从实例最终配置 / manifest identity 解析 data/DSN,强制 --open-existing,
|
||
# 调用 ENV_BIN 对应版本。拒绝调用方覆盖 --data/--dsn/--open-existing。
|
||
agent_do_user() {
|
||
local a
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
--data|--dsn|--open-existing|--data=*|--dsn=*|--open-existing=*)
|
||
c_err "[$ENV_NAME] 账户操作禁止覆盖存储绑定(勿传 --data/--dsn/--open-existing;由实例 manifest 决定)"
|
||
return 2 ;;
|
||
esac
|
||
done
|
||
svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁(账户操作与启停互斥)"; return 1; }
|
||
# 有 manifest 时以 identity.data_root 为权威,拒绝 ENV_DATA 漂移到默认 ./data。
|
||
if [ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1; then
|
||
local mf_root
|
||
mf_root="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m["identity"]["data_root"])' \
|
||
"$ENV_DIR/manifest.json" 2>/dev/null || true)"
|
||
if [ -n "$mf_root" ]; then
|
||
ENV_DATA="$mf_root"
|
||
fi
|
||
fi
|
||
case "${ENV_DATA:-}" in
|
||
""|./data|data)
|
||
c_err "[$ENV_NAME] 未绑定数据目录:拒绝账户操作(不回落默认 ./data)"
|
||
return 1 ;;
|
||
esac
|
||
resolve_db_config || {
|
||
c_err "[$ENV_NAME] 数据库绑定无效:拒绝账户操作(不回落默认 ./data)"
|
||
return 1
|
||
}
|
||
if [ "$DB_MODE" = mock ]; then
|
||
c_err "[$ENV_NAME] mock 档不支持账户操作"
|
||
return 1
|
||
fi
|
||
if [ ! -x "${ENV_BIN:-}" ]; then
|
||
c_err "[$ENV_NAME] 实例二进制不存在:$ENV_BIN(先 deploy)"
|
||
return 1
|
||
fi
|
||
if [ "$DB_MODE" = pg ] && [ -z "${DB_DSN:-}" ] && [ "${DB_SOURCE:-}" != conffile ]; then
|
||
c_err "[$ENV_NAME] PostgreSQL 档缺少 DSN:拒绝账户操作"
|
||
return 1
|
||
fi
|
||
# 嵌入式档:数据文件必须已存在(OpenExisting);PG 档由 --open-existing 在后端核验。
|
||
if [ "$DB_MODE" != pg ]; then
|
||
if [ ! -d "$ENV_DATA" ] || [ ! -f "$ENV_DATA/agent-system.db" ]; then
|
||
c_err "[$ENV_NAME] 既有库不存在:$ENV_DATA/agent-system.db(OpenExisting:拒绝创建空库)"
|
||
return 1
|
||
fi
|
||
fi
|
||
local bin_args=(--open-existing --data "$ENV_DATA")
|
||
if [ "$DB_MODE" = pg ] && [ -n "${DB_DSN:-}" ]; then
|
||
bin_args+=(--dsn "$DB_DSN")
|
||
fi
|
||
c_info "[$ENV_NAME] 账户 CLI → $ENV_BIN user $* (data=$ENV_DATA open-existing)"
|
||
# 子命令参数在前,绑定 flags 在后(Go flag 不区分顺序)。
|
||
AGENT_SYSTEM_DSN="${DB_DSN:-}" "$ENV_BIN" user "$@" "${bin_args[@]}"
|
||
}
|
||
|
||
# ---- agent:部署与启动 ----
|
||
|
||
# source_kind_prefix LABEL:取 source= 的种类前缀(dev@sha → dev;空 → 空)。
|
||
source_kind_prefix() {
|
||
case "$1" in
|
||
*@*) printf '%s' "${1%%@*}" ;;
|
||
*) printf '%s' "$1" ;;
|
||
esac
|
||
}
|
||
|
||
# require_allow_source_switch:同名 agent 换源种类(dev↔rdm↔release↔local)须显式确认;
|
||
# 同种类换 sha(含 update)不要求。与 service kind 冲突门禁正交。
|
||
require_allow_source_switch() {
|
||
local prev prev_kind new_kind
|
||
prev="$(svc_get source)"
|
||
[ -n "$prev" ] || return 0
|
||
prev_kind="$(source_kind_prefix "$prev")"
|
||
new_kind="$(source_kind_prefix "$SRC_LABEL")"
|
||
[ -n "$prev_kind" ] && [ -n "$new_kind" ] || return 0
|
||
[ "$prev_kind" = "$new_kind" ] && return 0
|
||
if [ "${CLI_ALLOW_SOURCE_SWITCH:-0}" = 1 ]; then
|
||
c_warn "[$ENV_NAME] 换源已确认:$prev → $SRC_LABEL(--allow-source-switch)"
|
||
return 0
|
||
fi
|
||
c_err "[$ENV_NAME] 换源种类 $prev_kind → $new_kind($prev → $SRC_LABEL)需显式确认"
|
||
c_err " 加 --allow-source-switch 后重试(会改变环境是否自带 UI)"
|
||
return 1
|
||
}
|
||
|
||
rdm_companion_name() { printf '%sweb' "$1"; }
|
||
|
||
# http_code URL:返回 HTTP 状态码(连接失败 → 000)。
|
||
http_code() {
|
||
curl -s -o /dev/null -m 2 -w '%{http_code}' "$1" 2>/dev/null || printf '000'
|
||
}
|
||
|
||
# rdm_workbench_missing NAME:rdm@* agent 且伴生未登记或未运行 → 0(真)。
|
||
rdm_workbench_missing() {
|
||
local name="$1" src cname st
|
||
src="$(env_kv_of "$name" source)"
|
||
case "$src" in rdm@*) ;; *) return 1 ;; esac
|
||
cname="$(rdm_companion_name "$name")"
|
||
[ -f "$RUNTIME/$cname/svc.env" ] || return 0
|
||
st="$(svc_pid_state "$cname")"
|
||
[ "${st%% *}" = alive ] && return 1
|
||
return 0
|
||
}
|
||
|
||
# deploy_rdm_companion_ui:子进程部署 ${ENV_NAME}web --kind static --dir htdocs。
|
||
deploy_rdm_companion_ui() {
|
||
local cname="$1" htdocs="$2"
|
||
if ! valid_env "$cname"; then
|
||
c_err "[$ENV_NAME] 伴生名非法:$cname(服务名 ≤16 字符;ui-missing)"
|
||
return 1
|
||
fi
|
||
if [ ! -d "$htdocs" ] || [ ! -f "$htdocs/index.html" ]; then
|
||
c_err "[$ENV_NAME] RDM 工作台缺失:$htdocs(ui-missing)"
|
||
return 1
|
||
fi
|
||
c_info "[$ENV_NAME] 自动部署伴生工作台:$cname --kind static --dir $htdocs"
|
||
if ! "$0" "$cname" deploy --kind static --dir "$htdocs"; then
|
||
c_err "[$ENV_NAME] 伴生工作台部署失败(ui-missing)"
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# assert_ui_ready:按源种类断言页面入口 HTTP 200(与进程探活分层)。
|
||
# rdm@* → 伴生 ${name}web;dev@*/local@*/release@* → agent 自身 GET /。
|
||
assert_ui_ready() {
|
||
local src="${SRC_LABEL:-$(svc_get source)}"
|
||
local code url host port addr cname probe
|
||
case "$src" in
|
||
rdm@*)
|
||
cname="$(rdm_companion_name "$ENV_NAME")"
|
||
addr="$(env_kv_of "$cname" addr)"
|
||
if [ -z "$addr" ]; then
|
||
c_err "[$ENV_NAME] UI 探活失败:伴生 $cname 未登记(ui-missing)"
|
||
return 1
|
||
fi
|
||
host="${addr%:*}"; port="${addr##*:}"
|
||
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
|
||
url="http://$host:$port/"
|
||
code="$(http_code "$url")"
|
||
if [ "$code" != 200 ]; then
|
||
c_err "[$ENV_NAME] UI 探活失败:伴生 $url → HTTP $code(期望 200;ui-missing)"
|
||
return 1
|
||
fi
|
||
c_ok "[$ENV_NAME] UI 就绪:伴生 $cname → $url"
|
||
return 0
|
||
;;
|
||
dev@*|local@*|release@*)
|
||
probe="${ENV_ADDR}"
|
||
case "$(svc_get exec)" in *--addr\ *)
|
||
probe="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" ;;
|
||
esac
|
||
[ -n "$probe" ] || probe="$ENV_ADDR"
|
||
host="${probe%:*}"; port="${probe##*:}"
|
||
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
|
||
url="http://$host:$port/"
|
||
code="$(http_code "$url")"
|
||
if [ "$code" != 200 ]; then
|
||
c_err "[$ENV_NAME] UI 探活失败:$url → HTTP $code(期望 200;ui-missing)"
|
||
return 1
|
||
fi
|
||
c_ok "[$ENV_NAME] UI 就绪:$url"
|
||
return 0
|
||
;;
|
||
*)
|
||
return 0
|
||
;;
|
||
esac
|
||
}
|
||
|
||
agent_do_deploy() {
|
||
resolve_source
|
||
if ! require_allow_source_switch; then
|
||
hist_event deploy fail "$(read_pid)" "换源种类需 --allow-source-switch($SRC_LABEL)"
|
||
return 1
|
||
fi
|
||
if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi
|
||
# 停旧之前的静态配置验证:数据库配置可解析(绑定有效/无冲突)、conffile 存在、
|
||
# 现产物承接能力——配置性问题不制造停机窗口(登记保持可恢复,失败直接返回)。
|
||
if ! agent_static_preflight; then
|
||
hist_event deploy fail "$(read_pid)" "预检拒绝:数据库配置/配置文件无效(登记未变更)"
|
||
return 1
|
||
fi
|
||
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
|
||
# U1/U2(构建前置):先构建/物化候选并过身份门禁,再停旧服务——构建失败或候选
|
||
# 不合格时旧服务继续运行、旧制品与登记不动(2026-10-12 rdm1/rdmcand 事故:停服
|
||
# →覆盖→启动即拒,只留「登记版本≠磁盘制品」的混合状态且无恢复路径)。
|
||
local wt RDM_UI_OK=0
|
||
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; }
|
||
svc_deploy_identity_gate "$BIN" \
|
||
|| { hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"; return 1; }
|
||
do_stop_quiet
|
||
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; }
|
||
if ! svc_deploy_identity_gate "$wt/bin/agent-system"; then
|
||
hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"
|
||
recycle_worktree "$SRC_SHA"
|
||
return 1
|
||
fi
|
||
do_stop_quiet
|
||
install_artifacts "$wt/bin/agent-system" "$wt/plugins" \
|
||
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
|
||
recycle_worktree "$SRC_SHA"
|
||
;;
|
||
release)
|
||
local relbin="$REL_ROOT/build/$REL_VERSION/agent-system"
|
||
if [ ! -f "$relbin" ]; then
|
||
c_err "[$ENV_NAME] Releases 仓无制品:$relbin"
|
||
c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)"
|
||
hist_event deploy fail "$(read_pid)" "制品缺失($SRC_LABEL)"
|
||
return 1
|
||
fi
|
||
svc_deploy_identity_gate "$relbin" \
|
||
|| { hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"; return 1; }
|
||
do_stop_quiet
|
||
materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; }
|
||
;;
|
||
rdm)
|
||
wt="$(materialize_rdm_commit "$SRC_FULL" "$SRC_SHA")" \
|
||
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
|
||
# RDM 组合插件编译进二进制(provides 无声明式资产)——插件目录按空集安装。
|
||
mkdir -p "$wt/.svc-empty-plugins"
|
||
if ! svc_deploy_identity_gate "$wt/bin/agent-system-rdm"; then
|
||
hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"
|
||
recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR"
|
||
return 1
|
||
fi
|
||
do_stop_quiet
|
||
install_artifacts "$wt/bin/agent-system-rdm" "$wt/.svc-empty-plugins" \
|
||
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
|
||
# RDM 自带零构建工作台(web/)落 htdocs/;收尾自动部署伴生 ${name}web。
|
||
if [ -d "$wt/web" ]; then
|
||
rm -rf "$ENV_DIR/htdocs"
|
||
if cp -r "$wt/web" "$ENV_DIR/htdocs"; then
|
||
RDM_UI_OK=1
|
||
c_ok "[$ENV_NAME] RDM 工作台就位:$ENV_DIR/htdocs(将自动部署伴生 $(rdm_companion_name "$ENV_NAME"))"
|
||
else
|
||
c_warn "[$ENV_NAME] RDM web/ 工作台复制失败"
|
||
fi
|
||
else
|
||
c_warn "[$ENV_NAME] RDM 源无 web/ 工作台目录"
|
||
fi
|
||
recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR"
|
||
;;
|
||
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; }
|
||
case "$SRC_KINDSRC" in
|
||
rdm)
|
||
if [ "$RDM_UI_OK" != 1 ]; then
|
||
c_err "[$ENV_NAME] RDM 工作台未就位(ui-missing):缺少可伺服的 htdocs/"
|
||
hist_event deploy fail "$(read_pid)" "ui-missing(htdocs 未就位)"
|
||
return 1
|
||
fi
|
||
if ! deploy_rdm_companion_ui "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR/htdocs"; then
|
||
hist_event deploy fail "$(read_pid)" "ui-missing(伴生部署失败)"
|
||
return 1
|
||
fi
|
||
;;
|
||
esac
|
||
if ! assert_ui_ready; then
|
||
hist_event deploy fail "$(read_pid)" "ui-missing"
|
||
return 1
|
||
fi
|
||
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 ;;
|
||
rdm)
|
||
printf ' 来源:%s(完整提交 %s;独立 RDM 源仓 %s)\n' "$SRC_LABEL" "$SRC_FULL" "$RDM_SRC_DIR"
|
||
printf ' 物化:worktree %s(pins submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/rdm-$SRC_SHA"
|
||
printf ' 产物:%s ← bin/agent-system-rdm + 空插件目录(组合插件编译进二进制)\n' "$ENV_BIN"
|
||
printf ' 工作台:web/ → %s/htdocs;自动伴生:scripts/svc.sh %s deploy --kind static --dir 该目录\n' \
|
||
"$ENV_DIR" "$(rdm_companion_name "$ENV_NAME")" ;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入 settings(幂等)。
|
||
# 认证开启(PG 档默认)时 PUT /api/settings/user 同样要求凭证:自动铸一把临时
|
||
# API key(name=svc-seed)完成写入后立即回收;owner 缺省取库内首个管理员(回落
|
||
# 首个账号),--seed-owner / SVC_SEED_OWNER 可指定。铸 key 失败维持无头 PUT(旧行为)。
|
||
seed_settings() {
|
||
local host="$1" port="$2"
|
||
local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}"
|
||
local lb="${CLI_LBASE:-${SVC_LIGHT_BASE:-}}" lk="${CLI_LKEY:-${SVC_LIGHT_KEY:-}}" ln="${CLI_LNAME:-${SVC_LIGHT_NAME:-}}"
|
||
local tk="${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}"
|
||
[ -z "$cb$ck$cn$lb$lk$ln$tk" ] && return 0
|
||
command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
|
||
local payload
|
||
payload="$(python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" <<'PY'
|
||
import json, sys
|
||
cb, ck, cn, lb, lk, ln, tk = sys.argv[1:8]
|
||
out = {}
|
||
def sub(base, key, name):
|
||
d = {}
|
||
if base: d["baseUrl"] = base
|
||
if key: d["apiKey"] = key
|
||
if name: d["model"] = name
|
||
return d
|
||
chat, light = sub(cb, ck, cn), sub(lb, lk, ln)
|
||
models = {}
|
||
if chat: models["chat"] = chat
|
||
if light: models["lightweight"] = light
|
||
if models: out["models"] = models
|
||
if tk: out["tools"] = {"tavily": {"apiKeys": tk}}
|
||
print(json.dumps(out))
|
||
PY
|
||
)"
|
||
# 认证开启时的播种凭证:临时 key,明文只存在于本函数局部变量,不回显、不落盘。
|
||
# apikey 子命令不读环境 DSN,只能经 CLI 传递(进程列表短暂可见;serve 走
|
||
# AGENT_SYSTEM_DSN 环境注入,此路对 CLI 子命令不可用)。
|
||
local seed_key="" seed_owner="${CLI_SEED_OWNER:-${SVC_SEED_OWNER:-}}"
|
||
local -a seed_hdr=() seed_dbf=()
|
||
if db_auth_enabled; then
|
||
if [ "$DB_MODE" = pg ]; then seed_dbf=(--dsn "$DB_DSN"); else seed_dbf=(--data "$ENV_DATA"); fi
|
||
if [ -z "$seed_owner" ]; then
|
||
seed_owner="$("$ENV_BIN" user list ${seed_dbf[@]+"${seed_dbf[@]}"} 2>/dev/null | python3 -c 'import json, sys
|
||
try:
|
||
users = json.load(sys.stdin)
|
||
except Exception:
|
||
sys.exit(0)
|
||
for u in users:
|
||
if u.get("isAdmin"):
|
||
print(u["owner"]); break
|
||
else:
|
||
for u in users:
|
||
print(u["owner"]); break' 2>/dev/null || true)"
|
||
fi
|
||
if [ -n "$seed_owner" ]; then
|
||
seed_key="$("$ENV_BIN" apikey create ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-seed 2>/dev/null || true)"
|
||
[ -n "$seed_key" ] && seed_hdr=(-H "Authorization: Bearer $seed_key")
|
||
fi
|
||
fi
|
||
local resp
|
||
resp="$(curl -s -m 5 -X PUT "http://$host:$port/api/settings/user" -H 'Content-Type: application/json' ${seed_hdr[@]+"${seed_hdr[@]}"} -d "$payload")"
|
||
# 用后即回收;失败只提示吊销途径,不回显任何密钥材料。
|
||
if [ -n "$seed_key" ]; then
|
||
"$ENV_BIN" apikey revoke ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-seed >/dev/null 2>&1 \
|
||
|| c_warn "[$ENV_NAME] 临时播种密钥回收失败:请手动吊销 owner=$seed_owner name=svc-seed"
|
||
fi
|
||
if echo "$resp" | grep -q '"ok": *true'; then
|
||
local did=""
|
||
[ -n "$cb$ck$cn" ] && did="主模型"
|
||
[ -n "$lb$lk$ln" ] && did="${did:+$did、}轻量模型"
|
||
[ -n "$tk" ] && did="${did:+$did、}Tavily"
|
||
c_ok "[$ENV_NAME] 已播种设置:$did(持久化于 $ENV_DATA,后续启动无需再传)"
|
||
else
|
||
c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(认证开启时需库内已有账号以铸临时密钥,或用 --seed-owner 指定;也可在设置页手动配置)"
|
||
fi
|
||
}
|
||
|
||
# dsn_from_dbref DBREF:由 dbref(pg实例/库名)从该实例登记重建连接串。
|
||
# 实例未登记 / 非 postgres / 缺 addr·user·password 时返回非零且不输出。
|
||
dsn_from_dbref() {
|
||
local dbref="$1" inst tdb taddr tuser tpass
|
||
inst="${dbref%%/*}"; tdb="${dbref#*/}"
|
||
{ [ -n "$inst" ] && [ -n "$tdb" ] && [ "$inst" != "$dbref" ]; } || return 1
|
||
[ -f "$RUNTIME/$inst/svc.env" ] || return 1
|
||
[ "$(svc_kind "$inst")" = postgres ] || return 1
|
||
taddr="$(env_kv_of "$inst" addr)"
|
||
tuser="$(env_kv_of "$inst" user)"
|
||
tpass="$(env_kv_of "$inst" password)"
|
||
{ [ -n "$taddr" ] && [ -n "$tuser" ] && [ -n "$tpass" ]; } || return 1
|
||
printf 'postgresql://%s:%s@%s:%s/%s' "$tuser" "$tpass" "${taddr%:*}" "${taddr##*:}" "$tdb"
|
||
}
|
||
|
||
# addr_is_loopback HOST:回环写法白名单判定(127.x / ::1 / localhost)。
|
||
# 只服务启动预检的快速拦截:其余形态一律按非回环处理,权威判定在二进制 listen guard。
|
||
addr_is_loopback() {
|
||
case "$1" in
|
||
localhost|127.*|::1|[::1]) return 0 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
# dsn_target DSN → host:port/db(配置一致性比较用;解析失败回空串)。
|
||
dsn_target() {
|
||
local t="${1%%\?*}"
|
||
case "$t" in *://*) t="${t#*://}" ;; esac
|
||
case "$t" in *@*) t="${t#*@}" ;; esac
|
||
local hostport="${t%%/*}" db="${t#*/}"
|
||
[ "$db" = "$t" ] && db=""
|
||
printf '%s/%s' "$hostport" "$db"
|
||
}
|
||
|
||
# dsn_ref_match DSN:DSN 的 host:port/db 命中已登记 postgres 实例时回显 <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
|
||
# 无 config explain 子命令的组合二进制(下游仓产物):静态证据兜底——ELF 二进制内
|
||
# 引用 AGENT_SYSTEM_DSN 即视为消费环境层 DSN(下游经基座 extension.Main 的 env
|
||
# 回退生效)。仅对 ELF 生效:脚本文本必然含该字符串字样,会把「不支持」误判为
|
||
# 「支持」(回归教训:test_svc 的 legacy 桩即脚本形态)。
|
||
[ "$(head -c 4 "$1" 2>/dev/null)" = $'\x7fELF' ] || return 1
|
||
command -v strings >/dev/null 2>&1 || return 1
|
||
strings "$1" 2>/dev/null | grep -q 'AGENT_SYSTEM_DSN' && return 0
|
||
return 1
|
||
}
|
||
|
||
# agent_static_preflight:停止旧进程 / 更换产物之前可静态检查的配置验证——数据库配置
|
||
# 可解析(含绑定有效性与冲突)、conffile 存在、现产物能承接环境 DSN。失败返回 1(输出
|
||
# 脱敏原因;除存量迁移外不写登记)。
|
||
agent_static_preflight() {
|
||
resolve_db_config || return 1
|
||
local cf
|
||
cf="$(svc_get conffile)"
|
||
if [ -n "$cf" ] && [ ! -f "$cf" ]; then
|
||
c_err "[$ENV_NAME] 拒绝启动:配置文件不存在:$cf(恢复文件或 config --clear)"
|
||
return 1
|
||
fi
|
||
if [ "$DB_MODE" = pg ] && [ "$DB_SOURCE" != conffile ] && [ -x "$ENV_BIN" ] \
|
||
&& ! bin_env_dsn_supported "$ENV_BIN"; then
|
||
c_err "[$ENV_NAME] 拒绝启动:当前产物不消费环境变量 DSN(AGENT_SYSTEM_DSN),无法按绑定非降级交付数据库配置"
|
||
c_err " 更新产物(deploy 新版本),或改用携带 dsn 的配置文件(scripts/svc.sh $ENV_NAME config <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[@<版本>]|rdm[@<sha>]]"
|
||
return 1
|
||
fi
|
||
# timeout 护栏:组合二进制若不识别子命令可能直接进入服务态阻塞探测(旧产物形态)。
|
||
SVC_VERSION="$(timeout 5 "$ENV_BIN" version 2>/dev/null || true)"
|
||
# 统一解析数据库配置(复用与重建两路共用;失败拒绝启动,绝不静默回退 SQLite)。
|
||
# 2026-10-11 修复:复用 exec= 分支此前不查 dbref——G1-7 迁移把 --dsn 剥入环境后
|
||
# record_state 写回不含 DSN 的 exec=,重新绑定后的第二次裸 restart 即丢库。
|
||
if ! resolve_db_config; then
|
||
hist_event start fail "" "数据库配置解析拒绝(绑定失效/配置冲突)"
|
||
return 1
|
||
fi
|
||
SVC_DB_MODE="$DB_MODE"; SVC_DB_SOURCE="$DB_SOURCE"; SVC_DB_REF="$DB_REF"; SVC_DB_DSN_SAVE="$DB_DSN_SAVE"
|
||
# 组装启动参数:显式配置 → 重建;否则复用上次持久化 exec=(DSN 已由解析迁移出 exec)。
|
||
local exec_args
|
||
if ! agent_has_explicit_cfg && [ -n "$(svc_get exec)" ]; then
|
||
exec_args="$(svc_get exec)"
|
||
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")
|
||
# U1 身份校验:文件 marker + DB verify(不一致 fail-closed;ensure 只在领养与
|
||
# 显式 init,普通启动不补行——缺行即事件,见 svc_manifest_verify)。
|
||
svc_manifest_verify || { hist_event start fail "" "manifest 身份校验失败"; return 1; }
|
||
# 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作——
|
||
# 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。
|
||
# 认证判定依据统一解析的最终有效 DSN 与 auth(含环境变量与配置文件来源),
|
||
# 与二进制 AuthEnabled 口径一致(2026-10-11 修复:此前只看命令行 --dsn,
|
||
# DSN 迁入环境变量后 auto 被误判为未开认证;PR #317 同因,本方案为其超集)。
|
||
if agent_listen_rejected "$exec_args" "$ENV_ADDR" "$insecure"; then
|
||
local pf_addr; pf_addr="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')"
|
||
pf_addr="${pf_addr:-$ENV_ADDR}"
|
||
c_err "[$ENV_NAME] 拒绝启动:非回环地址 $pf_addr 且认证判定为关(auto 无 DSN 即关)。"
|
||
c_err " 任选其一:--addr 127.0.0.1:${pf_addr##*:};数据库绑定 scripts/svc.sh $ENV_NAME db <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
|
||
# U1 I3:把服务实例身份交给 serve 二次门禁(与 --instance 进程标识无关)。
|
||
if [ -n "${AGENT_SYSTEM_INSTANCE_ID:-}" ]; then
|
||
guard_env+=("AGENT_SYSTEM_INSTANCE_ID=$AGENT_SYSTEM_INSTANCE_ID")
|
||
guard_env+=("AGENT_SYSTEM_DBREF=${AGENT_SYSTEM_DBREF:-sqlite}")
|
||
fi
|
||
# 9>&-:显式关闭锁 fd——服务进程绝不能继承 .start.lock(长驻持有会令后续
|
||
# 生命周期操作在锁上自锁死等;2026-10-11 自恢复任务实测发现)。
|
||
svc_write_serve_env
|
||
svc_launch_serve "$exec_args"
|
||
echo $! > "$ENV_PID"
|
||
# 健康探测:取 exec 里的 --addr(复用持久化配置时可能与当前派生值不同)。
|
||
local probe="${ENV_ADDR}"
|
||
case "$exec_args" in *--addr\ *)
|
||
probe="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" ;;
|
||
esac
|
||
local phost="${probe%:*}"
|
||
[ "$phost" = "0.0.0.0" ] || [ "$phost" = "::" ] && phost="127.0.0.1"
|
||
for _ in $(seq 1 100); do
|
||
if curl -s -o /dev/null -m 2 "http://$phost:${probe##*:}/" 2>/dev/null; then
|
||
# 健康通过才写 svc.env——启动失败不再用新参数覆盖上次的可用配置
|
||
#(2026-10-05 dev 事故:失败后坏 exec= 持久化,裸 start 复用导致二次失败)。
|
||
# fresh:库由本次启动建立后再 ensure,升为 established。失败则停掉进程,
|
||
# 不把半套身份登记成已启动。
|
||
if [ "$(svc_db_marker 2>/dev/null || true)" = fresh ]; then
|
||
if ! svc_db_identity_ensure; then
|
||
kill "$(read_pid)" 2>/dev/null || true
|
||
c_err "[$ENV_NAME] 启动后建立 DB 身份失败,已停进程"
|
||
hist_event start fail "$(read_pid)" "fresh ensure 失败"
|
||
return 1
|
||
fi
|
||
fi
|
||
record_state
|
||
svc_mark_autostart
|
||
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)"
|
||
hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}"
|
||
# rdm 组合产物无 user/apikey CLI 子命令:设置播种(--model-*/--tavily-keys)仅
|
||
# 基座产物支持;记录态 svc.env 的 source=rdm@* 跨裸 start/restart 稳定标识形态。
|
||
case "${SVC_SOURCE:-}" in
|
||
rdm@*) ;;
|
||
*) seed_settings "$phost" "${probe##*:}" ;;
|
||
esac
|
||
return 0
|
||
fi
|
||
pid_alive "$(read_pid)" || {
|
||
c_err "[$ENV_NAME] 进程启动后即退出(登记未变更,svc.env 保留上次 exec=),查看日志:tail -40 $ENV_DIR/svc.log"
|
||
hist_event start fail "$(read_pid)" "进程即退(日志 svc.log)"
|
||
return 1
|
||
}
|
||
sleep 0.2
|
||
done
|
||
# 进程存活但未响应:按实际运行参数登记(进程确以新参数在跑,登记与现实一致)。
|
||
record_state
|
||
svc_mark_autostart
|
||
c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_DIR/svc.log"
|
||
hist_event start fail "$(read_pid)" "健康检查超时"
|
||
return 1
|
||
}
|
||
|
||
# ---- static:前端静态服务 ----
|
||
|
||
static_do_deploy() {
|
||
# --dir:登记既有目录(不构建);否则 dev@sha/local 构建 frontend dist → htdocs/。
|
||
if [ -n "$CLI_DIR" ]; then
|
||
[ -d "$CLI_DIR" ] || { c_err "[$ENV_NAME] --dir 目录不存在:$CLI_DIR"; return 1; }
|
||
resolve_dir_deploy
|
||
return 0
|
||
fi
|
||
resolve_source
|
||
if [ "$CLI_DRYRUN" = 1 ]; then print_static_plan; return 0; fi
|
||
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
|
||
do_stop_quiet
|
||
local src_dist wt
|
||
case "$SRC_KINDSRC" in
|
||
local)
|
||
c_info "[$ENV_NAME] 构建当前工作区前端(make frontend)——写主检出构建产物(gitignore 目录)"
|
||
make -C "$ROOT" frontend || { hist_event deploy fail "$(read_pid)" "make frontend 失败"; return 1; }
|
||
src_dist="$ROOT/modules/frontend/dist"
|
||
;;
|
||
dev)
|
||
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" frontend)" \
|
||
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
|
||
src_dist="$wt/modules/frontend/dist"
|
||
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
|
||
;;
|
||
rdm)
|
||
c_err "[$ENV_NAME] rdm 源不支持 static 构建(RDM 工作台为 agent 环境内 htdocs/ 的零构建资产)"
|
||
c_err " 用法:先 <agent环境> deploy --from rdm,再 scripts/svc.sh <name> deploy --kind static --dir <agent环境>/htdocs"
|
||
hist_event deploy fail "$(read_pid)" "rdm 源不支持 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 覆写)。
|
||
# 同步写入 pidcheck,避免首启在 record_state 前 pid_alive 误回退到 agent-system。
|
||
write_static_reg() {
|
||
local htdocs="$1"
|
||
local old_pid=""; [ -f "$ENV_PID" ] && old_pid="$(read_pid)"
|
||
local merged_tags; merged_tags="$(svc_merge_tags)" # 先读旧文件再截断
|
||
mkdir -p "$ENV_DIR"
|
||
SVC_PIDCHECK="--directory $htdocs"
|
||
{
|
||
printf 'kind=static\n'
|
||
printf 'env=%s\n' "$ENV_NAME"
|
||
printf 'pid=%s\n' "${old_pid:-}"
|
||
printf 'addr=%s\n' "$ENV_ADDR"
|
||
printf 'tags=%s\n' "$merged_tags"
|
||
printf 'source=%s\n' "$SRC_LABEL"
|
||
printf 'started=%s\n' "$(date -Is)"
|
||
printf 'htdocs=%s\n' "$htdocs"
|
||
printf 'pidcheck=%s\n' "$SVC_PIDCHECK"
|
||
} > "$ENV_DIR/svc.env"
|
||
chmod 600 "$ENV_DIR/svc.env"
|
||
}
|
||
|
||
print_static_plan() {
|
||
c_info "[$ENV_NAME] 部署计划(dry-run,不执行)"
|
||
printf ' 服务:%s(kind=static)→ %s(addr %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR"
|
||
if [ -n "$CLI_DIR" ]; then
|
||
printf ' 目录:%s(--dir 直接服务,不构建)\n' "$CLI_DIR"
|
||
else
|
||
case "$SRC_KINDSRC" in
|
||
local) printf ' 来源:%s(make frontend → htdocs/)\n' "$SRC_LABEL" ;;
|
||
dev) printf ' 来源:%s(完整提交 %s;独立部署源仓 %s)\n 物化:worktree %s(make frontend → htdocs/)\n' \
|
||
"$SRC_LABEL" "$SRC_FULL" "$SRC_DIR" "$RUNTIME/.worktrees/$SRC_SHA" ;;
|
||
release) printf ' 来源:release —— 不支持(制品内嵌前端);请用 dev|local 或 --dir\n' ;;
|
||
esac
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
static_do_start() {
|
||
mkdir -p "$ENV_DIR"
|
||
check_port_free || return 1
|
||
command -v python3 >/dev/null 2>&1 || { c_err "[$ENV_NAME] static 需要 python3"; return 1; }
|
||
local htdocs; htdocs="$(svc_get htdocs)"
|
||
[ -n "$htdocs" ] || htdocs="$ENV_DIR/htdocs"
|
||
[ -d "$htdocs" ] || { c_err "[$ENV_NAME] 静态目录缺失:$htdocs(先 deploy 或 --dir)"; return 1; }
|
||
local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}"
|
||
SVC_EXEC="--directory $htdocs"
|
||
SVC_PIDCHECK="--directory $htdocs"
|
||
SVC_VERSION=""
|
||
c_info "[$ENV_NAME] 启动静态服务:python3 http.server $port --bind $host --directory $htdocs"
|
||
nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 9>&- &
|
||
echo $! > "$ENV_PID"
|
||
local probe_host="$host" code
|
||
[ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1"
|
||
for _ in $(seq 1 50); do
|
||
code="$(http_code "http://$probe_host:$port/")"
|
||
if [ "$code" = 200 ]; then
|
||
record_state "htdocs=$htdocs" # 健康通过才写 svc.env(与 agent 路径同规)
|
||
svc_mark_autostart
|
||
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$probe_host:$port/(目录 $htdocs)"
|
||
hist_event start ok "$(read_pid)" "http://$probe_host:$port/"
|
||
return 0
|
||
fi
|
||
pid_alive "$(read_pid)" || {
|
||
c_err "[$ENV_NAME] 进程启动后即退出(登记未变更,svc.env 保留上次配置),查看日志:tail -40 $ENV_DIR/svc.log"
|
||
hist_event start fail "$(read_pid)" "进程即退"
|
||
return 1
|
||
}
|
||
sleep 0.2
|
||
done
|
||
record_state "htdocs=$htdocs"
|
||
svc_mark_autostart
|
||
c_err "[$ENV_NAME] 10s 健康检查未通过(末次 HTTP ${code:-?})"
|
||
hist_event start fail "$(read_pid)" "健康检查超时"
|
||
return 1
|
||
}
|
||
|
||
# ---- postgres:数据库服务 ----
|
||
|
||
pg_bindir() {
|
||
if [ -n "${SVC_PG_BIN:-}" ] && [ -x "${SVC_PG_BIN}/initdb" ]; then printf '%s' "$SVC_PG_BIN"; return 0; fi
|
||
local d
|
||
d="$(pg_config --bindir 2>/dev/null || true)"
|
||
[ -n "$d" ] && [ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; }
|
||
for d in /usr/lib/postgresql/*/bin /usr/local/pgsql/bin; do
|
||
[ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; }
|
||
done
|
||
return 1
|
||
}
|
||
|
||
cmd_db() {
|
||
local sub="${1:-}"
|
||
[ $# -gt 0 ] && shift
|
||
case "$sub" in
|
||
create) db_create "$@" ;;
|
||
dsn) db_dsn "$@" ;;
|
||
backup) db_backup "$@" ;;
|
||
backups) db_backups "$@" ;;
|
||
restore) db_restore "$@" ;;
|
||
clone) db_clone "$@" ;;
|
||
copy) db_copy "$@" ;;
|
||
ls) db_ls "$@" ;;
|
||
set) cmd_set "$@" ;;
|
||
*) c_err "用法:scripts/svc.sh db create|dsn|backup|backups|restore|clone|copy|ls|set <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 9>&-; then
|
||
c_err "[$ENV_NAME] pg_ctl start 失败,查看日志:tail -40 $ENV_DIR/pg.log"
|
||
hist_event start fail "" "pg_ctl start 失败"
|
||
return 1
|
||
fi
|
||
local pid; pid="$(head -1 "$pgdata/postmaster.pid" 2>/dev/null || true)"
|
||
[ -n "$pid" ] && echo "$pid" > "$ENV_PID"
|
||
SVC_PIDCHECK="$pgdata"
|
||
SVC_EXEC="pg_ctl -D $pgdata -p ${ENV_ADDR##*:}"
|
||
SVC_VERSION="$(svc_get version)"
|
||
# 首次启动:建默认库(已存在则跳过)。
|
||
local dbname; dbname="$(svc_get dbname)"
|
||
if [ -n "$dbname" ]; then
|
||
if ! pg_psql "SELECT 1 FROM pg_database WHERE datname='$dbname'" tA | grep -q 1; then
|
||
if pg_psql "CREATE DATABASE \"$dbname\""; then
|
||
c_ok "[$ENV_NAME] 已建库:$dbname"
|
||
else
|
||
c_warn "[$ENV_NAME] 建库 $dbname 未成功(可手动创建)"
|
||
fi
|
||
fi
|
||
fi
|
||
record_state "user=$(svc_get user)" "password=$(svc_get password)" "dbname=$(svc_get dbname)" \
|
||
"authmode=$(svc_get authmode)" "pgdata=$pgdata"
|
||
svc_mark_autostart
|
||
if "$pgbin/pg_isready" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" >/dev/null 2>&1; then
|
||
c_ok "[$ENV_NAME] postgres 就绪 pid=$(read_pid) → $ENV_ADDR(库 $(svc_get dbname),用户 $(svc_get user))"
|
||
hist_event start ok "$(read_pid)" "pg_isready ok"
|
||
return 0
|
||
fi
|
||
c_warn "[$ENV_NAME] pg_isready 未通过(进程已起,查看 $ENV_DIR/pg.log)"
|
||
hist_event start warn "$(read_pid)" "进程起但 pg_isready 未过"
|
||
return 0
|
||
}
|
||
|
||
pg_do_stop() {
|
||
local pgbin pgdata pid
|
||
pgbin="$(pg_bindir)" || { c_err "[$ENV_NAME] 找不到 postgres 工具链"; return 1; }
|
||
pgdata="$(svc_get pgdata)"
|
||
pid="$(read_pid)"
|
||
if [ -z "$pid" ] || ! pid_alive "$pid"; then
|
||
c_info "[$ENV_NAME] postgres 未在运行"
|
||
rm -f "$ENV_PID"
|
||
svc_clear_autostart
|
||
return 0
|
||
fi
|
||
c_info "[$ENV_NAME] 停止 postgres pid=$pid(pg_ctl -m fast,优雅回写)"
|
||
if [ -n "$pgdata" ] && "$pgbin/pg_ctl" -D "$pgdata" -m fast -w -t 30 stop >/dev/null 2>&1; then
|
||
rm -f "$ENV_PID"
|
||
svc_clear_autostart
|
||
c_ok "[$ENV_NAME] postgres 已停止"
|
||
hist_event stop ok "$pid" "pg_ctl fast"
|
||
return 0
|
||
fi
|
||
c_warn "[$ENV_NAME] pg_ctl stop 失败,尝试 SIGTERM(绝不 SIGKILL postmaster)"
|
||
kill -TERM "$pid" 2>/dev/null || true
|
||
for _ in $(seq 1 100); do
|
||
pid_alive "$pid" || { rm -f "$ENV_PID"; svc_clear_autostart; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; }
|
||
sleep 0.1
|
||
done
|
||
c_err "[$ENV_NAME] postgres 未能停止(保留现场;查 $pgdata/postmaster.pid 与 $ENV_DIR/pg.log)"
|
||
hist_event stop fail "$pid" "未能停止"
|
||
return 1
|
||
}
|
||
|
||
db_dsn() {
|
||
local name="${1:-}"
|
||
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db dsn <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 的单键。manifest 治理下经 manifest
|
||
# 整份提交(原子 + revision);legacy 路径保持先读后写 + 临时文件原子替换。
|
||
svc_set_key() {
|
||
local key="$1" val="$2" rest tmp
|
||
if manifest_ok; then
|
||
python3 "$MANIFEST_PY" "$ENV_DIR" set "$key" "$val" && return 0
|
||
c_warn "[$ENV_NAME] manifest set 失败,回退 legacy 单键写"
|
||
fi
|
||
[ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; }
|
||
rest="$(grep -v "^$key=" "$ENV_DIR/svc.env" || true)"
|
||
tmp="$ENV_DIR/svc.env.tmp.$$"
|
||
{ [ -n "$rest" ] && printf '%s\n' "$rest"; printf '%s=%s\n' "$key" "$val"; } > "$tmp"
|
||
chmod 600 "$tmp"
|
||
mv -f "$tmp" "$ENV_DIR/svc.env"
|
||
return 0
|
||
}
|
||
|
||
# svc_del_key KEY:从当前服务 svc.env 删除单键(键不存在则无操作)。manifest 治理下
|
||
# 同样经 manifest 整份提交。
|
||
svc_del_key() {
|
||
local key="$1"
|
||
if manifest_ok; then
|
||
python3 "$MANIFEST_PY" "$ENV_DIR" del "$key" && return 0
|
||
c_warn "[$ENV_NAME] manifest del 失败,回退 legacy 单键写"
|
||
fi
|
||
[ -f "$ENV_DIR/svc.env" ] || return 0
|
||
{ grep -v "^$key=" "$ENV_DIR/svc.env" || true; } > "$ENV_DIR/svc.env.tmp"
|
||
mv "$ENV_DIR/svc.env.tmp" "$ENV_DIR/svc.env"
|
||
chmod 600 "$ENV_DIR/svc.env"
|
||
return 0
|
||
}
|
||
|
||
# 显式参数版 psql/dump/restore:clone/copy 需要同时操作两个实例,不依赖全局 ENV_*。
|
||
pg_run() { # HOST PORT USER PASS DB SQL [tA]
|
||
local host="$1" port="$2" user="$3" pass="$4" db="$5" sql="$6" ta="${7:-}" pgbin
|
||
pgbin="$(pg_bindir)"
|
||
PGPASSWORD="$pass" "$pgbin/psql" -h "$host" -p "$port" -U "$user" -d "$db" ${ta:+-tA} -c "$sql" 2>/dev/null
|
||
}
|
||
|
||
pg_dump_run() { # HOST PORT USER PASS DB OUT
|
||
local host="$1" port="$2" user="$3" pass="$4" db="$5" out="$6" pgbin
|
||
pgbin="$(pg_bindir)"
|
||
PGPASSWORD="$pass" "$pgbin/pg_dump" -h "$host" -p "$port" -U "$user" -d "$db" -Fc -f "$out"
|
||
}
|
||
|
||
pg_restore_list() { # FILE:exit 0 = 合法 pg_dump 归档
|
||
"$(pg_bindir)/pg_restore" --list "$1" >/dev/null 2>&1
|
||
}
|
||
|
||
pg_restore_run() { # HOST PORT USER PASS DB FILE:出错回显 stderr 尾部
|
||
local host="$1" port="$2" user="$3" pass="$4" db="$5" file="$6" pgbin err
|
||
pgbin="$(pg_bindir)"
|
||
if ! err="$(PGPASSWORD="$pass" "$pgbin/pg_restore" -h "$host" -p "$port" -U "$user" -d "$db" \
|
||
--no-owner --no-privileges "$file" 2>&1)"; then
|
||
printf '%s\n' "$err" | tail -5 >&2
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# copy_database SH SP SU SPASS SDB DH DP DU DPASS DDB [FORCE]:单库逻辑复制
|
||
# (dump → 建库/覆盖 → restore);目标库非空且无 FORCE 时拒绝。返回 0 = 成功。
|
||
copy_database() {
|
||
local sh="$1" sp="$2" su="$3" spass="$4" sdb="$5"
|
||
local dh="$6" dp="$7" du="$8" dpass="$9" ddb="${10}" force="${11:-0}"
|
||
local tmp exists objects
|
||
tmp="$(mktemp /tmp/svc-copy-XXXXXXXX.dump)"
|
||
if ! pg_dump_run "$sh" "$sp" "$su" "$spass" "$sdb" "$tmp"; then
|
||
rm -f "$tmp"; c_err "pg_dump $sdb 失败(源 $sh:$sp)"; return 1
|
||
fi
|
||
if ! pg_restore_list "$tmp"; then
|
||
rm -f "$tmp"; c_err "备份归档自检失败"; return 1
|
||
fi
|
||
exists="$(pg_run "$dh" "$dp" "$du" "$dpass" postgres "SELECT 1 FROM pg_database WHERE datname='$ddb'" tA || true)"
|
||
if [ -n "$exists" ]; then
|
||
objects="$(pg_run "$dh" "$dp" "$du" "$dpass" "$ddb" "SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')" tA || true)"
|
||
case "$objects" in ''|*[!0-9]*) objects=0 ;; esac
|
||
if [ "$objects" -gt 0 ] && [ "$force" != 1 ]; then
|
||
rm -f "$tmp"
|
||
c_err "目标库 $ddb 已有 $objects 张表——拒绝覆盖(--force 覆盖)"
|
||
return 1
|
||
fi
|
||
if [ "$force" = 1 ]; then
|
||
pg_run "$dh" "$dp" "$du" "$dpass" postgres "DROP DATABASE IF EXISTS \"$ddb\" WITH (FORCE)" >/dev/null \
|
||
|| { rm -f "$tmp"; c_err "drop $ddb 失败"; return 1; }
|
||
pg_run "$dh" "$dp" "$du" "$dpass" postgres "CREATE DATABASE \"$ddb\"" >/dev/null \
|
||
|| { rm -f "$tmp"; c_err "create $ddb 失败"; return 1; }
|
||
fi
|
||
else
|
||
pg_run "$dh" "$dp" "$du" "$dpass" postgres "CREATE DATABASE \"$ddb\"" >/dev/null \
|
||
|| { rm -f "$tmp"; c_err "create $ddb 失败"; return 1; }
|
||
fi
|
||
if ! pg_restore_run "$dh" "$dp" "$du" "$dpass" "$ddb" "$tmp"; then
|
||
rm -f "$tmp"; return 1
|
||
fi
|
||
rm -f "$tmp"
|
||
return 0
|
||
}
|
||
|
||
db_backup() {
|
||
local name="${1:-}"
|
||
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db backup <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
|
||
}
|
||
|
||
# ---- agent 数据目录快照(SQLite 一致性备份;2026-10-12)----
|
||
# 快照根在 runtime-backups/<name>/(runtime/ 之外):2026-10-11 runtime/ 被整体
|
||
# 误删的事故里,放在 runtime/<name>/backups/ 的任何东西都会一起消失。每个 *.db
|
||
# 经 sqlite3 .backup 导出一致性副本(并入 WAL 内容)并 integrity_check 自检,
|
||
# 不裸拷 .db(约束见 modules/documents/docs/proposals/
|
||
# 2026-10-12-service-restart-data-recovery.md §6);-wal/-shm 不入快照。
|
||
BACKUP_KEEP_DEFAULT=7
|
||
BACKUP_TS_GLOB='[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z'
|
||
|
||
agent_backup_root() { printf '%s/runtime-backups/%s' "$ROOT" "$ENV_NAME"; }
|
||
|
||
agent_backup_guard() { # 公共前置:agent 已登记(backups 列表也走这里)
|
||
if [ "$SVC_KIND" = postgres ]; then
|
||
c_err "[$ENV_NAME] postgres 用 db backup(pg_dump);backup 是 agent 数据目录快照"
|
||
exit 2
|
||
elif [ "$SVC_KIND" != agent ]; then
|
||
c_err "[$ENV_NAME] kind=$SVC_KIND 无数据目录,不适用 backup"
|
||
exit 2
|
||
fi
|
||
[ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 未登记(无 svc.env);先 deploy"; exit 2; }
|
||
}
|
||
|
||
agent_backup_snapshot() { # $1=dest:svc.env + history.jsonl + data/(*.db 一致性化)
|
||
local dest="$1" rel
|
||
cp -p "$ENV_DIR/svc.env" "$dest/svc.env" || return 1
|
||
[ -f "$ENV_DIR/history.jsonl" ] && cp -p "$ENV_DIR/history.jsonl" "$dest/history.jsonl"
|
||
mkdir -p "$dest/data" || return 1
|
||
# 先整树拷贝(非 db 内容的原样副本),再对 *.db 用一致性导出覆盖:cp 得到的
|
||
# 撕裂 db/-wal 副本在下一步被丢弃替换,不影响最终一致性。
|
||
cp -a "$ENV_DATA/." "$dest/data/" || return 1
|
||
while IFS= read -r rel; do
|
||
[ -f "$ENV_DATA/$rel" ] || continue
|
||
case "$rel" in
|
||
*"'"*|*'"'*)
|
||
if head -c 15 "$ENV_DATA/$rel" | grep -qa '^SQLite format 3$'; then
|
||
c_err "[$ENV_NAME] 路径含引号,无法走 sqlite 一致性导出:$rel"
|
||
return 1
|
||
fi
|
||
continue ;;
|
||
esac
|
||
if sqlite3 "$ENV_DATA/$rel" ".backup '$dest/data/$rel.bk'" 2>/dev/null \
|
||
&& [ "$(sqlite3 "$dest/data/$rel.bk" 'PRAGMA integrity_check;' 2>/dev/null)" = ok ]; then
|
||
mv -f "$dest/data/$rel.bk" "$dest/data/$rel" || return 1
|
||
chmod 600 "$dest/data/$rel"
|
||
rm -f "$dest/data/$rel-wal" "$dest/data/$rel-shm" # .backup 已并入 WAL 内容
|
||
else
|
||
rm -f "$dest/data/$rel.bk" # 失败尝试的半成品不留在快照里
|
||
if head -c 15 "$ENV_DATA/$rel" | grep -qa '^SQLite format 3$'; then
|
||
c_err "[$ENV_NAME] SQLite 一致性备份/自检失败:$rel"
|
||
return 1
|
||
fi
|
||
c_warn "[$ENV_NAME] $rel 不是 SQLite 库(无文件头),保留原样副本"
|
||
fi
|
||
done < <(cd "$dest/data" 2>/dev/null && find . -type f -name '*.db' | sort)
|
||
return 0
|
||
}
|
||
|
||
agent_backup_manifest() { # $1=dest:快照自描述(600)——逐文件 sha256 供恢复时校验
|
||
local dest="$1"
|
||
{
|
||
printf 'service=%s\nkind=%s\ncreated=%s\nsource=%s\nversion=%s\ndata_dir=%s\n' \
|
||
"$ENV_NAME" "$SVC_KIND" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||
"$(svc_get source)" "$(svc_get version)" "$ENV_DATA"
|
||
( cd "$dest" && find . -type f ! -name MANIFEST -print0 | sort -z | xargs -0 -r sha256sum )
|
||
} > "$dest/MANIFEST"
|
||
chmod 600 "$dest/MANIFEST"
|
||
}
|
||
|
||
agent_backup_prune() { # $1=root $2=keep:只清理符合时间戳命名的快照目录(最新在前保留 keep 份)
|
||
local root="$1" keep="$2" n=0 d
|
||
[ -d "$root" ] || return 0
|
||
while IFS= read -r d; do
|
||
n=$((n + 1))
|
||
[ "$n" -le "$keep" ] && continue
|
||
rm -rf "$root/$d"
|
||
c_info "[$ENV_NAME] 清理旧快照:$d(保留最近 $keep 份)"
|
||
done < <(cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB" | sort -r)
|
||
return 0
|
||
}
|
||
|
||
svc_agent_backup() { # [--out DIR] [--keep N]
|
||
local out="" keep="$BACKUP_KEEP_DEFAULT"
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--out) need_value "$@"; out="$2"; shift 2 ;;
|
||
--keep) need_value "$@"; keep="$2"; shift 2
|
||
case "$keep" in ''|*[!0-9]*) c_err "--keep 须为非负整数"; exit 2 ;; esac ;;
|
||
*) c_err "用法:scripts/svc.sh <name> backup [--out DIR] [--keep N](未知参数:$1)"; exit 2 ;;
|
||
esac
|
||
done
|
||
agent_backup_guard
|
||
[ -d "$ENV_DATA" ] || { c_err "[$ENV_NAME] 数据目录不存在:$ENV_DATA"; exit 2; }
|
||
command -v sqlite3 >/dev/null 2>&1 \
|
||
|| { c_err "缺少 sqlite3——一致性备份必需,不回退裸拷贝(会漏 WAL 得到撕裂副本)"; exit 1; }
|
||
local ts dest size
|
||
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||
dest="${out:-$(agent_backup_root)/$ts}"
|
||
case "$dest" in /*) ;; *) dest="$PWD/$dest" ;; esac
|
||
[ ! -e "$dest" ] || { c_err "备份目标已存在:$dest"; exit 1; }
|
||
mkdir -p "$dest" || { c_err "无法创建备份目录:$dest"; exit 1; }
|
||
if ! agent_backup_snapshot "$dest" || ! agent_backup_manifest "$dest"; then
|
||
rm -rf "$dest"
|
||
hist_event backup fail "$(read_pid)" "dir=$dest"
|
||
c_err "[$ENV_NAME] 备份失败,半成品已清理:$dest"
|
||
exit 1
|
||
fi
|
||
chmod 700 "$dest" 2>/dev/null || true
|
||
size="$(du -sh "$dest" 2>/dev/null | cut -f1)"
|
||
hist_event backup ok "$(read_pid)" "dir=$dest size=$size"
|
||
[ -n "$out" ] || agent_backup_prune "$(agent_backup_root)" "$keep"
|
||
c_ok "[$ENV_NAME] 数据目录快照完成:$dest($size;svc.env+history+data,db 已一致性自检)"
|
||
}
|
||
|
||
svc_agent_backups() { # [--prune] [--keep N]
|
||
local prune=0 keep="$BACKUP_KEEP_DEFAULT"
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--prune) prune=1; shift ;;
|
||
--keep) need_value "$@"; keep="$2"; shift 2
|
||
case "$keep" in ''|*[!0-9]*) c_err "--keep 须为非负整数"; exit 2 ;; esac ;;
|
||
*) c_err "用法:scripts/svc.sh <name> backups [--prune] [--keep N](未知参数:$1)"; exit 2 ;;
|
||
esac
|
||
done
|
||
agent_backup_guard
|
||
local root n=0
|
||
root="$(agent_backup_root)"
|
||
if [ -d "$root" ]; then
|
||
while IFS= read -r _; do n=$((n + 1)); done \
|
||
< <(cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB")
|
||
fi
|
||
if [ "$n" -eq 0 ]; then
|
||
c_info "[$ENV_NAME] 暂无快照(root=$root;scripts/svc.sh $ENV_NAME backup)"
|
||
return 0
|
||
fi
|
||
c_info "[$ENV_NAME] 快照列表($root,最新在前)"
|
||
( cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB" | sort -r ) \
|
||
| while IFS= read -r d; do
|
||
printf ' %s %6s %s\n' "${d#./}" "$(du -sh "$root/$d" | cut -f1)" \
|
||
"$(find "$root/$d/data" -type f 2>/dev/null | wc -l) 个文件"
|
||
done
|
||
if [ "$prune" = 1 ]; then agent_backup_prune "$root" "$keep"; fi
|
||
return 0
|
||
}
|
||
|
||
cmd_set() {
|
||
local name="${1:-}"
|
||
[ -n "$name" ] || { c_err "用法:scripts/svc.sh set <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——没有由本脚本启动的实例"
|
||
# 仍回收本服务残留占口(update 误清 pidfile 后的孤儿)。
|
||
reclaim_own_port || true
|
||
svc_clear_autostart
|
||
return 0
|
||
fi
|
||
if ! pid_alive "$pid"; then
|
||
c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile"
|
||
rm -f "$ENV_PID"
|
||
reclaim_own_port || true
|
||
svc_clear_autostart # 显式 stop 对已死进程 = 期望保持停止
|
||
return 0
|
||
fi
|
||
case "$SVC_KIND" in
|
||
postgres) pg_do_stop ;;
|
||
*)
|
||
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
|
||
proc_stop "$pid"
|
||
rm -f "$ENV_PID"
|
||
svc_clear_autostart
|
||
c_ok "[$ENV_NAME] 已停止"
|
||
hist_event stop ok "$pid" "SIGTERM"
|
||
;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# do_stop_quiet:deploy 前置——未运行时静默返回(同时清除期望态:
|
||
# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。
|
||
do_stop_quiet() {
|
||
local pid; pid="$(read_pid)"
|
||
if [ -z "$pid" ]; then
|
||
reclaim_own_port || true
|
||
svc_clear_autostart
|
||
return 0
|
||
fi
|
||
if ! pid_alive "$pid"; then
|
||
rm -f "$ENV_PID"
|
||
reclaim_own_port || true
|
||
svc_clear_autostart
|
||
return 0
|
||
fi
|
||
case "$SVC_KIND" in
|
||
postgres) pg_do_stop ;;
|
||
*)
|
||
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
|
||
proc_stop "$pid"
|
||
rm -f "$ENV_PID"
|
||
svc_clear_autostart
|
||
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:?}"
|
||
local bkroot="$ROOT/runtime-backups/$name"
|
||
if [ -d "$bkroot" ]; then
|
||
rm -rf "$bkroot"
|
||
c_ok "[$ENV_NAME] 数据目录快照已删除:$bkroot"
|
||
fi
|
||
c_ok "[$ENV_NAME] 已注销并删除整个目录:$ENV_DIR(含数据与历史)"
|
||
else
|
||
c_ok "[$ENV_NAME] 已注销登记;目录保留(数据/历史在 $ENV_DIR;彻底删除用 --purge)"
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# ---- 总览与历史 ----
|
||
|
||
svc_pid_state() { # NAME → "alive <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 会登记)"
|
||
for name in ${names[@]+"${names[@]}"}; do
|
||
if rdm_workbench_missing "$name"; then
|
||
c_warn " [$name] 无工作台入口(伴生 $(rdm_companion_name "$name") 未登记或未运行)"
|
||
fi
|
||
done
|
||
return 0
|
||
}
|
||
|
||
env_status_detail() {
|
||
local pid; pid="$(read_pid)"
|
||
local addr="$ENV_ADDR" a src="" ver="" tags=""
|
||
local kind="$SVC_KIND"
|
||
if [ -f "$ENV_DIR/svc.env" ]; then
|
||
a="$(svc_get addr)"; [ -n "$a" ] && addr="$a"
|
||
src="$(svc_get source)"; ver="$(svc_get version)"; tags="$(svc_get tags)"
|
||
fi
|
||
if pid_alive "$pid"; then
|
||
printf ' [%s] %s 运行中 pid=%s\n' "$ENV_NAME" "$kind" "$pid"
|
||
else
|
||
printf ' [%s] %s 未运行\n' "$ENV_NAME" "$kind"
|
||
fi
|
||
printf ' 地址 %s 数据 %s\n' "$addr" "$ENV_DATA"
|
||
[ -n "$src" ] && printf ' 来源 %s 版本 %s\n' "$src" "${ver:--}"
|
||
[ -n "$tags" ] && printf ' 标签 %s\n' "$tags"
|
||
case "$kind" in
|
||
agent)
|
||
local dbref cfile
|
||
dbref="$(db_ref_of "$ENV_NAME")"
|
||
printf ' 数据库 %s\n' "${dbref:-无(内嵌 SQLite 档;绑定:<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)"
|
||
if rdm_workbench_missing "$ENV_NAME"; then
|
||
printf ' 工作台 无入口(伴生 %s 未登记或未运行;scripts/svc.sh %s deploy --kind static --dir %s/htdocs)\n' \
|
||
"$(rdm_companion_name "$ENV_NAME")" "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR"
|
||
fi
|
||
;;
|
||
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 ;;
|
||
--quiet) CLI_QUIET=1; shift ;;
|
||
--no-fetch) CLI_NOFETCH=1; shift ;;
|
||
--) shift; break ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
-*) c_err "update 未知参数:$1(可用:--check --all --no-fetch)"; return 2 ;;
|
||
*) names+=("$1"); shift ;;
|
||
esac
|
||
done
|
||
local targets=() d e
|
||
if [ "${#names[@]}" -gt 0 ]; then
|
||
for a in ${names[@]+"${names[@]}"}; do
|
||
valid_env "$a" || { c_err "非法服务名:$a"; return 2; }
|
||
[ -f "$RUNTIME/$a/svc.env" ] || { c_err "[$a] 未登记(scripts/svc.sh list 查看)"; return 2; }
|
||
targets+=("$a")
|
||
done
|
||
else
|
||
for d in "$RUNTIME"/*/; do
|
||
[ -f "${d}svc.env" ] || continue
|
||
e="$(basename "$d")"
|
||
[ "$e" = ".worktrees" ] && continue
|
||
valid_env "$e" || continue
|
||
targets+=("$e")
|
||
done
|
||
fi
|
||
[ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; }
|
||
|
||
# 按目标来源决定访问哪些仓:local@/dev@ 走独立部署源仓;release@ 走 Releases 仓;
|
||
# rdm@ 走独立 RDM 源仓。
|
||
local latest_sha="" latest_rel="" latest_rdm="" need_rel=0 need_dev=0 need_rdm=0 e_src
|
||
for e in ${targets[@]+"${targets[@]}"}; do
|
||
e_src="$(sed -n 's/^source=//p' "$RUNTIME/$e/svc.env" | head -1)"
|
||
case "$e_src" in release@*) need_rel=1 ;; local@*|dev@*) need_dev=1 ;; rdm@*) need_rdm=1 ;; esac
|
||
done
|
||
local fetch_note="" src_args=""
|
||
[ "$CLI_NOFETCH" = 1 ] && { fetch_note="--no-fetch,基于本地缓存引用"; src_args="--no-fetch"; }
|
||
if [ "$need_dev" = 1 ]; then
|
||
if svc_src_ready $src_args; then
|
||
latest_sha="$(git -C "$SRC_DIR" rev-parse --verify --quiet origin/main || true)"
|
||
else
|
||
c_warn "部署源仓不可用——local/dev 源服务本轮无法检测与更新"
|
||
fi
|
||
fi
|
||
if [ "$need_rel" = 1 ]; then
|
||
releases_root
|
||
if [ "$CLI_NOFETCH" != 1 ]; then
|
||
git -C "$REL_ROOT" fetch --quiet 2>/dev/null || c_warn "Releases 仓 fetch 失败——基于本地缓存"
|
||
fi
|
||
local rel_head
|
||
rel_head="$(git -C "$REL_ROOT" rev-parse --verify --quiet HEAD || true)"
|
||
if [ -n "$rel_head" ]; then
|
||
latest_rel="$(git -C "$REL_ROOT" ls-tree --name-only "$rel_head:versions" 2>/dev/null | sort -V | tail -1 || true)"
|
||
fi
|
||
fi
|
||
if [ "$need_rdm" = 1 ]; then
|
||
if rdm_src_ready $src_args; then
|
||
latest_rdm="$(git -C "$RDM_SRC_DIR" rev-parse --verify --quiet origin/main || true)"
|
||
else
|
||
c_warn "独立 RDM 源仓不可用——rdm 源服务本轮无法检测与更新"
|
||
fi
|
||
fi
|
||
|
||
# 逐服务分析:状态判定 + 收集可更新动作。
|
||
c_info "更新检测($fetch_note;root: $ROOT)"
|
||
printf ' %-12s %-20s %-12s %s\n' 服务 当前来源 最新 状态
|
||
local acts=() state to latest_disp="" updated=0 failed=0 skipped=0
|
||
for e in ${targets[@]+"${targets[@]}"}; do
|
||
local kind src cur st_running
|
||
kind="$(svc_kind "$e")"
|
||
src="$(sed -n 's/^source=//p' "$RUNTIME/$e/svc.env" | head -1)"
|
||
to=""
|
||
latest_disp=""
|
||
case "$kind" in
|
||
postgres)
|
||
state="不适用(数据库)" ;;
|
||
*)
|
||
case "$src" in
|
||
dir@*)
|
||
state="不适用(外部目录)" ;;
|
||
"")
|
||
state="来源未知(重新 deploy 后可参与更新)" ;;
|
||
local@*|dev@*)
|
||
cur="${src##*@}"
|
||
if [ -z "$latest_sha" ]; then
|
||
state="远端 main 未知"; latest_disp="-"
|
||
elif ! git -C "$SRC_DIR" cat-file -e "${cur}^{commit}" 2>/dev/null; then
|
||
state="提交不在远端(不自动更新;重新 deploy 可纳入更新轨道)"
|
||
latest_disp="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || echo "-")"
|
||
elif [ "${latest_sha:0:${#cur}}" = "$cur" ]; then
|
||
state="✓ 最新"; latest_disp="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || echo "-")"
|
||
elif git -C "$SRC_DIR" merge-base --is-ancestor "$cur" "$latest_sha" 2>/dev/null; then
|
||
to="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || true)"
|
||
latest_disp="$to"
|
||
state="可更新"
|
||
elif git -C "$SRC_DIR" merge-base --is-ancestor "$latest_sha" "$cur" 2>/dev/null; then
|
||
state="领先远端 main(不动作)"; latest_disp="$(git -C "$SRC_DIR" rev-parse --short "$latest_sha" 2>/dev/null || echo "-")"
|
||
else
|
||
state="与远端 main 分叉(不自动更新)"; latest_disp="-"
|
||
fi
|
||
;;
|
||
release@*)
|
||
cur="${src#release@}"
|
||
latest_disp="${latest_rel:--}"
|
||
if [ -z "$latest_rel" ]; then
|
||
state="Releases 仓不可用"
|
||
elif [ "$cur" = "$latest_rel" ]; then
|
||
state="✓ 最新"
|
||
else
|
||
local ordered
|
||
ordered="$(printf '%s\n%s\n' "$cur" "$latest_rel" | sort -V | tail -1)"
|
||
if [ "$ordered" = "$latest_rel" ]; then
|
||
state="可更新"; to="$latest_rel"
|
||
else
|
||
state="领先 Releases(不动作)"
|
||
fi
|
||
fi
|
||
;;
|
||
rdm@*)
|
||
cur="${src##*@}"
|
||
if [ -z "$latest_rdm" ]; then
|
||
state="RDM 远端 main 未知"; latest_disp="-"
|
||
elif ! git -C "$RDM_SRC_DIR" cat-file -e "${cur}^{commit}" 2>/dev/null; then
|
||
state="提交不在 RDM 远端(不自动更新;重新 deploy 可纳入更新轨道)"
|
||
latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")"
|
||
elif [ "${latest_rdm:0:${#cur}}" = "$cur" ]; then
|
||
state="✓ 最新"; latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")"
|
||
elif git -C "$RDM_SRC_DIR" merge-base --is-ancestor "$cur" "$latest_rdm" 2>/dev/null; then
|
||
to="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || true)"
|
||
latest_disp="$to"
|
||
state="可更新"
|
||
elif git -C "$RDM_SRC_DIR" merge-base --is-ancestor "$latest_rdm" "$cur" 2>/dev/null; then
|
||
state="领先 RDM 远端 main(不动作)"; latest_disp="$(git -C "$RDM_SRC_DIR" rev-parse --short "$latest_rdm" 2>/dev/null || echo "-")"
|
||
else
|
||
state="与 RDM 远端 main 分叉(不自动更新)"; latest_disp="-"
|
||
fi
|
||
;;
|
||
*)
|
||
state="来源未知(重新 deploy 后可参与更新)" ;;
|
||
esac
|
||
;;
|
||
esac
|
||
printf ' %-12s %-20s %-12s %s\n' "$e" "${src:--}" "$latest_disp" "$state"
|
||
if [ "$state" = "可更新" ]; then
|
||
st_running="$(svc_pid_state "$e")"
|
||
if [ "${st_running%% *}" = alive ] || [ "$CLI_ALL" = 1 ] || [ "${#names[@]}" -gt 0 ]; then
|
||
acts+=("$e|$src|$to")
|
||
else
|
||
c_warn "[$e] 已停止且可更新——加 --all 或点名以更新(更新会启动服务)"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if [ "$CLI_CHECK" = 1 ]; then
|
||
c_info "--check:仅报告,未做任何变更"
|
||
return 0
|
||
fi
|
||
[ ${#acts[@]} -gt 0 ] || { c_ok "无需更新(全部最新或不适用)"; return 0; }
|
||
|
||
# 执行更新:逐服务走 deploy 全链路(复用持久化配置与标签)。
|
||
for a in ${acts[@]+"${acts[@]}"}; do
|
||
local name="${a%%|*}" rest="${a#*|}"
|
||
local from="${rest%%|*}" tov="${rest#*|}"
|
||
local deploy_from=""
|
||
env_config "$name"
|
||
# local@/dev@ 源统一重新部署为 dev@<远端最新>:独立部署源仓 + 隔离 worktree 构建,
|
||
# 不 pull 主检出、不看工作树状态(脏树、任意分支均可更新)。
|
||
case "$from" in
|
||
local@*|dev@*) deploy_from="dev@$tov" ;;
|
||
release@*) deploy_from="release@$tov" ;;
|
||
rdm@*) deploy_from="rdm@$tov" ;;
|
||
esac
|
||
c_info "[$name] 更新:$from → $tov($deploy_from)"
|
||
hist_event update begin "$(read_pid)" "from=$from to=$tov"
|
||
# 每个服务独立子进程、清空环境,避免批内 AGENT_SYSTEM_* 串实例。
|
||
if svc_lifecycle_child deploy "$name" --from "$deploy_from"; then
|
||
hist_event update ok "$(read_pid)" "from=$from to=$tov"
|
||
c_ok "[$name] 已更新:$from → $tov"
|
||
updated=$((updated + 1))
|
||
else
|
||
hist_event update fail "$(read_pid)" "from=$from to=$tov(部署失败)"
|
||
c_err "[$name] 更新失败($from → $tov),其余服务继续"
|
||
failed=$((failed + 1))
|
||
fi
|
||
done
|
||
c_info "更新汇总:成功 $updated / 失败 $failed / 跳过 $skipped / 其余最新或不适用"
|
||
[ "$failed" -eq 0 ] && return 0 || return 1
|
||
}
|
||
|
||
# ---- 自恢复:recover 扫描拉回 + watchdog(systemd 用户 timer)----
|
||
|
||
# recover_targets [NAME...]:postgres 在前(绑定的 agent 依赖实例先起),其余按登记序。
|
||
recover_targets() {
|
||
local n
|
||
while IFS= read -r n; do
|
||
[ "$(svc_kind "$n")" = postgres ] && printf '%s\n' "$n"
|
||
done < <(registered_names)
|
||
while IFS= read -r n; do
|
||
[ "$(svc_kind "$n")" != postgres ] && printf '%s\n' "$n"
|
||
done < <(registered_names)
|
||
}
|
||
|
||
# cmd_recover [NAME...]:把 autostart=1 但已死的服务重新拉起(裸 start 复用持久化
|
||
# exec= 与数据库绑定;逐服务非阻塞持锁,被 deploy/手动 start 持有时跳过本轮)。
|
||
# 退出码:0 = 全部成功或无需动作;1 = 有恢复尝试失败(端口被占/启动失败等)。
|
||
cmd_recover() {
|
||
local names=() a
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--quiet) CLI_QUIET=1; shift ;;
|
||
--) shift; break ;;
|
||
-h|--help) cat >&2 <<'EOF'
|
||
用法:scripts/svc.sh recover [name...] [--quiet]
|
||
自恢复:扫描已登记服务(缺省全部;postgres 优先),对 svc.env autostart=1
|
||
且进程已死(pidfile+cmdline 校验)的服务执行裸 start 拉回。
|
||
通常由 watchdog timer 每分钟调用;手动执行用于立即恢复。
|
||
EOF
|
||
return 0 ;;
|
||
-*) c_err "recover 未知参数:$1(可用:--quiet)"; return 2 ;;
|
||
*) names+=("$1"); shift ;;
|
||
esac
|
||
done
|
||
local targets=() n rc=0 recovered=0 failed=0 skipped=0
|
||
if [ "${#names[@]}" -gt 0 ]; then
|
||
for a in ${names[@]+"${names[@]}"}; do
|
||
valid_env "$a" || { c_err "非法服务名:$a"; return 2; }
|
||
[ -f "$RUNTIME/$a/svc.env" ] || { c_err "[$a] 未登记(scripts/svc.sh list 查看)"; return 2; }
|
||
done
|
||
# 点名时保持用户顺序,但 postgres 仍前置(依赖顺序)。
|
||
local pgs=() others=()
|
||
for a in ${names[@]+"${names[@]}"}; do
|
||
[ "$(svc_kind "$a")" = postgres ] && pgs+=("$a") || others+=("$a")
|
||
done
|
||
targets=(${pgs[@]+"${pgs[@]}"} ${others[@]+"${others[@]}"})
|
||
else
|
||
while IFS= read -r n; do targets+=("$n"); done < <(recover_targets)
|
||
fi
|
||
[ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; }
|
||
for n in ${targets[@]+"${targets[@]}"}; do
|
||
[ "$(env_kv_of "$n" autostart)" = "1" ] || { skipped=$((skipped + 1)); continue; }
|
||
local st; st="$(svc_pid_state "$n")"
|
||
if [ "${st%% *}" = alive ]; then skipped=$((skipped + 1)); continue; fi
|
||
c_info "[$n] recover:autostart=1 且进程已死,重新拉起(复用持久化配置)"
|
||
# 子进程自己抢锁;锁被占用时 __lifecycle 以 75 退出,本轮记跳过。
|
||
local child_rc=0
|
||
svc_lifecycle_child recover "$n" || child_rc=$?
|
||
env_config "$n"
|
||
if [ "$child_rc" -eq 0 ]; then
|
||
hist_event recover ok "$(read_pid)" "autostart revival"
|
||
recovered=$((recovered + 1))
|
||
elif [ "$child_rc" -eq 75 ]; then
|
||
c_warn "[$n] 生命周期锁被占用(deploy/手动操作进行中),本轮跳过"
|
||
skipped=$((skipped + 1))
|
||
else
|
||
hist_event recover fail "" "autostart revival 失败(见 start fail 事件与 svc.log)"
|
||
failed=$((failed + 1)); rc=1
|
||
fi
|
||
done
|
||
if [ "$recovered" -gt 0 ] || [ "$failed" -gt 0 ]; then
|
||
c_event "recover 汇总:拉回 $recovered / 失败 $failed / 无需动作 $skipped"
|
||
fi
|
||
return "$rc"
|
||
}
|
||
|
||
# ---- watchdog:systemd 用户级 timer,每分钟执行 recover ----
|
||
WD_UNIT_BASE="agent-system-svc-recover"
|
||
WD_UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||
|
||
# wd_service_text ROOT / wd_timer_text:单元文件内容(纯函数,治理测试直接断言)。
|
||
wd_service_text() {
|
||
printf '%s
|
||
' \
|
||
"# 由 scripts/svc.sh watchdog install 生成(ROOT=$(printf '%s' "$1" | sed 's/[[:space:]]/\\ /g'));修改请重装或手工维护" \
|
||
"[Unit]" \
|
||
"Description=agent-system svc auto-recovery (restart crashed/killed services)" \
|
||
"" \
|
||
"[Service]" \
|
||
"Type=oneshot" \
|
||
"# KillMode=process:oneshot 结束时只回收脚本自身——默认 control-group 会把" \
|
||
"# recover 刚拉起的服务一并杀掉(2026-10-11 实测:recover ok 后进程立即消失)。" \
|
||
"KillMode=process" \
|
||
"ExecStart=$1/scripts/svc.sh recover --quiet" \
|
||
"Nice=5"
|
||
}
|
||
wd_timer_text() {
|
||
printf '%s\n' \
|
||
"[Unit]" \
|
||
"Description=Run agent-system svc recovery every minute" \
|
||
"" \
|
||
"[Timer]" \
|
||
"OnBootSec=1min" \
|
||
"OnUnitActiveSec=60s" \
|
||
"AccuracySec=10s" \
|
||
"Unit=${WD_UNIT_BASE}.service" \
|
||
"" \
|
||
"[Install]" \
|
||
"WantedBy=timers.target"
|
||
}
|
||
|
||
# wd_systemd_ok:systemd 用户会话是否可用(不可用环境给出 cron 降级指引)。
|
||
wd_systemd_ok() {
|
||
command -v systemctl >/dev/null 2>&1 || return 1
|
||
systemctl --user show-environment >/dev/null 2>&1
|
||
}
|
||
|
||
# cmd_watchdog install|remove|status [--print]:看护 timer 的安装/移除/状态。
|
||
cmd_watchdog() {
|
||
local sub="${1:-status}"; [ $# -gt 0 ] && shift || true
|
||
case "$sub" in
|
||
install)
|
||
local print=0
|
||
case "${1:-}" in --print) print=1 ;; "") ;; *) c_err "watchdog install 未知参数:$1(可用:--print)"; return 2 ;; esac
|
||
local svc="$WD_UNIT_DIR/${WD_UNIT_BASE}.service" tmr="$WD_UNIT_DIR/${WD_UNIT_BASE}.timer"
|
||
if [ "$print" = 1 ]; then
|
||
c_info "watchdog install --print:只打印计划,不写文件、不碰 systemd"
|
||
printf '── %s ──\n%s\n' "$svc" "$(wd_service_text "$ROOT")"
|
||
printf '── %s ──\n%s\n' "$tmr" "$(wd_timer_text)"
|
||
wd_systemd_ok || c_warn "当前环境 systemd 用户会话不可用——正式 install 需要它;或退化用 cron(help recovery)"
|
||
return 0
|
||
fi
|
||
wd_systemd_ok || {
|
||
c_err "systemd 用户会话不可用(systemctl --user 失败)——无法安装 timer"
|
||
c_err " 降级方案(cron,每分钟):"
|
||
c_err " (crontab -l 2>/dev/null; echo '* * * * * $ROOT/scripts/svc.sh recover --quiet >/dev/null 2>&1') | crontab -"
|
||
return 1
|
||
}
|
||
mkdir -p "$WD_UNIT_DIR"
|
||
{ wd_service_text "$ROOT"; printf '\n'; } > "$svc"
|
||
{ wd_timer_text; printf '\n'; } > "$tmr"
|
||
systemctl --user daemon-reload || { c_err "daemon-reload 失败"; return 1; }
|
||
systemctl --user enable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || {
|
||
c_err "timer enable 失败(systemctl --user status ${WD_UNIT_BASE}.timer 查原因)"; return 1; }
|
||
c_ok "watchdog timer 已安装并启用:${WD_UNIT_BASE}.timer(每分钟 recover;remove 移除)"
|
||
[ "$(loginctl show-user "$(id -un)" -p Linger 2>/dev/null)" = "Linger=yes" ] \
|
||
|| c_warn "用户未开 linger(sudo loginctl enable-linger $(id -un))——开机后 timer 不自启,登出不受影响"
|
||
c_info "立即自检:systemctl --user list-timers ${WD_UNIT_BASE}.timer;人工触发:systemctl --user start ${WD_UNIT_BASE}.service"
|
||
return 0
|
||
;;
|
||
remove|uninstall)
|
||
wd_systemd_ok || { c_info "systemd 用户会话不可用——只清理单元文件"; }
|
||
if wd_systemd_ok; then
|
||
systemctl --user disable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || true
|
||
systemctl --user reset-failed "${WD_UNIT_BASE}.service" >/dev/null 2>&1 || true
|
||
fi
|
||
rm -f "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" "$WD_UNIT_DIR/${WD_UNIT_BASE}.timer"
|
||
if wd_systemd_ok; then systemctl --user daemon-reload || true; fi
|
||
c_ok "watchdog 已移除(单元文件与 timer;服务登记不受影响)"
|
||
return 0
|
||
;;
|
||
status)
|
||
local n auto_list=""
|
||
if wd_systemd_ok; then
|
||
systemctl --user list-timers --no-pager "${WD_UNIT_BASE}.timer" 2>/dev/null | sed 's/^/ /' >&2 || true
|
||
local exec_line
|
||
exec_line="$(sed -n 's/^ExecStart=//p' "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" 2>/dev/null || true)"
|
||
if [ -n "$exec_line" ] && [ "$exec_line" != "$ROOT/scripts/svc.sh recover --quiet" ]; then
|
||
c_warn "已安装 timer 指向其他检出:$exec_line(当前 ROOT=$ROOT;重装:watchdog install)"
|
||
fi
|
||
else
|
||
c_warn "systemd 用户会话不可用——timer 未安装(watchdog install 或用 cron,见 help recovery)"
|
||
fi
|
||
while IFS= read -r n; do
|
||
[ "$(env_kv_of "$n" autostart)" = "1" ] && auto_list="${auto_list:+$auto_list }$n"
|
||
done < <(registered_names)
|
||
c_info "autostart=1 的服务:${auto_list:-无(start 成功后自动登记)}"
|
||
return 0
|
||
;;
|
||
-h|--help|help)
|
||
cat >&2 <<'EOF'
|
||
用法:scripts/svc.sh watchdog install|remove|status [install --print]
|
||
install 写入并启用 systemd 用户 timer(每分钟 svc.sh recover --quiet)
|
||
install --print 只打印将写入的单元文件与计划(不落盘)
|
||
remove 停用并删除 timer(服务登记与运行状态不受影响)
|
||
status timer 状态 + autostart=1 的服务清单
|
||
EOF
|
||
return 0 ;;
|
||
*) c_err "未知子命令:$sub(可用:install|remove|status)"; return 2 ;;
|
||
esac
|
||
}
|
||
|
||
# ---- main ----
|
||
|
||
# svc_lifecycle_main ACTION NAME [--from SPEC] [--quiet]
|
||
# 锁内:迁移 → 身份策略(在各动作里)→ 动作 → 解锁。供 update/recover 子进程使用。
|
||
svc_lifecycle_main() {
|
||
local action="${1:-}" name="${2:-}"
|
||
[ -n "$action" ] && [ -n "$name" ] || { c_err "__lifecycle 需要动作与服务名"; exit 2; }
|
||
shift 2
|
||
valid_env "$name" || exit 2
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--from) CLI_FROM="$2"; shift 2 ;;
|
||
--quiet) CLI_QUIET=1; shift ;;
|
||
*) c_err "__lifecycle 未知参数:$1"; exit 2 ;;
|
||
esac
|
||
done
|
||
env_config "$name"
|
||
if ! svc_lock -n; then
|
||
exit 75
|
||
fi
|
||
case "$action" in
|
||
deploy|recover|start) svc_manifest_migrate || { svc_unlock; exit 1; } ;;
|
||
esac
|
||
local rc=0
|
||
case "$action" in
|
||
deploy)
|
||
case "$SVC_KIND" in
|
||
agent) agent_do_deploy || rc=$? ;;
|
||
static) static_do_deploy && static_do_start || rc=$? ;;
|
||
*) c_err "[$name] 该类型不支持 deploy"; rc=2 ;;
|
||
esac
|
||
;;
|
||
recover|start) do_start || rc=$? ;;
|
||
*) c_err "未知生命周期动作:$action"; rc=2 ;;
|
||
esac
|
||
svc_unlock
|
||
exit "$rc"
|
||
}
|
||
|
||
main() {
|
||
if [ "${1:-}" = "__lifecycle" ]; then
|
||
shift
|
||
svc_lifecycle_main "$@"
|
||
fi
|
||
[ $# -ge 1 ] || { usage_short; exit 2; }
|
||
# 预扫 --config/--env(FILE 与 =FILE 两种写法,可出现在任意位置)。
|
||
local svc_envfile="" pre=()
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--config|--env) [ $# -ge 2 ] || { c_err "$1 需要文件路径"; exit 2; }; svc_envfile="$2"; shift 2 ;;
|
||
--config=*|--env=*) svc_envfile="${1#*=}"; shift ;;
|
||
*) pre+=("$1"); shift ;;
|
||
esac
|
||
done
|
||
set -- ${pre[@]+"${pre[@]}"}
|
||
SVC_ENVFILE="$svc_envfile"
|
||
[ -f "$ROOT/.svc.env" ] && load_env_file "$ROOT/.svc.env"
|
||
[ -n "$svc_envfile" ] && load_env_file "$svc_envfile"
|
||
case "$1" in
|
||
-h) usage_short; exit 0 ;;
|
||
--help) usage; exit 0 ;;
|
||
help) shift; cmd_help "$@"; exit $? ;;
|
||
esac
|
||
case "$1" in
|
||
list|status)
|
||
shift
|
||
LIST_TAG=""
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--tag) need_value "$@"; LIST_TAG="$2"; shift 2 ;;
|
||
*) c_err "list/status 只接受 --tag(未知参数:$1)"; exit 2 ;;
|
||
esac
|
||
done
|
||
global_list
|
||
exit 0
|
||
;;
|
||
db) shift; cmd_db "$@"; exit 0 ;;
|
||
set) shift; cmd_set "$@"; exit 0 ;;
|
||
rm) shift; cmd_rm "$@"; exit 0 ;;
|
||
info) shift; cmd_info "$@" || exit $?; exit 0 ;;
|
||
commits) shift; cmd_commits "$@"; exit 0 ;;
|
||
history) shift; cmd_history "$@"; exit 0 ;;
|
||
update) shift; cmd_update "$@" || exit $?; exit 0 ;;
|
||
recover) shift; cmd_recover "$@" || exit $?; exit 0 ;;
|
||
watchdog) shift; cmd_watchdog "$@" || exit $?; exit 0 ;;
|
||
esac
|
||
ENV_NAME="$1"; shift
|
||
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
|
||
local action="deploy"
|
||
case "${1:-}" in
|
||
deploy|stop|start|restart|status|logs|db|config|init|adopt|user|backup|backups) action="$1"; shift ;;
|
||
esac
|
||
if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
|
||
CLI_LOGS_N="$1"; shift
|
||
fi
|
||
# db / config / user / backup / backups:自带参数解析(位置参数不走 parse_args;
|
||
# 未知 flags 由其自行拒绝)。
|
||
if [ "$action" = db ] || [ "$action" = config ] || [ "$action" = user ]; then
|
||
env_config "$ENV_NAME"
|
||
case "$action" in
|
||
db) svc_db_bind "$@" || exit $? ;;
|
||
config) svc_config "$@" || exit $? ;;
|
||
user) agent_do_user "$@" || exit $? ;;
|
||
esac
|
||
exit 0
|
||
fi
|
||
if [ "$action" = backup ] || [ "$action" = backups ]; then
|
||
env_config "$ENV_NAME"
|
||
case "$action" in
|
||
backup) svc_agent_backup "$@" || exit $? ;;
|
||
backups) svc_agent_backups "$@" || exit $? ;;
|
||
esac
|
||
exit 0
|
||
fi
|
||
local adopt_confirm=""
|
||
if [ "$action" = adopt ]; then
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--confirm) adopt_confirm="$2"; shift 2 ;;
|
||
*) c_err "adopt 用法:scripts/svc.sh $ENV_NAME adopt --confirm <instance_id>"; exit 2 ;;
|
||
esac
|
||
done
|
||
fi
|
||
parse_args "$@"
|
||
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ] || [ "$CLI_ALLOW_SOURCE_SWITCH" = 1 ]; } && [ "$action" != deploy ]; then
|
||
c_err "--from/--pick/--dry-run/--dir/--allow-source-switch 仅用于 deploy 动作"
|
||
exit 2
|
||
fi
|
||
env_config "$ENV_NAME"
|
||
# 生命周期动作持 .start.lock(wait 模式;进程退出自动释放)——deploy 全程含
|
||
# 构建窗口,防 recover/并发 start 在停机-换产物间隙抢跑。只读动作不加锁。
|
||
case "$action" in
|
||
deploy|stop|start|restart|init|adopt) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;;
|
||
esac
|
||
# U1 身份:锁内一次性迁移(幂等;含既有数据领养 marker)。归属冲突(rc=4)中止;
|
||
# 其余失败保持 legacy 治理继续运行。
|
||
case "$action" in
|
||
start|restart|deploy|init|adopt) svc_manifest_migrate || exit 1 ;;
|
||
esac
|
||
case "$action" in
|
||
deploy)
|
||
case "$SVC_KIND" in
|
||
agent) agent_do_deploy ;;
|
||
static) static_do_deploy && static_do_start ;;
|
||
postgres) c_err "[$ENV_NAME] postgres 无 deploy;db create 建库、start 启动"; exit 2 ;;
|
||
esac ;;
|
||
stop) do_stop ;;
|
||
start) do_start ;;
|
||
restart) do_restart ;;
|
||
init)
|
||
# 只用于 fresh/pending 的显式建立。established 缺行不得用 init 补,走 adopt。
|
||
command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || {
|
||
c_err "[$ENV_NAME] init 需要 python3 与 scripts/svc_manifest.py"; exit 2; }
|
||
if [ -f "$ENV_DIR/manifest.json" ]; then
|
||
case "$(svc_db_marker)" in
|
||
established)
|
||
c_err "[$ENV_NAME] 身份已建立。缺行或不匹配时先调查,再 scripts/svc.sh $ENV_NAME adopt --confirm <instance_id>"
|
||
exit 1 ;;
|
||
esac
|
||
c_ok "[$ENV_NAME] 已在 manifest 治理下(init 补齐 fresh/pending):"
|
||
elif [ -f "$ENV_DIR/svc.env" ]; then
|
||
python3 "$MANIFEST_PY" "$ENV_DIR" init \
|
||
|| { c_err "[$ENV_NAME] init 失败(数据根冲突等,见上)"; exit 1; }
|
||
else
|
||
c_err "[$ENV_NAME] 尚无 svc.env:先用 start 完成首次登记;init 用于显式化已有实例"; exit 2
|
||
fi
|
||
resolve_db_config 2>/dev/null || true
|
||
svc_db_identity_ensure || { c_err "[$ENV_NAME] DB 身份 ensure 失败"; exit 1; }
|
||
python3 "$MANIFEST_PY" "$ENV_DIR" status ;;
|
||
adopt)
|
||
# 显式重审已建立实例的 DB 身份。先打印库大小、行数、最近写入,再要求 --confirm。
|
||
[ -n "$adopt_confirm" ] || { c_err "adopt 必须 --confirm <instance_id>"; exit 2; }
|
||
[ -f "$ENV_DIR/manifest.json" ] || { c_err "[$ENV_NAME] 无 manifest,不能 adopt"; exit 1; }
|
||
local iid
|
||
iid="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["instance_id"])' "$ENV_DIR/manifest.json")"
|
||
[ "$adopt_confirm" = "$iid" ] || { c_err "[$ENV_NAME] --confirm 与 instance_id 不符($iid)"; exit 1; }
|
||
resolve_db_config 2>/dev/null || true
|
||
python3 - "$ENV_DIR" <<'PY'
|
||
import json, os, sqlite3, sys, time
|
||
env_dir = sys.argv[1]
|
||
m = json.load(open(os.path.join(env_dir, "manifest.json"), encoding="utf-8"))
|
||
data = (m.get("identity") or {}).get("data_root") or ""
|
||
db = os.path.join(data, "agent-system.db")
|
||
print("adopt 证据 instance_id=%s" % m["instance_id"])
|
||
if not os.path.isfile(db):
|
||
print("库文件不存在:%s" % db)
|
||
sys.exit(0)
|
||
st = os.stat(db)
|
||
print("库大小=%d 字节 最近写入=%s" % (st.st_size, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime))))
|
||
con = sqlite3.connect(db)
|
||
try:
|
||
for table in ("agent_system_identity", "conversations", "users"):
|
||
try:
|
||
n = con.execute("SELECT COUNT(*) FROM %s" % table).fetchone()[0]
|
||
print("%s 行数=%s" % (table, n))
|
||
except sqlite3.Error:
|
||
print("%s 行数=(无此表)" % table)
|
||
finally:
|
||
con.close()
|
||
PY
|
||
svc_db_marker_set pending || exit 1
|
||
svc_db_identity_ensure || { c_err "[$ENV_NAME] adopt ensure 失败"; exit 1; }
|
||
c_ok "[$ENV_NAME] adopt 完成,db_marker=established" ;;
|
||
status) env_status_detail ;;
|
||
logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;;
|
||
esac
|
||
}
|
||
|
||
# 仅作为脚本直接执行时进入主流程;被 source(治理单测 fixtures)时只定义函数。
|
||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||
main "$@"
|
||
fi
|