Files
knowledge-governance/cmd/release-worker/config.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

191 lines
7.3 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 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
}