diff --git a/.task/archive/feature-svc-db-ops.md b/.task/archive/feature-svc-db-ops.md new file mode 100644 index 00000000..b3fbff93 --- /dev/null +++ b/.task/archive/feature-svc-db-ops.md @@ -0,0 +1,70 @@ +# feature/svc-db-ops 任务卡 + +任务主记录:PR #59 https://git-panel.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/59(merge 30a09686ea);状态:已完成(回填);owner:weichen + +## 基线与授权 + +- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= d54bfdd8e99d5d3d9a7f5b1db65ab90c841dd7e0 +- 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-svc-db-ops(worktree);分支:feature/svc-db-ops +- 授权与交付终点:用户确认方案后授权实施 svc 数据库运维扩展;终点 = 实现合入 main 并回收工作区。 + 仅改 Dev 仓 `scripts/svc.sh`(及 Makefile 帮助行);不碰主检出 .video_agent/(已本地 exclude)。 +- 主记录 PR 链接: + +## 目标与非目标 + +目标(scripts/svc.sh 数据库运维扩展,均沿用既有登记/事件/安全边界): +1. `set [--port N] [--host H] [--apply] [--tag T...]`:显式变更登记。postgres 换端口 + 运行中须 --apply(stop→改登记→start);未运行直接生效;端口冲突预检(登记+监听双查)。 + agent/static 的 set 只改 tags(换端口走 restart --port)。 +2. `db backup [--db X] [--out F]`:pg_dump -Fc → runtime//backups/-.dump + (600),pg_restore --list 自校验;`db backups` 列表。 +3. `db restore --file F [--db X] [--force]`:恢复;目标库非空拒绝覆盖,--force 才 + DROP WITH (FORCE)+重建+恢复;完成后回显表数。 +4. `db clone [--port P] [--password PW] [--tag T] [--online]`:默认物理克隆 + (短暂停源→cp -a pgdata→登记目标(新端口/继承用户库认证)→复启源;目标不自动启动); + --online 走 dump/restore 不停源(目标须已 create+start)。 +5. `db copy [--db X] [--force]`:跨实例单库逻辑复制(双方须运行;目标非空须 --force)。 +6. `db ls `:列出实例内数据库。 + +非目标:agent SQLite 备份(本轮不做,记剩余项);不做定时/增量备份;不做跨主机传输; +restore/clone 不删除备份文件;不动后端二进制(无 migrate 子命令,迁移=数据搬运)。 + +## 同步记录 + +| 节点 | sync_base_sha | tested_head_sha | checked_at | +|---|---|---|---| +| 启动 | d54bfdd8e99d5d3d9a7f5b1db65ab90c841dd7e0 | d54bfdd8e99d5d3d9a7f5b1db65ab90c841dd7e0 | 2026-09-30T14:01:04Z | + +## 关键实现约定 + +- 显式参数 psql/pg_dump/pg_restore 助手(不依赖全局 ENV_*),支持 clone/copy 双实例切换; +- svc.env 单键改写(svc_set_key)先读后写,避开"重定向块内读同文件"截断陷阱; +- 备份/克隆/搬库全部进 history.jsonl;restore/copy 的覆盖必须 --force; +- 物理克隆复用既有"只停自己进程"约束;复制剔除 postmaster.pid/.opts,socket 目录(-k) + 本就在环境目录而非 pgdata,不随 cp 传播。 + +## 结果与验证 + +- 结果:svc.sh 新增数据库运维族:`set`(换端口/标签,运行中须 --apply)、`db backup/backups/ + restore`(pg_dump -Fc + 归档自检 + --force 覆盖防护)、`db clone`(默认物理:短暂停源→ + cp pgdata→登记目标→复启源;--online 不停源)、`db copy`(跨实例单库,--force 防护)、 + `db ls`;显式参数 pg_run/dump/restore 助手、svc_set_key 单键改写、check_port_available。 + Makefile 帮助行同步。 +- 验证(真实 postgresql@18,全部在任务工作区 runtime 内;进程已停、目录已清): + - 备份→backups 列表→归档自检;坏文件 restore 被拒(非 pg_dump 归档)。 + - set:运行中无 --apply 拒绝;--apply 后旧端口释放/新端口监听/DSN 联动/数据完整(3 行)。 + - 物理克隆:源短暂停止→复制→源在自身端口复启→目标 8896 启动后双实例数据一致; + 测试中发现并修复 CLI --port 泄漏进源复启的 bug(clone/copy 入口清空地址覆盖)。 + - copy:空目标成功;目标非空拒绝;--force 覆盖后行数与源一致(自动生成密码实例亦验证)。 + - restore:新库成功(回显表数);再恢复同库拒绝;--force 成功。 + - --online 克隆:源 pid 全程不变,目标数据一致。 + - set --tag 合并去重;list --tag 过滤;history 记录 backup/restore/clone/copy/set 事件。 + - 错误路径:目标已存在/端口被登记占用/坏归档/非 postgres 服务均明确报错。 +- 影响与剩余项:agent/static 的 SQLite/静态资源备份未做(登记为后续项);定时/增量备份不在 + 本轮;restore --force 的 DROP WITH (FORCE) 需 PG13+(宿主 18 满足)。 + +## 闭合记录(card-autoclose 回填) + +- 依据:merge 30a09686ea812023e5f5e7283776e182af86ad61(PR #59)已包含于 origin/main,分支成果已落盘;本段为合入后回填。 +- 合并主题:Merge pull request 'Add db ops to svc.sh: port change, backup, restore, clone, cross-instance copy' (#59) from feature/svc-db-ops into main +- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。 diff --git a/.task/feature-duplication-lesson-records.md b/.task/feature-duplication-lesson-records.md index e1c90dff..9375a1d6 100644 --- a/.task/feature-duplication-lesson-records.md +++ b/.task/feature-duplication-lesson-records.md @@ -39,3 +39,4 @@ | 同步前验收 | 1fd4fade4a882f11240a39256354fd04c8101ff1 | 599a6448d4e5b13e89753eaddc168292689b1367 | 2026-09-30T12:41:46Z | | 同步前验收 | 8c4fd019475e57aa70646bed9f2ee1a334b72c45 | 8ba1b0e71570aed2ef277d11763a89ffb99f1b56 | 2026-09-30T13:20:32Z | | 同步前验收 | a2657bc25f6b43f9b3e1a23cea86e3ebcfd80ecc | 60261a835a6167f1b3295cfbbc1644a5532f97df | 2026-09-30T13:45:01Z | +| 同步前验收 | d54bfdd8e99d5d3d9a7f5b1db65ab90c841dd7e0 | 69871136e591b2b8416660651679554824efc823 | 2026-09-30T14:11:30Z | diff --git a/Makefile b/Makefile index 254d6b0d..810cf1c2 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ help: @echo "make build 构建单二进制 bin/agent-system(契约同步→前端构建→embed→静态链接)" @echo "make test 全量测试(core + backend + frontend)" @echo "make svc 服务级操作:make svc ENV= [ACTION=deploy|start|stop|restart|status|logs] [ARGS='--from dev@ --tag ...']" - @echo "make svc-list 服务总览:make svc-list [ARGS='--tag core'];数据库:scripts/svc.sh db create|dsn " + @echo "make svc-list 服务总览:make svc-list [ARGS='--tag core'];数据库运维:scripts/svc.sh db create|dsn|backup|backups|restore|clone|copy|ls|set" @echo "make svc-commits 列出可部署提交:make svc-commits SRC=dev|release [N=20]" @echo "make svc-history 事件历史:make svc-history [ENV] [N=20]" @echo "make dev 开发模式说明(前后端分离热更新)" diff --git a/scripts/svc.sh b/scripts/svc.sh index a656a893..76d1e348 100755 --- a/scripts/svc.sh +++ b/scripts/svc.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # svc.sh —— 本机服务管理(服务模型 v2):按提交/版本部署并启动 后端(agent) / 前端(static) # / 数据库(postgres) 三类服务,统一登记(svc.env + history.jsonl)、标签、配置分层、 -# 生命周期(start/stop/restart/rm),全程不触碰主检出与其他已启动服务。 +# 生命周期(start/stop/restart/rm)与数据库运维(换端口/备份/恢复/克隆/搬库), +# 全程不触碰主检出与其他已启动服务。 # # 用法: # scripts/svc.sh [action] [参数...] # 服务级操作(name 见命名规则) @@ -9,6 +10,13 @@ # scripts/svc.sh list [--tag T] # 全服务总览(status 等价) # scripts/svc.sh db create [参数] # 创建 PostgreSQL 服务(initdb) # scripts/svc.sh db dsn [--reveal] [--db X] # 打印数据库连接串(默认掩码密码) +# scripts/svc.sh db backup [--db X] [--out F] # pg_dump 备份(自动自检归档) +# scripts/svc.sh db backups # 列出已有备份 +# scripts/svc.sh db restore --file F [--db X] [--force] # 恢复(覆盖非空库须 --force) +# scripts/svc.sh db clone [--port P] [--password PW] [--online] # 克隆实例 +# scripts/svc.sh db copy [--db X] [--force] # 跨实例搬库(双方须运行) +# scripts/svc.sh db ls # 列出实例内数据库 +# scripts/svc.sh set [--port N --apply] [--tag T] # 变更登记(换端口/标签) # scripts/svc.sh rm [--purge] # 注销服务(--purge 连数据一起删) # scripts/svc.sh commits [dev|release] [N] # 列出可部署提交(默认 dev,N=20) # scripts/svc.sh history [name] [N] # 事件历史(默认全部,最近 20 条) @@ -51,6 +59,11 @@ # 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 --db app --force # # 安全约束: # - 只写 runtime// 与 runtime/.worktrees/;不改主检出 git 状态、不动他人现场; @@ -59,7 +72,10 @@ # - 只停自己启动的进程:pidfile + /proc//cmdline 标记双校验;postgres 走 # pg_ctl -m fast 优雅停机,SIGKILL 不用于 postmaster; # - 端口被非本脚本进程占用时报错并给出占用者,不抢不杀; -# - svc.env 含密码时权限 600;DSN 默认掩码密码(--reveal 才显示)。 +# - svc.env 含密码时权限 600;DSN 默认掩码密码(--reveal 才显示); +# - 备份/克隆只增不删;restore/copy 覆盖非空库必须显式 --force(drop 重建再恢复); +# 备份文件在 runtime//backups/(600),随 rm --purge 一并删除; +# - 物理克隆期间源实例短暂停止(一致性要求),完成后自动复启;--online 不停源。 # <> set -euo pipefail @@ -105,6 +121,7 @@ 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=() @@ -137,6 +154,11 @@ parse_args() { --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 ;; @@ -159,7 +181,7 @@ addr_compose() { } # ---- 名称与端口 ---- -RESERVED_ENVS=" status list add db rm create dsn commits history deploy stop start restart logs help version " +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() { @@ -930,9 +952,23 @@ cmd_db() { local sub="${1:-}" [ $# -gt 0 ] && shift case "$sub" in - create) db_create "$@" ;; - dsn) db_dsn "$@" ;; - *) c_err "用法:scripts/svc.sh db create [--port --user --password --db --auth-mode --tag] | db dsn [--reveal] [--db X]"; exit 2 ;; + 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 [...] + db create [--port --user --password --db --auth-mode --tag] + db dsn [--reveal] [--db X] + db backup [--db X] [--out F] | db backups + db restore --file F [--db X] [--force] + db clone [--port P] [--password PW] [--tag T] [--online] + db copy [--db X] [--force] + db ls | db set [--port N --apply]"; exit 2 ;; esac } @@ -1093,6 +1129,398 @@ db_dsn() { 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 [--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 "; 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 --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 [--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 [--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 "; 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 [--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() { @@ -1313,6 +1741,7 @@ main() { 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 ;;