dcdc8d1ddd
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环): - 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口) - 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入 parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截 - 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过): * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id) * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配 * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数 * 回滚 job payload 支持 prior_execution_id(strict 解码) - 三通道发布+回滚演练实测:publish 4/4 verified,检索命中, rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false - 前端 A·Data-console 风格全站统一,120 用例通过 - 门禁:go vet / go test 全包通过,gofmt 干净
156 lines
5.7 KiB
Go
156 lines
5.7 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"`
|
|
Routes []releaseRouteConfig `json:"routes"`
|
|
}
|
|
|
|
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")
|
|
}
|
|
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 }
|
|
|
|
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
|
|
}
|