diff --git a/Dockerfile b/Dockerfile index d7a656a..6019fdb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,9 +13,13 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/api ./cmd/api \ FROM alpine:3.22 ENV PYTHONDONTWRITEBYTECODE=1 -RUN apk add --no-cache ca-certificates python3 postgresql-client curl gcompat libc6-compat \ +RUN apk add --no-cache ca-certificates python3 postgresql-client curl openssh-client gcompat libc6-compat \ && addgroup -g 65532 governance \ && adduser -D -H -u 65532 -G governance governance + +# ssh 客户端需要"本机 uid 有 passwd 条目"(否则报 No user exists for uid 1000); +# 备份计划的下发与读回都走 ssh,容器以 1000 运行,这里给该 uid 建一个名字。 +RUN adduser -D -u 1000 -h /home/app appuser 2>/dev/null || true # dws (DingTalk Workspace CLI) v1.0.63:版本+校验和固定(禁用 dws upgrade)。 # 官方产物是动态链接 glibc 的二进制,alpine(musl) 靠 gcompat/libc6-compat 运行, # 已验证端到端取件并与 glibc 原生环境字节一致。构建期 `dws version` 即自检。 diff --git a/api/openapi.yaml b/api/openapi.yaml index 1f93b2a..97cd4d6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -44,6 +44,129 @@ } ], "paths": { + "/api/v1/backup-schedule": { + "get": { + "tags": [ + "Settings" + ], + "operationId": "getBackupSchedule", + "summary": "读取整库逻辑备份的定时计划(不探测主机)", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "operationId": "saveBackupSchedule", + "summary": "保存整库逻辑备份的定时时间与储存天数,并下发到目标主机(管理员)", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v1/backup-schedule/host": { + "get": { + "tags": [ + "Settings" + ], + "operationId": "getBackupScheduleHost", + "summary": "读取目标主机上生效的 cron 计划与最近一次备份结果", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/api/v1/backup-schedule/apply": { + "post": { + "tags": [ + "Settings" + ], + "operationId": "applyBackupSchedule", + "summary": "把当前设置重新下发到目标主机(管理员)", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v1/backup-schedule/run": { + "post": { + "tags": [ + "Settings" + ], + "operationId": "runBackupNow", + "summary": "在目标主机上立即执行一次整库备份(管理员,异步)", + "responses": { + "202": { + "$ref": "#/components/responses/Envelope" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/health": { "get": { "tags": [ diff --git a/cmd/api/main.go b/cmd/api/main.go index ec2e477..99887a6 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,6 +14,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "knowledge-governance/internal/appconfig" + "knowledge-governance/internal/backuphost" "knowledge-governance/internal/httpapi" "knowledge-governance/internal/monitoring" postgresstore "knowledge-governance/internal/platform/postgres" @@ -124,7 +125,14 @@ func run(ctx context.Context, getenv func(string) string) error { ProductionBackupRestoreVerified: config.ProductionBackupRestoreVerified, ProductionReleaseAuthorizerRef: config.ProductionReleaseAuthorizerRef, ProductionReleaseAuthorizationToken: releaseAuthorizationToken, - Auth: &httpapi.AuthConfig{Store: authStore, SessionTTL: 12 * time.Hour}, + BackupSchedule: postgresstore.NewStore(pool), + BackupHost: backuphost.New(backuphost.Config{ + SSHConfig: config.BackupHostSSHConfig, + Target: config.BackupHostTarget, + ScriptDir: config.BackupHostScriptDir, + Root: config.BackupHostRoot, + }), + Auth: &httpapi.AuthConfig{Store: authStore, SessionTTL: 12 * time.Hour}, }) if err != nil { return err diff --git a/compose.release-sim.yaml b/compose.release-sim.yaml index 74ed05f..f431d94 100644 --- a/compose.release-sim.yaml +++ b/compose.release-sim.yaml @@ -10,8 +10,15 @@ services: # 凭据只引用 0600 文件(值不入库、不回显);appconfig 要求两者同时给出 PRODUCTION_WEKNORA_BASE_URL: https://weknora-test.dobot.cn PRODUCTION_WEKNORA_API_KEY_FILE: /run/release-secrets/target_weknora_api_key + # 整库逻辑备份的目标主机(「系统设置 → 存储与备份」下发 cron 与立即备份都走这里)。 + # 凭据是挂载进来的 0600 ssh 配置 + 私钥;不挂载则页面显示"未接线",不会报错。 + BACKUP_HOST_SSH_CONFIG: /run/backup-host/ssh_config + BACKUP_HOST_TARGET: weknora-test + BACKUP_HOST_SCRIPT_DIR: /opt/weknora-test + BACKUP_HOST_ROOT: /srv/kb-backups volumes: - ./secrets/release-sim:/run/release-secrets:ro + - ./secrets/backup-host:/run/backup-host:ro release-worker: environment: diff --git a/docs/PRODUCTION_BACKUP_RECOVERY.md b/docs/PRODUCTION_BACKUP_RECOVERY.md index c21737c..3f7038a 100644 --- a/docs/PRODUCTION_BACKUP_RECOVERY.md +++ b/docs/PRODUCTION_BACKUP_RECOVERY.md @@ -9,7 +9,8 @@ |---|---|---| | 导入前留档(PG 行 + ES 文档) | ✅ 已建:`scripts/target_kb_snapshot.sh` 云内执行,8 张表 + 该库在 ES 的全部文档(只读 scroll,gzip JSONL,按 `_id` 可重建)+ `MANIFEST.json`(行数/字节/sha256/每库 ES 文档数)+ `export.sql` + `README.md`。实测 **2 分 52 秒**(两个库 13.1 万条 ES 文档 / 692 MB,字节留云主机);`PULL_ES=1` 拉回本机则 **19 分 40 秒** | `$KB_BACKUP_ROOT/target-kb-snapshots/-/`(云主机全量;本地镜像 PG 行 + 清单) | | 发布前硬门禁 | ✅ 已上线(`internal/releasesnapshot`):写目标库之前必须存在覆盖该库、24h 内、**PG 行与 ES 文档两层都齐备且哈希可校验**的留档,否则 `release_target_snapshot_missing`(不可重试),目标库一行不碰 | 单测 10(门禁)+ 6(发布器);`go run ./cmd/guardcheck $KB_BACKUP_ROOT/target-kb-snapshots ` 实测:两个绑定库 PASS、未绑定库 BLOCK | -| 自管整库逻辑备份 | ✅ 已建并在云主机实跑:`scripts/logical-backup.sh`(`pg_dump -Fd -j4 -Z6` 目录格式,可并行/按表恢复;按天轮转保留 7 份 + 每月一份)。实测 **33 秒 / 742.6 MiB / 214 文件 / 失败 0**,覆盖该实例全部 8 个库(`weknora_test`、`weknora_prod`、`weknora_drill74`、`dobotai_prod/test`、`knowledge_adapter_prod/test`、`postgres`);RDS 管理库 `rdsadmin` 默认跳过,单库失败只记账不中止 | `$KB_BACKUP_ROOT/rds-logical//<库>.dir` + `MANIFEST.json`(含 `failed_databases`) | +| 自管整库逻辑备份 | ✅ 已建并在云主机实跑:`scripts/logical-backup.sh`(`pg_dump -Fd -j4 -Z6` 目录格式,可并行/按表恢复)。实测 **33 秒 / 742.6 MiB / 214 文件 / 失败 0**,覆盖该实例全部 8 个库(`weknora_test`、`weknora_prod`、`weknora_drill74`、`dobotai_prod/test`、`knowledge_adapter_prod/test`、`postgres`);RDS 管理库 `rdsadmin` 默认跳过,单库失败只记账不中止;被中断的运行会把目录改名 `*.incomplete` | `$KB_BACKUP_ROOT/rds-logical//<库>.dir` + `MANIFEST.json`(含 `failed_databases`) | +| 定时与保留(平台管理) | ✅ 已建:平台「系统设置 → 存储与备份」设置定时时间与储存天数,保存即下发到目标主机的 `/etc/cron.d/kb-logical-backup`,并读回主机上生效的计划与最近一次结果。当前:**每天 03:30、保留 7 天、每月另留 3 份**,cron 服务 active | 接口 `GET/PUT /api/v1/backup-schedule`、`GET …/host`、`POST …/apply`、`POST …/run`(写操作仅管理员) | | ES 快照 | ✅ 已建:仓库 `kb_backup`(容器内 `path.repo=/usr/share/elasticsearch/backup`,宿主 `$KB_BACKUP_ROOT/es-snapshots`)→ 首个快照 `initial-20261009t024209z` **36 秒 / 4.7 GB / 0 分片失败**;SLM `kb-hourly`(留 3 天)+ `kb-daily`(留 7 天)已各成功执行 | `GET _snapshot/kb_backup/_all`、`_slm/policy/*` | | OSS 对象 | ✅ 目标桶 `dobot-weknora-test` 已开启**版本控制**(覆盖/删除可按 VersionId 回滚);未配跨区域复制 | `GET /?versioning` = `Enabled` | | RDS 托管备份 | ⏳ **待控制台确认**:库里 `archive_mode=on` 但 `archive_command` 为空、`pg_stat_archiver.archived_count=0`;当前连接 `is_superuser=off`(看不到备节点)→ 系列与日志备份开关只能看控制台 | 见下节 | @@ -84,6 +85,24 @@ python3 scripts/restore_drill.py --snapshot-dir $KB_BACKUP_ROOT/target-kb-snapsh 合计 **13,539 行**回填成功,抽样页面正文与标题逐字段相同;`knowledge_tags` / `knowledge_tag_relations` 为空表。 演练在目标库临时建一张 `_drill_restored` 跟踪表记录插入了哪些行,核对后**按行精确删除**并断言残留为 0(需要目标库有建表权限,故演练只用测试库)。 +## 定时与保留怎么下发(平台 → 目标主机) + +备份必须在**目标库所在主机**上本地执行(内网直连 RDS、内网写盘),所以平台只负责"下发计划 + 读回结果": + +- 页面:系统设置 → 存储与备份(管理员可改;保存 = 写入库并立刻下发;另有"重新下发当前设置"与"立即备份一次")。 +- 接口:`GET /api/v1/backup-schedule`(不探测主机,首屏快)、`GET …/host`(SSH 读回主机 cron 与最近一次结果)、 + `PUT`(校验时间 `HH:MM` 与 1~365 天 / 0~24 月,非法值直接 400 且不落库不下发)、`POST …/apply`、`POST …/run`。 + 写操作按角色网关(`classifyRole`)限管理员——这份计划会落到数据面主机的 cron 上。 +- 主机侧:`scripts/install_backup_schedule.sh`(幂等)写 `/etc/knowledge-governance/backup-schedule.env` + 与 `/etc/cron.d/kb-logical-backup`;`logical-backup.sh` 从该文件读保留策略(显式传 `KEEP_DAILY/KEEP_MONTHS` 可覆盖)。 +- 凭据:容器内只读挂载 `secrets/backup-host/{ssh_config,id_ed25519,known_hosts}`(0600), + 环境变量 `BACKUP_HOST_SSH_CONFIG / BACKUP_HOST_TARGET / BACKUP_HOST_SCRIPT_DIR / BACKUP_HOST_ROOT`; + **三项缺任一即"未接线"**,页面降级提示而不是报错。 +- 主机状态读取只认**有 `MANIFEST.json` 的完整目录**:被中断的运行会被脚本改名 `*.incomplete`, + 所以"最近一次备份"永远不会显示一份半成品。 +- 依赖两个运行前提:运行镜像里有 `openssh-client`,且容器用户的 uid 在 `/etc/passwd` 有名字 + (否则 ssh 报 `No user exists for uid 1000`)。`Dockerfile` 已加 `openssh-client` 与 `adduser -u 1000`。 + ## 每次导入/发布前的标准动作 1. `bash scripts/target_kb_snapshot.sh`(默认云内执行 + ES 字节留云主机,实测 **2 分 52 秒**; diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 66b8edb..a64a9be 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -53,6 +53,12 @@ type Config struct { TestWeKnoraStalledAfter time.Duration TestWeKnoraMaxUploadBytes int64 TestAcceptancePolicyVersion string + // 目标库所在主机(整库逻辑备份真正执行的地方)。凭据是挂载进来的 0600 ssh + // 配置文件与私钥;三项同时给出才接线,缺任一项即"未接线"(页面降级展示)。 + BackupHostSSHConfig string + BackupHostTarget string + BackupHostScriptDir string + BackupHostRoot string } func Load(getenv func(string) string) (Config, error) { @@ -88,6 +94,11 @@ func Load(getenv func(string) string) (Config, error) { TestWeKnoraStalledAfter: 30 * time.Minute, TestWeKnoraMaxUploadBytes: 50 * 1024 * 1024, TestAcceptancePolicyVersion: valueOrDefault(getenv("TEST_ACCEPTANCE_POLICY_VERSION"), "structural-v1"), + // 整库逻辑备份的目标主机:三项同时给出才接线(脚本目录/备份根有默认值)。 + BackupHostSSHConfig: strings.TrimSpace(getenv("BACKUP_HOST_SSH_CONFIG")), + BackupHostTarget: strings.TrimSpace(getenv("BACKUP_HOST_TARGET")), + BackupHostScriptDir: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_SCRIPT_DIR")), "/opt/weknora-test"), + BackupHostRoot: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_ROOT")), "/srv/kb-backups"), } if config.DatabaseURL == "" { return Config{}, errors.New("DATABASE_URL is required") @@ -198,6 +209,10 @@ func Load(getenv func(string) string) (Config, error) { return Config{}, errors.New("TEST_ACCEPTANCE_POLICY_VERSION must not be empty") } if config.ProductionReleaseRequestsEnabled { + // 目标主机凭据要么都给要么都不给:只给一半会把"未接线"伪装成"已接线"。 + if (config.BackupHostSSHConfig == "") != (config.BackupHostTarget == "") { + return Config{}, errors.New("BACKUP_HOST_SSH_CONFIG and BACKUP_HOST_TARGET must be set together") + } if config.ProductionReleaseAuthorizerRef == "" || len(config.ProductionReleaseAuthorizerRef) > 128 || strings.ContainsAny(config.ProductionReleaseAuthorizerRef, "\x00\r\n ") { return Config{}, errors.New("PRODUCTION_RELEASE_AUTHORIZER_REF is required and must be safe when production release requests are enabled") } diff --git a/internal/backuphost/runner.go b/internal/backuphost/runner.go new file mode 100644 index 0000000..afe3a9d --- /dev/null +++ b/internal/backuphost/runner.go @@ -0,0 +1,283 @@ +// 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, " | ") +} diff --git a/internal/backuphost/runner_test.go b/internal/backuphost/runner_test.go new file mode 100644 index 0000000..5794158 --- /dev/null +++ b/internal/backuphost/runner_test.go @@ -0,0 +1,210 @@ +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) + } +} diff --git a/internal/governance/backup_schedule.go b/internal/governance/backup_schedule.go new file mode 100644 index 0000000..a764d0a --- /dev/null +++ b/internal/governance/backup_schedule.go @@ -0,0 +1,83 @@ +package governance + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// 整库逻辑备份的定时计划(平台「系统设置 → 存储与备份」)。校验集中在领域层: +// 非法值一律拒绝,绝不把半成品下发到数据面主机。 +const ( + BackupScheduleDefaultTime = "03:30" + BackupScheduleMinDays = 1 + BackupScheduleMaxDays = 365 + BackupScheduleMinMonths = 0 + BackupScheduleMaxMonths = 24 +) + +// backupScheduleTime 形如 03:30;cron 用 24 小时制,不接受 24:00 及秒位。 +var backupScheduleTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`) + +// BackupSchedule 是一份可下发的备份计划(时间 + 保留策略)。 +type BackupSchedule struct { + ID string + Enabled bool + ScheduleTime string + RetentionDays int + RetentionMonths int + LastAppliedAt *time.Time + LastAppliedSummary string + UpdatedBy string + UpdatedAt time.Time +} + +// Validate 校验时间格式与保留范围;返回的错误直接可展示给操作者。 +func (schedule BackupSchedule) Validate() error { + if !backupScheduleTime.MatchString(strings.TrimSpace(schedule.ScheduleTime)) { + return fmt.Errorf("定时时间必须是 HH:MM(00:00~23:59),收到 %q", schedule.ScheduleTime) + } + if schedule.RetentionDays < BackupScheduleMinDays || schedule.RetentionDays > BackupScheduleMaxDays { + return fmt.Errorf("储存天数需在 %d~%d 之间,收到 %d", + BackupScheduleMinDays, BackupScheduleMaxDays, schedule.RetentionDays) + } + if schedule.RetentionMonths < BackupScheduleMinMonths || schedule.RetentionMonths > BackupScheduleMaxMonths { + return fmt.Errorf("每月归档保留需在 %d~%d 之间,收到 %d", + BackupScheduleMinMonths, BackupScheduleMaxMonths, schedule.RetentionMonths) + } + return nil +} + +// Normalize 填默认值并去掉时间两侧空白,便于比较"是否变化"。 +func (schedule BackupSchedule) Normalize() BackupSchedule { + schedule.ScheduleTime = strings.TrimSpace(schedule.ScheduleTime) + if schedule.ScheduleTime == "" { + schedule.ScheduleTime = BackupScheduleDefaultTime + } + return schedule +} + +// SameHostPlan 判断两份计划在数据面主机上是否等价(只看会写进 cron 的字段)。 +func (schedule BackupSchedule) SameHostPlan(other BackupSchedule) bool { + return schedule.Enabled == other.Enabled && + schedule.ScheduleTime == other.ScheduleTime && + schedule.RetentionDays == other.RetentionDays && + schedule.RetentionMonths == other.RetentionMonths +} + +// DescribesNextRunUtc 仅用于日志:给出下一次执行的 UTC 时刻(按当天算)。 +func (schedule BackupSchedule) NextRunAfter(now time.Time) (time.Time, error) { + if err := schedule.Validate(); err != nil { + return time.Time{}, err + } + hour, minute := 0, 0 + if _, err := fmt.Sscanf(schedule.ScheduleTime, "%d:%d", &hour, &minute); err != nil { + return time.Time{}, err + } + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) + if !next.After(now) { + next = next.Add(24 * time.Hour) + } + return next, nil +} diff --git a/internal/governance/backup_schedule_test.go b/internal/governance/backup_schedule_test.go new file mode 100644 index 0000000..83120ac --- /dev/null +++ b/internal/governance/backup_schedule_test.go @@ -0,0 +1,85 @@ +package governance + +import ( + "testing" + "time" +) + +func TestBackupScheduleValidateRejectsBadInput(t *testing.T) { + valid := BackupSchedule{Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3} + if err := valid.Validate(); err != nil { + t.Fatalf("合法计划被拒: %v", err) + } + cases := []struct { + name string + schedule BackupSchedule + }{ + {"时间缺前导零", BackupSchedule{ScheduleTime: "3:30", RetentionDays: 7}}, + {"时间越界", BackupSchedule{ScheduleTime: "24:00", RetentionDays: 7}}, + {"时间带秒", BackupSchedule{ScheduleTime: "03:30:00", RetentionDays: 7}}, + {"时间空", BackupSchedule{ScheduleTime: "", RetentionDays: 7}}, + {"天数为零", BackupSchedule{ScheduleTime: "03:30", RetentionDays: 0}}, + {"天数过大", BackupSchedule{ScheduleTime: "03:30", RetentionDays: 366}}, + {"月数越界", BackupSchedule{ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 25}}, + } + for _, item := range cases { + if err := item.schedule.Validate(); err == nil { + t.Errorf("%s:应当拒绝 %+v", item.name, item.schedule) + } + } +} + +func TestBackupScheduleNormalizeFillsDefaultTime(t *testing.T) { + normalized := BackupSchedule{Enabled: false, ScheduleTime: " "}.Normalize() + if normalized.ScheduleTime != BackupScheduleDefaultTime { + t.Fatalf("空时间应回落到默认 %q,得到 %q", BackupScheduleDefaultTime, normalized.ScheduleTime) + } + if got := (BackupSchedule{ScheduleTime: " 03:30 "}).Normalize().ScheduleTime; got != "03:30" { + t.Fatalf("应去掉两侧空白,得到 %q", got) + } +} + +// 页面靠 SameHostPlan 判断"库里的设置"和"主机上生效的计划"是否一致(不一致显示"待下发")。 +func TestBackupScheduleSameHostPlan(t *testing.T) { + base := BackupSchedule{Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3} + if !base.SameHostPlan(base) { + t.Fatal("同一份计划应判定一致") + } + if base.SameHostPlan(BackupSchedule{Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3, UpdatedBy: "someone"}) == false { + t.Fatal("非下发字段(updated_by)不应影响一致性") + } + changes := []BackupSchedule{ + {Enabled: false, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3}, + {Enabled: true, ScheduleTime: "04:30", RetentionDays: 7, RetentionMonths: 3}, + {Enabled: true, ScheduleTime: "03:30", RetentionDays: 14, RetentionMonths: 3}, + {Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 6}, + } + for _, changed := range changes { + if base.SameHostPlan(changed) { + t.Errorf("以下变化应判定不一致: %+v", changed) + } + } +} + +func TestBackupScheduleNextRunAfter(t *testing.T) { + schedule := BackupSchedule{ScheduleTime: "03:30", RetentionDays: 7} + morning := time.Date(2026, 10, 9, 1, 0, 0, 0, time.UTC) + next, err := schedule.NextRunAfter(morning) + if err != nil { + t.Fatal(err) + } + if next.Hour() != 3 || next.Minute() != 30 || next.Day() != 9 { + t.Fatalf("当天还没到点应给当天,得到 %s", next) + } + evening := time.Date(2026, 10, 9, 22, 0, 0, 0, time.UTC) + next, err = schedule.NextRunAfter(evening) + if err != nil { + t.Fatal(err) + } + if next.Day() != 10 { + t.Fatalf("已过点应给次日,得到 %s", next) + } + if _, err := (BackupSchedule{ScheduleTime: "99:99", RetentionDays: 7}).NextRunAfter(evening); err == nil { + t.Fatal("非法时间应返回错误") + } +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index ee46ab0..b67f2a7 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -134,6 +134,11 @@ func classifyRole(method, path string) string { if path == "/api/v1/production-kb-bindings" && method != http.MethodGet { return roleAdmin } + // 整库备份的定时计划会落到数据面主机的 cron 上(可删数据、可占满磁盘), + // 与"谁能改生产绑定"同级:读任何角色可看,写只能管理员。 + if strings.HasPrefix(path, "/api/v1/backup-schedule") && method != http.MethodGet { + return roleAdmin + } // Everything else that mutates (review, batch, seal, confirm, start, retry, // route visibility, disable) is engineer work; viewers cannot POST at all. return roleEngineer diff --git a/internal/httpapi/backup_schedule.go b/internal/httpapi/backup_schedule.go new file mode 100644 index 0000000..33bacc6 --- /dev/null +++ b/internal/httpapi/backup_schedule.go @@ -0,0 +1,341 @@ +package httpapi + +import ( + "context" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "knowledge-governance/internal/backuphost" + "knowledge-governance/internal/governance" +) + +// BackupScheduleStore 持久化「系统设置 → 存储与备份」里的整库备份计划(migration 000045)。 +type BackupScheduleStore interface { + LoadBackupSchedule(context.Context) (governance.BackupSchedule, error) + SaveBackupSchedule(context.Context, governance.BackupSchedule, string, time.Time) error + MarkBackupScheduleApplied(context.Context, string, time.Time) error +} + +// BackupHost 在目标库所在主机上读回/下发计划并触发一次备份。未接线(Available=false)时 +// 页面降级为"主机凭据未挂载",而不是报 500。 +type BackupHost interface { + Available() bool + Target() string + Show(context.Context) (backuphost.ShowResult, error) + Apply(context.Context, bool, string, int, int) (backuphost.ApplyResult, error) + RunNow(context.Context) (backuphost.RunResult, error) + LastRun(context.Context) (backuphost.RunResult, error) +} + +// BackupRunTracker 记录"立即备份一次"的在途状态:避免重复点击跑两次全库 dump, +// 并让页面轮询到"正在备份"而不是一个被 WriteTimeout 截断的响应。 +// 进程重启会丢掉在途标记,最终事实以主机上的 MANIFEST.json 为准。 +type BackupRunTracker struct { + mutex sync.Mutex + running bool + startedAt time.Time + finishedAt time.Time + lastError string +} + +// Start 标记开始;已在跑时返回 false(调用方据此不重复触发)。 +func (tracker *BackupRunTracker) Start(now time.Time) bool { + tracker.mutex.Lock() + defer tracker.mutex.Unlock() + if tracker.running { + return false + } + tracker.running = true + tracker.startedAt = now + tracker.lastError = "" + return true +} + +// Finish 结束一次运行并记录错误(空字符串表示成功)。 +func (tracker *BackupRunTracker) Finish(errText string, now time.Time) { + tracker.mutex.Lock() + defer tracker.mutex.Unlock() + tracker.running = false + tracker.finishedAt = now + tracker.lastError = errText +} + +func (tracker *BackupRunTracker) snapshot() (bool, time.Time, time.Time, string) { + tracker.mutex.Lock() + defer tracker.mutex.Unlock() + return tracker.running, tracker.startedAt, tracker.finishedAt, tracker.lastError +} + +type backupScheduleView struct { + Enabled bool `json:"enabled"` + ScheduleTime string `json:"schedule_time"` + RetentionDays int `json:"retention_days"` + RetentionMonths int `json:"retention_months"` + UpdatedBy string `json:"updated_by"` + UpdatedAt string `json:"updated_at"` + // LastApplied* 记录最后一次成功下发到主机的结果(页面"当前主机上的计划")。 + LastAppliedAt string `json:"last_applied_at"` + LastAppliedSummary string `json:"last_applied_summary"` + // HostConfigured/HostReachable 区分"没接线"与"接线了但连不上"。 + HostConfigured bool `json:"host_configured"` + HostTarget string `json:"host_target"` + HostReachable bool `json:"host_reachable"` + HostError string `json:"host_error"` + + HostScheduleEnabled bool `json:"host_schedule_enabled"` + HostScheduleTime string `json:"host_schedule_time"` + HostKeepDays int `json:"host_keep_days"` + HostKeepMonths int `json:"host_keep_months"` + HostCronLines []string `json:"host_cron_lines"` + HostCronService string `json:"host_cron_service"` + HostScriptPresent bool `json:"host_script_present"` + + LastRunStamp string `json:"last_run_stamp"` + LastRunCreatedAt string `json:"last_run_created_at"` + LastRunDurationSeconds int `json:"last_run_duration_seconds"` + LastRunTotalBytes int64 `json:"last_run_total_bytes"` + LastRunDatabases []string `json:"last_run_databases"` + LastRunFailedDatabases []string `json:"last_run_failed_databases"` + LastRunError string `json:"last_run_error"` + + // InSync 表示库里的计划与主机上生效的计划一致(不一致时页面提示"待下发")。 + InSync bool `json:"in_sync"` + // Applied/ApplyError 是本次请求是否成功下发的问题说明(保存并下发、重新下发、立即备份都用)。 + Applied bool `json:"applied"` + ApplyError string `json:"apply_error"` + // Running 表示"立即备份一次"正在执行。 + Running bool `json:"running"` + RunningStartedAt string `json:"running_started_at"` + RunLastError string `json:"run_last_error"` +} + +// handleBackupSchedule 只读返回库里的计划(不探测主机,避免主机不可达时拖慢页面)。 +func (config Config) handleBackupSchedule(w http.ResponseWriter, r *http.Request) { + view, err := config.backupSchedulePayload(r.Context(), false) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": view}) +} + +// handleBackupScheduleHost 探测目标主机:当前 cron 计划 + 最近一次备份结果。 +func (config Config) handleBackupScheduleHost(w http.ResponseWriter, r *http.Request) { + view, err := config.backupSchedulePayload(r.Context(), true) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": view}) +} + +// handleSaveBackupSchedule 保存计划;body 里 apply(默认 true)为真时一并下发到主机。 +func (config Config) handleSaveBackupSchedule(w http.ResponseWriter, r *http.Request) { + var input struct { + Enabled *bool `json:"enabled"` + ScheduleTime string `json:"schedule_time"` + RetentionDays *int `json:"retention_days"` + RetentionMonths *int `json:"retention_months"` + Apply *bool `json:"apply"` + } + if err := decodeStrictJSON(r, &input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + current, err := config.BackupSchedule.LoadBackupSchedule(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"}) + return + } + next := current + if input.Enabled != nil { + next.Enabled = *input.Enabled + } + if strings.TrimSpace(input.ScheduleTime) != "" { + next.ScheduleTime = strings.TrimSpace(input.ScheduleTime) + } + if input.RetentionDays != nil { + next.RetentionDays = *input.RetentionDays + } + if input.RetentionMonths != nil { + next.RetentionMonths = *input.RetentionMonths + } + next = next.Normalize() + if err := next.Validate(); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := config.BackupSchedule.SaveBackupSchedule(r.Context(), next, actorFor(r), config.Now().UTC()); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not save backup schedule"}) + return + } + apply := input.Apply == nil || *input.Apply + view, err := config.backupSchedulePayload(r.Context(), true) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"}) + return + } + if !apply || !view.HostConfigured { + writeJSON(w, http.StatusOK, map[string]any{"data": view, "applied": false}) + return + } + payload, applied := config.applyBackupSchedule(r.Context(), next) + view.Applied = applied + view.ApplyError = payload + writeJSON(w, http.StatusOK, map[string]any{"data": view, "applied": applied, "apply_error": payload}) +} + +// handleApplyBackupSchedule 不改设置,只把库里的计划重新下发一次。 +func (config Config) handleApplyBackupSchedule(w http.ResponseWriter, r *http.Request) { + schedule, err := config.BackupSchedule.LoadBackupSchedule(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"}) + return + } + if err := schedule.Validate(); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + problem, applied := config.applyBackupSchedule(r.Context(), schedule) + view, err := config.backupSchedulePayload(r.Context(), true) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"}) + return + } + view.Applied = applied + view.ApplyError = problem + writeJSON(w, http.StatusOK, map[string]any{"data": view, "applied": applied, "apply_error": problem}) +} + +// handleRunBackupNow 触发一次整库备份。全库 dump 约 33 秒,超过 http 写超时,因此异步: +// 立即返回 202,页面轮询 GET /api/v1/backup-schedule/host 看到 running 与新的最近一次结果。 +func (config Config) handleRunBackupNow(w http.ResponseWriter, r *http.Request) { + if !config.BackupHost.Available() { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "目标主机凭据未挂载,无法立即备份(见 BACKUP_HOST_SSH_CONFIG)"}) + return + } + now := config.Now().UTC() + if !config.BackupRuns.Start(now) { + writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"started": false, "running": true}}) + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + _, err := config.BackupHost.RunNow(ctx) + message := "" + if err != nil { + message = err.Error() + } + config.BackupRuns.Finish(message, time.Now().UTC()) + }() + writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"started": true, "running": true}}) +} + +// applyBackupSchedule 把计划下发到目标主机,并把结果摘要写回库里(页面显示"最后下发")。 +// 返回 (错误说明, 是否成功)。 +func (config Config) applyBackupSchedule(ctx context.Context, schedule governance.BackupSchedule) (string, bool) { + result, err := config.BackupHost.Apply(ctx, schedule.Enabled, schedule.ScheduleTime, + schedule.RetentionDays, schedule.RetentionMonths) + if err != nil { + return err.Error(), false + } + summary := scheduleAppliedSummary(result) + if err := config.BackupSchedule.MarkBackupScheduleApplied(ctx, summary, config.Now().UTC()); err != nil { + return "计划已下发,但结果摘要未能写回:" + err.Error(), true + } + return "", true +} + +func scheduleAppliedSummary(result backuphost.ApplyResult) string { + if !result.Enabled { + return "已关闭定时(已有备份保留)" + } + parts := []string{"每天 " + result.Time, "保留 " + itoa(result.Days) + " 天", + "每月归档 " + itoa(result.Months) + " 份"} + if result.NextRun != "" { + parts = append(parts, "下次 "+result.NextRun) + } + return strings.Join(parts, " · ") +} + +// backupSchedulePayload 组装页面投影。probeHost=true 时探测主机(SSH 只读,短超时)。 +func (config Config) backupSchedulePayload(ctx context.Context, probeHost bool) (backupScheduleView, error) { + schedule, err := config.BackupSchedule.LoadBackupSchedule(ctx) + if err != nil { + return backupScheduleView{}, err + } + view := backupScheduleView{ + Enabled: schedule.Enabled, ScheduleTime: schedule.ScheduleTime, + RetentionDays: schedule.RetentionDays, RetentionMonths: schedule.RetentionMonths, + UpdatedBy: schedule.UpdatedBy, UpdatedAt: formatTime(schedule.UpdatedAt), + LastAppliedSummary: schedule.LastAppliedSummary, + InSync: true, + } + if schedule.LastAppliedAt != nil { + view.LastAppliedAt = formatTime(*schedule.LastAppliedAt) + } + view.HostConfigured = config.BackupHost.Available() + view.HostTarget = config.BackupHost.Target() + if !view.HostConfigured { + view.InSync = false + view.HostError = "目标主机凭据未挂载(BACKUP_HOST_SSH_CONFIG / BACKUP_HOST_TARGET)" + return view, nil + } + if !probeHost { + return view, nil + } + show, err := config.BackupHost.Show(ctx) + if err != nil { + view.HostReachable = false + view.HostError = err.Error() + view.InSync = false + } else { + view.HostReachable = true + view.HostScheduleEnabled = show.ScheduleEnabled + view.HostScheduleTime = show.ScheduleTime + view.HostKeepDays = show.KeepDays + view.HostKeepMonths = show.KeepMonths + view.HostCronLines = show.CronLines + view.HostCronService = show.CronService + view.HostScriptPresent = show.ScriptPresent + hostSchedule := governance.BackupSchedule{ + Enabled: show.ScheduleEnabled, ScheduleTime: show.ScheduleTime, + RetentionDays: show.KeepDays, RetentionMonths: show.KeepMonths, + } + view.InSync = show.ScriptPresent && schedule.SameHostPlan(hostSchedule) + } + lastRun, runErr := config.BackupHost.LastRun(ctx) + if runErr != nil { + view.LastRunError = runErr.Error() + } else { + view.LastRunStamp = lastRun.Stamp + view.LastRunCreatedAt = lastRun.CreatedAt + view.LastRunDurationSeconds = lastRun.DurationSeconds + view.LastRunTotalBytes = lastRun.TotalBytes + view.LastRunDatabases = lastRun.Databases + view.LastRunFailedDatabases = lastRun.FailedDatabases + } + running, startedAt, _, lastError := config.BackupRuns.snapshot() + view.Running = running + view.RunLastError = lastError + if running { + view.RunningStartedAt = formatTime(startedAt) + } + return view, nil +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + +func itoa(value int) string { + return strconv.Itoa(value) +} diff --git a/internal/httpapi/backup_schedule_test.go b/internal/httpapi/backup_schedule_test.go new file mode 100644 index 0000000..01e4e79 --- /dev/null +++ b/internal/httpapi/backup_schedule_test.go @@ -0,0 +1,355 @@ +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) + } + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index fe6fc83..55b7c69 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "knowledge-governance/internal/backuphost" "knowledge-governance/internal/governance" "knowledge-governance/internal/jobs" "knowledge-governance/internal/monitoring" @@ -108,6 +109,11 @@ type Config struct { KnowledgeBases KnowledgeBaseLister // ProductionBindings is the 源库 → 目标库 mapping store; ProductionKnowledgeBases // enumerates the production-side bases the operator can bind. + // BackupSchedule 是「系统设置 → 存储与备份」的计划存储;BackupHost 负责在目标库 + // 所在主机上读回/下发计划并触发一次整库备份(未接线时页面降级展示)。 + BackupSchedule BackupScheduleStore + BackupHost BackupHost + BackupRuns *BackupRunTracker ProductionBindings ProductionBindingStore ProductionKnowledgeBases ProductionKnowledgeBaseLister ProductionWeKnoraBaseURL string @@ -132,6 +138,13 @@ func NewHandler(config Config) (http.Handler, error) { if config.Monitor == nil { config.Monitor = monitoring.NewSimulationProvider(config.Now) } + if config.BackupRuns == nil { + config.BackupRuns = &BackupRunTracker{} + } + if config.BackupHost == nil { + // 未挂载主机凭据:Available()=false,页面显示"未接线"而不是 500。 + config.BackupHost = backuphost.New(backuphost.Config{}) + } for _, knowledgeBaseID := range []string{config.InternalRoutingKnowledgeBaseID, config.ExternalRoutingKnowledgeBaseID} { if knowledgeBaseID != "" && !validCatalogID(knowledgeBaseID) { return nil, errors.New("routing knowledge base ID is invalid") @@ -236,6 +249,41 @@ func NewHandler(config Config) (http.Handler, error) { } config.handleSaveRoutingBindings(w, r) }) + mux.HandleFunc("GET /api/v1/backup-schedule", func(w http.ResponseWriter, r *http.Request) { + if config.BackupSchedule == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"}) + return + } + config.handleBackupSchedule(w, r) + }) + mux.HandleFunc("GET /api/v1/backup-schedule/host", func(w http.ResponseWriter, r *http.Request) { + if config.BackupSchedule == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"}) + return + } + config.handleBackupScheduleHost(w, r) + }) + mux.HandleFunc("PUT /api/v1/backup-schedule", func(w http.ResponseWriter, r *http.Request) { + if config.BackupSchedule == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"}) + return + } + config.handleSaveBackupSchedule(w, r) + }) + mux.HandleFunc("POST /api/v1/backup-schedule/apply", func(w http.ResponseWriter, r *http.Request) { + if config.BackupSchedule == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"}) + return + } + config.handleApplyBackupSchedule(w, r) + }) + mux.HandleFunc("POST /api/v1/backup-schedule/run", func(w http.ResponseWriter, r *http.Request) { + if config.BackupSchedule == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"}) + return + } + config.handleRunBackupNow(w, r) + }) mux.HandleFunc("GET /api/v1/production-kb-bindings", func(w http.ResponseWriter, r *http.Request) { if config.ProductionBindings == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "production bindings storage unavailable"}) diff --git a/internal/platform/postgres/backup_schedule.go b/internal/platform/postgres/backup_schedule.go new file mode 100644 index 0000000..a8a6713 --- /dev/null +++ b/internal/platform/postgres/backup_schedule.go @@ -0,0 +1,72 @@ +package postgres + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + + "knowledge-governance/internal/governance" +) + +// LoadBackupSchedule reads the single active backup schedule row. A missing row +// (fresh database before the seed, or a rolled-back migration) reads back as the +// defaults instead of an error, so the settings page never hard-fails. +func (s *Store) LoadBackupSchedule(ctx context.Context) (governance.BackupSchedule, error) { + schedule := governance.BackupSchedule{ + ID: "active", + Enabled: false, + ScheduleTime: governance.BackupScheduleDefaultTime, + RetentionDays: 7, + RetentionMonths: 3, + } + err := s.db.QueryRow(ctx, ` +SELECT enabled,schedule_time,retention_days,retention_months,last_applied_at,last_applied_summary,updated_by,updated_at +FROM backup_schedule WHERE id='active'`). + Scan(&schedule.Enabled, &schedule.ScheduleTime, &schedule.RetentionDays, &schedule.RetentionMonths, + &schedule.LastAppliedAt, &schedule.LastAppliedSummary, &schedule.UpdatedBy, &schedule.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return schedule, nil + } + if err != nil { + return governance.BackupSchedule{}, errors.New("load backup schedule") + } + return schedule, nil +} + +// SaveBackupSchedule upserts the schedule row. Validation belongs to the caller +// (governance.BackupSchedule.Validate); this only persists what was accepted. +func (s *Store) SaveBackupSchedule(ctx context.Context, schedule governance.BackupSchedule, updatedBy string, now time.Time) error { + if now.IsZero() { + return errors.New("backup schedule save requires a timestamp") + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO backup_schedule(id,enabled,schedule_time,retention_days,retention_months,updated_by,updated_at) +VALUES('active',$1,$2,$3,$4,$5,$6) +ON CONFLICT(id) DO UPDATE SET + enabled=EXCLUDED.enabled, + schedule_time=EXCLUDED.schedule_time, + retention_days=EXCLUDED.retention_days, + retention_months=EXCLUDED.retention_months, + updated_by=EXCLUDED.updated_by, + updated_at=EXCLUDED.updated_at`, + schedule.Enabled, schedule.ScheduleTime, schedule.RetentionDays, schedule.RetentionMonths, updatedBy, now); err != nil { + return errors.New("save backup schedule") + } + return nil +} + +// MarkBackupScheduleApplied records the last successful push to the data-plane +// host (cron file + next run), which the page shows as "当前主机上的计划". +func (s *Store) MarkBackupScheduleApplied(ctx context.Context, summary string, appliedAt time.Time) error { + if appliedAt.IsZero() { + return errors.New("backup schedule apply requires a timestamp") + } + if _, err := s.db.Exec(ctx, ` +UPDATE backup_schedule SET last_applied_at=$1, last_applied_summary=$2 WHERE id='active'`, + appliedAt, summary); err != nil { + return errors.New("mark backup schedule applied") + } + return nil +} diff --git a/internal/platform/postgres/migrations/000045_backup_schedule.down.sql b/internal/platform/postgres/migrations/000045_backup_schedule.down.sql new file mode 100644 index 0000000..360bb6c --- /dev/null +++ b/internal/platform/postgres/migrations/000045_backup_schedule.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS backup_schedule; \ No newline at end of file diff --git a/internal/platform/postgres/migrations/000045_backup_schedule.up.sql b/internal/platform/postgres/migrations/000045_backup_schedule.up.sql new file mode 100644 index 0000000..07729f0 --- /dev/null +++ b/internal/platform/postgres/migrations/000045_backup_schedule.up.sql @@ -0,0 +1,20 @@ +-- 整库逻辑备份(pg_dump 目录格式,scripts/logical-backup.sh)的定时计划由平台 +-- 「系统设置 → 存储与备份」管理:定时时间与保留策略存在这里,下发后在**目标库所在主机** +-- 上落成 /etc/cron.d 条目。备份必须在数据面主机本地执行(内网直连 RDS、内网写盘), +-- 平台只下发计划并读回结果,见 docs/PRODUCTION_BACKUP_RECOVERY.md。 +-- +-- 单行表:last_applied_* 记录最后一次成功下发的结果,供页面显示"当前主机上的计划"。 +CREATE TABLE backup_schedule ( + id TEXT PRIMARY KEY DEFAULT 'active' CHECK (id = 'active'), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + schedule_time VARCHAR(5) NOT NULL DEFAULT '03:30', + retention_days INTEGER NOT NULL DEFAULT 7, + retention_months INTEGER NOT NULL DEFAULT 3, + last_applied_at TIMESTAMPTZ, + last_applied_summary TEXT NOT NULL DEFAULT '', + updated_by VARCHAR(64) NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO backup_schedule(id, updated_by) VALUES('active', 'system:migration') +ON CONFLICT (id) DO NOTHING; \ No newline at end of file diff --git a/scripts/install_backup_schedule.sh b/scripts/install_backup_schedule.sh new file mode 100644 index 0000000..d6af2ed --- /dev/null +++ b/scripts/install_backup_schedule.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# 在"目标库所在主机"上安装/更新**整库逻辑备份**的定时计划(幂等)。 +# 平台的「系统设置 → 存储与备份」通过 SSH 下发时会调用它;也可在主机上手工执行。 +# +# 用法(在目标主机上,需要 root): +# bash install_backup_schedule.sh --show # 只打印当前计划 +# bash install_backup_schedule.sh --time 03:30 --days 7 --months 3 +# bash install_backup_schedule.sh --disable # 关闭定时(保留已生成的备份) +# +# 落盘: +# /etc/knowledge-governance/backup-schedule.env 计划参数(0600) +# /etc/cron.d/kb-logical-backup cron 条目(0644,root:root) +# $KB_BACKUP_ROOT/logs/logical-backup.log 运行日志 +# +# 输出最后一行固定为 SCHEDULE_APPLIED …,供平台解析核对。 +set -euo pipefail + +if [ -f /etc/knowledge-governance/backup.env ]; then + # shellcheck disable=SC1091 + . /etc/knowledge-governance/backup.env +fi +KB_BACKUP_ROOT="${KB_BACKUP_ROOT:-/srv/kb-backups}" +BACKUP_SCRIPT="${BACKUP_SCRIPT:-/opt/weknora-test/logical-backup.sh}" +SCHEDULE_ENV="/etc/knowledge-governance/backup-schedule.env" +CRON_FILE="/etc/cron.d/kb-logical-backup" +LOG_FILE="$KB_BACKUP_ROOT/logs/logical-backup.log" + +TIME=""; DAYS=""; MONTHS=""; MODE="apply" +while [ $# -gt 0 ]; do + case "$1" in + --time) TIME="${2:-}"; shift 2 ;; + --days) DAYS="${2:-}"; shift 2 ;; + --months) MONTHS="${2:-}"; shift 2 ;; + --disable) MODE="disable"; shift ;; + --show) MODE="show"; shift ;; + *) echo "未知参数: $1" >&2; exit 2 ;; + esac +done + +show_state() { + echo "备份脚本: $BACKUP_SCRIPT $([ -x "$BACKUP_SCRIPT" ] || [ -f "$BACKUP_SCRIPT" ] && echo '(存在)' || echo '(缺失)')" + if [ -f "$SCHEDULE_ENV" ]; then + echo "计划参数: $(grep -v '^#' "$SCHEDULE_ENV" | paste -sd' ' -)" + else + echo "计划参数: 未配置" + fi + if [ -f "$CRON_FILE" ]; then + echo "cron 条目:" + # 只列计划行(去掉 SHELL=/PATH= 这类环境行),否则平台会把环境变量当计划回显 + grep -E '^[0-9*@]' "$CRON_FILE" | sed 's/^/ /' + else + echo "cron 条目: 未安装(定时关闭)" + fi + if command -v systemctl >/dev/null 2>&1; then + echo "cron 服务: $(systemctl is-active cron 2>/dev/null || systemctl is-active crond 2>/dev/null || echo unknown)" + fi + echo "备份根目录: $KB_BACKUP_ROOT($(du -sh "$KB_BACKUP_ROOT/rds-logical" 2>/dev/null | cut -f1 || echo 0) 已用)" +} + +if [ "$MODE" = "show" ]; then + show_state + exit 0 +fi + +if [ "$MODE" = "disable" ]; then + rm -f "$CRON_FILE" + if [ -f "$SCHEDULE_ENV" ]; then + sed -i 's/^KB_BACKUP_SCHEDULE_ENABLED=.*/KB_BACKUP_SCHEDULE_ENABLED=0/' "$SCHEDULE_ENV" || true + fi + echo "已关闭定时备份(已生成的备份保留)。" + echo "SCHEDULE_APPLIED enabled=0" + exit 0 +fi + +# ---- 参数校验(错就拒绝,不落半成品)---- +case "$TIME" in + [0-2][0-9]:[0-5][0-9]) ;; + *) echo "--time 必须是 HH:MM(00:00~23:59),收到 '$TIME'" >&2; exit 2 ;; +esac +case "$DAYS" in ''|*[!0-9]*) echo "--days 必须是整数" >&2; exit 2 ;; esac +case "$MONTHS" in ''|*[!0-9]*) echo "--months 必须是整数" >&2; exit 2 ;; esac +[ "$DAYS" -ge 1 ] && [ "$DAYS" -le 365 ] || { echo "--days 需在 1~365" >&2; exit 2; } +[ "$MONTHS" -ge 0 ] && [ "$MONTHS" -le 24 ] || { echo "--months 需在 0~24" >&2; exit 2; } +[ -f "$BACKUP_SCRIPT" ] || { echo "备份脚本不存在: $BACKUP_SCRIPT" >&2; exit 2; } + +HOUR="${TIME%%:*}"; MINUTE="${TIME##*:}" +# cron 里不能出现未转义的 %;这里全程不使用 % 以免歧义。 +CRON_LINE="$MINUTE $HOUR * * * root /bin/bash $BACKUP_SCRIPT >> $LOG_FILE 2>&1" + +mkdir -p "$KB_BACKUP_ROOT/logs" "$(dirname "$SCHEDULE_ENV")" +cat >"$SCHEDULE_ENV" <"$CRON_FILE" </dev/null || true + +NEXT_RUN="$(date -d "today $TIME" '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo unknown)" +if [ "$NEXT_RUN" != "unknown" ] && [ "$(date -d "today $TIME" +%s)" -le "$(date +%s)" ]; then + NEXT_RUN="$(date -d "tomorrow $TIME" '+%Y-%m-%dT%H:%M:%S%z')" +fi + +echo "计划已下发:每天 $TIME(保留 $DAYS 天,另按自然月保留 $MONTHS 份)" +echo "cron 文件:$CRON_FILE" +echo "下次执行:$NEXT_RUN" +echo "SCHEDULE_APPLIED enabled=1 time=$TIME days=$DAYS months=$MONTHS next=$NEXT_RUN cron=$CRON_FILE" \ No newline at end of file diff --git a/scripts/logical-backup.sh b/scripts/logical-backup.sh index c7813ba..241d8b2 100644 --- a/scripts/logical-backup.sh +++ b/scripts/logical-backup.sh @@ -35,8 +35,16 @@ BACKUP_ROOT="${BACKUP_ROOT:-$KB_BACKUP_ROOT/rds-logical}" APP_CONTAINER="${APP_CONTAINER:-weknora-test-v071-app-1}" PG_IMAGE="${PG_IMAGE:-postgres:17.9-alpine}" JOBS="${JOBS:-4}" -KEEP_DAILY="${KEEP_DAILY:-7}" -KEEP_MONTHS="${KEEP_MONTHS:-3}" +# 定时计划(平台「存储与备份」下发到 /etc/knowledge-governance/backup-schedule.env)优先, +# 显式传入的 KEEP_DAILY / KEEP_MONTHS 仍可覆盖(便于手工跑一次性保留策略)。 +if [ -z "${KEEP_DAILY:-}" ] || [ -z "${KEEP_MONTHS:-}" ]; then + if [ -f /etc/knowledge-governance/backup-schedule.env ]; then + # shellcheck disable=SC1091 + . /etc/knowledge-governance/backup-schedule.env + fi +fi +KEEP_DAILY="${KEEP_DAILY:-${KB_BACKUP_KEEP_DAYS:-7}}" +KEEP_MONTHS="${KEEP_MONTHS:-${KB_BACKUP_KEEP_MONTHS:-3}}" DRY_RUN="${DRY_RUN:-0}" DBS="${DBS:-}" EXCLUDE_DBS="${EXCLUDE_DBS:-rdsadmin}" @@ -74,9 +82,21 @@ fi if [ -z "$DBS" ]; then echo "没有可备份的库" >&2; exit 1; fi TARGET="$BACKUP_ROOT/$STAMP" + +# 失败或被中断(含被 head/管道 SIGPIPE 杀掉):把目录显式标记为半成品, +# 这样"最近一次备份"的选择器与人都不会把它误当成功结果。 +mark_incomplete() { + if [ -d "$TARGET" ] && [ ! -f "$TARGET/MANIFEST.json" ]; then + rm -rf "${TARGET}.incomplete" + mv "$TARGET" "${TARGET}.incomplete" 2>/dev/null || true + echo "本次备份未完成,已标记为 ${TARGET}.incomplete" >&2 + fi +} +trap mark_incomplete EXIT echo "备份目标: $TARGET" echo "库列表 : $DBS" echo "镜像 : $PG_IMAGE (并行 $JOBS)" +echo "保留策略: 每日 $KEEP_DAILY 份 / 每月 $KEEP_MONTHS 份" if [ "$DRY_RUN" = "1" ]; then echo "DRY_RUN=1,不执行"; exit 0; fi mkdir -p "$TARGET" diff --git a/web/src/BackupScheduleSection.test.tsx b/web/src/BackupScheduleSection.test.tsx new file mode 100644 index 0000000..af50cfb --- /dev/null +++ b/web/src/BackupScheduleSection.test.tsx @@ -0,0 +1,97 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +import { BackupScheduleView, backupDaysValid, backupMonthsValid, backupTimeValid, formatBackupBytes } from './BackupScheduleSection' +import type { BackupSchedule } from './backupSchedule' + +const base: BackupSchedule = { + enabled: true, schedule_time: '03:30', retention_days: 7, retention_months: 3, + updated_by: 'yaozhanghao', updated_at: '2026-10-09T12:00:00Z', + last_applied_at: '2026-10-09T12:00:00Z', last_applied_summary: '每天 03:30 · 保留 7 天 · 每月归档 3 份', + host_configured: true, host_target: 'weknora-test', host_reachable: true, host_error: '', + host_schedule_enabled: true, host_schedule_time: '03:30', host_keep_days: 7, host_keep_months: 3, + host_cron_lines: ['30 03 * * * root /bin/bash /opt/weknora-test/logical-backup.sh >> /srv/kb-backups/logs/logical-backup.log 2>&1'], + host_cron_service: 'active', host_script_present: true, + last_run_stamp: '20261009T052909Z', last_run_created_at: '2026-10-09T05:29:09Z', last_run_duration_seconds: 33, + last_run_total_bytes: 778_502_144, last_run_databases: ['weknora_test', 'dobotai_prod'], + last_run_failed_databases: [], last_run_error: '', in_sync: true, + applied: true, apply_error: '', running: false, running_started_at: '', run_last_error: '', +} + +const view = (overrides: Partial = {}, extra: Record = {}) => { + const schedule = { ...base, ...overrides } + return renderToStaticMarkup() +} + +describe('BackupScheduleView', () => { + it('管理员看到定时时间/储存天数与下发按钮,并显示主机上生效的计划与最近一次结果', () => { + const html = view() + for (const text of ['存储与备份', '目标库所在主机', '定时时间', '储存天数', '每月归档保留', + '保存并下发到主机', '重新下发当前设置', '立即备份一次', 'weknora-test · 已连接', + '每天 03:30 · 保留 7 天 · 每月 3 份', '已一致', '20261009T052909Z', '33 秒', '742.4 MiB', + 'weknora_test、dobotai_prod']) expect(html).toContain(text) + expect(html).toContain('type="time"') + expect(html).toContain('value="03:30"') + }) + + it('非管理员只读:没有保存/下发/立即备份按钮,只给当前设置', () => { + const html = view({}, { isAdmin: false }) + expect(html).toContain('定时备份设置仅管理员可修改') + expect(html).not.toContain('保存并下发到主机') + expect(html).not.toContain('立即备份一次') + }) + + it('未接线:显式说明缺少主机凭据,下发与立即备份不可点', () => { + const html = view({ host_configured: false, host_reachable: false, host_error: '目标主机凭据未挂载(BACKUP_HOST_SSH_CONFIG / BACKUP_HOST_TARGET)', in_sync: false }) + expect(html).toContain('未接线(缺少主机凭据)') + expect(html).toContain('待下发') + expect(html).toContain('目标主机凭据未挂载') + expect(html).toContain('disabled') + }) + + it('待下发(主机上的计划与设置不一致)时提示', () => { + const html = view({ host_schedule_time: '05:00', in_sync: false }) + expect(html).toContain('待下发') + expect(html).toContain('每天 05:00') + }) + + it('在途备份:按钮变"备份进行中…"并给出开始时间', () => { + const html = view({ running: true, running_started_at: '2026-10-09T12:30:00Z' }) + expect(html).toContain('备份进行中…') + expect(html).toContain('正在备份') + }) + + it('非法输入就地拦截,且保存按钮在未改动时不可点', () => { + const html = view({}, { timeDraft: '25:99', daysDraft: '0' }) + expect(html).toContain('定时时间需为 HH:MM(00:00~23:59),储存天数 1~365,每月归档 0~24。') + expect(html).toContain('disabled') + expect(backupTimeValid('03:30')).toBe(true) + expect(backupTimeValid('24:00')).toBe(false) + expect(backupDaysValid(0)).toBe(false) + expect(backupDaysValid(365)).toBe(true) + expect(backupMonthsValid(0)).toBe(true) + expect(backupMonthsValid(25)).toBe(false) + }) + + it('展示失败的库与最近一次读取错误', () => { + const html = view({ last_run_failed_databases: ['rdsadmin'], last_run_error: '清单无法解析' }) + expect(html).toContain('rdsadmin') + expect(html).toContain('清单无法解析') + }) + + it('体积格式化:MiB/GiB', () => { + expect(formatBackupBytes(0)).toBe('—') + expect(formatBackupBytes(1024 * 1024)).toBe('1.0 MiB') + expect(formatBackupBytes(2 * 1024 * 1024 * 1024)).toBe('2.00 GiB') + }) +}) \ No newline at end of file diff --git a/web/src/BackupScheduleSection.tsx b/web/src/BackupScheduleSection.tsx new file mode 100644 index 0000000..be085cf --- /dev/null +++ b/web/src/BackupScheduleSection.tsx @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useOptionalAuth } from './auth' +import { applyBackupSchedule, fetchBackupSchedule, fetchBackupScheduleHost, runBackupNow, saveBackupSchedule } from './backupSchedule' +import type { BackupSchedule } from './backupSchedule' + +export const formatBackupBytes = (bytes: number): string => { + if (!bytes) return '—' + const mib = bytes / (1024 * 1024) + return mib >= 1024 ? `${(mib / 1024).toFixed(2)} GiB` : `${mib.toFixed(1)} MiB` +} + +export const formatBackupMoment = (value: string): string => { + if (!value) return '—' + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false }) +} + +export const backupTimeValid = (value: string): boolean => /^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(value) +export const backupDaysValid = (value: number): boolean => Number.isInteger(value) && value >= 1 && value <= 365 +export const backupMonthsValid = (value: number): boolean => Number.isInteger(value) && value >= 0 && value <= 24 + +export interface BackupScheduleViewProps { + schedule: BackupSchedule + isAdmin: boolean + loading: boolean + probing: boolean + busy: 'save' | 'apply' | 'run' | null + error: string | null + message: string + enabledDraft: boolean + timeDraft: string + daysDraft: string + monthsDraft: string + onEnabledChange: (next: boolean) => void + onTimeChange: (next: string) => void + onDaysChange: (next: string) => void + onMonthsChange: (next: string) => void + onSave: () => void + onApply: () => void + onRunNow: () => void + onReload: () => void +} + +// 纯视图:所有状态由 props 驱动,便于在测试里直接渲染各种状态(管理员/只读、已一致/待下发、在途)。 +export function BackupScheduleView(props: BackupScheduleViewProps) { + const { + schedule, isAdmin, loading, probing, busy, error, message, + enabledDraft, timeDraft, daysDraft, monthsDraft, + onEnabledChange, onTimeChange, onDaysChange, onMonthsChange, onSave, onApply, onRunNow, onReload, + } = props + const days = Number(daysDraft) + const months = Number(monthsDraft) + const valid = backupTimeValid(timeDraft) && backupDaysValid(days) && backupMonthsValid(months) + const dirty = enabledDraft !== schedule.enabled || timeDraft !== schedule.schedule_time || + days !== schedule.retention_days || months !== schedule.retention_months + const hostLine = !schedule.host_configured + ? '未接线(缺少主机凭据)' + : schedule.host_reachable + ? `${schedule.host_target} · 已连接` + : `${schedule.host_target} · 连接失败` + + return
+
+

