Files
knowledge-governance/internal/httpapi/backup_schedule_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

356 lines
14 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 httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/backuphost"
"knowledge-governance/internal/governance"
)
type fakeBackupScheduleStore struct {
schedule governance.BackupSchedule
loadErr error
saveErr error
saved []governance.BackupSchedule
appliedAt []string
}
func (store *fakeBackupScheduleStore) LoadBackupSchedule(context.Context) (governance.BackupSchedule, error) {
if store.loadErr != nil {
return governance.BackupSchedule{}, store.loadErr
}
return store.schedule, nil
}
func (store *fakeBackupScheduleStore) SaveBackupSchedule(_ context.Context, schedule governance.BackupSchedule, _ string, _ time.Time) error {
if store.saveErr != nil {
return store.saveErr
}
store.schedule = schedule
store.saved = append(store.saved, schedule)
return nil
}
func (store *fakeBackupScheduleStore) MarkBackupScheduleApplied(_ context.Context, summary string, _ time.Time) error {
store.appliedAt = append(store.appliedAt, summary)
return nil
}
type fakeBackupHost struct {
available bool
target string
show backuphost.ShowResult
showErr error
apply backuphost.ApplyResult
applyErr error
lastRun backuphost.RunResult
lastRunErr error
runErr error
applied int
runs int
}
func (host *fakeBackupHost) Available() bool { return host.available }
func (host *fakeBackupHost) Target() string { return host.target }
func (host *fakeBackupHost) Show(context.Context) (backuphost.ShowResult, error) {
return host.show, host.showErr
}
func (host *fakeBackupHost) Apply(context.Context, bool, string, int, int) (backuphost.ApplyResult, error) {
host.applied++
return host.apply, host.applyErr
}
func (host *fakeBackupHost) RunNow(context.Context) (backuphost.RunResult, error) {
host.runs++
return host.lastRun, host.runErr
}
func (host *fakeBackupHost) LastRun(context.Context) (backuphost.RunResult, error) {
return host.lastRun, host.lastRunErr
}
func backupConfig(store BackupScheduleStore, host BackupHost) Config {
return Config{
Store: nil,
BackupSchedule: store,
BackupHost: host,
BackupRuns: &BackupRunTracker{},
Now: func() time.Time { return time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) },
}
}
func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
t.Helper()
var payload struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("响应不是 JSON: %v(%s)", err, recorder.Body.String())
}
return payload.Data
}
var goldenSchedule = governance.BackupSchedule{
Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3, UpdatedBy: "admin",
}
func TestHandleBackupScheduleReadsPlanWithoutProbingHost(t *testing.T) {
host := &fakeBackupHost{available: true, target: "weknora-test", showErr: errors.New("ssh: connect timed out")}
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, host)
recorder := httptest.NewRecorder()
config.handleBackupSchedule(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/backup-schedule", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("期望 200,得到 %d", recorder.Code)
}
data := decodeData(t, recorder)
if data["schedule_time"] != "03:30" || data["retention_days"] != float64(7) || data["enabled"] != true {
t.Fatalf("计划字段不对: %+v", data)
}
// 只读接口不探测主机:即使主机不可达也不应带错误(首屏不被 SSH 拖慢)。
if data["host_error"] != "" {
t.Fatalf("只读接口不应探测主机: %+v", data["host_error"])
}
if data["host_configured"] != true || data["host_target"] != "weknora-test" {
t.Fatalf("应报告已接线与目标主机: %+v", data)
}
}
func TestHandleBackupScheduleHostReportsPlanAndLastRun(t *testing.T) {
host := &fakeBackupHost{
available: true, target: "weknora-test",
show: backuphost.ShowResult{
ScheduleEnabled: true, ScheduleTime: "03:30", KeepDays: 7, KeepMonths: 3,
CronLines: []string{"30 03 * * * root /bin/bash /opt/weknora-test/logical-backup.sh"}, CronService: "active",
ScriptPresent: true,
},
lastRun: backuphost.RunResult{
Stamp: "20261009T052909Z", DurationSeconds: 33, TotalBytes: 778502144,
Databases: []string{"weknora_test"}, FailedDatabases: []string{},
},
}
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, host)
recorder := httptest.NewRecorder()
config.handleBackupScheduleHost(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/backup-schedule/host", nil))
data := decodeData(t, recorder)
if data["host_reachable"] != true || data["host_cron_service"] != "active" {
t.Fatalf("主机状态不对: %+v", data)
}
if data["last_run_stamp"] != "20261009T052909Z" || data["last_run_duration_seconds"] != float64(33) {
t.Fatalf("最近一次结果不对: %+v", data)
}
// 主机上的计划与库里的设置一致 → in_sync=true(页面显示"已一致")
if data["in_sync"] != true {
t.Fatalf("一致的计划应判为已同步: %+v", data)
}
}
func TestHandleBackupScheduleHostFlagsDrift(t *testing.T) {
host := &fakeBackupHost{
available: true, target: "weknora-test",
show: backuphost.ShowResult{ScheduleEnabled: true, ScheduleTime: "05:00", KeepDays: 7, KeepMonths: 3, ScriptPresent: true},
}
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, host)
recorder := httptest.NewRecorder()
config.handleBackupScheduleHost(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/backup-schedule/host", nil))
data := decodeData(t, recorder)
if data["in_sync"] != false {
t.Fatalf("主机是 05:00、设置是 03:30,应判为待下发: %+v", data)
}
if data["host_schedule_time"] != "05:00" {
t.Fatalf("应回显主机上真实时间: %+v", data)
}
}
func TestHandleBackupScheduleHostUnconfiguredDegrades(t *testing.T) {
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, &fakeBackupHost{available: false})
recorder := httptest.NewRecorder()
config.handleBackupScheduleHost(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/backup-schedule/host", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("未接线应降级为 200(页面显示未接线),得到 %d", recorder.Code)
}
data := decodeData(t, recorder)
if data["host_configured"] != false || data["in_sync"] != false {
t.Fatalf("未接线应明确报告: %+v", data)
}
if !strings.Contains(data["host_error"].(string), "未挂载") {
t.Fatalf("应说明缺少主机凭据: %+v", data["host_error"])
}
}
func TestHandleSaveBackupScheduleRejectsInvalidAndDoesNotTouchHost(t *testing.T) {
store := &fakeBackupScheduleStore{schedule: goldenSchedule}
host := &fakeBackupHost{available: true, target: "weknora-test"}
config := backupConfig(store, host)
body := `{"schedule_time":"25:99","retention_days":0,"retention_months":3,"enabled":true}`
recorder := httptest.NewRecorder()
config.handleSaveBackupSchedule(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/backup-schedule", strings.NewReader(body)))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("非法计划应 400,得到 %d(%s)", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "定时时间必须是 HH:MM") {
t.Fatalf("应给出中文原因: %s", recorder.Body.String())
}
if len(store.saved) != 0 {
t.Fatal("非法计划不应写库")
}
if host.applied != 0 {
t.Fatal("非法计划不应下发到主机")
}
}
func TestHandleSaveBackupScheduleAppliesToHost(t *testing.T) {
store := &fakeBackupScheduleStore{schedule: governance.BackupSchedule{Enabled: false, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3}}
host := &fakeBackupHost{
available: true, target: "weknora-test",
apply: backuphost.ApplyResult{Enabled: true, Time: "04:15", Days: 14, Months: 6, NextRun: "2026-10-10T04:15:00+0800", CronFile: "/etc/cron.d/kb-logical-backup"},
show: backuphost.ShowResult{ScheduleEnabled: true, ScheduleTime: "04:15", KeepDays: 14, KeepMonths: 6, ScriptPresent: true},
}
config := backupConfig(store, host)
body := `{"enabled":true,"schedule_time":"04:15","retention_days":14,"retention_months":6}`
recorder := httptest.NewRecorder()
config.handleSaveBackupSchedule(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/backup-schedule", strings.NewReader(body)))
if recorder.Code != http.StatusOK {
t.Fatalf("期望 200,得到 %d(%s)", recorder.Code, recorder.Body.String())
}
if len(store.saved) != 1 || store.saved[0].ScheduleTime != "04:15" || !store.saved[0].Enabled {
t.Fatalf("应把新计划写库: %+v", store.saved)
}
if host.applied != 1 {
t.Fatalf("默认应下发到主机,实际 %d 次", host.applied)
}
if len(store.appliedAt) != 1 || !strings.Contains(store.appliedAt[0], "每天 04:15") {
t.Fatalf("应记录下发摘要: %+v", store.appliedAt)
}
var payload struct {
Applied bool `json:"applied"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if !payload.Applied || payload.Data["in_sync"] != true {
t.Fatalf("下发后应报告 applied=true 且已一致: %+v", payload)
}
}
func TestHandleSaveBackupScheduleWithoutHostCredentials(t *testing.T) {
store := &fakeBackupScheduleStore{schedule: goldenSchedule}
host := &fakeBackupHost{available: false}
config := backupConfig(store, host)
recorder := httptest.NewRecorder()
config.handleSaveBackupSchedule(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/backup-schedule",
strings.NewReader(`{"schedule_time":"04:15","retention_days":14,"retention_months":6,"enabled":true}`)))
if recorder.Code != http.StatusOK {
t.Fatalf("未接线时保存仍应成功(只是不下发),得到 %d", recorder.Code)
}
var payload struct {
Applied bool `json:"applied"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Applied {
t.Fatal("未接线不应报告已下发")
}
if host.applied != 0 {
t.Fatal("未接线不应调用主机")
}
if len(store.saved) != 1 {
t.Fatal("计划仍应写库")
}
}
func TestHandleApplyBackupSchedulePushesCurrentSettings(t *testing.T) {
store := &fakeBackupScheduleStore{schedule: goldenSchedule}
host := &fakeBackupHost{available: true, target: "weknora-test",
apply: backuphost.ApplyResult{Enabled: true, Time: "03:30", Days: 7, Months: 3}}
config := backupConfig(store, host)
recorder := httptest.NewRecorder()
config.handleApplyBackupSchedule(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/backup-schedule/apply", nil))
if recorder.Code != http.StatusOK || host.applied != 1 {
t.Fatalf("应重新下发一次: code=%d applied=%d", recorder.Code, host.applied)
}
if len(store.saved) != 0 {
t.Fatal("重新下发不应改库里的设置")
}
}
func TestHandleRunBackupNowIsAsyncAndSingleFlight(t *testing.T) {
store := &fakeBackupScheduleStore{schedule: goldenSchedule}
host := &fakeBackupHost{available: true, target: "weknora-test",
lastRun: backuphost.RunResult{Stamp: "20261009T060000Z", DurationSeconds: 34}}
config := backupConfig(store, host)
// 先占住在途标记:重复点击不能跑第二次整库 dump。
config.BackupRuns.Start(config.Now())
recorder := httptest.NewRecorder()
config.handleRunBackupNow(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/backup-schedule/run", nil))
if recorder.Code != http.StatusAccepted {
t.Fatalf("期望 202(异步),得到 %d", recorder.Code)
}
data := decodeData(t, recorder)
if data["started"] != false || data["running"] != true {
t.Fatalf("已在跑时不应重复启动: %+v", data)
}
if host.runs != 0 {
t.Fatal("已被占住时不应触发主机执行")
}
}
func TestHandleRunBackupNowStartsAndFinishesTracking(t *testing.T) {
host := &fakeBackupHost{available: true, target: "weknora-test",
lastRun: backuphost.RunResult{Stamp: "20261009T060000Z", DurationSeconds: 34}}
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, host)
recorder := httptest.NewRecorder()
config.handleRunBackupNow(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/backup-schedule/run", nil))
if recorder.Code != http.StatusAccepted {
t.Fatalf("期望 202,得到 %d", recorder.Code)
}
if data := decodeData(t, recorder); data["started"] != true {
t.Fatalf("应报告已启动: %+v", data)
}
// 后台 goroutine 完成(假 RunNow 立即返回)。
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if running, _, _, _ := config.BackupRuns.snapshot(); !running {
if host.runs != 1 {
t.Fatalf("应恰好执行一次,实际 %d", host.runs)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("在途标记未在预期时间内结束")
}
func TestHandleRunBackupNowWithoutHostCredentials(t *testing.T) {
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, &fakeBackupHost{available: false})
recorder := httptest.NewRecorder()
config.handleRunBackupNow(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/backup-schedule/run", nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("未接线应 503,得到 %d", recorder.Code)
}
}
// 写操作必须是管理员:计划会落到数据面主机的 cron(能删数据、能占满磁盘)。
func TestBackupScheduleRoleClassification(t *testing.T) {
cases := []struct {
method, path, want string
}{
{http.MethodGet, "/api/v1/backup-schedule", roleViewer},
{http.MethodGet, "/api/v1/backup-schedule/host", roleViewer},
{http.MethodPut, "/api/v1/backup-schedule", roleAdmin},
{http.MethodPost, "/api/v1/backup-schedule/apply", roleAdmin},
{http.MethodPost, "/api/v1/backup-schedule/run", roleAdmin},
}
for _, item := range cases {
if got := classifyRole(item.method, item.path); got != item.want {
t.Errorf("%s %s: 期望 %s,得到 %s", item.method, item.path, item.want, got)
}
}
}