Files
knowledge-governance/internal/releasesnapshot/guard.go
T
姚章浩 1fa35e9870 feat(release): 发布前快照硬门禁 + 云内快照 + ES/OSS 保护
按生产级备份组合落地:

- 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 增补硬门禁与云内执行;真实快照目录上门禁实测:两个绑定库通过、
  未覆盖库拒绝、未接线拒绝。
2026-10-09 10:59:55 +08:00

190 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package 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
}