存储与备份

+

整库逻辑备份(pg_dump 目录格式)在目标库所在主机上本地执行:这里设置定时时间与储存天数,保存即下发到主机 cron,并可读回主机上生效的计划与最近一次结果。

+
+ {error ?

{error}

: null} +
+
+
目标主机
{hostLine}
+
主机 cron
{schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}{schedule.host_script_present ? '' : ' · 备份脚本缺失'}
+
最近一次备份
{schedule.last_run_stamp || '—'}{schedule.last_run_created_at ? ` · ${formatBackupMoment(schedule.last_run_created_at)}` : ''}
+
备份耗时 / 体积
{schedule.last_run_stamp ? `${schedule.last_run_duration_seconds} 秒 · ${formatBackupBytes(schedule.last_run_total_bytes)}` : '—'}
+
+
+
覆盖的库
{schedule.last_run_databases.length ? schedule.last_run_databases.join('、') : '—'}
+
失败的库
{schedule.last_run_failed_databases.length ? schedule.last_run_failed_databases.join('、') : '无'}
+
主机上生效的计划
{schedule.host_schedule_enabled ? `每天 ${schedule.host_schedule_time} · 保留 ${schedule.host_keep_days} 天 · 每月 ${schedule.host_keep_months} 份` : schedule.host_configured ? '未启用定时' : '—'}
+
与设置是否一致
{schedule.in_sync ? '已一致' : '待下发'}
+
+
+ {schedule.host_error ?

主机探测:{schedule.host_error}

: null} + {schedule.last_run_error ?

最近一次结果读取:{schedule.last_run_error}

: null} + {schedule.host_cron_lines.length ?

主机 cron 条目:{schedule.host_cron_lines.join(' ; ')}

: null} + {schedule.last_applied_at ?

最后下发:{formatBackupMoment(schedule.last_applied_at)}{schedule.last_applied_summary ? ` · ${schedule.last_applied_summary}` : ''}

: null} +

