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 增补硬门禁与云内执行;真实快照目录上门禁实测:两个绑定库通过、
未覆盖库拒绝、未接线拒绝。
191 lines
7.3 KiB
Go
191 lines
7.3 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
)
|
||
|
||
const maxReleaseConfigBytes = 1024 * 1024
|
||
|
||
type releaseRuntimeConfig struct {
|
||
ArchiveRoot string `json:"archive_root"`
|
||
// TargetSnapshot 是"写目标库前必须留档"的门禁配置。
|
||
TargetSnapshot releaseTargetSnapshotConfig `json:"target_snapshot"`
|
||
Routes []releaseRouteConfig `json:"routes"`
|
||
}
|
||
|
||
// releaseTargetSnapshotConfig 指向快照根目录(scripts/target_kb_snapshot.sh 的输出
|
||
// 目录),并声明门禁对哪些项生效。
|
||
type releaseTargetSnapshotConfig struct {
|
||
Root string `json:"root"`
|
||
MaxAgeHours int `json:"max_age_hours"`
|
||
// Policy:mapped(默认,只对 源库→目标库 映射的共享库强制)| always(一律强制)。
|
||
Policy string `json:"policy"`
|
||
}
|
||
|
||
type releaseRouteConfig struct {
|
||
TenantID uint64 `json:"tenant_id"`
|
||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||
EmbeddingModel string `json:"embedding_model"`
|
||
Source releaseEndpointConfig `json:"source"`
|
||
Target releaseEndpointConfig `json:"target"`
|
||
}
|
||
|
||
type releaseEndpointConfig struct {
|
||
DatabaseURLFile string `json:"database_url_file"`
|
||
Elasticsearch releaseElasticsearchConfig `json:"elasticsearch"`
|
||
Object releaseObjectConfig `json:"object"`
|
||
WeKnora releaseWeKnoraConfig `json:"weknora"`
|
||
}
|
||
|
||
type releaseElasticsearchConfig struct {
|
||
BaseURL string `json:"base_url"`
|
||
Index string `json:"index"`
|
||
TokenFile string `json:"token_file"`
|
||
}
|
||
|
||
type releaseObjectConfig struct {
|
||
Provider string `json:"provider"`
|
||
Endpoint string `json:"endpoint"`
|
||
Region string `json:"region"`
|
||
Bucket string `json:"bucket"`
|
||
StorageBackendID string `json:"storage_backend_id"`
|
||
AccessKeyFile string `json:"access_key_file"`
|
||
SecretKeyFile string `json:"secret_key_file"`
|
||
}
|
||
|
||
type releaseWeKnoraConfig struct {
|
||
BaseURL string `json:"base_url"`
|
||
APIKeyFile string `json:"api_key_file"`
|
||
AllowDockerHTTP bool `json:"allow_docker_http"`
|
||
}
|
||
|
||
func loadReleaseRuntimeConfig(path string) (releaseRuntimeConfig, error) {
|
||
if !filepath.IsAbs(path) {
|
||
return releaseRuntimeConfig{}, errors.New("release config path must be absolute")
|
||
}
|
||
fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW, 0)
|
||
if err != nil {
|
||
return releaseRuntimeConfig{}, errors.New("open release config")
|
||
}
|
||
file := os.NewFile(uintptr(fd), "release-config")
|
||
if file == nil {
|
||
syscall.Close(fd)
|
||
return releaseRuntimeConfig{}, errors.New("open release config")
|
||
}
|
||
defer file.Close()
|
||
info, err := file.Stat()
|
||
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 || info.Size() <= 0 || info.Size() > maxReleaseConfigBytes {
|
||
return releaseRuntimeConfig{}, errors.New("release config must be a non-empty regular 0600 file")
|
||
}
|
||
payload, err := io.ReadAll(io.LimitReader(file, maxReleaseConfigBytes+1))
|
||
if err != nil || len(payload) > maxReleaseConfigBytes {
|
||
return releaseRuntimeConfig{}, errors.New("read release config")
|
||
}
|
||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||
decoder.DisallowUnknownFields()
|
||
var config releaseRuntimeConfig
|
||
if err := decoder.Decode(&config); err != nil {
|
||
return releaseRuntimeConfig{}, errors.New("decode release config")
|
||
}
|
||
var trailing any
|
||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||
return releaseRuntimeConfig{}, errors.New("release config contains trailing data")
|
||
}
|
||
if err := validateReleaseRuntimeConfig(config); err != nil {
|
||
return releaseRuntimeConfig{}, err
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
func validateReleaseRuntimeConfig(config releaseRuntimeConfig) error {
|
||
if !filepath.IsAbs(config.ArchiveRoot) || len(config.Routes) == 0 {
|
||
return errors.New("release archive root and routes are required")
|
||
}
|
||
if err := validateTargetSnapshotConfig(config.TargetSnapshot); err != nil {
|
||
return err
|
||
}
|
||
seen := map[string]bool{}
|
||
for _, route := range config.Routes {
|
||
if route.TenantID == 0 || !validConfigIdentifier(route.KnowledgeBaseID) || !validConfigIdentifier(route.EmbeddingModel) || seen[route.KnowledgeBaseID] {
|
||
return errors.New("invalid or duplicate release route")
|
||
}
|
||
seen[route.KnowledgeBaseID] = true
|
||
if !validReleaseEndpoint(route.Source, false) || !validReleaseEndpoint(route.Target, true) {
|
||
return errors.New("invalid release route endpoint")
|
||
}
|
||
if route.Source.DatabaseURLFile == route.Target.DatabaseURLFile ||
|
||
route.Source.Elasticsearch.BaseURL == route.Target.Elasticsearch.BaseURL ||
|
||
(route.Source.Object.Endpoint == route.Target.Object.Endpoint && route.Source.Object.Bucket == route.Target.Object.Bucket) {
|
||
return errors.New("release source and target must be distinct")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validReleaseEndpoint(endpoint releaseEndpointConfig, target bool) bool {
|
||
if !absoluteFile(endpoint.DatabaseURLFile) || !validHTTPURL(endpoint.Elasticsearch.BaseURL) || !validConfigIdentifier(endpoint.Elasticsearch.Index) ||
|
||
(endpoint.Elasticsearch.TokenFile != "" && !absoluteFile(endpoint.Elasticsearch.TokenFile)) ||
|
||
!validObjectEndpoint(endpoint.Object) {
|
||
return false
|
||
}
|
||
if target {
|
||
return validConfigIdentifier(endpoint.Object.StorageBackendID) && validHTTPURL(endpoint.WeKnora.BaseURL) && absoluteFile(endpoint.WeKnora.APIKeyFile)
|
||
}
|
||
return endpoint.WeKnora.BaseURL == "" && endpoint.WeKnora.APIKeyFile == ""
|
||
}
|
||
|
||
func validObjectEndpoint(object releaseObjectConfig) bool {
|
||
return (object.Provider == "minio" || object.Provider == "s3" || object.Provider == "oss") && validHTTPURL(object.Endpoint) &&
|
||
validConfigIdentifier(object.Bucket) && absoluteFile(object.AccessKeyFile) && absoluteFile(object.SecretKeyFile)
|
||
}
|
||
|
||
func absoluteFile(path string) bool { return filepath.IsAbs(path) && filepath.Clean(path) == path }
|
||
|
||
// validateTargetSnapshotConfig 校验快照门禁配置。留档目录缺省即视为"未接线",
|
||
// 但一旦声明要留档(policy=always)就必须给出目录——否则是假门禁。
|
||
func validateTargetSnapshotConfig(config releaseTargetSnapshotConfig) error {
|
||
if config.Policy != "" && config.Policy != "mapped" && config.Policy != "always" {
|
||
return errors.New("release target snapshot policy must be mapped or always")
|
||
}
|
||
if config.MaxAgeHours < 0 || config.MaxAgeHours > 720 {
|
||
return errors.New("release target snapshot max_age_hours must be between 0 and 720")
|
||
}
|
||
if config.Root == "" {
|
||
if config.Policy == "always" {
|
||
return errors.New("release target snapshot root is required when policy is always")
|
||
}
|
||
return nil
|
||
}
|
||
if !filepath.IsAbs(config.Root) || filepath.Clean(config.Root) != config.Root {
|
||
return errors.New("release target snapshot root must be an absolute clean path")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validHTTPURL(value string) bool {
|
||
parsed, err := url.Parse(value)
|
||
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" && parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == ""
|
||
}
|
||
|
||
func validConfigIdentifier(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || len(value) > 128 {
|
||
return false
|
||
}
|
||
for _, char := range value {
|
||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|