3f603aa293
需求:整库逻辑备份做定时,但要在「系统设置 → 存储与备份」里设置定时时间与储存天数。 后端 - 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)。
284 lines
9.6 KiB
Go
284 lines
9.6 KiB
Go
// 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, " | ")
|
||
}
|