留档分层:PG 行级留档(发布前,含 ES 文档)+ ES 快照仓 + OSS 版本控制 + 本节的整库逻辑备份 + RDS 托管备份(控制台配置)。详见 docs/PRODUCTION_BACKUP_RECOVERY.md。

+ {isAdmin ?
+ + + + + {!valid ?

定时时间需为 HH:MM(00:00~23:59),储存天数 1~365,每月归档 0~24。

: null} + + + + {message ?

{message}

: null} + {schedule.running ?

正在备份({formatBackupMoment(schedule.running_started_at)} 开始),完成后自动刷新。

: null} + {schedule.run_last_error && !schedule.running ?

上一次立即备份失败:{schedule.run_last_error}

: null} +
:

定时备份设置仅管理员可修改;当前设置:{schedule.enabled ? `每天 ${schedule.schedule_time} · 保留 ${schedule.retention_days} 天` : '未启用'}。

} +
+} + +// 容器:读取 + 下发 + 在途轮询。真正执行备份的是目标库所在主机(同 VPC 直连 RDS)。 +export function BackupScheduleSection() { + const auth = useOptionalAuth() + const isAdmin = auth?.identity?.role === 'admin' + const [schedule, setSchedule] = useState(null) + const [loading, setLoading] = useState(true) + const [probing, setProbing] = useState(false) + const [busy, setBusy] = useState<'save' | 'apply' | 'run' | null>(null) + const [error, setError] = useState(null) + const [message, setMessage] = useState('') + const [enabledDraft, setEnabledDraft] = useState(false) + const [timeDraft, setTimeDraft] = useState('03:30') + const [daysDraft, setDaysDraft] = useState('7') + const [monthsDraft, setMonthsDraft] = useState('3') + const polling = useRef(null) + + const adopt = useCallback((next: BackupSchedule) => { + setSchedule(next) + setEnabledDraft(next.enabled) + setTimeDraft(next.schedule_time || '03:30') + setDaysDraft(String(next.retention_days ?? 7)) + setMonthsDraft(String(next.retention_months ?? 3)) + }, []) + + const load = useCallback(async () => { + setLoading(true); setError(null) + try { + adopt(await fetchBackupSchedule()) + setProbing(true) + try { + adopt(await fetchBackupScheduleHost()) + } catch (cause) { + setError(cause instanceof Error ? cause.message : '读取目标主机上的备份状态失败') + } finally { setProbing(false) } + } catch (cause) { + setError(cause instanceof Error ? cause.message : '读取备份计划失败') + } finally { setLoading(false) } + }, [adopt]) + + useEffect(() => { void load() }, [load]) + + // 立即备份约 30~60 秒:在途时轮询主机状态,直到 running 转假并出现新的最近一次结果。 + useEffect(() => { + if (!schedule?.running) { + if (polling.current !== null) { window.clearInterval(polling.current); polling.current = null } + return + } + if (polling.current !== null) return + polling.current = window.setInterval(() => { + void fetchBackupScheduleHost().then(adopt).catch(() => undefined) + }, 5000) + return () => { + if (polling.current !== null) { window.clearInterval(polling.current); polling.current = null } + } + }, [schedule?.running, adopt]) + + const save = async () => { + setBusy('save'); setError(null); setMessage('') + try { + const next = await saveBackupSchedule({ + enabled: enabledDraft, schedule_time: timeDraft, + retention_days: Number(daysDraft), retention_months: Number(monthsDraft), apply: true, + }) + adopt(next) + if (!next.host_configured) { + setMessage('计划已保存。目标主机凭据未挂载,因此还不能下发到主机上的 cron。') + } else if (next.applied) { + setMessage(`计划已保存并下发:每天 ${next.schedule_time} 执行,保留 ${next.retention_days} 天(每月另留 ${next.retention_months} 份)。`) + } else { + setMessage(`计划已保存,但下发失败:${next.apply_error}`) + } + } catch (cause) { setError(cause instanceof Error ? cause.message : '保存备份计划失败') } + finally { setBusy(null) } + } + + const apply = async () => { + setBusy('apply'); setError(null); setMessage('') + try { + const next = await applyBackupSchedule() + adopt(next) + setMessage(next.applied ? '已按当前设置重新下发到目标主机。' : `下发失败:${next.apply_error}`) + } catch (cause) { setError(cause instanceof Error ? cause.message : '下发备份计划失败') } + finally { setBusy(null) } + } + + const runNow = async () => { + setBusy('run'); setError(null); setMessage('') + try { + const start = await runBackupNow() + setMessage(start.started ? '已在目标主机上开始备份(整库 pg_dump,约 30~60 秒),下方会自动刷新结果。' : '已有一个备份任务在执行中。') + adopt(await fetchBackupScheduleHost()) + } catch (cause) { setError(cause instanceof Error ? cause.message : '触发备份失败') } + finally { setBusy(null) } + } + + if (!schedule) { + return
+

