#!/usr/bin/env bash # svc.sh —— 多环境服务管理:按环境隔离地 停止旧服务 → 构建最新 → 启动。 # # 用法: # scripts/svc.sh [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 插件目录(默认 /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//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 :进程存在且 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 "$@"