1fa35e9870
按生产级备份组合落地:
- internal/releasesnapshot:新增门禁包,要求 root 下存在覆盖目标库、未过期
(默认 24h)、含 wiki_pages.jsonl 与 sha256 的快照;本地文件再校验哈希。
7 条单测(过期/换库/篡改/缺页/未接线/半成品)。
- 发布器:写目标库之前(Publish/Delete/Rollback 三条路)先过门禁;映射目标
(源库→目标库,共享真实库)或 policy=always 时必须有快照;门禁未接线
同样拒绝(fail-closed)。新错误码 release_target_snapshot_missing(不可重试)
+ 页面中文提示。6 条发布器单测。
- worker 配置新增 target_snapshot{root,max_age_hours,policy},校验 policy/
max_age/绝对路径;compose.release-sim.yaml 只读挂载快照目录。
- scripts/target_kb_snapshot.sh:默认云内执行(与 DB 同 VPC,凭据取自 WeKnora
容器)+ 默认不导 chunks(44s 出 8 张表);修复 \copy 不能跨行/不能带 WITH、
ES 计数改用 knowledge_base_id.keyword。
- ES:注册 kb_backup 仓库(path.repo),首个快照 36s/4.7GB/0 分片失败,
SLM kb-hourly(3d)+kb-daily(7d) 已建并执行成功。
- OSS:目标桶开启版本控制(此前未开启)。
- 文档 P6 增补硬门禁与云内执行;真实快照目录上门禁实测:两个绑定库通过、
未覆盖库拒绝、未接线拒绝。
124 lines
4.5 KiB
Go
124 lines
4.5 KiB
Go
package releasesnapshot
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func writeSnapshot(t *testing.T, root, name, createdAt string, kbIDs []string, wikiContent string, hashOverride string) string {
|
|
t.Helper()
|
|
directory := filepath.Join(root, name)
|
|
if err := os.MkdirAll(directory, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if wikiContent != "" {
|
|
if err := os.WriteFile(filepath.Join(directory, "wiki_pages.jsonl"), []byte(wikiContent), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
digest := sha256.Sum256([]byte(wikiContent))
|
|
sha := hex.EncodeToString(digest[:])
|
|
if hashOverride != "" {
|
|
sha = hashOverride
|
|
}
|
|
files := []map[string]any{}
|
|
if wikiContent != "" {
|
|
files = append(files, map[string]any{
|
|
"file": "wiki_pages.jsonl", "rows": 1, "bytes": len(wikiContent), "sha256": sha, "location": "local",
|
|
})
|
|
}
|
|
manifestPayload, err := json.Marshal(map[string]any{
|
|
"kind": manifestKind, "created_at": createdAt, "knowledge_base_ids": kbIDs, "files": files,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(directory, "MANIFEST.json"), manifestPayload, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return directory
|
|
}
|
|
|
|
func fixedNow() func() time.Time {
|
|
return func() time.Time { return time.Date(2026, time.October, 9, 3, 0, 0, 0, time.UTC) }
|
|
}
|
|
|
|
func TestGuardAcceptsFreshSnapshotCoveringTargetKnowledgeBase(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeSnapshot(t, root, "20261009T025017Z-0b217e8a", "20261009T025017Z", []string{"kb-a", "kb-b"}, "{}\n", "")
|
|
guard := Guard{Root: root, MaxAge: 24 * time.Hour, Now: fixedNow(), VerifyLocalFiles: true}
|
|
if err := guard.Check("kb-a"); err != nil {
|
|
t.Fatalf("fresh snapshot must pass: %v", err)
|
|
}
|
|
}
|
|
|
|
// 过期快照不算数:门禁的语义是"发布前刚留过档"。
|
|
func TestGuardRejectsExpiredSnapshot(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeSnapshot(t, root, "20261006T025017Z-0b217e8a", "20261006T025017Z", []string{"kb-a"}, "{}\n", "")
|
|
guard := Guard{Root: root, MaxAge: 24 * time.Hour, Now: fixedNow(), VerifyLocalFiles: true}
|
|
err := guard.Check("kb-a")
|
|
if !errors.Is(err, ErrNoFreshSnapshot) {
|
|
t.Fatalf("expired snapshot must be refused, got %v", err)
|
|
}
|
|
}
|
|
|
|
// 不覆盖目标库的快照不能用(另一个库的快照救不了这个库)。
|
|
func TestGuardRejectsSnapshotOfAnotherKnowledgeBase(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeSnapshot(t, root, "20261009T025017Z-0b217e8a", "20261009T025017Z", []string{"kb-other"}, "{}\n", "")
|
|
guard := Guard{Root: root, Now: fixedNow()}
|
|
if err := guard.Check("kb-a"); !errors.Is(err, ErrNoFreshSnapshot) {
|
|
t.Fatalf("snapshot of another base must be refused, got %v", err)
|
|
}
|
|
}
|
|
|
|
// 关键文件哈希对不上 = 这份快照不可恢复,必须拒绝。
|
|
func TestGuardRejectsTamperedSnapshot(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeSnapshot(t, root, "20261009T025017Z-0b217e8a", "20261009T025017Z", []string{"kb-a"}, "{}\n",
|
|
strings.Repeat("0", 64))
|
|
guard := Guard{Root: root, Now: fixedNow(), VerifyLocalFiles: true}
|
|
if err := guard.Check("kb-a"); !errors.Is(err, ErrNoFreshSnapshot) {
|
|
t.Fatalf("tampered snapshot must be refused, got %v", err)
|
|
}
|
|
}
|
|
|
|
// 没有 wiki_pages.jsonl 的快照不算"现场留档"。
|
|
func TestGuardRejectsSnapshotWithoutWikiPages(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeSnapshot(t, root, "20261009T025017Z-0b217e8a", "20261009T025017Z", []string{"kb-a"}, "", "")
|
|
guard := Guard{Root: root, Now: fixedNow()}
|
|
if err := guard.Check("kb-a"); !errors.Is(err, ErrNoFreshSnapshot) {
|
|
t.Fatalf("snapshot without wiki pages must be refused, got %v", err)
|
|
}
|
|
}
|
|
|
|
// 根目录未配置(门禁未接线)必须拒绝,而不是放行。
|
|
func TestGuardFailsClosedWithoutRoot(t *testing.T) {
|
|
guard := Guard{Root: "", Now: fixedNow()}
|
|
if err := guard.Check("kb-a"); !errors.Is(err, ErrNoFreshSnapshot) {
|
|
t.Fatalf("unconfigured guard must fail closed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// 半成品目录(清单写了一半/缺失)要被跳过,最新那份可用即可。
|
|
func TestGuardSkipsHalfWrittenSnapshotAndPicksNewestValid(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(root, "20261009T030000Z-0b217e8a"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeSnapshot(t, root, "20261009T025017Z-0b217e8a", "20261009T025017Z", []string{"kb-a"}, "{}\n", "")
|
|
guard := Guard{Root: root, MaxAge: 24 * time.Hour, Now: fixedNow(), VerifyLocalFiles: true}
|
|
if err := guard.Check("kb-a"); err != nil {
|
|
t.Fatalf("valid snapshot behind a half-written dir must still pass: %v", err)
|
|
}
|
|
}
|