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)。
211 lines
7.9 KiB
Go
211 lines
7.9 KiB
Go
package backuphost
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func testRunner(exec func(ctx context.Context, timeout time.Duration, remote string) (string, error)) *Runner {
|
||
runner := New(Config{
|
||
SSHConfig: "/run/backup-host/ssh_config",
|
||
Target: "weknora-test",
|
||
ScriptDir: "/opt/weknora-test",
|
||
Root: "/srv/kb-backups",
|
||
})
|
||
if exec != nil {
|
||
runner.exec = exec
|
||
}
|
||
return runner
|
||
}
|
||
|
||
func TestRunnerUnavailableWithoutCredentials(t *testing.T) {
|
||
runner := New(Config{})
|
||
if runner.Available() {
|
||
t.Fatal("没有凭据时不应判定为已接线")
|
||
}
|
||
for name, call := range map[string]func() error{
|
||
"Show": func() error { _, err := runner.Show(context.Background()); return err },
|
||
"Apply": func() error { _, err := runner.Apply(context.Background(), true, "03:30", 7, 3); return err },
|
||
"RunNow": func() error { _, err := runner.RunNow(context.Background()); return err },
|
||
} {
|
||
if err := call(); !errors.Is(err, ErrNotConfigured) {
|
||
t.Errorf("%s: 期望 ErrNotConfigured,得到 %v", name, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 只给一半参数(缺 Target)也算未接线,避免"看起来接上了"。
|
||
func TestRunnerPartialConfigIsNotAvailable(t *testing.T) {
|
||
runner := New(Config{SSHConfig: "/run/backup-host/ssh_config"})
|
||
if runner.Available() {
|
||
t.Fatal("缺少 Target 时不应判定为已接线")
|
||
}
|
||
}
|
||
|
||
func TestRunnerApplyBuildsCommandAndParsesResult(t *testing.T) {
|
||
var seen string
|
||
runner := testRunner(func(_ context.Context, _ time.Duration, remote string) (string, error) {
|
||
seen = remote
|
||
return "计划已下发:每天 03:30(保留 7 天,另按自然月保留 3 份)\n" +
|
||
"SCHEDULE_APPLIED enabled=1 time=03:30 days=7 months=3 next=2026-10-10T03:30:00+0800 cron=/etc/cron.d/kb-logical-backup\n", nil
|
||
})
|
||
result, err := runner.Apply(context.Background(), true, "03:30", 7, 3)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if seen != "bash /opt/weknora-test/install_backup_schedule.sh --time 03:30 --days 7 --months 3" {
|
||
t.Fatalf("下发的远端命令不对: %q", seen)
|
||
}
|
||
if !result.Enabled || result.Time != "03:30" || result.Days != 7 || result.Months != 3 {
|
||
t.Fatalf("结果解析不对: %+v", result)
|
||
}
|
||
if result.CronFile != "/etc/cron.d/kb-logical-backup" || result.NextRun != "2026-10-10T03:30:00+0800" {
|
||
t.Fatalf("cron/下次执行解析不对: %+v", result)
|
||
}
|
||
}
|
||
|
||
func TestRunnerApplyDisable(t *testing.T) {
|
||
var seen string
|
||
runner := testRunner(func(_ context.Context, _ time.Duration, remote string) (string, error) {
|
||
seen = remote
|
||
return "已关闭定时备份(已生成的备份保留)。\nSCHEDULE_APPLIED enabled=0\n", nil
|
||
})
|
||
result, err := runner.Apply(context.Background(), false, "03:30", 7, 3)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if seen != "bash /opt/weknora-test/install_backup_schedule.sh --disable" {
|
||
t.Fatalf("关闭定时应调用 --disable: %q", seen)
|
||
}
|
||
if result.Enabled {
|
||
t.Fatal("关闭后 enabled 应为 false")
|
||
}
|
||
}
|
||
|
||
// 主机没按预期输出 SCHEDULE_APPLIED(例如脚本版本旧)时必须报错,绝不能默默当成功。
|
||
func TestRunnerApplyFailsWithoutMarker(t *testing.T) {
|
||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||
return "bash: /opt/weknora-test/install_backup_schedule.sh: No such file or directory\n", nil
|
||
})
|
||
if _, err := runner.Apply(context.Background(), true, "03:30", 7, 3); err == nil {
|
||
t.Fatal("缺少 SCHEDULE_APPLIED 应报错")
|
||
}
|
||
}
|
||
|
||
func TestRunnerShowParsesHostState(t *testing.T) {
|
||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||
return `备份脚本: /opt/weknora-test/logical-backup.sh (存在)
|
||
计划参数: KB_BACKUP_SCHEDULE_ENABLED=1 KB_BACKUP_SCHEDULE_TIME=03:30 KB_BACKUP_KEEP_DAYS=7 KB_BACKUP_KEEP_MONTHS=3 KB_BACKUP_SCRIPT=/opt/weknora-test/logical-backup.sh KB_BACKUP_LOG=/srv/kb-backups/logs/logical-backup.log
|
||
cron 条目:
|
||
30 03 * * * root /bin/bash /opt/weknora-test/logical-backup.sh >> /srv/kb-backups/logs/logical-backup.log 2>&1
|
||
cron 服务: active
|
||
备份根目录: /srv/kb-backups(758M 已用)
|
||
`, nil
|
||
})
|
||
show, err := runner.Show(context.Background())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !show.ScheduleEnabled || show.ScheduleTime != "03:30" || show.KeepDays != 7 || show.KeepMonths != 3 {
|
||
t.Fatalf("计划参数解析不对: %+v", show)
|
||
}
|
||
if len(show.CronLines) != 1 || !strings.Contains(show.CronLines[0], "logical-backup.sh") {
|
||
t.Fatalf("cron 条目解析不对: %+v", show.CronLines)
|
||
}
|
||
if show.CronService != "active" || !show.ScriptPresent {
|
||
t.Fatalf("服务/脚本状态解析不对: %+v", show)
|
||
}
|
||
}
|
||
|
||
// 未配置计划时主机输出"未配置/未安装",解析不能崩、也不能谎报为已启用。
|
||
func TestRunnerShowHandlesUnconfiguredHost(t *testing.T) {
|
||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||
return "备份脚本: /opt/weknora-test/logical-backup.sh (存在)\n计划参数: 未配置\ncron 条目: 未安装(定时关闭)\ncron 服务: active\n", nil
|
||
})
|
||
show, err := runner.Show(context.Background())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if show.ScheduleEnabled || show.ScheduleTime != "" || len(show.CronLines) != 0 {
|
||
t.Fatalf("未配置主机不应解析成已启用: %+v", show)
|
||
}
|
||
}
|
||
|
||
func TestRunnerLastRunParsesManifest(t *testing.T) {
|
||
var seen string
|
||
runner := testRunner(func(_ context.Context, _ time.Duration, remote string) (string, error) {
|
||
seen = remote
|
||
return `STAMP=20261009T052909Z
|
||
{
|
||
"kind": "rds-logical-backup",
|
||
"created_at": "20261009T052909Z",
|
||
"duration_seconds": 33,
|
||
"databases": ["dobotai_prod", "weknora_test"],
|
||
"failed_databases": ["rdsadmin"],
|
||
"total_bytes": 778502144
|
||
}
|
||
`, nil
|
||
})
|
||
lastRun, err := runner.LastRun(context.Background())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(seen, "/srv/kb-backups/rds-logical") {
|
||
t.Fatalf("应读取契约路径下的 rds-logical: %q", seen)
|
||
}
|
||
if lastRun.Stamp != "20261009T052909Z" || lastRun.DurationSeconds != 33 || lastRun.TotalBytes != 778502144 {
|
||
t.Fatalf("清单解析不对: %+v", lastRun)
|
||
}
|
||
if len(lastRun.FailedDatabases) != 1 || lastRun.FailedDatabases[0] != "rdsadmin" {
|
||
t.Fatalf("失败库解析不对: %+v", lastRun.FailedDatabases)
|
||
}
|
||
}
|
||
|
||
// 还没跑过备份:STAMP 为空且无清单,应得到空结果而不是错误。
|
||
func TestRunnerLastRunWithoutSnapshot(t *testing.T) {
|
||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||
return "STAMP=\n", nil
|
||
})
|
||
lastRun, err := runner.LastRun(context.Background())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if lastRun.Stamp != "" || lastRun.DurationSeconds != 0 {
|
||
t.Fatalf("无备份时应为空结果: %+v", lastRun)
|
||
}
|
||
}
|
||
|
||
// 主机侧错误(ssh 失败、脚本非零退出)必须带着输出回传,页面才能显示原因。
|
||
func TestRunnerSurfacesHostFailure(t *testing.T) {
|
||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||
return "", errors.New("ssh weknora-test 执行失败: bash: install_backup_schedule.sh: No such file")
|
||
})
|
||
if _, err := runner.Show(context.Background()); err == nil || !strings.Contains(err.Error(), "No such file") {
|
||
t.Fatalf("应把主机侧失败原因带回来,得到 %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRunnerRunNowReadsBackLastRun(t *testing.T) {
|
||
calls := 0
|
||
runner := testRunner(func(_ context.Context, _ time.Duration, remote string) (string, error) {
|
||
calls++
|
||
if strings.Contains(remote, "logical-backup.sh") && !strings.Contains(remote, "ls -1t") {
|
||
return "备份目标: /srv/kb-backups/rds-logical/20261009T060000Z\ndone: /srv/kb-backups/rds-logical/20261009T060000Z\n", nil
|
||
}
|
||
return "STAMP=20261009T060000Z\n{\"duration_seconds\": 34, \"total_bytes\": 800000000, \"failed_databases\": []}\n", nil
|
||
})
|
||
run, err := runner.RunNow(context.Background())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if calls != 2 {
|
||
t.Fatalf("应立即执行一次并回读一次,实际调用 %d 次", calls)
|
||
}
|
||
if run.Stamp != "20261009T060000Z" || run.DurationSeconds != 34 || run.TotalBytes != 800000000 {
|
||
t.Fatalf("立即备份结果不对: %+v", run)
|
||
}
|
||
}
|