Files
knowledge-governance/internal/releasebundle/manifest.go
T
姚章浩 6440e3f679 feat(release): 发布目标知识库可选(源库 → 目标库映射)
- 迁移 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 集成
2026-10-08 15:41:13 +08:00

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/?#\\")
}