Files
knowledge-governance/internal/backuphost/runner.go
T
姚章浩 3f603aa293 feat(backup): 系统设置里的存储与备份——定时时间与储存天数可配、下发到目标主机 cron
需求:整库逻辑备份做定时,但要在「系统设置 → 存储与备份」里设置定时时间与储存天数。

后端
- migration 000045 backup_schedule(单行:enabled/schedule_time/retention_days/retention_months/
  last_applied_*),store + 领域校验(HH:MM、1~365 天、0~24 月,非法值 400 且不落库不下发)
- internal/backuphost:通过 ssh 在目标主机上读回/下发计划并立即备份(未接线时 Available=false,
  页面降级提示;主机侧错误带输出回传)。最近一次只认有 MANIFEST.json 的完整目录,跳 *.incomplete
- 接口 GET/PUT /api/v1/backup-schedule、GET …/host、POST …/apply、POST …/run;
  写操作经 classifyRole 限管理员;run 为异步 202 + 在途单飞(http 写超时 30s,备份约 35s)
- openapi.yaml 补齐 5 条路由(契约测试要求每条都写 400/500)

主机侧
- scripts/install_backup_schedule.sh(幂等):写 /etc/knowledge-governance/backup-schedule.env 与
  /etc/cron.d/kb-logical-backup,参数校验,--show/--disable,最后一行 SCHEDULE_APPLIED 供平台核对
- logical-backup.sh 从该文件读保留策略;失败或被中断把目录改名 *.incomplete,不留'像成功'的半成品
- install --show 只回显计划行(原先把 SHELL=/PATH= 当计划显示)

前端
- web/src/backupSchedule.ts(严格解析:缺计划字段即报错,不把默认值伪装成设置)
- BackupScheduleSection:容器 + 纯视图(管理员表单/只读、已一致/待下发、在途轮询)

运行前提(已落实):运行镜像加 openssh-client,且容器 uid 1000 在 /etc/passwd 有名字
(否则 ssh 报 No user exists for uid 1000);凭据只读挂载 secrets/backup-host(git 忽略)

实测:主机 cron 每天 03:30 / 保留 7 天 / 每月 3 份;立即备份一次 34 秒 / 742.7 MiB / 失败 0;
Go 与前端测试全绿(170 vitest)。
2026-10-09 13:50:23 +08:00

