6440e3f679
- 迁移 000044:production_kb_bindings + release_item_executions.target_knowledge_base_id - 系统设置新增「生产知识库绑定(对内/对外)」:从生产 WeKnora 实际列表选库, 保存时真校验(不存在拒收;生产端不可达只提示并如实回报),仅管理员可改 - 执行计划按绑定解析目标库并冻结进 release_item_executions;空目标直接拒收; 已发布过的条目保留原目标(重试/回滚回到同一处) - DocumentBundle.RemapKnowledgeBase:knowledges/chunks/knowledge_tags/wiki_pages/ wiki_folders 的 knowledge_base_id 与 ES 文档字段改写为目标库,重算表/文档摘要并重新封装 - 契约比对改为「声明的映射对」(KnowledgeBaseMapping),其余字段继续 fail-closed - 契约检查器 / 目标身份 / 验证器全部走映射后的目标库;对象通道无需改写 - Compose 拆分:数据面接线 compose.release-sim.yaml + 两道门禁 compose.release-gates.yaml - 测试:RemapKnowledgeBase 全通道/无操作/fail-closed、映射契约、绑定接口、RBAC、真实 PG 集成
214 lines
7.7 KiB
Go
214 lines
7.7 KiB
Go
package releasebundle
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
FormatVersionV1 = "weknora-release-bundle/v1"
|
|
WeKnoraVersionV071 = "v0.7.1"
|
|
WeKnoraMigrationV071 = 74
|
|
EmbeddingDimensionV071 = 1024
|
|
)
|
|
|
|
var allowedTables = map[string]bool{
|
|
"knowledges": true,
|
|
"chunks": true,
|
|
"knowledge_tags": true,
|
|
"knowledge_tag_relations": true,
|
|
"resources": true,
|
|
"resource_bindings": true,
|
|
"wiki_pages": true,
|
|
"wiki_folders": true,
|
|
}
|
|
|
|
type Manifest struct {
|
|
FormatVersion string `json:"format_version"`
|
|
ExecutionID string `json:"execution_id"`
|
|
PublicationID string `json:"publication_id"`
|
|
SourceItemVersionID string `json:"source_item_version_id"`
|
|
SourceRevision string `json:"source_revision"`
|
|
ParseRevision string `json:"parse_revision"`
|
|
ReleaseRevision string `json:"release_revision"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Contract Contract `json:"contract"`
|
|
Tables []TableDigest `json:"tables"`
|
|
Search SearchDigest `json:"search"`
|
|
Assets AssetDigest `json:"assets"`
|
|
BundleSHA256 string `json:"bundle_sha256"`
|
|
}
|
|
|
|
type Contract struct {
|
|
WeKnoraVersion string `json:"weknora_version"`
|
|
MigrationVersion int `json:"migration_version"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
KnowledgeBaseID string `json:"knowledge_base_id"`
|
|
KnowledgeID string `json:"knowledge_id"`
|
|
RetrieverEngine string `json:"retriever_engine"`
|
|
EmbeddingModel string `json:"embedding_model"`
|
|
EmbeddingDimension int `json:"embedding_dimension"`
|
|
ElasticsearchMappingSHA256 string `json:"elasticsearch_mapping_sha256"`
|
|
}
|
|
|
|
type TargetContract struct {
|
|
WeKnoraVersion string
|
|
MigrationVersion int
|
|
TenantID uint64
|
|
KnowledgeBaseID string
|
|
RetrieverEngine string
|
|
EmbeddingModel string
|
|
EmbeddingDimension int
|
|
ElasticsearchMappingSHA256 string
|
|
}
|
|
|
|
type TableDigest struct {
|
|
Name string `json:"name"`
|
|
RowCount int `json:"row_count"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
type SearchDigest struct {
|
|
DocumentCount int `json:"document_count"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
type AssetDigest struct {
|
|
ObjectCount int `json:"object_count"`
|
|
TotalBytes int64 `json:"total_bytes"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
func Seal(manifest Manifest) (Manifest, error) {
|
|
manifest.BundleSHA256 = ""
|
|
manifest.Tables = append([]TableDigest(nil), manifest.Tables...)
|
|
sort.Slice(manifest.Tables, func(left, right int) bool { return manifest.Tables[left].Name < manifest.Tables[right].Name })
|
|
if err := validateManifestFields(manifest); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
digest, err := manifestDigest(manifest)
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
manifest.BundleSHA256 = digest
|
|
return manifest, nil
|
|
}
|
|
|
|
// KnowledgeBaseMapping is the declared 源库 → 目标库 pair for a release item.
|
|
// An empty TargetKnowledgeBaseID means the historical same-id semantics: the
|
|
// production base must carry exactly the tested base's id.
|
|
type KnowledgeBaseMapping struct {
|
|
SourceKnowledgeBaseID string
|
|
TargetKnowledgeBaseID string
|
|
}
|
|
|
|
// EffectiveTarget is the base the release writes into.
|
|
func (mapping KnowledgeBaseMapping) EffectiveTarget() string {
|
|
if trimmed := strings.TrimSpace(mapping.TargetKnowledgeBaseID); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
return mapping.SourceKnowledgeBaseID
|
|
}
|
|
|
|
// Validate proves the bundle contract against the target data plane. The only
|
|
// thing a cross-base release relaxes is the base identity itself: the bundle
|
|
// keeps the SOURCE base and the target must match the DECLARED mapping, so an
|
|
// undeclared (or stale) mapping still fails closed.
|
|
func Validate(manifest Manifest, target TargetContract, mapping KnowledgeBaseMapping) error {
|
|
if err := validateManifestFields(manifest); err != nil {
|
|
return err
|
|
}
|
|
if !validSHA256(manifest.BundleSHA256) {
|
|
return errors.New("invalid bundle digest")
|
|
}
|
|
expected, err := manifestDigest(manifest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if expected != manifest.BundleSHA256 {
|
|
return errors.New("bundle digest mismatch")
|
|
}
|
|
contract := manifest.Contract
|
|
if mapping.SourceKnowledgeBaseID != contract.KnowledgeBaseID {
|
|
return errors.New("release mapping does not match bundle")
|
|
}
|
|
if target.KnowledgeBaseID != mapping.EffectiveTarget() {
|
|
return errors.New("target release contract mismatch")
|
|
}
|
|
if target.WeKnoraVersion != WeKnoraVersionV071 || target.MigrationVersion != WeKnoraMigrationV071 ||
|
|
contract.WeKnoraVersion != target.WeKnoraVersion || contract.MigrationVersion != target.MigrationVersion ||
|
|
contract.TenantID != target.TenantID ||
|
|
contract.RetrieverEngine != target.RetrieverEngine || contract.EmbeddingModel != target.EmbeddingModel ||
|
|
contract.EmbeddingDimension != target.EmbeddingDimension ||
|
|
contract.ElasticsearchMappingSHA256 != target.ElasticsearchMappingSHA256 {
|
|
return errors.New("target release contract mismatch")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateManifestFields(manifest Manifest) error {
|
|
if manifest.FormatVersion != FormatVersionV1 || manifest.Contract.WeKnoraVersion != WeKnoraVersionV071 ||
|
|
manifest.Contract.MigrationVersion != WeKnoraMigrationV071 || manifest.Contract.EmbeddingDimension != EmbeddingDimensionV071 ||
|
|
manifest.Contract.RetrieverEngine != "elasticsearch_v8" || manifest.Contract.TenantID == 0 || manifest.CreatedAt.IsZero() {
|
|
return errors.New("unsupported release contract")
|
|
}
|
|
for _, value := range []string{manifest.ExecutionID, manifest.PublicationID, manifest.SourceItemVersionID,
|
|
manifest.SourceRevision, manifest.ParseRevision, manifest.ReleaseRevision, manifest.Contract.KnowledgeBaseID,
|
|
manifest.Contract.KnowledgeID, manifest.Contract.EmbeddingModel} {
|
|
if !validIdentifier(value) {
|
|
return errors.New("invalid release identity")
|
|
}
|
|
}
|
|
if !validSHA256(manifest.Contract.ElasticsearchMappingSHA256) || !validSHA256(manifest.Search.SHA256) || !validSHA256(manifest.Assets.SHA256) ||
|
|
manifest.Search.DocumentCount < 0 || manifest.Assets.ObjectCount < 0 || manifest.Assets.TotalBytes < 0 {
|
|
return errors.New("invalid release digest")
|
|
}
|
|
seen := make(map[string]bool, len(manifest.Tables))
|
|
hasKnowledge, hasChunks := false, false
|
|
for _, table := range manifest.Tables {
|
|
if !allowedTables[table.Name] || seen[table.Name] || table.RowCount < 0 || !validSHA256(table.SHA256) {
|
|
return errors.New("invalid release table")
|
|
}
|
|
seen[table.Name] = true
|
|
hasKnowledge = hasKnowledge || table.Name == "knowledges" && table.RowCount == 1
|
|
hasChunks = hasChunks || table.Name == "chunks" && table.RowCount > 0
|
|
}
|
|
if !hasKnowledge || !hasChunks {
|
|
return errors.New("release manifest requires one knowledge and at least one chunk")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func manifestDigest(manifest Manifest) (string, error) {
|
|
manifest.BundleSHA256 = ""
|
|
manifest.Tables = append([]TableDigest(nil), manifest.Tables...)
|
|
sort.Slice(manifest.Tables, func(left, right int) bool { return manifest.Tables[left].Name < manifest.Tables[right].Name })
|
|
payload, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return "", errors.New("encode release manifest")
|
|
}
|
|
digest := sha256.Sum256(payload)
|
|
return hex.EncodeToString(digest[:]), nil
|
|
}
|
|
|
|
func validSHA256(value string) bool {
|
|
if len(value) != 64 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validIdentifier(value string) bool {
|
|
return value == strings.TrimSpace(value) && value != "" && len(value) <= 128 && !strings.ContainsAny(value, "\x00\r\n\t/?#\\")
|
|
}
|