Files
knowledge-governance/internal/workflows/release/sidecar_publisher.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

172 lines
7.8 KiB
Go

package release
import (
"context"
"errors"
"strings"
"knowledge-governance/internal/releasebundle"
)
type BundleExporter interface {
Export(ctx context.Context, item PlanItem) (ExportedBundle, error)
}
type ExportedBundle interface {
ReleaseManifest() releasebundle.Manifest
}
type PreparedChange struct {
PreviousRevision string
ProductionRevision string
}
type SidecarTarget interface {
Contract(ctx context.Context, item PlanItem) (releasebundle.TargetContract, error)
Prepare(ctx context.Context, item PlanItem, bundle ExportedBundle) (PreparedChange, error)
Activate(ctx context.Context, item PlanItem, change PreparedChange) error
Verify(ctx context.Context, item PlanItem, change PreparedChange) error
Compensate(ctx context.Context, item PlanItem, change PreparedChange) error
Restore(ctx context.Context, item PlanItem, revision string) (string, error)
VerifyRestored(ctx context.Context, item PlanItem, revision string) error
}
type SidecarPublisher struct {
exporter BundleExporter
target SidecarTarget
}
func NewSidecarPublisher(exporter BundleExporter, target SidecarTarget) (*SidecarPublisher, error) {
if exporter == nil || target == nil {
return nil, errors.New("release sidecar dependencies are required")
}
return &SidecarPublisher{exporter: exporter, target: target}, nil
}
// releaseItemMapping declares which production base this item must land in: the
// tested base it came from, and the 源库 → 目标库 target the execution resolved.
func releaseItemMapping(item PlanItem) releasebundle.KnowledgeBaseMapping {
return releasebundle.KnowledgeBaseMapping{
SourceKnowledgeBaseID: item.TestKnowledgeBaseID,
TargetKnowledgeBaseID: item.TargetKnowledgeBaseID,
}
}
func (publisher *SidecarPublisher) Publish(ctx context.Context, item PlanItem) (ItemResult, error) {
bundle, err := publisher.exporter.Export(ctx, item)
if err != nil {
return ItemResult{}, newSidecarError(ErrorCodeTargetUnavailable, true, err)
}
if bundle == nil {
return ItemResult{}, newSidecarError(ErrorCodeBundleInvalid, false, errors.New("empty release bundle"))
}
manifest := bundle.ReleaseManifest()
if manifest.ExecutionID != item.ReleaseExecutionID || manifest.PublicationID != item.PublicationID ||
manifest.SourceItemVersionID != item.SourceItemVersionID || manifest.SourceRevision != item.SourceRevision ||
manifest.ParseRevision != item.ParseRevision || manifest.ReleaseRevision != item.ReleaseRevision ||
manifest.Contract.KnowledgeBaseID != item.TestKnowledgeBaseID || manifest.Contract.KnowledgeID != item.KnowledgeID {
return ItemResult{}, newSidecarError(ErrorCodeContractMismatch, false, errors.New("bundle identity mismatch"))
}
targetContract, err := publisher.target.Contract(ctx, item)
if err != nil {
return ItemResult{}, newSidecarError(ErrorCodeTargetUnavailable, true, err)
}
if err := releasebundle.Validate(manifest, targetContract, releaseItemMapping(item)); err != nil {
return ItemResult{}, newSidecarError(ErrorCodeContractMismatch, false, err)
}
change, err := publisher.target.Prepare(ctx, item, bundle)
if err != nil {
return ItemResult{}, newSidecarError(ErrorCodeTargetWriteFailed, true, err)
}
if !validRevision(change.ProductionRevision) || (change.PreviousRevision != "" && !validRevision(change.PreviousRevision)) {
return ItemResult{}, publisher.compensateError(ctx, item, change, ErrorCodeBundleInvalid, errors.New("invalid prepared revision"))
}
if err := publisher.target.Activate(ctx, item, change); err != nil {
return ItemResult{}, publisher.compensateError(ctx, item, change, ErrorCodeTargetWriteFailed, err)
}
if err := publisher.target.Verify(ctx, item, change); err != nil {
return ItemResult{}, publisher.compensateError(ctx, item, change, ErrorCodeVerificationFailed, err)
}
return ItemResult{
BundleSHA256: manifest.BundleSHA256,
PreviousProductionRevision: change.PreviousRevision,
ProductionRevision: change.ProductionRevision,
}, nil
}
func (publisher *SidecarPublisher) Rollback(ctx context.Context, item PlanItem) (ItemResult, error) {
if !validRevision(item.PreviousProductionRevision) {
return ItemResult{}, newSidecarError(ErrorCodeRollbackArtifactMissing, false, errors.New("missing rollback revision"))
}
restoredRevision, err := publisher.target.Restore(ctx, item, item.PreviousProductionRevision)
if err != nil {
return ItemResult{}, newSidecarError(ErrorCodeTargetWriteFailed, true, err)
}
if restoredRevision != item.PreviousProductionRevision {
return ItemResult{}, newSidecarError(ErrorCodeContractMismatch, false, errors.New("restored revision mismatch"))
}
if err := publisher.target.VerifyRestored(ctx, item, restoredRevision); err != nil {
return ItemResult{}, newSidecarError(ErrorCodeVerificationFailed, false, err)
}
return ItemResult{ProductionRevision: restoredRevision}, nil
}
func (publisher *SidecarPublisher) Delete(ctx context.Context, item PlanItem) (ItemResult, error) {
bundle, err := publisher.exporter.Export(ctx, item)
if err != nil {
return ItemResult{}, newSidecarError(ErrorCodeTargetUnavailable, true, err)
}
if bundle == nil {
return ItemResult{}, newSidecarError(ErrorCodeBundleInvalid, false, errors.New("empty release bundle"))
}
manifest := bundle.ReleaseManifest()
if manifest.ExecutionID != item.ReleaseExecutionID || manifest.PublicationID != item.PublicationID ||
manifest.SourceItemVersionID != item.SourceItemVersionID || manifest.SourceRevision != item.SourceRevision ||
manifest.ParseRevision != item.ParseRevision || manifest.ReleaseRevision != item.ReleaseRevision ||
manifest.Contract.KnowledgeBaseID != item.TestKnowledgeBaseID || manifest.Contract.KnowledgeID != item.KnowledgeID {
return ItemResult{}, newSidecarError(ErrorCodeContractMismatch, false, errors.New("bundle identity mismatch"))
}
targetContract, err := publisher.target.Contract(ctx, item)
if err != nil {
return ItemResult{}, newSidecarError(ErrorCodeTargetUnavailable, true, err)
}
if err := releasebundle.Validate(manifest, targetContract, releaseItemMapping(item)); err != nil {
return ItemResult{}, newSidecarError(ErrorCodeContractMismatch, false, err)
}
change, err := publisher.target.Prepare(ctx, item, bundle)
if err != nil {
return ItemResult{}, newSidecarError(ErrorCodeTargetWriteFailed, true, err)
}
if !validRevision(change.PreviousRevision) || change.ProductionRevision != item.ReleaseRevision {
return ItemResult{}, publisher.compensateError(ctx, item, change, ErrorCodeBundleInvalid, errors.New("invalid delete snapshot revision"))
}
restoredRevision, err := publisher.target.Restore(ctx, item, "absent")
if err != nil {
return ItemResult{}, publisher.compensateError(ctx, item, change, ErrorCodeTargetWriteFailed, err)
}
if restoredRevision != "absent" {
return ItemResult{}, publisher.compensateError(ctx, item, change, ErrorCodeContractMismatch, errors.New("deleted revision mismatch"))
}
if err := publisher.target.VerifyRestored(ctx, item, "absent"); err != nil {
return ItemResult{}, publisher.compensateError(ctx, item, change, ErrorCodeVerificationFailed, err)
}
return ItemResult{BundleSHA256: manifest.BundleSHA256, PreviousProductionRevision: change.PreviousRevision, ProductionRevision: "absent"}, nil
}
func (publisher *SidecarPublisher) compensateError(ctx context.Context, item PlanItem, change PreparedChange, code string, cause error) error {
if err := publisher.target.Compensate(ctx, item, change); err != nil {
return newSidecarError(ErrorCodeRollbackFailed, false, err)
}
return newSidecarError(code, false, cause)
}
func newSidecarError(code string, retryable bool, cause error) error {
return &ItemError{Code: code, Retryable: retryable, Cause: cause}
}
func validRevision(value string) bool {
return value == strings.TrimSpace(value) && value != "" && len(value) <= 128 && !strings.ContainsAny(value, "\x00\r\n\t/?#\\")
}
var _ Publisher = (*SidecarPublisher)(nil)