284 lines
9.6 KiB
Go
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.
// Package backuphost 在"目标库所在主机"上执行整库备份计划的下发、查询与立即执行。
//
// 为什么走 SSH 而不是平台自己连库:备份必须在数据面主机本地跑(同 VPC 直连 RDS、
// 内网写盘),平台的职责只是下发计划、读回结果。凭据是挂载进来的 0600 ssh 配置文件与
// 私钥(`secrets/backup-host/`),平台不保存任何主机密码。
package backuphost
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// ErrNotConfigured 表示没有挂载主机凭据(未接线):调用方据此降级展示,而不是报 500。
var ErrNotConfigured = errors.New("backup host is not configured")
// Config 描述如何连到目标库所在主机。
type Config struct {
// SSHConfig 是 `ssh -F` 用的配置文件路径(挂载进来的 0600 文件)。
SSHConfig string
// Target 是配置文件里的 Host 别名(如 weknora-test)。
Target string
// ScriptDir 是主机上备份脚本所在目录(install_backup_schedule.sh / logical-backup.sh)。
ScriptDir string
// Root 是主机上的备份根目录(与 backup_layout.sh 的契约一致,默认 /srv/kb-backups)。
Root string
// QueryTimeout 用于只读查询(查看计划、读最近一次结果)。
QueryTimeout time.Duration
// ApplyTimeout 用于下发计划。
ApplyTimeout time.Duration
// RunTimeout 用于"立即备份一次"(全库 pg_dump 约为分钟级)。
RunTimeout time.Duration
}
// ShowResult 是主机上当前的计划状态(页面"当前主机上的计划")。
type ShowResult struct {
ScheduleEnabled bool
ScheduleTime string
KeepDays int
KeepMonths int
CronLines []string
CronService string
ScriptPresent bool
Raw string
}
// ApplyResult 是下发结果,字段来自脚本最后一行 SCHEDULE_APPLIED。
type ApplyResult struct {
Enabled bool
Time string
Days int
Months int
NextRun string
CronFile string
Raw string
}
// RunResult 是一次整库备份的结果(来自主机上的 MANIFEST.json)。
type RunResult struct {
Stamp string
CreatedAt string
DurationSeconds int
TotalBytes int64
Databases []string
FailedDatabases []string
Raw string
}
// Runner 通过 ssh 执行主机侧脚本。exec 可注入,便于测试。
type Runner struct {
config Config
exec func(ctx context.Context, timeout time.Duration, remote string) (string, error)
}
// New 构造 Runner;config 为空时仍可用,只是 Available() 为 false。
func New(config Config) *Runner {
if config.QueryTimeout <= 0 {
config.QueryTimeout = 30 * time.Second
}
if config.ApplyTimeout <= 0 {
config.ApplyTimeout = 90 * time.Second
}
if config.RunTimeout <= 0 {
config.RunTimeout = 15 * time.Minute
}
if config.Root == "" {
config.Root = "/srv/kb-backups"
}
runner := &Runner{config: config}
runner.exec = runner.runRemote
return runner
}
// Available 报告主机凭据是否已接线。
func (r *Runner) Available() bool {
return r != nil && r.config.SSHConfig != "" && r.config.Target != "" && r.config.ScriptDir != ""
}
// Target 便于日志与页面显示连的是哪台主机。
func (r *Runner) Target() string {
if r == nil {
return ""
}
return r.config.Target
}
func (r *Runner) runRemote(ctx context.Context, timeout time.Duration, remote string) (string, error) {
if !r.Available() {
return "", ErrNotConfigured
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
command := exec.CommandContext(ctx, "ssh",
"-F", r.config.SSHConfig,
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
r.config.Target, remote)
output, err := command.CombinedOutput()
text := strings.TrimSpace(string(output))
if err != nil {
if ctx.Err() != nil {
return text, fmt.Errorf("ssh %s 超时(%s)", r.config.Target, timeout)
}
// 主机侧脚本会用退出码表达"参数非法/有库失败",把输出带回去便于页面展示。
return text, fmt.Errorf("ssh %s 执行失败: %s", r.config.Target, firstLines(text, 6))
}
return text, nil
}
var scheduleAppliedPattern = regexp.MustCompile(
`SCHEDULE_APPLIED enabled=(\d)(?: time=(\S+))?(?: days=(\d+))?(?: months=(\d+))?(?: next=(\S+))?(?: cron=(\S+))?`)
// Apply 下发计划(enabled=false 时关闭定时但保留已有备份)。
func (r *Runner) Apply(ctx context.Context, enabled bool, hhmm string, days, months int) (ApplyResult, error) {
if !r.Available() {
return ApplyResult{}, ErrNotConfigured
}
remote := fmt.Sprintf("bash %s/install_backup_schedule.sh --disable", r.config.ScriptDir)
if enabled {
remote = fmt.Sprintf("bash %s/install_backup_schedule.sh --time %s --days %d --months %d",
r.config.ScriptDir, hhmm, days, months)
}
output, err := r.exec(ctx, r.config.ApplyTimeout, remote)
if err != nil {
return ApplyResult{}.withRaw(output), err
}
result := ApplyResult{Raw: output}
match := scheduleAppliedPattern.FindStringSubmatch(output)
if match == nil {
return result, fmt.Errorf("下发结果无法识别(主机未按预期输出 SCHEDULE_APPLIED)")
}
result.Enabled = match[1] == "1"
result.Time = match[2]
result.Days = atoiOrZero(match[3])
result.Months = atoiOrZero(match[4])
result.NextRun = match[5]
result.CronFile = match[6]
return result, nil
}
func (result ApplyResult) withRaw(raw string) ApplyResult {
result.Raw = raw
return result
}
// Show 读回主机上的当前计划(参数文件 + cron 条目 + cron 服务状态)。
func (r *Runner) Show(ctx context.Context) (ShowResult, error) {
if !r.Available() {
return ShowResult{}, ErrNotConfigured
}
output, err := r.exec(ctx, r.config.QueryTimeout,
fmt.Sprintf("bash %s/install_backup_schedule.sh --show", r.config.ScriptDir))
if err != nil {
return ShowResult{Raw: output}, err
}
result := ShowResult{Raw: output}
for _, line := range strings.Split(output, "\n") {
switch {
case strings.HasPrefix(line, "备份脚本:"):
result.ScriptPresent = strings.Contains(line, "(存在)")
case strings.HasPrefix(line, "计划参数:"):
fields := parseKeyValues(strings.TrimPrefix(line, "计划参数:"))
result.ScheduleEnabled = fields["KB_BACKUP_SCHEDULE_ENABLED"] == "1"
result.ScheduleTime = fields["KB_BACKUP_SCHEDULE_TIME"]
result.KeepDays = atoiOrZero(fields["KB_BACKUP_KEEP_DAYS"])
result.KeepMonths = atoiOrZero(fields["KB_BACKUP_KEEP_MONTHS"])
case strings.HasPrefix(line, "cron 服务:"):
result.CronService = strings.TrimSpace(strings.TrimPrefix(line, "cron 服务:"))
case strings.HasPrefix(line, " "):
// 只收计划行:SHELL=/PATH= 这类环境行不是计划,回显出来会误导操作者。
if entry := strings.TrimSpace(line); entry != "" && !strings.HasPrefix(entry, "#") &&
!strings.Contains(entry, "=") {
result.CronLines = append(result.CronLines, entry)
}
}
}
return result, nil
}
// RunNow 立刻在主机上跑一次整库备份,并读回结果(页面"立即备份一次")。
func (r *Runner) RunNow(ctx context.Context) (RunResult, error) {
if !r.Available() {
return RunResult{}, ErrNotConfigured
}
if _, err := r.exec(ctx, r.config.RunTimeout,
fmt.Sprintf("bash %s/logical-backup.sh", r.config.ScriptDir)); err != nil {
return RunResult{}, err
}
return r.LastRun(ctx)
}
// LastRun 读回最近一次备份的清单摘要(时间/耗时/大小/失败的库)。
func (r *Runner) LastRun(ctx context.Context) (RunResult, error) {
if !r.Available() {
return RunResult{}, ErrNotConfigured
}
// 只认"有 MANIFEST.json 的完整目录":被中断的运行会被备份脚本改名成 *.incomplete,
// 这里再兜一层,避免把半成品当成"最近一次成功备份"。
remote := fmt.Sprintf(
`S=""; I=0; for d in $(ls -1t %s/rds-logical 2>/dev/null); do case "$d" in *.incomplete) I=$((I+1)); continue ;; esac; if [ -f "%s/rds-logical/$d/MANIFEST.json" ]; then S="$d"; break; fi; I=$((I+1)); done; echo "STAMP=$S"; echo "SKIPPED_INCOMPLETE=$I"; if [ -n "$S" ]; then cat "%s/rds-logical/$S/MANIFEST.json"; fi`,
r.config.Root, r.config.Root, r.config.Root)
output, err := r.exec(ctx, r.config.QueryTimeout, remote)
if err != nil {
return RunResult{Raw: output}, err
}
result := RunResult{Raw: output}
jsonStart := strings.Index(output, "{")
if strings.HasPrefix(output, "STAMP=") {
line := strings.SplitN(output, "\n", 2)[0]
result.Stamp = strings.TrimSpace(strings.TrimPrefix(line, "STAMP="))
}
if jsonStart < 0 {
return result, nil
}
var manifest struct {
CreatedAt string `json:"created_at"`
DurationSeconds int `json:"duration_seconds"`
TotalBytes int64 `json:"total_bytes"`
Databases []string `json:"databases"`
FailedDatabases []string `json:"failed_databases"`
}
if err := json.Unmarshal([]byte(output[jsonStart:]), &manifest); err != nil {
return result, fmt.Errorf("最近一次备份清单无法解析: %w", err)
}
result.CreatedAt = manifest.CreatedAt
result.DurationSeconds = manifest.DurationSeconds
result.TotalBytes = manifest.TotalBytes
result.Databases = manifest.Databases
result.FailedDatabases = manifest.FailedDatabases
return result, nil
}
func parseKeyValues(line string) map[string]string {
fields := map[string]string{}
for _, token := range strings.Fields(line) {
if key, value, found := strings.Cut(token, "="); found {
fields[key] = value
}
}
return fields
}
func atoiOrZero(text string) int {
value, err := strconv.Atoi(strings.TrimSpace(text))
if err != nil {
return 0
}
return value
}
func firstLines(text string, limit int) string {
lines := strings.Split(strings.TrimSpace(text), "\n")
if len(lines) > limit {
lines = lines[:limit]
}
return strings.Join(lines, " | ")
}