Files
knowledge-governance/internal/backuphost/runner_test.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

211 lines
7.9 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
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)
}
}