Files
Agent-System-Dev/scripts/svc.sh
T
weichen 2460e49157 Support --env config files with CLI override
Why: per-environment bring-up needs a persistent file for the SVC_*
knobs (model keys, addresses, tavily pool) instead of relying on shell
history or the single global .svc.env; CLI flags must still win for
one-off overrides like a different port.

Evidence: live matrix - .env.dev parsed (comments, paired quotes,
export prefix) with its address and model values applied and seeded;
--port 8831 overrode the file's port while keeping its 0.0.0.0 host;
missing file fails fast; .svc.env and --env layer correctly (overlap
keys resolve to --env, disjoint keys merge).

Impact: --env FILE / --env=FILE anywhere on the command line; dotenv
parsed safely line-by-line (valid identifiers only, never sourced);
precedence CLI > --env file > .svc.env > OS env > defaults; help and
make svc ARGS examples updated.

Refs: feature/svc-envfile task card.
2026-09-30 12:00:25 +00:00

349 lines
14 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# svc.sh —— 多环境服务管理:按环境隔离地 停止旧服务 → 构建最新 → 启动。
#
# 用法:
# scripts/svc.sh <dev|prod> [action] [参数...]
# action:deploy(默认)| stop | start | restart | status | logs [行数]
# scripts/svc.sh status # 全环境总览
#
# 参数(优先级:命令行 > --env 配置文件 > 环境变量/.svc.env > 默认值):
# --env FILE 环境配置文件(dotenv:KEY=VALUE,支持 # 注释、成对引号、
# export 前缀;只识别合法变量名,不执行文件内容)。
# 文件里写 SVC_DEV_ADDR/SVC_MODEL_* 等,CLI 参数可覆盖其中任意项
# --addr host:port 监听地址(整体覆盖)
# --host host 只改主机部分(如 0.0.0.0 对外开放)
# --port port 只改端口部分
# --data dir 数据目录(SQLite 与工件)
# --dsn DSN PostgreSQL 档 DSN(数据库地址/端口/库名都在 DSN 内,如
# postgres://user:pass@dbhost:5432/agentdb)——含密码时建议
# 改用环境变量 SVC_PROD_DSN 传递,避免进 shell 历史
# --auth on|off|auto API key 认证(默认 auto:PG 档开、嵌入式关)
# --mock 演示模式(dev 常用)
# --plugins dir 插件目录(默认 <repo>/plugins)
# --model-base URL 启动后自动播种主模型配置(settings.models.chat.baseUrl)
# --model-key KEY 主模型 API Key(播种用;勿写入脚本/仓库)
# --model-name NAME 主模型名称
# --light-base/--light-key/--light-name 轻量模型(标题生成等;可选)
# --tavily-keys K1,K2 网络搜索密钥池(播种 tools.tavily.apiKeys)
# -- <剩余参数> 原样透传给 agent-system serve(escape hatch)
#
# 环境变量默认值:
# dev: 127.0.0.1:8788 数据 runtime/dev/data;SVC_DEV_ADDR/DATA/MOCK=1 可覆盖
# prod: 127.0.0.1:8787 数据 runtime/prod/data;SVC_PROD_ADDR/DATA/DSN/AUTH 可覆盖
#
# 一次性配置:.svc.env(自动加载)或 --env 指定文件(如 .env.dev/.env.prod,
# 均建议 gitignore)。文件示例(.env.dev):
# SVC_DEV_ADDR=0.0.0.0:8790
# SVC_MODEL_BASE=https://api.xxx.com/v1
# SVC_MODEL_KEY=sk-xx
# SVC_MODEL_NAME=glm-4.7
#
# 示例:
# scripts/svc.sh dev --env ./.env.dev # 用配置文件启动
# scripts/svc.sh dev --env ./.env.dev --port 9000 # CLI 覆盖文件里的端口
# scripts/svc.sh prod --host 0.0.0.0 --dsn postgres://u:p@127.0.0.1:5432/as
# make svc ENV=dev ARGS="--env .env.dev --port 9000"
#
# 安全约束:
# - 只停自己启动过的进程(pidfile + /proc/<pid>/cmdline 双重校验),绝不动他人现场;
# - 端口被非本脚本进程占用时明确报错并给出占用者信息,不抢不杀。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME="$ROOT/runtime"
BIN="$ROOT/bin/agent-system"
c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*"; }
c_ok() { printf '\033[1;32m[svc]\033[0m %s\n' "$*"; }
c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*"; }
c_err() { printf '\033[1;31m[svc]\033[0m %s\n' "$*" >&2; }
usage() {
sed -n '2,46p' "${BASH_SOURCE[0]}" | sed '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=""
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 ;;
--) 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"
}
env_config() {
case "$1" in
dev)
ENV_ADDR="$(addr_compose "${SVC_DEV_ADDR:-127.0.0.1:8788}")"
ENV_DATA="${CLI_DATA:-${SVC_DEV_DATA:-$RUNTIME/dev/data}}"
ENV_DIR="$RUNTIME/dev"
;;
prod)
ENV_ADDR="$(addr_compose "${SVC_PROD_ADDR:-127.0.0.1:8787}")"
ENV_DATA="${CLI_DATA:-${SVC_PROD_DATA:-$RUNTIME/prod/data}}"
ENV_DIR="$RUNTIME/prod"
;;
*) c_err "未知环境:$1(可用:dev | prod)"; usage; exit 2 ;;
esac
ENV_PID="$ENV_DIR/svc.pid"
ENV_LOG="$ENV_DIR/svc.log"
ENV_PLUGINS="${CLI_PLUGINS:-$ROOT/plugins}"
# 运行参数:mock/DSN/auth(CLI 与环境变量取并集,CLI 优先)。
ENV_ARGS=""
if [ "$ENV_NAME" = dev ]; then
if [ "${SVC_DEV_MOCK:-0}" = "1" ] || [ "$CLI_MOCK" = 1 ]; then
ENV_ARGS="--mock"
fi
else
local dsn="${CLI_DSN:-${SVC_PROD_DSN:-}}"
if [ -n "$dsn" ]; then ENV_ARGS="$ENV_ARGS --dsn $dsn"; fi
ENV_ARGS="$ENV_ARGS --auth ${CLI_AUTH:-${SVC_PROD_AUTH:-auto}}"
fi
}
# pid_alive <pid>:进程存在且 cmdline 是我们的 serve(防 PID 复用误杀)。
pid_alive() {
local pid="$1"
[ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || return 1
local cmd
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
case "$cmd" in
*agent-system*serve*) return 0 ;;
*) return 1 ;;
esac
}
read_pid() { [ -f "$ENV_PID" ] && cat "$ENV_PID" 2>/dev/null || true; }
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
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
kill -TERM "$pid"
for _ in $(seq 1 100); do
pid_alive "$pid" || { c_ok "[$ENV_NAME] 已停止"; rm -f "$ENV_PID"; return 0; }
sleep 0.1
done
c_warn "[$ENV_NAME] 10s 未退出,SIGKILL 兜底"
kill -KILL "$pid" 2>/dev/null || true
rm -f "$ENV_PID"
c_ok "[$ENV_NAME] 已强制停止"
}
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
}
do_build() {
c_info "[$ENV_NAME] 构建最新组合(make build:前端 + 内嵌后端)"
if ! make -C "$ROOT" build; then
c_err "[$ENV_NAME] 构建失败——旧服务已停止,本次部署中止(修复后重跑)"
exit 1
fi
}
# 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
}
do_start() {
if pid_alive "$(read_pid)"; then
c_warn "[$ENV_NAME] 已在运行 pid=$(read_pid)(如需换参数/新版本请用 deploy/restart)"
return 0
fi
mkdir -p "$ENV_DATA" "$ENV_DIR"
# 端口占用预检:他人现场不动,只报告。
local owner; owner="$(port_owner)"
if [ -n "$owner" ]; then
c_err "[$ENV_NAME] 端口被占用(非本脚本管理):$owner"
c_err "处理:停掉该进程,或换端口(--port 8799 / --addr host:port)"
exit 1
fi
[ -x "$BIN" ] || { c_err "[$ENV_NAME] 缺少二进制 $BIN——先构建(deploy 会自动构建)"; exit 1; }
c_info "[$ENV_NAME] 启动:$BIN serve --addr $ENV_ADDR --data $ENV_DATA --plugins $ENV_PLUGINS $ENV_ARGS $CLI_EXTRA"
# 直接 nohup 简单命令(不经 cd 复合后台作业):$! 即服务进程本身,pidfile 语义才成立。
nohup "$BIN" serve --addr "$ENV_ADDR" --data "$ENV_DATA" --plugins "$ENV_PLUGINS" $ENV_ARGS $CLI_EXTRA \
>> "$ENV_LOG" 2>&1 &
echo $! > "$ENV_PID"
printf 'addr=%s\ndata=%s\n' "$ENV_ADDR" "$ENV_DATA" > "$ENV_DIR/svc.env"
# 健康等待:最多 20s;探测主机取绑定主机(0.0.0.0/:: → 127.0.0.1)。
local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}"
if [ "$host" = "0.0.0.0" ] || [ "$host" = "::" ]; then host="127.0.0.1"; fi
for _ in $(seq 1 100); do
if curl -s -o /dev/null -m 2 "http://$host:$port/" 2>/dev/null; then
c_ok "[$ENV_NAME] 已启动 pid=$(cat "$ENV_PID") → http://$host:$port(日志:$ENV_LOG)"
seed_settings "$host" "$port"
return 0
fi
pid_alive "$(read_pid)" || { c_err "[$ENV_NAME] 进程启动后即退出,查看日志:tail -40 $ENV_LOG"; exit 1; }
sleep 0.2
done
c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_LOG"
exit 1
}
env_status_line() {
local pid; pid="$(read_pid)"
# 运行态显示实际启动参数(svc.env 由 start 落盘);未运行显示当前配置默认值。
local addr="$ENV_ADDR" data="$ENV_DATA"
if pid_alive "$pid" && [ -f "$ENV_DIR/svc.env" ]; then
addr="$(sed -n 's/^addr=//p' "$ENV_DIR/svc.env")"
data="$(sed -n 's/^data=//p' "$ENV_DIR/svc.env")"
fi
if pid_alive "$pid"; then
printf ' %-5s 运行中 pid=%-7s %s 数据 %s\n' "$ENV_NAME" "$pid" "$addr" "$data"
else
printf ' %-5s 未运行 %s 数据 %s\n' "$ENV_NAME" "$addr" "$data"
fi
}
main() {
[ $# -ge 1 ] || { usage; exit 2; }
# 预扫 --env(支持 --env FILE 与 --env=FILE,可出现在任意位置):
# 配置层优先级 = CLI 参数 > --env 文件 > .svc.env > 系统环境变量 > 默认值。
local svc_envfile="" a
local pre=()
while [ $# -gt 0 ]; do
case "$1" in
--env) [ $# -ge 2 ] || { c_err "--env 需要文件路径"; exit 2; }; svc_envfile="$2"; shift 2 ;;
--env=*) svc_envfile="${1#--env=}"; shift ;;
*) pre+=("$1"); shift ;;
esac
done
set -- ${pre[@]+"${pre[@]}"}
[ -f "$ROOT/.svc.env" ] && load_env_file "$ROOT/.svc.env"
[ -n "$svc_envfile" ] && load_env_file "$svc_envfile"
if [ "$1" = "status" ] && [ $# -eq 1 ]; then
c_info "服务总览(root: $ROOT)"
for e in dev prod; do ENV_NAME="$e"; env_config "$e"; env_status_line; done
exit 0
fi
ENV_NAME="$1"; shift
# 动作词可省略(省略 = deploy);flags 可直接跟随环境名。
local action="deploy"
case "${1:-}" in
deploy|stop|start|restart|status|logs) action="$1"; shift ;;
esac
# logs 的行数(兼容 svc.sh dev logs 100)。
if [ "$action" = "logs" ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
CLI_LOGS_N="$1"; shift
fi
parse_args "$@"
env_config "$ENV_NAME"
case "$action" in
deploy) do_stop; do_build; do_start ;;
stop) do_stop ;;
start) do_start ;;
restart) do_stop; do_start ;;
status) env_status_line ;;
logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;;
esac
}
main "$@"