存储与备份

+ {error ?

{error}

: null} +

{loading ? '正在读取备份计划…' : '备份计划暂不可用。'}

+
+ } + + return void save()} onApply={() => void apply()} onRunNow={() => void runNow()} + onReload={() => void load()} + /> +} \ No newline at end of file diff --git a/web/src/SystemSettings.test.tsx b/web/src/SystemSettings.test.tsx index 1c00b22..b51a7e6 100644 --- a/web/src/SystemSettings.test.tsx +++ b/web/src/SystemSettings.test.tsx @@ -32,8 +32,10 @@ describe('SystemSettingsView',()=>{ const servicesHtml=renderToStaticMarkup() for(const text of ['测试 WeKnora','生产 WeKnora','测试地址','生产地址','生产 ID','知识库 ID'])expect(servicesHtml).toContain(text) const storageHtml=renderToStaticMarkup() - expect(storageHtml).toContain('生产发布请求') - expect(storageHtml).toContain('仅保存凭据引用,不可回读') + // 存储与备份分区已换成真实功能(整库逻辑备份的定时计划),静态占位文案不再存在。 + expect(storageHtml).toContain('存储与备份') + expect(storageHtml).toContain('正在读取备份计划…') + expect(storageHtml).not.toContain('仅保存凭据引用,不可回读') const refreshHtml=renderToStaticMarkup() for(const text of ['刷新与告警参数已验证并启用','停滞30分钟','finalizing 2小时','文档状态刷新间隔','15秒','解析停滞判定','30分钟','保存并应用刷新与告警参数'])expect(refreshHtml).toContain(text) expect(visibleSaveMessage('已保存',true)).toBeNull();expect(visibleSaveMessage('已保存',false)).toBe('已保存') diff --git a/web/src/SystemSettings.tsx b/web/src/SystemSettings.tsx index 2bb4177..bda8366 100644 --- a/web/src/SystemSettings.tsx +++ b/web/src/SystemSettings.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' +import { BackupScheduleSection } from './BackupScheduleSection' import { MembersSection } from './MembersSection' import { DingtalkSessionControl } from './DingtalkSessionControl' import { RoutingBindingsSection } from './RoutingBindingsSection' @@ -264,10 +265,7 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr : null} {section === 'bindings' ? : null} {section === 'production-bindings' ? : null} - {section === 'storage' ?
-

存储与备份

文件资源与生产发布前快照使用独立受限配置。

-
Endpoint / Bucket
尚未配置
AccessKey / Secret
仅保存凭据引用,不可回读
CDN / 公开访问域名
尚未配置
临时签名有效期
待配置
测试备份前缀
/backup/weknora/test/
生产备份前缀
/backup/weknora/prod/
快照保留
最近 30 个生产快照
恢复验证
由生产门禁读回
  • PostgreSQL 未配置
  • Elasticsearch 未配置
  • 对象存储 未配置
  • Neo4j 未配置
  • 生产发布请求 {productionEnabled ? '等待管理员核对' : '关闭'}
  • Release Worker 网页不可启用
-
: null} + {section === 'storage' ? : null} {section === 'members' && identity?.role === 'admin' ? : null} {section === 'refresh' ?

刷新策略

这些值保存在服务器活动版本中;保存不会修改端点、凭据或生产开关。

当前活动版本
diff --git a/web/src/backupSchedule.test.ts b/web/src/backupSchedule.test.ts new file mode 100644 index 0000000..f129dc4 --- /dev/null +++ b/web/src/backupSchedule.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { fetchBackupSchedule, fetchBackupScheduleHost, runBackupNow, saveBackupSchedule } from './backupSchedule' + +const payload = { + enabled: true, schedule_time: '03:30', retention_days: 7, retention_months: 3, + updated_by: 'yaozhanghao', updated_at: '2026-10-09T12:00:00Z', last_applied_at: '', last_applied_summary: '', + host_configured: true, host_target: 'weknora-test', host_reachable: true, host_error: '', + host_schedule_enabled: true, host_schedule_time: '03:30', host_keep_days: 7, host_keep_months: 3, + host_cron_lines: ['30 03 * * * root …'], host_cron_service: 'active', host_script_present: true, + last_run_stamp: '20261009T052909Z', last_run_created_at: '2026-10-09T05:29:09Z', + last_run_duration_seconds: 33, last_run_total_bytes: 778502144, + last_run_databases: ['weknora_test'], last_run_failed_databases: [], last_run_error: '', + in_sync: true, applied: true, apply_error: '', running: false, running_started_at: '', run_last_error: '', +} + +const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +afterEach(() => { vi.unstubAllGlobals() }) + +describe('backupSchedule 客户端', () => { + it('解析计划三要素与主机回执', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: payload }))) + const schedule = await fetchBackupSchedule() + expect(schedule.schedule_time).toBe('03:30') + expect(schedule.retention_days).toBe(7) + expect(schedule.host_cron_lines).toHaveLength(1) + expect(schedule.last_run_failed_databases).toEqual([]) + }) + + it('缺计划字段就报错,绝不把默认值伪装成真实设置', async () => { + const broken = { ...payload } as Record + delete broken.retention_days + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: broken }))) + await expect(fetchBackupScheduleHost()).rejects.toThrow('存储与备份响应缺少计划字段') + }) + + it('保存失败时把后端的中文原因透出来', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: '储存天数需在 1~365 之间,收到 0' }, 400))) + await expect(saveBackupSchedule({ enabled: true, schedule_time: '03:30', retention_days: 0, retention_months: 3 })) + .rejects.toThrow('储存天数需在 1~365 之间,收到 0') + }) + + it('立即备份返回 202 时识别为已启动', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: { started: true, running: true } }, 202))) + await expect(runBackupNow()).resolves.toEqual({ started: true, running: true }) + }) +}) diff --git a/web/src/backupSchedule.ts b/web/src/backupSchedule.ts new file mode 100644 index 0000000..3ed9f2a --- /dev/null +++ b/web/src/backupSchedule.ts @@ -0,0 +1,163 @@ +import { newOperationID } from './operationKey' + +// 「系统设置 → 存储与备份」:整库逻辑备份的定时计划(时间 + 储存天数)与主机回执。 +export interface BackupSchedule { + enabled: boolean + schedule_time: string + retention_days: number + retention_months: number + updated_by: string + updated_at: string + last_applied_at: string + last_applied_summary: string + host_configured: boolean + host_target: string + host_reachable: boolean + host_error: string + host_schedule_enabled: boolean + host_schedule_time: string + host_keep_days: number + host_keep_months: number + host_cron_lines: string[] + host_cron_service: string + host_script_present: boolean + last_run_stamp: string + last_run_created_at: string + last_run_duration_seconds: number + last_run_total_bytes: number + last_run_databases: string[] + last_run_failed_databases: string[] + last_run_error: string + in_sync: boolean + applied: boolean + apply_error: string + running: boolean + running_started_at: string + run_last_error: string +} + +export interface BackupRunStart { + started: boolean + running: boolean +} + +const record = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const asString = (value: unknown): string => (typeof value === 'string' ? value : '') +const asNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0) +const asStringList = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] + +// 严格解析:计划三要素(时间/天数/月数)必须是正确类型,缺一个就整块报错, +// 而不是让界面显示成"默认 03:30 / 7 天"这种假状态。 +function scheduleProjection(item: Record): BackupSchedule { + if (typeof item.schedule_time !== 'string' || typeof item.retention_days !== 'number' || typeof item.retention_months !== 'number') { + throw new Error('存储与备份响应缺少计划字段(schedule_time / retention_days / retention_months)') + } + return { + enabled: item.enabled === true, + schedule_time: item.schedule_time, + retention_days: item.retention_days, + retention_months: item.retention_months, + updated_by: asString(item.updated_by), + updated_at: asString(item.updated_at), + last_applied_at: asString(item.last_applied_at), + last_applied_summary: asString(item.last_applied_summary), + host_configured: item.host_configured === true, + host_target: asString(item.host_target), + host_reachable: item.host_reachable === true, + host_error: asString(item.host_error), + host_schedule_enabled: item.host_schedule_enabled === true, + host_schedule_time: asString(item.host_schedule_time), + host_keep_days: asNumber(item.host_keep_days), + host_keep_months: asNumber(item.host_keep_months), + host_cron_lines: asStringList(item.host_cron_lines), + host_cron_service: asString(item.host_cron_service), + host_script_present: item.host_script_present === true, + last_run_stamp: asString(item.last_run_stamp), + last_run_created_at: asString(item.last_run_created_at), + last_run_duration_seconds: asNumber(item.last_run_duration_seconds), + last_run_total_bytes: asNumber(item.last_run_total_bytes), + last_run_databases: asStringList(item.last_run_databases), + last_run_failed_databases: asStringList(item.last_run_failed_databases), + last_run_error: asString(item.last_run_error), + in_sync: item.in_sync === true, + applied: item.applied === true, + apply_error: asString(item.apply_error), + running: item.running === true, + running_started_at: asString(item.running_started_at), + run_last_error: asString(item.run_last_error), + } +} + +async function readSchedule(path: string, failure: string, signal?: AbortSignal): Promise { + const response = await fetch(path, { signal, headers: { Accept: 'application/json' } }) + if (!response.ok) throw new Error(`${failure}(HTTP ${response.status})`) + const value = await response.json() as unknown + if (!record(value) || !record(value.data)) throw new Error('存储与备份响应无效') + return scheduleProjection(value.data) +} + +export function fetchBackupSchedule(signal?: AbortSignal): Promise { + return readSchedule('/api/v1/backup-schedule', '读取备份计划失败', signal) +} + +// host 端点会 SSH 探测目标主机(计划 + 最近一次结果),单独调用以免拖慢首屏。 +export function fetchBackupScheduleHost(signal?: AbortSignal): Promise { + return readSchedule('/api/v1/backup-schedule/host', '读取目标主机上的备份状态失败', signal) +} + +async function writeSchedule(path: string, method: string, body: unknown, failure: string, signal?: AbortSignal): Promise { + const response = await fetch(path, { + method, signal, + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': newOperationID() }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + if (!response.ok) { + let reason = `${failure}(HTTP ${response.status})` + try { + const payload = await response.json() as unknown + if (record(payload) && typeof payload.error === 'string') reason = payload.error + } catch { /* keep the status text */ } + throw new Error(reason) + } + const value = await response.json() as unknown + if (!record(value) || !record(value.data)) throw new Error('存储与备份响应无效') + return scheduleProjection(value.data) +} + +export interface BackupScheduleInput { + enabled: boolean + schedule_time: string + retention_days: number + retention_months: number + // 默认 true:保存后立刻下发到目标主机(页面按钮"保存并下发")。 + apply?: boolean +} + +export function saveBackupSchedule(input: BackupScheduleInput, signal?: AbortSignal): Promise { + return writeSchedule('/api/v1/backup-schedule', 'PUT', input, '保存备份计划失败', signal) +} + +export function applyBackupSchedule(signal?: AbortSignal): Promise { + return writeSchedule('/api/v1/backup-schedule/apply', 'POST', undefined, '下发备份计划失败', signal) +} + +export async function runBackupNow(signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/backup-schedule/run', { + method: 'POST', signal, + headers: { Accept: 'application/json', 'Idempotency-Key': newOperationID() }, + }) + if (!response.ok) { + let reason = `触发备份失败(HTTP ${response.status})` + try { + const payload = await response.json() as unknown + if (record(payload) && typeof payload.error === 'string') reason = payload.error + } catch { /* keep the status text */ } + throw new Error(reason) + } + const value = await response.json() as unknown + if (!record(value) || !record(value.data)) throw new Error('触发备份响应无效') + return { started: value.data.started === true, running: value.data.running === true } +} \ No newline at end of file