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 集成
172 lines
7.8 KiB
Go
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)
|