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)。
356 lines
14 KiB
Go
356 lines
14 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|