Why: The service manager could create and run postgres instances but had no way to retarget a port explicitly, back data up, clone an instance to another port, or move a database between instances — the user asked for exactly these operations. Evidence: - "set <name> [--port N] [--host H] [--apply] [--tag T...]": explicit registration change; running postgres requires --apply (stop → rewrite registration → start); port availability checked against both registrations and live listeners. Verified: 8890→8895 with data intact and DSN updated, old port freed; refusal without --apply. - "db backup <name> [--db X] [--out F]": pg_dump -Fc into runtime/<name>/backups/ (0600) with pg_restore --list self-check; "db backups" lists them. - "db restore <name> --file F [--db X] [--force]": restores into a new db or refuses on a non-empty target unless --force (drop with FORCE + recreate + restore), reporting table counts afterwards. Verified all three paths. - "db clone <src> <dst> [--port P] [--password PW] [--tag] [--online]": default physical clone (brief source stop, cp -a of pgdata minus postmaster files, new registration, source restarted on its own port); --online copies via dump/restore without stopping the source (verified source pid unchanged). Clone lands on the requested or derived port. - "db copy <src> <dst> [--db X] [--force]": single-database logical move between two running instances with the same non-empty-target guard. "db ls <name>" lists databases. - Fixed during verification: clone/copy leaked the CLI --port into the source restart path (source briefly came up on the clone's port); the address overrides are now cleared inside those commands. Impact: All operations stay inside runtime/<name>/, append to history.jsonl, and reuse the existing safety constraints (own-process stop, port conflicts reported not seized, secrets in 0600 files). Overwriting a non-empty database requires an explicit --force everywhere. Makefile help line mentions the new db subcommands. Refs: .task/feature-svc-db-ops.md(任务卡=PR 描述)
1780 lines
74 KiB
Bash
Executable File
1780 lines
74 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# svc.sh —— 本机服务管理(服务模型 v2):按提交/版本部署并启动 后端(agent) / 前端(static)
|
||
# / 数据库(postgres) 三类服务,统一登记(svc.env + history.jsonl)、标签、配置分层、
|
||
# 生命周期(start/stop/restart/rm)与数据库运维(换端口/备份/恢复/克隆/搬库),
|
||
# 全程不触碰主检出与其他已启动服务。
|
||
#
|
||
# 用法:
|
||
# scripts/svc.sh <name> [action] [参数...] # 服务级操作(name 见命名规则)
|
||
# action:deploy(默认,agent/static)| start | stop | restart | status | logs [行数]
|
||
# scripts/svc.sh list [--tag T] # 全服务总览(status 等价)
|
||
# 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 备份(自动自检归档)
|
||
# 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 set <name> [--port N --apply] [--tag T] # 变更登记(换端口/标签)
|
||
# scripts/svc.sh rm <name> [--purge] # 注销服务(--purge 连数据一起删)
|
||
# scripts/svc.sh commits [dev|release] [N] # 列出可部署提交(默认 dev,N=20)
|
||
# scripts/svc.sh history [name] [N] # 事件历史(默认全部,最近 20 条)
|
||
#
|
||
# 服务模型:
|
||
# - name:^[a-z][a-z0-9-]{0,15}$(保留字不可用);dev/prod 有默认端口,其余按名字
|
||
# 确定性派生(8800-9099,避让已登记/监听端口),SVC_<NAME 大写>_ADDR 可覆盖
|
||
# - 每个服务自包含于 runtime/<name>/:产物(bin/htdocs)/数据(data/pgdata)/svc.pid/
|
||
# svc.log|pg.log/svc.env(登记+持久化配置,600)/history.jsonl(追加事件流)
|
||
# - kind(svc.env kind=,缺省 agent,兼容旧登记):
|
||
# agent 后端单二进制(内嵌前端);deploy 三源:local | dev[@<sha>] | release[@<v>]
|
||
# static 前端静态服务;deploy --from dev[@sha]|local 构建 frontend dist 复制到
|
||
# htdocs/,或 deploy --dir PATH 直接服务既有目录
|
||
# postgres 数据库;db create 后 start/stop/restart(pg_ctl 生命周期,绝不裸杀)
|
||
#
|
||
# 配置优先级(三类服务一致):命令行 > --config/--env 文件 > svc.env 持久化的上次配置
|
||
# > SVC_<NAME>_* 系统环境变量(.svc.env 同)> 默认值。裸 start/restart 复用上次持久化
|
||
# 配置(svc.env exec= 等),显式参数出现即覆盖。
|
||
#
|
||
# 常用参数(服务级):
|
||
# --config FILE / --env FILE dotenv 配置文件(KEY=VALUE;SVC_<NAME>_ADDR/DSN/...)
|
||
# --addr host:port / --host H / --port P 监听地址(pg=TCP,static=HTTP)
|
||
# --tag T 标签(可重复;登记于 svc.env,list --tag 过滤)
|
||
# --kind agent|static deploy 时指定类型(postgres 用 db create 建)
|
||
# --from SOURCE 部署来源(见上);--dir PATH(static)直接服务目录
|
||
# --dry-run 只打印部署计划;--pick [dev|release] 交互式选提交
|
||
# --plugins/--dsn/--auth/--mock/--data 与 --model-* / --light-* / --tavily-keys
|
||
# (agent 播种/运行参数)同上一版
|
||
# -- <剩余参数> 原样透传给 agent-system serve(escape hatch)
|
||
# db create 专属:--user U(默认 agent)--password P(缺省自动生成,db dsn --reveal 查看)
|
||
# --db NAME(默认 agent)--auth-mode scram-sha-256|md5(默认 scram)
|
||
# rm 专属:--purge 删除 runtime/<name>/ 整个目录(含数据与历史)
|
||
#
|
||
# 示例:
|
||
# 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 list --tag core && scripts/svc.sh history
|
||
# scripts/svc.sh pg1 restart && scripts/svc.sh rm tmp1 --purge
|
||
# scripts/svc.sh set pg1 --port 8895 --apply # 换端口并重启生效(登记/DSN 联动)
|
||
# scripts/svc.sh db backup pg1 && scripts/svc.sh db backups pg1
|
||
# scripts/svc.sh db clone pg1 pg2 --port 8896 # 物理克隆一套到新端口
|
||
# scripts/svc.sh db copy pg1 pg2 --db app # 把 pg1 的 app 库搬到 pg2
|
||
# scripts/svc.sh db restore pg2 --file <dump> --db app --force
|
||
#
|
||
# 安全约束:
|
||
# - 只写 runtime/<name>/ 与 runtime/.worktrees/;不改主检出 git 状态、不动他人现场;
|
||
# --from local 是唯一写主检出构建产物(bin/、node_modules/、dist/,均 gitignore)
|
||
# 的路径,dev@sha/release/static 构建全部在隔离 worktree;
|
||
# - 只停自己启动的进程:pidfile + /proc/<pid>/cmdline 标记双校验;postgres 走
|
||
# pg_ctl -m fast 优雅停机,SIGKILL 不用于 postmaster;
|
||
# - 端口被非本脚本进程占用时报错并给出占用者,不抢不杀;
|
||
# - svc.env 含密码时权限 600;DSN 默认掩码密码(--reveal 才显示);
|
||
# - 备份/克隆只增不删;restore/copy 覆盖非空库必须显式 --force(drop 重建再恢复);
|
||
# 备份文件在 runtime/<name>/backups/(600),随 rm --purge 一并删除;
|
||
# - 物理克隆期间源实例短暂停止(一致性要求),完成后自动复启;--online 不停源。
|
||
# <<usage-end>>
|
||
set -euo pipefail
|
||
|
||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
RUNTIME="$ROOT/runtime"
|
||
BIN="$ROOT/bin/agent-system"
|
||
SVC_ENVFILE=""
|
||
|
||
# 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。
|
||
c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; }
|
||
c_ok() { printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; }
|
||
c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*" >&2; }
|
||
c_err() { printf '\033[1;31m[svc]\033[0m %s\n' "$*" >&2; }
|
||
|
||
usage() {
|
||
sed -n '2,/^# <<usage-end>>$/p' "${BASH_SOURCE[0]}" | sed '$d;s/^# \{0,1\}//'
|
||
}
|
||
|
||
# load_env_file FILE:dotenv 安全解析——逐行 KEY=VALUE 赋值(剥 export 前缀与成对
|
||
# 引号,忽略注释/空行/非法行),不 source 执行文件内容。
|
||
load_env_file() {
|
||
local f="$1" line k v
|
||
[ -f "$f" ] || { c_err "配置文件不存在:$f"; exit 2; }
|
||
while IFS= read -r line || [ -n "$line" ]; do
|
||
line="${line%$'\r'}"
|
||
case "$line" in ''|'#'*) continue ;; esac
|
||
[[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*= ]] || continue
|
||
k="${line%%=*}"
|
||
k="${k#"${k%%[![:space:]]*}"}" # 左去空白
|
||
k="${k#export}"
|
||
k="${k#"${k%%[![:space:]]*}"}"
|
||
v="${line#*=}"
|
||
v="${v#"${v%%[![:space:]]*}"}" # 值左去空白
|
||
v="${v%\"}"; v="${v%\'}"; v="${v#\"}"; v="${v#\'}" # 剥成对引号
|
||
printf -v "$k" '%s' "$v"
|
||
done < "$f"
|
||
}
|
||
|
||
# ---- 命令行参数(覆盖层)----
|
||
CLI_ADDR=""; CLI_HOST=""; CLI_PORT=""; CLI_DATA=""; CLI_DSN=""
|
||
CLI_AUTH=""; CLI_MOCK=0; CLI_PLUGINS=""; CLI_EXTRA=""; CLI_LOGS_N=""
|
||
CLI_MBASE=""; CLI_MKEY=""; CLI_MNAME=""
|
||
CLI_LBASE=""; CLI_LKEY=""; CLI_LNAME=""; CLI_TAVILY=""
|
||
CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0
|
||
CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0
|
||
CLI_APPLY=0; CLI_FORCE=0; CLI_ONLINE=0; CLI_FILE=""; CLI_OUT=""
|
||
CLI_DB_USER=""; CLI_DB_PASSWORD=""; CLI_DB_NAME=""; CLI_DB_AUTH=""
|
||
CLI_TAGS=()
|
||
|
||
need_value() { [ $# -ge 2 ] || { c_err "参数 $1 需要值"; exit 2; }; }
|
||
|
||
parse_args() {
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--addr) need_value "$@"; CLI_ADDR="$2"; shift 2 ;;
|
||
--host) need_value "$@"; CLI_HOST="$2"; shift 2 ;;
|
||
--port) need_value "$@"; CLI_PORT="$2"; shift 2 ;;
|
||
--data) need_value "$@"; CLI_DATA="$2"; shift 2 ;;
|
||
--dsn) need_value "$@"; CLI_DSN="$2"; shift 2 ;;
|
||
--auth) need_value "$@"; CLI_AUTH="$2"; shift 2 ;;
|
||
--plugins) need_value "$@"; CLI_PLUGINS="$2"; shift 2 ;;
|
||
--mock) CLI_MOCK=1; shift ;;
|
||
--model-base) need_value "$@"; CLI_MBASE="$2"; shift 2 ;;
|
||
--model-key) need_value "$@"; CLI_MKEY="$2"; shift 2 ;;
|
||
--model-name) need_value "$@"; CLI_MNAME="$2"; shift 2 ;;
|
||
--light-base) need_value "$@"; CLI_LBASE="$2"; shift 2 ;;
|
||
--light-key) need_value "$@"; CLI_LKEY="$2"; shift 2 ;;
|
||
--light-name) need_value "$@"; CLI_LNAME="$2"; shift 2 ;;
|
||
--tavily-keys) need_value "$@"; CLI_TAVILY="$2"; shift 2 ;;
|
||
--from) need_value "$@"; CLI_FROM="$2"; shift 2 ;;
|
||
--pick) CLI_PICK="dev"
|
||
case "${2:-}" in dev|release) CLI_PICK="$2"; shift 2 ;; *) shift ;; esac ;;
|
||
--dry-run) CLI_DRYRUN=1; shift ;;
|
||
--kind) need_value "$@"; CLI_KIND="$2"; shift 2 ;;
|
||
--dir) need_value "$@"; CLI_DIR="$2"; shift 2 ;;
|
||
--tag) need_value "$@"; CLI_TAGS+=("$2"); shift 2 ;;
|
||
--purge) CLI_PURGE=1; shift ;;
|
||
--reveal) CLI_REVEAL=1; shift ;;
|
||
--apply) CLI_APPLY=1; shift ;;
|
||
--force) CLI_FORCE=1; shift ;;
|
||
--online) CLI_ONLINE=1; shift ;;
|
||
--file) need_value "$@"; CLI_FILE="$2"; shift 2 ;;
|
||
--out) need_value "$@"; CLI_OUT="$2"; shift 2 ;;
|
||
--user) need_value "$@"; CLI_DB_USER="$2"; shift 2 ;;
|
||
--password) need_value "$@"; CLI_DB_PASSWORD="$2"; shift 2 ;;
|
||
--db) need_value "$@"; CLI_DB_NAME="$2"; shift 2 ;;
|
||
--auth-mode) need_value "$@"; CLI_DB_AUTH="$2"; shift 2 ;;
|
||
--) shift; CLI_EXTRA="$*"; break ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
*) c_err "未知参数:$1"; usage; 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 deploy stop start restart logs help version "
|
||
|
||
# valid_env NAME:服务名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用)。
|
||
valid_env() {
|
||
case "$RESERVED_ENVS" in *" $1 "*) return 1 ;; esac
|
||
[[ "$1" =~ ^[a-z][a-z0-9-]{0,15}$ ]]
|
||
}
|
||
|
||
# env_var NAME SUFFIX:取 SVC_<NAME 大写,- → _>_<SUFFIX> 变量值(无则空)。
|
||
env_var() {
|
||
local v="SVC_$(printf '%s' "$1" | tr 'a-z-' 'A-Z_')_$2"
|
||
printf '%s' "${!v:-}"
|
||
}
|
||
|
||
ports_recorded() {
|
||
local p="$1" skip="$2" d e a
|
||
for d in "$RUNTIME"/*/; do
|
||
[ -f "${d}svc.env" ] || continue
|
||
e="$(basename "$d")"
|
||
[ "$e" = "$skip" ] && continue
|
||
a="$(sed -n 's/^addr=.*:\([0-9]\+\)$/\1/p' "${d}svc.env")"
|
||
[ "$a" = "$p" ] && return 0
|
||
done
|
||
return 1
|
||
}
|
||
|
||
port_listening() {
|
||
local p="$1"
|
||
command -v ss >/dev/null 2>&1 || return 1
|
||
ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}'
|
||
}
|
||
|
||
# derive_port NAME:由服务名确定性派生端口(8800+hash%300),冲突时在 8800..9099 递增。
|
||
derive_port() {
|
||
local name="$1" p i=0
|
||
p=$(( 8800 + ($(printf '%s' "$name" | cksum | cut -d' ' -f1) % 300) ))
|
||
while [ "$i" -le 300 ]; do
|
||
if ! ports_recorded "$p" "$name" && ! port_listening "$p"; then
|
||
echo "$p"; return 0
|
||
fi
|
||
p=$(( 8800 + (p - 8800 + 1) % 300 ))
|
||
i=$((i + 1))
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# svc_get KEY:读当前服务 svc.env 的键值(无则空)。
|
||
svc_get() {
|
||
[ -f "$ENV_DIR/svc.env" ] || return 0
|
||
sed -n "s/^$1=//p" "$ENV_DIR/svc.env" | head -1
|
||
}
|
||
|
||
# svc_kind NAME:服务类型(svc.env kind=,无登记或无 kind 键 → agent,兼容旧登记)。
|
||
svc_kind() {
|
||
local k=""
|
||
[ -f "$RUNTIME/$1/svc.env" ] && k="$(sed -n 's/^kind=//p' "$RUNTIME/$1/svc.env" | head -1)"
|
||
[ -n "$k" ] && printf '%s' "$k" || printf agent
|
||
}
|
||
|
||
# ---- 登记与事件 ----
|
||
|
||
jclean() { printf '%s' "$1" | tr -d '"\001-\037'; }
|
||
|
||
# hist_event EVENT RESULT PID DETAIL:向 runtime/<name>/history.jsonl 追加事件。
|
||
hist_event() {
|
||
mkdir -p "$ENV_DIR" 2>/dev/null || return 0
|
||
printf '{"ts":"%s","event":"%s","env":"%s","kind":"%s","source":"%s","result":"%s","pid":"%s","addr":"%s","detail":"%s"}\n' \
|
||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||
"$(jclean "$1")" "$(jclean "$ENV_NAME")" "$(jclean "$SVC_KIND")" "$(jclean "${SRC_LABEL:-}")" \
|
||
"$(jclean "$2")" "$(jclean "$3")" "$(jclean "$ENV_ADDR")" "$(jclean "$4")" \
|
||
>> "$ENV_DIR/history.jsonl" 2>/dev/null || true
|
||
}
|
||
|
||
# svc_merge_tags:合并已有 tags 与本次 --tag(去重,逗号连接)。
|
||
svc_merge_tags() {
|
||
local prev t out="" seen=" "
|
||
prev="$(svc_get tags)"
|
||
for t in $(printf '%s' "$prev" | tr ',' ' ') ${CLI_TAGS[@]+"${CLI_TAGS[@]}"}; do
|
||
[ -n "$t" ] || continue
|
||
case "$seen" in *" $t "*) continue ;; esac
|
||
seen="$seen $t "
|
||
out="${out:+$out,}$t"
|
||
done
|
||
printf '%s' "$out"
|
||
}
|
||
|
||
# record_state EXTRA_KEY=VALUE...:把当前态+持久化配置写入 svc.env(600)。
|
||
# 基本键:kind/env/pid/addr/tags/source/version/started/pidcheck/exec;
|
||
# kind 特有键(user/password/dbname/authmode/pgdata/htdocs...)经参数追加。
|
||
record_state() {
|
||
# 先读完旧文件再开重定向(块内 {} > svc.env 会先截断文件,读旧值必须在其之前)。
|
||
local pid prev_src merged_tags
|
||
pid="$(read_pid)"
|
||
prev_src=""
|
||
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
|
||
SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}"
|
||
merged_tags="$(svc_merge_tags)"
|
||
mkdir -p "$ENV_DIR"
|
||
{
|
||
printf 'kind=%s\n' "${SVC_KIND:-agent}"
|
||
printf 'env=%s\n' "$ENV_NAME"
|
||
printf 'pid=%s\n' "${pid:-}"
|
||
printf 'addr=%s\n' "$ENV_ADDR"
|
||
printf 'tags=%s\n' "$merged_tags"
|
||
printf 'source=%s\n' "${SVC_SOURCE:-}"
|
||
printf 'version=%s\n' "${SVC_VERSION:-}"
|
||
printf 'started=%s\n' "$(date -Is)"
|
||
printf 'pidcheck=%s\n' "${SVC_PIDCHECK:-}"
|
||
printf 'exec=%s\n' "${SVC_EXEC:-}"
|
||
[ $# -gt 0 ] && printf '%s\n' "$@"
|
||
} > "$ENV_DIR/svc.env"
|
||
chmod 600 "$ENV_DIR/svc.env"
|
||
}
|
||
|
||
# ---- pid 管理(泛化:pidfile + cmdline 标记双校验)----
|
||
|
||
pid_alive_mark() { # PID MARKER:cmdline 含 MARKER 视为我们的进程
|
||
local pid="$1" mark="$2" cmd
|
||
[ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || return 1
|
||
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
|
||
case "$cmd" in *"$mark"*) return 0 ;; *) return 1 ;; esac
|
||
}
|
||
|
||
# pid_alive PID:当前服务的 pid 校验(svc.env pidcheck 优先;旧登记回退 agent-system)。
|
||
pid_alive() {
|
||
local pid="$1" mark
|
||
mark="$(svc_get pidcheck)"
|
||
[ -z "$mark" ] && mark="agent-system"
|
||
pid_alive_mark "$pid" "$mark"
|
||
}
|
||
|
||
read_pid() { [ -f "$ENV_PID" ] && cat "$ENV_PID" 2>/dev/null || true; }
|
||
|
||
# proc_stop PID(agent/static):TERM 优雅 → KILL 兜底(postgres 不走此路径)。
|
||
proc_stop() {
|
||
local pid="$1"
|
||
kill -TERM "$pid"
|
||
for _ in $(seq 1 100); do
|
||
pid_alive "$pid" || return 0
|
||
sleep 0.1
|
||
done
|
||
c_warn "[$ENV_NAME] 10s 未退出,SIGKILL 兜底"
|
||
kill -KILL "$pid" 2>/dev/null || true
|
||
return 0
|
||
}
|
||
|
||
port_owner() {
|
||
local port="${ENV_ADDR##*:}"
|
||
if command -v ss >/dev/null 2>&1; then
|
||
ss -ltnp 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {print $0; exit}'
|
||
fi
|
||
}
|
||
|
||
check_port_free() {
|
||
local owner; owner="$(port_owner)"
|
||
if [ -n "$owner" ]; then
|
||
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
|
||
c_err "处理:停掉该进程,或换端口(--port / --addr)"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# ---- 服务配置解析(CLI > 配置文件/环境变量 > svc.env 持久化 > 默认)----
|
||
|
||
env_config() {
|
||
local name="$1"
|
||
valid_env "$name" || {
|
||
c_err "非法服务名:$name(^[a-z][a-z0-9-]{0,15}$;保留字 status/list/db/rm 等不可用)"
|
||
exit 2
|
||
}
|
||
ENV_NAME="$name"
|
||
ENV_DIR="$RUNTIME/$name"
|
||
ENV_PID="$ENV_DIR/svc.pid"
|
||
ENV_LOG="$ENV_DIR/svc.log"
|
||
ENV_BIN_DIR="$ENV_DIR/bin"
|
||
ENV_BIN="$ENV_BIN_DIR/agent-system"
|
||
local registered=""
|
||
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
|
||
SVC_KIND="${registered:-agent}"
|
||
[ -n "$CLI_KIND" ] && SVC_KIND="$CLI_KIND"
|
||
# 类型与既有登记冲突防护:同名服务换类型必须先 rm。
|
||
if [ -n "$registered" ] && [ -n "$CLI_KIND" ] && [ "$CLI_KIND" != "$registered" ]; then
|
||
c_err "[$ENV_NAME] 已登记为 kind=$registered,不能改类型;先 scripts/svc.sh rm $ENV_NAME"
|
||
exit 2
|
||
fi
|
||
[ "$SVC_KIND" = postgres ] && ENV_LOG="$ENV_DIR/pg.log"
|
||
|
||
local base_addr base_data persisted_addr
|
||
base_addr="$(env_var "$name" ADDR)"
|
||
base_data="$(env_var "$name" DATA)"
|
||
persisted_addr=""
|
||
[ -f "$ENV_DIR/svc.env" ] && persisted_addr="$(svc_get addr)"
|
||
if [ -z "$base_addr" ]; then
|
||
if [ -n "$persisted_addr" ]; then
|
||
base_addr="$persisted_addr"
|
||
else
|
||
case "$name" in
|
||
dev) base_addr="127.0.0.1:8788" ;;
|
||
prod) base_addr="127.0.0.1:8787" ;;
|
||
*)
|
||
local p
|
||
p="$(derive_port "$name")" || {
|
||
c_err "[$name] 无空闲派生端口(8800-9099),请用 --port/--addr 或 SVC_$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')_ADDR 显式指定"
|
||
exit 1
|
||
}
|
||
base_addr="127.0.0.1:$p"
|
||
;;
|
||
esac
|
||
fi
|
||
fi
|
||
if [ -z "$base_data" ]; then
|
||
case "$SVC_KIND" in
|
||
postgres) base_data="$ENV_DIR/pgdata" ;;
|
||
*) base_data="$ENV_DIR/data" ;;
|
||
esac
|
||
fi
|
||
ENV_ADDR="$(addr_compose "$base_addr")"
|
||
ENV_DATA="${CLI_DATA:-$base_data}"
|
||
return 0
|
||
}
|
||
|
||
# agent_has_explicit_cfg:用户是否显式给了运行参数(决定是否复用持久化 exec=)。
|
||
agent_has_explicit_cfg() {
|
||
[ -n "$CLI_ADDR$CLI_HOST$CLI_PORT$CLI_DSN$CLI_AUTH$CLI_PLUGINS$CLI_EXTRA" ] && return 0
|
||
[ "$CLI_MOCK" = 1 ] && return 0
|
||
[ -n "$SVC_ENVFILE" ] && return 0
|
||
[ -n "$(env_var "$ENV_NAME" ADDR)$(env_var "$ENV_NAME" DATA)$(env_var "$ENV_NAME" DSN)$(env_var "$ENV_NAME" AUTH)$(env_var "$ENV_NAME" MOCK)" ] && return 0
|
||
return 1
|
||
}
|
||
|
||
# adjust_args_for_bin BIN ARGS:按二进制 serve 用法过滤旧产物不认识的 --dsn/--auth。
|
||
adjust_args_for_bin() {
|
||
local bin="$1" args="$2" out
|
||
out="$("$bin" serve -h 2>&1 || true)"
|
||
local filtered="" w drop_next=0
|
||
for w in $args; do
|
||
case "$w" in
|
||
--dsn|--auth)
|
||
if printf '%s\n' "$out" | grep -q -- "$w"; 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
|
||
}
|
||
|
||
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
|
||
c_info "Dev 仓提交($ROOT;倒序取 $count 条)——部署:scripts/svc.sh <name> deploy --from dev@<sha>"
|
||
printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题'
|
||
local head_short; head_short="$(git -C "$ROOT" rev-parse --short HEAD)"
|
||
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" = "$head_short" ] && marks="${marks}[HEAD]"
|
||
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 "$ROOT" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short)
|
||
else
|
||
c_info "Releases 仓提交($REL_ROOT;倒序取 $count 条)——部署:scripts/svc.sh <name> deploy --from release@<版本>"
|
||
printf ' %3s %-9s %-10s %-8s %-9s %s\n' '#' 'sha' '日期' '版本' 'dev提交' '主题'
|
||
local head_full; head_full="$(git -C "$REL_ROOT" rev-parse HEAD)"
|
||
local n=0 full h date subject ver dc marks envs
|
||
while IFS="$sep" read -r full h date subject; do
|
||
[ -z "$full" ] && continue
|
||
n=$((n + 1))
|
||
ver="$(git -C "$REL_ROOT" ls-tree --name-only "$full:versions" 2>/dev/null | sort -V | tail -1 || true)"
|
||
dc=""
|
||
[ -n "$ver" ] && dc="$(git -C "$REL_ROOT" show "$full:versions/$ver/manifest.json" 2>/dev/null | sed -n 's/.*"devCommit": *"\([0-9a-f]\{8,\}\)".*/\1/p' || true)"
|
||
[ -z "$ver" ] && ver="-"
|
||
marks=""
|
||
[ "$full" = "$head_full" ] && marks="${marks}[HEAD]"
|
||
envs="$(deployed_envs "release@$ver")"
|
||
[ -n "$envs" ] && marks="${marks}[已部署:$envs]"
|
||
printf ' %3d %-9s %-10s %-8s %-9s %s %s\n' "$n" "$h" "$date" "$ver" "${dc:0:8}" "$subject" "$marks"
|
||
[ "$pick" = 1 ] && PICK_OPTS+=("$ver")
|
||
done < <(git -C "$REL_ROOT" log -n "$count" --format="%H${sep}%h${sep}%ad${sep}%s" --date=short)
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
cmd_commits() {
|
||
local src="${1:-dev}" count="${2:-20}"
|
||
case "$src" in
|
||
dev|release) ;;
|
||
*) c_err "用法:scripts/svc.sh commits [dev|release] [N]"; exit 2 ;;
|
||
esac
|
||
case "$count" in ''|*[!0-9]*|0) c_err "N 须为正整数"; exit 2 ;; esac
|
||
[ "$src" = release ] && releases_root
|
||
print_commit_list "$src" "$count" 0
|
||
}
|
||
|
||
pick_commit() { # SRC → stdout 只回显 "dev@<sha>"/"release@<版本>"(列表走 stderr)
|
||
local src="$1"
|
||
[ -t 0 ] || { c_err "--pick 需要交互终端;非交互环境请用 --from $src@<sha|版本>"; exit 2; }
|
||
PICK_OPTS=()
|
||
print_commit_list "$src" 20 1 >&2
|
||
local sel
|
||
printf '\n输入序号选择要部署的提交(1-%s,回车取消):' "${#PICK_OPTS[@]}" >&2
|
||
read -r sel || { printf '\n' >&2; c_info "已取消"; exit 0; }
|
||
case "$sel" in
|
||
''|*[!0-9]*) c_info "已取消(未选择)"; exit 0 ;;
|
||
*) [ "$sel" -ge 1 ] && [ "$sel" -le "${#PICK_OPTS[@]}" ] || { c_err "序号超范围:$sel"; exit 2; } ;;
|
||
esac
|
||
echo "$src@${PICK_OPTS[$((sel - 1))]}"
|
||
}
|
||
|
||
# ---- 部署来源解析与物化 ----
|
||
|
||
resolve_release() { # REF(空=最新) → REL_COMMIT/REL_VERSION/SRC_DEVCOMMIT
|
||
local ref="${1:-}" commit ver
|
||
if [ -z "$ref" ]; then
|
||
commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet HEAD)"
|
||
[ -n "$commit" ] || { c_err "Releases 仓无法解析 HEAD:$REL_ROOT"; exit 2; }
|
||
elif commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet "${ref}^{commit}")"; then
|
||
:
|
||
elif git -C "$REL_ROOT" cat-file -e "HEAD:versions/$ref/manifest.json" 2>/dev/null; then
|
||
commit="$(git -C "$REL_ROOT" rev-parse HEAD)"
|
||
else
|
||
c_err "Releases 仓无此引用:$ref(可用版本号或提交号;先看可用项:scripts/svc.sh commits release)"
|
||
exit 2
|
||
fi
|
||
ver="$(git -C "$REL_ROOT" ls-tree --name-only "$commit:versions" 2>/dev/null | sort -V | tail -1 || true)"
|
||
[ -n "$ver" ] || { c_err "提交 $(git -C "$REL_ROOT" rev-parse --short "$commit") 下无 versions/ 快照,无法确定版本号"; exit 2; }
|
||
REL_COMMIT="$commit"; REL_VERSION="$ver"
|
||
SRC_DEVCOMMIT="$(git -C "$REL_ROOT" show "$commit:versions/$ver/manifest.json" 2>/dev/null | sed -n 's/.*"devCommit": *"\([0-9a-f]\{8,\}\)".*/\1/p' || true)"
|
||
}
|
||
|
||
resolve_source() { # → SRC_KINDSRC(local|dev|release) + SRC_LABEL(+SRC_FULL/SRC_SHA/REL_*)
|
||
local spec="${CLI_FROM:-local}"
|
||
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@*)
|
||
local ref="HEAD"
|
||
case "$spec" in dev@*) ref="${spec#dev@}" ;; esac
|
||
local full
|
||
full="$(git -C "$ROOT" rev-parse --verify --quiet "${ref}^{commit}")" || {
|
||
c_err "Dev 仓无此提交:$ref(若是远端新提交,先 git -C $ROOT fetch;可用项:scripts/svc.sh commits dev)"
|
||
exit 2
|
||
}
|
||
SRC_KINDSRC=dev
|
||
SRC_FULL="$full"
|
||
SRC_SHA="$(git -C "$ROOT" rev-parse --short "$full")"
|
||
SRC_LABEL="dev@$SRC_SHA"
|
||
;;
|
||
release|release@*)
|
||
releases_root
|
||
local ref=""
|
||
case "$spec" in release@*) ref="${spec#release@}" ;; esac
|
||
resolve_release "$ref"
|
||
SRC_KINDSRC=release
|
||
SRC_LABEL="release@$REL_VERSION"
|
||
;;
|
||
*)
|
||
c_err "无法解析 --from $spec(可用:local | dev[@<sha>] | release[@<版本|sha>])"
|
||
exit 2
|
||
;;
|
||
esac
|
||
}
|
||
|
||
install_artifacts() { # SRCBIN SRCPLUGINS:agent 产物复制进服务目录。
|
||
local srcbin="$1" srcplug="$2"
|
||
mkdir -p "$ENV_BIN_DIR"
|
||
rm -rf "$ENV_DIR/plugins"
|
||
cp -f "$srcbin" "$ENV_BIN" || return 1
|
||
if [ -d "$srcplug" ]; then
|
||
cp -r "$srcplug" "$ENV_DIR/plugins" || return 1
|
||
else
|
||
c_warn "[$ENV_NAME] 源插件目录缺失:$srcplug(按空插件集部署)"
|
||
mkdir -p "$ENV_DIR/plugins"
|
||
fi
|
||
c_ok "[$ENV_NAME] 产物就位:$ENV_BIN(plugins:$ENV_DIR/plugins)"
|
||
}
|
||
|
||
# materialize_dev_commit FULL SHA TARGET:隔离 worktree 物化+构建;stdout 只回显 worktree
|
||
# 路径(提示信息走 stderr)。TARGET=build(agent)| frontend(static)。
|
||
materialize_dev_commit() {
|
||
local full="$1" sha="$2" target="$3"
|
||
local wt="$RUNTIME/.worktrees/$sha"
|
||
if [ -d "$wt" ]; then
|
||
c_info "[$ENV_NAME] 复用已有构建 worktree:$wt"
|
||
else
|
||
c_info "[$ENV_NAME] 物化 Dev 提交 $sha → 隔离 worktree(不触碰当前工作区)"
|
||
git -C "$ROOT" worktree add --detach "$wt" "$full" >/dev/null || return 1
|
||
fi
|
||
c_info "[$ENV_NAME] 物化子模块组合(git submodule update --init --recursive;缺对象时需网络)"
|
||
git -C "$wt" submodule update --init --recursive || {
|
||
c_err "[$ENV_NAME] 子模块物化失败(对象缺失时先 git -C $ROOT fetch origin)"
|
||
return 1
|
||
}
|
||
c_info "[$ENV_NAME] 构建(make $target:npm ci + 构建,需网络)"
|
||
make -C "$wt" "$target" || return 1
|
||
echo "$wt"
|
||
}
|
||
|
||
# recycle_worktree SHA:回收构建 worktree(尽力而为)。
|
||
recycle_worktree() {
|
||
local wt="$RUNTIME/.worktrees/$1"
|
||
c_info "[$ENV_NAME] 回收构建 worktree"
|
||
git -C "$ROOT" worktree remove --force "$wt" >/dev/null 2>&1 \
|
||
|| c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $ROOT worktree remove --force $wt"
|
||
}
|
||
|
||
materialize_release() {
|
||
local art="$REL_ROOT/build/$REL_VERSION" bin="$REL_ROOT/build/$REL_VERSION/agent-system"
|
||
local mf="$REL_ROOT/versions/$REL_VERSION/manifest.json"
|
||
if [ ! -f "$bin" ]; then
|
||
c_err "[$ENV_NAME] Releases 仓无制品:$bin"
|
||
c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)"
|
||
return 1
|
||
fi
|
||
if [ -f "$mf" ]; then
|
||
local want have
|
||
want="$(sed -n 's/.*"agent-system": *"\([0-9a-f]\{64\}\)".*/\1/p' "$mf" | head -1)"
|
||
have="$(sha256sum "$bin" | cut -d' ' -f1)"
|
||
if [ -n "$want" ] && [ "$want" != "$have" ]; then
|
||
c_err "[$ENV_NAME] sha256 校验失败:制品与 manifest 不符($bin)——拒绝部署"
|
||
return 1
|
||
fi
|
||
[ -n "$want" ] && c_ok "[$ENV_NAME] sha256 校验通过(manifest:$REL_VERSION)"
|
||
else
|
||
c_warn "[$ENV_NAME] 无 manifest 可校验:$mf"
|
||
fi
|
||
[ -n "${SRC_DEVCOMMIT:-}" ] && c_info "[$ENV_NAME] 版本对应 Dev 提交:$SRC_DEVCOMMIT"
|
||
install_artifacts "$bin" "$art/plugins" || return 1
|
||
}
|
||
|
||
# ---- agent:部署与启动 ----
|
||
|
||
agent_do_deploy() {
|
||
resolve_source
|
||
if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi
|
||
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
|
||
do_stop_quiet
|
||
local wt
|
||
case "$SRC_KINDSRC" in
|
||
local)
|
||
c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径"
|
||
make -C "$ROOT" build || { hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"; return 1; }
|
||
install_artifacts "$BIN" "$ROOT/plugins" \
|
||
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
|
||
;;
|
||
dev)
|
||
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \
|
||
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
|
||
install_artifacts "$wt/bin/agent-system" "$wt/plugins" \
|
||
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
|
||
recycle_worktree "$SRC_SHA"
|
||
;;
|
||
release)
|
||
materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; }
|
||
;;
|
||
esac
|
||
agent_do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; return 1; }
|
||
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
|
||
return 0
|
||
}
|
||
|
||
print_agent_plan() {
|
||
c_info "[$ENV_NAME] 部署计划(dry-run,不执行)"
|
||
printf ' 服务:%s(kind=agent)→ %s(addr %s,data %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR" "$ENV_DATA"
|
||
case "$SRC_KINDSRC" in
|
||
local)
|
||
printf ' 来源:%s(make build 当前工作区;唯一写主检出构建产物的路径)\n' "$SRC_LABEL"
|
||
printf ' 产物:%s ← %s + %s/plugins\n' "$ENV_BIN" "$BIN" "$ROOT" ;;
|
||
dev)
|
||
printf ' 来源:%s(完整提交 %s)\n' "$SRC_LABEL" "$SRC_FULL"
|
||
printf ' 物化:worktree %s(submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/$SRC_SHA" ;;
|
||
release)
|
||
printf ' 来源:%s(Releases 提交 %s)\n' "$SRC_LABEL" "$(git -C "$REL_ROOT" rev-parse --short "$REL_COMMIT")"
|
||
[ -n "${SRC_DEVCOMMIT:-}" ] && printf ' 对应 Dev 提交:%s\n' "$SRC_DEVCOMMIT"
|
||
local art="$REL_ROOT/build/$REL_VERSION/agent-system"
|
||
if [ -f "$art" ]; then printf ' 制品:%s(存在,将做 sha256 校验)\n' "$art"
|
||
else printf ' 制品:%s(缺失!需先在 Releases 仓构建,或换版本)\n' "$art"; fi ;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入 settings(幂等)。
|
||
seed_settings() {
|
||
local host="$1" port="$2"
|
||
local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}"
|
||
local lb="${CLI_LBASE:-${SVC_LIGHT_BASE:-}}" lk="${CLI_LKEY:-${SVC_LIGHT_KEY:-}}" ln="${CLI_LNAME:-${SVC_LIGHT_NAME:-}}"
|
||
local tk="${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}"
|
||
[ -z "$cb$ck$cn$lb$lk$ln$tk" ] && return 0
|
||
command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
|
||
local payload
|
||
payload="$(python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" <<'PY'
|
||
import json, sys
|
||
cb, ck, cn, lb, lk, ln, tk = sys.argv[1:8]
|
||
out = {}
|
||
def sub(base, key, name):
|
||
d = {}
|
||
if base: d["baseUrl"] = base
|
||
if key: d["apiKey"] = key
|
||
if name: d["model"] = name
|
||
return d
|
||
chat, light = sub(cb, ck, cn), sub(lb, lk, ln)
|
||
models = {}
|
||
if chat: models["chat"] = chat
|
||
if light: models["lightweight"] = light
|
||
if models: out["models"] = models
|
||
if tk: out["tools"] = {"tavily": {"apiKeys": tk}}
|
||
print(json.dumps(out))
|
||
PY
|
||
)"
|
||
local resp
|
||
resp="$(curl -s -m 5 -X PUT "http://$host:$port/api/settings/user" -H 'Content-Type: application/json' -d "$payload")"
|
||
if echo "$resp" | grep -q '"ok": *true'; then
|
||
local did=""
|
||
[ -n "$cb$ck$cn" ] && did="主模型"
|
||
[ -n "$lb$lk$ln" ] && did="${did:+$did、}轻量模型"
|
||
[ -n "$tk" ] && did="${did:+$did、}Tavily"
|
||
c_ok "[$ENV_NAME] 已播种设置:$did(持久化于 $ENV_DATA,后续启动无需再传)"
|
||
else
|
||
c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(若开启了 API key 认证,需在设置页手动配置)"
|
||
fi
|
||
}
|
||
|
||
agent_do_start() {
|
||
mkdir -p "$ENV_DATA" "$ENV_DIR"
|
||
check_port_free || return 1
|
||
if [ ! -x "$ENV_BIN" ]; then
|
||
c_err "[$ENV_NAME] 环境产物缺失:$ENV_BIN"
|
||
c_err "请先部署:scripts/svc.sh $ENV_NAME deploy [--from local|dev[@<sha>]|release[@<版本>]]"
|
||
return 1
|
||
fi
|
||
SVC_VERSION="$("$ENV_BIN" version 2>/dev/null || true)"
|
||
# 组装启动参数:显式配置 → 重建;否则复用上次持久化 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=;显式参数可覆盖):$exec_args"
|
||
else
|
||
local args=""
|
||
if [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$ENV_NAME" MOCK)" = "1" ]; then
|
||
args="--mock"
|
||
elif [ "$ENV_NAME" != dev ]; then
|
||
local dsn="${CLI_DSN:-$(env_var "$ENV_NAME" DSN)}"
|
||
[ -n "$dsn" ] && args="$args --dsn $dsn"
|
||
local auth="${CLI_AUTH:-$(env_var "$ENV_NAME" AUTH)}"
|
||
[ -z "$auth" ] && auth=auto
|
||
args="$args --auth $auth"
|
||
fi
|
||
local adjusted; adjusted="$(adjust_args_for_bin "$ENV_BIN" "$args")"
|
||
if [ "$adjusted" != "$args" ]; then
|
||
c_warn "[$ENV_NAME] 该产物不支持 --dsn/--auth(旧版本二进制),已自动省略:${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"
|
||
c_info "[$ENV_NAME] 启动:$ENV_BIN serve $exec_args"
|
||
# 直接 nohup 简单命令:$! 即服务进程本身,pidfile 语义才成立。
|
||
nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 &
|
||
echo $! > "$ENV_PID"
|
||
record_state
|
||
# 健康探测:取 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
|
||
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)"
|
||
hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}"
|
||
seed_settings "$phost" "${probe##*:}"
|
||
return 0
|
||
fi
|
||
pid_alive "$(read_pid)" || {
|
||
c_err "[$ENV_NAME] 进程启动后即退出,查看日志:tail -40 $ENV_DIR/svc.log"
|
||
hist_event start fail "$(read_pid)" "进程即退(日志 svc.log)"
|
||
return 1
|
||
}
|
||
sleep 0.2
|
||
done
|
||
c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_DIR/svc.log"
|
||
hist_event start fail "$(read_pid)" "健康检查超时"
|
||
return 1
|
||
}
|
||
|
||
# ---- static:前端静态服务 ----
|
||
|
||
static_do_deploy() {
|
||
# --dir:登记既有目录(不构建);否则 dev@sha/local 构建 frontend dist → htdocs/。
|
||
if [ -n "$CLI_DIR" ]; then
|
||
[ -d "$CLI_DIR" ] || { c_err "[$ENV_NAME] --dir 目录不存在:$CLI_DIR"; return 1; }
|
||
resolve_dir_deploy
|
||
return 0
|
||
fi
|
||
resolve_source
|
||
if [ "$CLI_DRYRUN" = 1 ]; then print_static_plan; return 0; fi
|
||
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
|
||
do_stop_quiet
|
||
local src_dist wt
|
||
case "$SRC_KINDSRC" in
|
||
local)
|
||
c_info "[$ENV_NAME] 构建当前工作区前端(make frontend)——写主检出构建产物(gitignore 目录)"
|
||
make -C "$ROOT" frontend || { hist_event deploy fail "$(read_pid)" "make frontend 失败"; return 1; }
|
||
src_dist="$ROOT/modules/frontend/dist"
|
||
;;
|
||
dev)
|
||
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" frontend)" \
|
||
|| { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; }
|
||
src_dist="$wt/modules/frontend/dist"
|
||
recycle_worktree "$SRC_SHA"
|
||
;;
|
||
release)
|
||
c_err "[$ENV_NAME] release 制品内嵌前端(无独立 dist);static 请用 --from dev|local 或 --dir"
|
||
hist_event deploy fail "$(read_pid)" "release 源不支持 static"
|
||
return 1
|
||
;;
|
||
esac
|
||
[ -d "$src_dist" ] || { c_err "[$ENV_NAME] 前端产物缺失:$src_dist"; hist_event deploy fail "$(read_pid)" "dist 缺失"; return 1; }
|
||
mkdir -p "$ENV_DIR"
|
||
rm -rf "${ENV_DIR:?}/htdocs"
|
||
cp -r "$src_dist" "$ENV_DIR/htdocs" || { hist_event deploy fail "$(read_pid)" "dist 复制失败"; return 1; }
|
||
write_static_reg "$ENV_DIR/htdocs"
|
||
c_ok "[$ENV_NAME] 前端产物就位:$ENV_DIR/htdocs(源 $SRC_LABEL)"
|
||
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
|
||
return 0
|
||
}
|
||
|
||
resolve_dir_deploy() { # --dir 路径:登记(含 dry-run 计划)后交由 start 提供服务
|
||
local dir; dir="$(cd "$CLI_DIR" && pwd)"
|
||
SRC_LABEL="dir@${dir}"
|
||
if [ "$CLI_DRYRUN" = 1 ]; then
|
||
print_static_plan
|
||
return 0
|
||
fi
|
||
do_stop_quiet
|
||
write_static_reg "$dir"
|
||
c_ok "[$ENV_NAME] 静态目录登记:$dir(source=$SRC_LABEL)"
|
||
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
|
||
return 0
|
||
}
|
||
|
||
# write_static_reg HTDOCS:登记/更新 static 服务(保留 pid 等当前态由 start 覆写)。
|
||
write_static_reg() {
|
||
local htdocs="$1"
|
||
local old_pid=""; [ -f "$ENV_PID" ] && old_pid="$(read_pid)"
|
||
local merged_tags; merged_tags="$(svc_merge_tags)" # 先读旧文件再截断
|
||
mkdir -p "$ENV_DIR"
|
||
{
|
||
printf 'kind=static\n'
|
||
printf 'env=%s\n' "$ENV_NAME"
|
||
printf 'pid=%s\n' "${old_pid:-}"
|
||
printf 'addr=%s\n' "$ENV_ADDR"
|
||
printf 'tags=%s\n' "$merged_tags"
|
||
printf 'source=%s\n' "$SRC_LABEL"
|
||
printf 'started=%s\n' "$(date -Is)"
|
||
printf 'htdocs=%s\n' "$htdocs"
|
||
} > "$ENV_DIR/svc.env"
|
||
chmod 600 "$ENV_DIR/svc.env"
|
||
}
|
||
|
||
print_static_plan() {
|
||
c_info "[$ENV_NAME] 部署计划(dry-run,不执行)"
|
||
printf ' 服务:%s(kind=static)→ %s(addr %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR"
|
||
if [ -n "$CLI_DIR" ]; then
|
||
printf ' 目录:%s(--dir 直接服务,不构建)\n' "$CLI_DIR"
|
||
else
|
||
case "$SRC_KINDSRC" in
|
||
local) printf ' 来源:%s(make frontend → htdocs/)\n' "$SRC_LABEL" ;;
|
||
dev) printf ' 来源:%s(完整提交 %s)\n 物化:worktree %s(make frontend → htdocs/)\n' \
|
||
"$SRC_LABEL" "$SRC_FULL" "$RUNTIME/.worktrees/$SRC_SHA" ;;
|
||
release) printf ' 来源:release —— 不支持(制品内嵌前端);请用 dev|local 或 --dir\n' ;;
|
||
esac
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
static_do_start() {
|
||
mkdir -p "$ENV_DIR"
|
||
check_port_free || return 1
|
||
command -v python3 >/dev/null 2>&1 || { c_err "[$ENV_NAME] static 需要 python3"; return 1; }
|
||
local htdocs; htdocs="$(svc_get htdocs)"
|
||
[ -n "$htdocs" ] || htdocs="$ENV_DIR/htdocs"
|
||
[ -d "$htdocs" ] || { c_err "[$ENV_NAME] 静态目录缺失:$htdocs(先 deploy 或 --dir)"; return 1; }
|
||
local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}"
|
||
SVC_EXEC="--directory $htdocs"
|
||
SVC_PIDCHECK="--directory $htdocs"
|
||
SVC_VERSION=""
|
||
c_info "[$ENV_NAME] 启动静态服务:python3 http.server $port --bind $host --directory $htdocs"
|
||
nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 &
|
||
echo $! > "$ENV_PID"
|
||
record_state "htdocs=$htdocs"
|
||
local probe_host="$host"
|
||
[ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1"
|
||
for _ in $(seq 1 50); do
|
||
if curl -s -o /dev/null -m 2 "http://$probe_host:$port/" 2>/dev/null; then
|
||
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] 进程启动后即退出,查看日志:tail -40 $ENV_DIR/svc.log"
|
||
hist_event start fail "$(read_pid)" "进程即退"
|
||
return 1
|
||
}
|
||
sleep 0.2
|
||
done
|
||
c_err "[$ENV_NAME] 10s 健康检查未通过"
|
||
hist_event start fail "$(read_pid)" "健康检查超时"
|
||
return 1
|
||
}
|
||
|
||
# ---- postgres:数据库服务 ----
|
||
|
||
pg_bindir() {
|
||
if [ -n "${SVC_PG_BIN:-}" ] && [ -x "${SVC_PG_BIN}/initdb" ]; then printf '%s' "$SVC_PG_BIN"; return 0; fi
|
||
local d
|
||
d="$(pg_config --bindir 2>/dev/null || true)"
|
||
[ -n "$d" ] && [ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; }
|
||
for d in /usr/lib/postgresql/*/bin /usr/local/pgsql/bin; do
|
||
[ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; }
|
||
done
|
||
return 1
|
||
}
|
||
|
||
cmd_db() {
|
||
local sub="${1:-}"
|
||
[ $# -gt 0 ] && shift
|
||
case "$sub" in
|
||
create) db_create "$@" ;;
|
||
dsn) db_dsn "$@" ;;
|
||
backup) db_backup "$@" ;;
|
||
backups) db_backups "$@" ;;
|
||
restore) db_restore "$@" ;;
|
||
clone) db_clone "$@" ;;
|
||
copy) db_copy "$@" ;;
|
||
ls) db_ls "$@" ;;
|
||
set) cmd_set "$@" ;;
|
||
*) c_err "用法:scripts/svc.sh db create|dsn|backup|backups|restore|clone|copy|ls|set <name> [...]
|
||
db create <name> [--port --user --password --db --auth-mode --tag]
|
||
db dsn <name> [--reveal] [--db X]
|
||
db backup <name> [--db X] [--out F] | db backups <name>
|
||
db restore <name> --file F [--db X] [--force]
|
||
db clone <src> <dst> [--port P] [--password PW] [--tag T] [--online]
|
||
db copy <src> <dst> [--db X] [--force]
|
||
db ls <name> | db set <name> [--port N --apply]"; exit 2 ;;
|
||
esac
|
||
}
|
||
|
||
db_create() {
|
||
local name="${1:-}"
|
||
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db create <name> [...]"; exit 2; }
|
||
shift
|
||
parse_args "$@"
|
||
env_config "$name"
|
||
if [ -f "$ENV_DIR/svc.env" ]; then
|
||
c_err "[$ENV_NAME] 已有登记(kind=$(svc_kind "$name"));重建先 scripts/svc.sh rm $ENV_NAME [--purge]"
|
||
exit 2
|
||
fi
|
||
local pgbin
|
||
pgbin="$(pg_bindir)" || { c_err "找不到 postgres 工具链(initdb/pg_ctl);安装 postgresql 或设 SVC_PG_BIN=<bindir>"; exit 2; }
|
||
local user="${CLI_DB_USER:-agent}" dbname="${CLI_DB_NAME:-agent}" authmode="${CLI_DB_AUTH:-scram-sha-256}"
|
||
local password="${CLI_DB_PASSWORD:-$(head -c 18 /dev/urandom | od -An -tx1 | tr -d ' \n')}"
|
||
local pgdata="$ENV_DIR/pgdata"
|
||
if [ -e "$pgdata" ]; then
|
||
c_err "[$ENV_NAME] 数据目录已存在:$pgdata(rm --purge 后重建,或换名)"
|
||
exit 2
|
||
fi
|
||
check_port_free || exit 1
|
||
mkdir -p "$ENV_DIR"
|
||
chmod 700 "$ENV_DIR"
|
||
local pwf="$ENV_DIR/.pwfile"
|
||
( umask 077; printf '%s\n' "$password" > "$pwf" )
|
||
c_info "[$ENV_NAME] initdb(用户 $user,认证 $authmode,数据 $pgdata)"
|
||
if ! "$pgbin/initdb" -D "$pgdata" -U "$user" -A "$authmode" --pwfile="$pwf" -E UTF8 >/dev/null; then
|
||
rm -f "$pwf"
|
||
c_err "[$ENV_NAME] initdb 失败(上方输出)"
|
||
exit 1
|
||
fi
|
||
rm -f "$pwf"
|
||
SVC_KIND=postgres
|
||
SRC_LABEL="postgres@local"
|
||
local pgver merged_tags
|
||
pgver="$("$pgbin/postgres" --version 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+(\.[0-9]+)+$/) {print $i; exit}}' || true)"
|
||
merged_tags="$(svc_merge_tags)"
|
||
{
|
||
printf 'kind=postgres\n'
|
||
printf 'env=%s\n' "$ENV_NAME"
|
||
printf 'addr=%s\n' "$ENV_ADDR"
|
||
printf 'tags=%s\n' "$merged_tags"
|
||
printf 'source=%s\n' "$SRC_LABEL"
|
||
printf 'version=%s\n' "$pgver"
|
||
printf 'user=%s\n' "$user"
|
||
printf 'password=%s\n' "$password"
|
||
printf 'dbname=%s\n' "$dbname"
|
||
printf 'authmode=%s\n' "$authmode"
|
||
printf 'pgdata=%s\n' "$pgdata"
|
||
} > "$ENV_DIR/svc.env"
|
||
chmod 600 "$ENV_DIR/svc.env"
|
||
rm -f "$ENV_PID"
|
||
hist_event create ok "" "user=$user db=$dbname authmode=$authmode port=${ENV_ADDR##*:}"
|
||
c_ok "[$ENV_NAME] 数据库已创建(未启动):scripts/svc.sh $ENV_NAME start"
|
||
c_info "[$ENV_NAME] 连接串:scripts/svc.sh db dsn $ENV_NAME --reveal(密码存 svc.env,权限 600)"
|
||
return 0
|
||
}
|
||
|
||
# pg_psql SQL [tA]:以登记的管理员+密码走 TCP 执行;tA=取纯值输出。
|
||
pg_psql() {
|
||
local sql="$1" ta="${2:-}" pgbin
|
||
pgbin="$(pg_bindir)"
|
||
PGPASSWORD="$(svc_get password)" "$pgbin/psql" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" \
|
||
-U "$(svc_get user)" -d postgres ${ta:+-tA} -c "$sql" 2>/dev/null
|
||
}
|
||
|
||
pg_do_start() {
|
||
local pgbin
|
||
pgbin="$(pg_bindir)" || { c_err "[$ENV_NAME] 找不到 postgres 工具链;设 SVC_PG_BIN"; return 1; }
|
||
local pgdata; pgdata="$(svc_get pgdata)"
|
||
[ -n "$pgdata" ] && [ -d "$pgdata" ] || { c_err "[$ENV_NAME] 无数据目录(先 db create)"; return 1; }
|
||
check_port_free || return 1
|
||
local listen="${ENV_ADDR%:*}"
|
||
[ "$listen" = "0.0.0.0" ] || [ "$listen" = "::" ] && listen="'*'"
|
||
c_info "[$ENV_NAME] 启动 postgres:$pgdata(addr $ENV_ADDR)"
|
||
if ! "$pgbin/pg_ctl" -D "$pgdata" -w -t 30 -l "$ENV_DIR/pg.log" \
|
||
-o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1; then
|
||
c_err "[$ENV_NAME] pg_ctl start 失败,查看日志:tail -40 $ENV_DIR/pg.log"
|
||
hist_event start fail "" "pg_ctl start 失败"
|
||
return 1
|
||
fi
|
||
local pid; pid="$(head -1 "$pgdata/postmaster.pid" 2>/dev/null || true)"
|
||
[ -n "$pid" ] && echo "$pid" > "$ENV_PID"
|
||
SVC_PIDCHECK="$pgdata"
|
||
SVC_EXEC="pg_ctl -D $pgdata -p ${ENV_ADDR##*:}"
|
||
SVC_VERSION="$(svc_get version)"
|
||
# 首次启动:建默认库(已存在则跳过)。
|
||
local dbname; dbname="$(svc_get dbname)"
|
||
if [ -n "$dbname" ]; then
|
||
if ! pg_psql "SELECT 1 FROM pg_database WHERE datname='$dbname'" tA | grep -q 1; then
|
||
if pg_psql "CREATE DATABASE \"$dbname\""; then
|
||
c_ok "[$ENV_NAME] 已建库:$dbname"
|
||
else
|
||
c_warn "[$ENV_NAME] 建库 $dbname 未成功(可手动创建)"
|
||
fi
|
||
fi
|
||
fi
|
||
record_state "user=$(svc_get user)" "password=$(svc_get password)" "dbname=$(svc_get dbname)" \
|
||
"authmode=$(svc_get authmode)" "pgdata=$pgdata"
|
||
if "$pgbin/pg_isready" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" >/dev/null 2>&1; then
|
||
c_ok "[$ENV_NAME] postgres 就绪 pid=$(read_pid) → $ENV_ADDR(库 $(svc_get dbname),用户 $(svc_get user))"
|
||
hist_event start ok "$(read_pid)" "pg_isready ok"
|
||
return 0
|
||
fi
|
||
c_warn "[$ENV_NAME] pg_isready 未通过(进程已起,查看 $ENV_DIR/pg.log)"
|
||
hist_event start warn "$(read_pid)" "进程起但 pg_isready 未过"
|
||
return 0
|
||
}
|
||
|
||
pg_do_stop() {
|
||
local pgbin pgdata pid
|
||
pgbin="$(pg_bindir)" || { c_err "[$ENV_NAME] 找不到 postgres 工具链"; return 1; }
|
||
pgdata="$(svc_get pgdata)"
|
||
pid="$(read_pid)"
|
||
if [ -z "$pid" ] || ! pid_alive "$pid"; then
|
||
c_info "[$ENV_NAME] postgres 未在运行"
|
||
rm -f "$ENV_PID"
|
||
return 0
|
||
fi
|
||
c_info "[$ENV_NAME] 停止 postgres pid=$pid(pg_ctl -m fast,优雅回写)"
|
||
if [ -n "$pgdata" ] && "$pgbin/pg_ctl" -D "$pgdata" -m fast -w -t 30 stop >/dev/null 2>&1; then
|
||
rm -f "$ENV_PID"
|
||
c_ok "[$ENV_NAME] postgres 已停止"
|
||
hist_event stop ok "$pid" "pg_ctl fast"
|
||
return 0
|
||
fi
|
||
c_warn "[$ENV_NAME] pg_ctl stop 失败,尝试 SIGTERM(绝不 SIGKILL postmaster)"
|
||
kill -TERM "$pid" 2>/dev/null || true
|
||
for _ in $(seq 1 100); do
|
||
pid_alive "$pid" || { rm -f "$ENV_PID"; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; }
|
||
sleep 0.1
|
||
done
|
||
c_err "[$ENV_NAME] postgres 未能停止(保留现场;查 $pgdata/postmaster.pid 与 $ENV_DIR/pg.log)"
|
||
hist_event stop fail "$pid" "未能停止"
|
||
return 1
|
||
}
|
||
|
||
db_dsn() {
|
||
local name="${1:-}"
|
||
[ -n "$name" ] || { c_err "用法:scripts/svc.sh db dsn <name> [--reveal] [--db X]"; exit 2; }
|
||
shift
|
||
parse_args "$@"
|
||
env_config "$name"
|
||
[ "$SVC_KIND" = postgres ] || { c_err "[$ENV_NAME] kind=$SVC_KIND 不是 postgres"; exit 2; }
|
||
local user dbname pass host port
|
||
user="$(svc_get user)"
|
||
dbname="${CLI_DB_NAME:-$(svc_get dbname)}"
|
||
pass="$(svc_get password)"
|
||
host="${ENV_ADDR%:*}"; port="${ENV_ADDR##*:}"
|
||
if [ "$CLI_REVEAL" = 1 ]; then
|
||
printf 'postgresql://%s:%s@%s:%s/%s\n' "$user" "$pass" "$host" "$port" "$dbname"
|
||
else
|
||
printf 'postgresql://%s:***@%s:%s/%s\n' "$user" "$host" "$port" "$dbname"
|
||
c_info "密码已登记于 $ENV_DIR/svc.env(--reveal 显示)"
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# ---- 数据库运维:换端口 / 备份 / 恢复 / 克隆 / 搬库 ----
|
||
|
||
# check_port_available PORT SKIP_NAME:显式选端口时校验(未被其他登记占用且未在监听)。
|
||
check_port_available() {
|
||
local p="$1" skip="$2"
|
||
if ports_recorded "$p" "$skip"; then
|
||
c_err "[$ENV_NAME] 端口 $p 已被其他服务登记占用"
|
||
return 1
|
||
fi
|
||
if port_listening "$p"; then
|
||
c_err "[$ENV_NAME] 端口 $p 正被监听(非本脚本登记的进程)"
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# svc_set_key KEY VALUE:改写当前服务 svc.env 的单键(先读后写,避开重定向截断陷阱)。
|
||
svc_set_key() {
|
||
local key="$1" val="$2" rest
|
||
[ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; }
|
||
rest="$(grep -v "^$key=" "$ENV_DIR/svc.env" || true)"
|
||
{ [ -n "$rest" ] && printf '%s\n' "$rest"; printf '%s=%s\n' "$key" "$val"; } > "$ENV_DIR/svc.env"
|
||
chmod 600 "$ENV_DIR/svc.env"
|
||
return 0
|
||
}
|
||
|
||
# 显式参数版 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
|
||
}
|
||
|
||
cmd_set() {
|
||
local name="${1:-}"
|
||
[ -n "$name" ] || { c_err "用法:scripts/svc.sh set <name> [--port N] [--host H] [--apply] [--tag T...]"; exit 2; }
|
||
shift
|
||
parse_args "$@"
|
||
env_config "$name"
|
||
[ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 无登记(先 db create / deploy)"; exit 2; }
|
||
# tags:给了 --tag 才改(合并去重;postgres/agent/static 通用)。
|
||
if [ "${#CLI_TAGS[@]}" -gt 0 ]; then
|
||
local merged; merged="$(svc_merge_tags)"
|
||
svc_set_key tags "$merged"
|
||
c_ok "[$ENV_NAME] 标签更新:$merged"
|
||
fi
|
||
case "$SVC_KIND" in
|
||
postgres)
|
||
if [ -n "$CLI_ADDR$CLI_HOST$CLI_PORT" ]; then
|
||
local old; old="$(svc_get addr)"; [ -n "$old" ] || old="$ENV_ADDR"
|
||
local running=0
|
||
if [ -n "$(read_pid)" ] && pid_alive "$(read_pid)"; then running=1; fi
|
||
if [ "$running" = 1 ] && [ "$CLI_APPLY" != 1 ]; then
|
||
c_err "[$ENV_NAME] 运行中——端口/地址变更需重启生效:加 --apply(停止→改登记→重启),或先 stop 再 set"
|
||
exit 1
|
||
fi
|
||
check_port_available "${ENV_ADDR##*:}" "$ENV_NAME" || exit 1
|
||
if [ "$running" = 1 ]; then
|
||
pg_do_stop
|
||
svc_set_key addr "$ENV_ADDR"
|
||
pg_do_start || { c_err "[$ENV_NAME] 新地址重启失败(登记已是 $ENV_ADDR);查看 $ENV_DIR/pg.log"; return 1; }
|
||
else
|
||
svc_set_key addr "$ENV_ADDR"
|
||
fi
|
||
hist_event set ok "$(read_pid)" "addr $old → $ENV_ADDR"
|
||
c_ok "[$ENV_NAME] 地址已更新:$old → $ENV_ADDR"
|
||
elif [ "${#CLI_TAGS[@]}" -eq 0 ]; then
|
||
c_warn "[$ENV_NAME] 未指定任何变更(--port/--host/--addr/--tag)"
|
||
fi
|
||
;;
|
||
agent|static)
|
||
[ -n "$CLI_ADDR$CLI_HOST$CLI_PORT" ] && \
|
||
c_warn "[$ENV_NAME] agent/static 换端口请用 restart --port N(进程与登记同步更新);set 只改登记"
|
||
[ "${#CLI_TAGS[@]}" -eq 0 ] && c_warn "[$ENV_NAME] 未指定任何变更(--tag)"
|
||
;;
|
||
*) c_err "[$ENV_NAME] 未知 kind:$SVC_KIND"; return 1 ;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# ---- 生命周期分派 ----
|
||
|
||
do_start() {
|
||
if pid_alive "$(read_pid)"; then
|
||
c_warn "[$ENV_NAME] 已在运行 pid=$(read_pid)(如需换参数/新版本请用 deploy/restart)"
|
||
return 0
|
||
fi
|
||
case "$SVC_KIND" in
|
||
agent) agent_do_start ;;
|
||
static) static_do_start ;;
|
||
postgres) pg_do_start ;;
|
||
*) c_err "[$ENV_NAME] 未知 kind:$SVC_KIND"; return 1 ;;
|
||
esac
|
||
}
|
||
|
||
do_stop() {
|
||
local pid; pid="$(read_pid)"
|
||
if [ -z "$pid" ]; then
|
||
c_info "[$ENV_NAME] 无 pidfile——没有由本脚本启动的实例"
|
||
return 0
|
||
fi
|
||
if ! pid_alive "$pid"; then
|
||
c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile"
|
||
rm -f "$ENV_PID"
|
||
return 0
|
||
fi
|
||
case "$SVC_KIND" in
|
||
postgres) pg_do_stop ;;
|
||
*)
|
||
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
|
||
proc_stop "$pid"
|
||
rm -f "$ENV_PID"
|
||
c_ok "[$ENV_NAME] 已停止"
|
||
hist_event stop ok "$pid" "SIGTERM"
|
||
;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# do_stop_quiet:deploy 前置——未运行时静默返回。
|
||
do_stop_quiet() {
|
||
local pid; pid="$(read_pid)"
|
||
if [ -z "$pid" ]; then return 0; fi
|
||
if ! pid_alive "$pid"; then
|
||
rm -f "$ENV_PID"
|
||
return 0
|
||
fi
|
||
case "$SVC_KIND" in
|
||
postgres) pg_do_stop ;;
|
||
*)
|
||
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
|
||
proc_stop "$pid"
|
||
rm -f "$ENV_PID"
|
||
c_ok "[$ENV_NAME] 已停止"
|
||
hist_event stop ok "$pid" "SIGTERM"
|
||
;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
cmd_rm() {
|
||
local name="${1:-}"
|
||
[ -n "$name" ] || { c_err "用法:scripts/svc.sh rm <name> [--purge]"; exit 2; }
|
||
shift
|
||
parse_args "$@"
|
||
env_config "$name"
|
||
if [ ! -f "$ENV_DIR/svc.env" ]; then
|
||
c_err "[$ENV_NAME] 无登记(runtime/$name/svc.env 不存在);rm 只管理本脚本登记的服务"
|
||
exit 2
|
||
fi
|
||
do_stop
|
||
hist_event rm ok "$(read_pid)" "${CLI_PURGE:+purge}"
|
||
rm -f "$ENV_DIR/svc.env" "$ENV_DIR/svc.pid"
|
||
if [ "$CLI_PURGE" = 1 ]; then
|
||
rm -rf "${ENV_DIR:?}"
|
||
c_ok "[$ENV_NAME] 已注销并删除整个目录:$ENV_DIR(含数据与历史)"
|
||
else
|
||
c_ok "[$ENV_NAME] 已注销登记;目录保留(数据/历史在 $ENV_DIR;彻底删除用 --purge)"
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# ---- 总览与历史 ----
|
||
|
||
svc_pid_state() { # NAME → "alive <pid>" | "dead"
|
||
local d="$RUNTIME/$1" pid mark
|
||
[ -f "$d/svc.pid" ] || { printf dead; return 0; }
|
||
pid="$(cat "$d/svc.pid" 2>/dev/null || true)"
|
||
[ -n "$pid" ] || { printf dead; return 0; }
|
||
mark="$(sed -n 's/^pidcheck=//p' "$d/svc.env" 2>/dev/null | head -1)"
|
||
[ -z "$mark" ] && mark="agent-system"
|
||
if pid_alive_mark "$pid" "$mark"; then printf 'alive %s' "$pid"; else printf dead; fi
|
||
return 0
|
||
}
|
||
|
||
global_list() {
|
||
c_info "服务总览(root: $ROOT)"
|
||
printf ' %-12s %-9s %-7s %-8s %-20s %-24s %s\n' NAME KIND STATE PID ADDR SOURCE TAGS
|
||
local names=() d e name kind addr src tags st
|
||
for d in "$RUNTIME"/*/; do
|
||
[ -f "${d}svc.env" ] || continue
|
||
e="$(basename "$d")"
|
||
[ "$e" = ".worktrees" ] && continue
|
||
valid_env "$e" || continue
|
||
names+=("$e")
|
||
done
|
||
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")"
|
||
if [ "${st%% *}" = alive ]; then
|
||
printf ' %-12s %-9s %-7s %-8s %-20s %-24s %s\n' "$name" "$kind" "运行中" "${st#alive }" "$addr" "${src:--}" "${tags:--}"
|
||
else
|
||
printf ' %-12s %-9s %-7s %-8s %-20s %-24s %s\n' "$name" "$kind" "未运行" "-" "$addr" "${src:--}" "${tags:--}"
|
||
fi
|
||
done
|
||
[ "${#names[@]}" -eq 0 ] && c_info "(暂无已登记服务;db create / deploy 会登记)"
|
||
return 0
|
||
}
|
||
|
||
env_status_detail() {
|
||
local pid; pid="$(read_pid)"
|
||
local addr="$ENV_ADDR" a src="" ver="" tags=""
|
||
local kind="$SVC_KIND"
|
||
if [ -f "$ENV_DIR/svc.env" ]; then
|
||
a="$(svc_get addr)"; [ -n "$a" ] && addr="$a"
|
||
src="$(svc_get source)"; ver="$(svc_get version)"; tags="$(svc_get tags)"
|
||
fi
|
||
if pid_alive "$pid"; then
|
||
printf ' [%s] %s 运行中 pid=%s\n' "$ENV_NAME" "$kind" "$pid"
|
||
else
|
||
printf ' [%s] %s 未运行\n' "$ENV_NAME" "$kind"
|
||
fi
|
||
printf ' 地址 %s 数据 %s\n' "$addr" "$ENV_DATA"
|
||
[ -n "$src" ] && printf ' 来源 %s 版本 %s\n' "$src" "${ver:--}"
|
||
[ -n "$tags" ] && printf ' 标签 %s\n' "$tags"
|
||
case "$kind" in
|
||
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 条)"
|
||
cat "${files[@]}" | 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
|
||
}
|
||
|
||
# ---- main ----
|
||
|
||
main() {
|
||
[ $# -ge 1 ] || { usage; 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|--help|help) usage; exit 0 ;;
|
||
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 ;;
|
||
commits) shift; cmd_commits "$@"; exit 0 ;;
|
||
history) shift; cmd_history "$@"; exit 0 ;;
|
||
esac
|
||
ENV_NAME="$1"; shift
|
||
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
|
||
local action="deploy"
|
||
case "${1:-}" in
|
||
deploy|stop|start|restart|status|logs) action="$1"; shift ;;
|
||
esac
|
||
if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
|
||
CLI_LOGS_N="$1"; shift
|
||
fi
|
||
parse_args "$@"
|
||
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ]; } && [ "$action" != deploy ]; then
|
||
c_err "--from/--pick/--dry-run/--dir 仅用于 deploy 动作"
|
||
exit 2
|
||
fi
|
||
env_config "$ENV_NAME"
|
||
case "$action" in
|
||
deploy)
|
||
case "$SVC_KIND" in
|
||
agent) agent_do_deploy ;;
|
||
static) static_do_deploy && static_do_start ;;
|
||
postgres) c_err "[$ENV_NAME] postgres 无 deploy;db create 建库、start 启动"; exit 2 ;;
|
||
esac ;;
|
||
stop) do_stop ;;
|
||
start) do_start ;;
|
||
restart) do_stop; do_start ;;
|
||
status) env_status_detail ;;
|
||
logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;;
|
||
esac
|
||
}
|
||
|
||
main "$@"
|