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 增补硬门禁与云内执行;真实快照目录上门禁实测:两个绑定库通过、
未覆盖库拒绝、未接线拒绝。
190 lines
6.2 KiB
Go
190 lines
6.2 KiB
Go
// Package releasesnapshot 实现"发布前必须有目标知识库快照"的硬门禁。
|
||
//
|
||
// 背景:发布链本身不做发布前备份(DocumentTarget.Prepare 只归档本包、解析
|
||
// previousRevision),而 wiki 通道在共享真实知识库上会覆盖整库页面。留档由
|
||
// scripts/target_kb_snapshot.sh 产生(KB 维度只读行级 JSONL + MANIFEST.json),
|
||
// 本包只负责在写目标库之前证明"这份现场确实存在且是新的"。
|
||
package releasesnapshot
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const manifestKind = "weknora-target-kb-snapshot"
|
||
|
||
// ErrNoFreshSnapshot 表示找不到覆盖目标库、且在有效期内的快照。
|
||
var ErrNoFreshSnapshot = errors.New("no fresh target knowledge base snapshot")
|
||
|
||
type manifestFile struct {
|
||
File string `json:"file"`
|
||
Rows int `json:"rows"`
|
||
// Bytes/SHA256 由快照脚本写入;本地文件会再校验一次。
|
||
Bytes int64 `json:"bytes"`
|
||
SHA256 string `json:"sha256"`
|
||
Location string `json:"location"`
|
||
}
|
||
|
||
type manifest struct {
|
||
Kind string `json:"kind"`
|
||
CreatedAt string `json:"created_at"`
|
||
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
||
Files []manifestFile `json:"files"`
|
||
}
|
||
|
||
// Guard 在快照根目录下寻找覆盖目标库的有效快照。
|
||
type Guard struct {
|
||
// Root 是快照根目录(每份快照一个子目录,内含 MANIFEST.json)。
|
||
Root string
|
||
// MaxAge 是允许的快照最大年龄(0 表示不限制年龄,仅要求存在)。
|
||
MaxAge time.Duration
|
||
// Now 便于测试注入时间。
|
||
Now func() time.Time
|
||
// VerifyLocalFiles 为真时校验本地 JSONL 的 sha256("可恢复"的最低证明)。
|
||
VerifyLocalFiles bool
|
||
}
|
||
|
||
// Check 要求 Root 下存在一份覆盖 knowledgeBaseID 的快照:类型正确、在有效期内、
|
||
// 含 wiki_pages.jsonl 且其 sha256 非空(本地文件再按哈希校验一次)。
|
||
// 找到即返回 nil;否则返回包装了 ErrNoFreshSnapshot 的原因。
|
||
func (guard Guard) Check(knowledgeBaseID string) error {
|
||
if strings.TrimSpace(knowledgeBaseID) == "" {
|
||
return fmt.Errorf("%w: 目标知识库为空", ErrNoFreshSnapshot)
|
||
}
|
||
root := strings.TrimSpace(guard.Root)
|
||
if root == "" {
|
||
return fmt.Errorf("%w: 未配置快照根目录(RELEASE_SNAPSHOT_ROOT)", ErrNoFreshSnapshot)
|
||
}
|
||
entries, err := os.ReadDir(root)
|
||
if err != nil {
|
||
return fmt.Errorf("%w: 读取快照目录失败: %v", ErrNoFreshSnapshot, err)
|
||
}
|
||
now := guard.now()
|
||
names := make([]string, 0, len(entries))
|
||
for _, entry := range entries {
|
||
if entry.IsDir() {
|
||
names = append(names, entry.Name())
|
||
}
|
||
}
|
||
// 目录名以 UTC 时间戳开头(<ts>-<kb前缀>),倒序即从最新开始找。
|
||
sort.Sort(sort.Reverse(sort.StringSlice(names)))
|
||
var newestProblem error
|
||
for _, name := range names {
|
||
ok, problem := guard.inspect(filepath.Join(root, name), knowledgeBaseID, now)
|
||
if ok {
|
||
return nil
|
||
}
|
||
if problem != nil && newestProblem == nil {
|
||
newestProblem = problem
|
||
}
|
||
}
|
||
if newestProblem != nil {
|
||
return newestProblem
|
||
}
|
||
return fmt.Errorf("%w: 未找到覆盖目标库 %s 的快照", ErrNoFreshSnapshot, knowledgeBaseID)
|
||
}
|
||
|
||
func (guard Guard) now() time.Time {
|
||
if guard.Now != nil {
|
||
return guard.Now().UTC()
|
||
}
|
||
return time.Now().UTC()
|
||
}
|
||
|
||
// inspect 判断一份快照是否可用;ok=true 表示通过。
|
||
func (guard Guard) inspect(directory, knowledgeBaseID string, now time.Time) (bool, error) {
|
||
path := filepath.Join(directory, "MANIFEST.json")
|
||
payload, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return false, nil // 目录里没有清单(半成品),跳过
|
||
}
|
||
var parsed manifest
|
||
if err := json.Unmarshal(payload, &parsed); err != nil {
|
||
return false, fmt.Errorf("%w: 快照清单无法解析(%s)", ErrNoFreshSnapshot, filepath.Base(directory))
|
||
}
|
||
if parsed.Kind != manifestKind {
|
||
return false, nil
|
||
}
|
||
covered := false
|
||
for _, id := range parsed.KnowledgeBaseIDs {
|
||
if id == knowledgeBaseID {
|
||
covered = true
|
||
break
|
||
}
|
||
}
|
||
if !covered {
|
||
return false, nil
|
||
}
|
||
created, err := parseStamp(parsed.CreatedAt)
|
||
if err != nil {
|
||
return false, fmt.Errorf("%w: 快照时间戳无法识别(%s)", ErrNoFreshSnapshot, filepath.Base(directory))
|
||
}
|
||
if guard.MaxAge > 0 && now.Sub(created) > guard.MaxAge {
|
||
return false, fmt.Errorf("%w: 最新快照已过期(%s,超过 %s)",
|
||
ErrNoFreshSnapshot, created.Format(time.RFC3339), guard.MaxAge)
|
||
}
|
||
wiki, present := findFile(parsed.Files, "wiki_pages.jsonl")
|
||
if !present || wiki.SHA256 == "" {
|
||
return false, fmt.Errorf("%w: 快照缺少 wiki_pages.jsonl 或哈希(%s)",
|
||
ErrNoFreshSnapshot, filepath.Base(directory))
|
||
}
|
||
if guard.VerifyLocalFiles {
|
||
if err := verifyLocal(directory, wiki); err != nil {
|
||
return false, err
|
||
}
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// parseStamp 接受脚本写出的 20261009T025017Z,也接受 RFC3339。
|
||
func parseStamp(value string) (time.Time, error) {
|
||
value = strings.TrimSpace(value)
|
||
if created, err := time.Parse("20060102T150405Z", value); err == nil {
|
||
return created.UTC(), nil
|
||
}
|
||
if created, err := time.Parse(time.RFC3339, value); err == nil {
|
||
return created.UTC(), nil
|
||
}
|
||
return time.Time{}, errors.New("unrecognized snapshot timestamp")
|
||
}
|
||
|
||
func findFile(files []manifestFile, name string) (manifestFile, bool) {
|
||
for _, file := range files {
|
||
if file.File == name {
|
||
return file, true
|
||
}
|
||
}
|
||
return manifestFile{}, false
|
||
}
|
||
|
||
// verifyLocal 校验本地 JSONL 的 sha256,作为"这份快照还能用"的最小证明。
|
||
// 放在云主机上(location=cloud:...)的文件无法在这里校验,跳过。
|
||
func verifyLocal(directory string, file manifestFile) error {
|
||
if strings.HasPrefix(file.Location, "cloud:") {
|
||
return nil
|
||
}
|
||
handle, err := os.Open(filepath.Join(directory, file.File))
|
||
if err != nil {
|
||
return fmt.Errorf("%w: 快照文件不可读(%s)", ErrNoFreshSnapshot, file.File)
|
||
}
|
||
defer handle.Close()
|
||
digest := sha256.New()
|
||
if _, err := io.Copy(digest, handle); err != nil {
|
||
return fmt.Errorf("%w: 快照文件校验失败(%s)", ErrNoFreshSnapshot, file.File)
|
||
}
|
||
actual := hex.EncodeToString(digest.Sum(nil))
|
||
if actual != file.SHA256 {
|
||
return fmt.Errorf("%w: 快照文件哈希不匹配(%s)", ErrNoFreshSnapshot, file.File)
|
||
}
|
||
return nil
|
||
}
|