Files
knowledge-governance/internal/workflows/release/sidecar_publisher.go
T
姚章浩 1fa35e9870 feat(release): 发布前快照硬门禁 + 云内快照 + ES/OSS 保护
按生产级备份组合落地:

- internal/releasesnapshot:新增门禁包,要求 root 下存在覆盖目标库、未过期
  (默认 24h)、含 wiki_pages.jsonl 与 sha256 的快照;本地文件再校验哈希。
  7 条单测(过期/换库/篡改/缺页/未接线/半成品)。
- 发布器:写目标库之前(Publish/Delete/Rollback 三条路)先过门禁;映射目标
  (源库→目标库,共享真实库)或 policy=always 时必须有快照;门禁未接线
  同样拒绝(fail-closed)。新错误码 release_target_snapshot_missing(不可重试)
  + 页面中文提示。6 条发布器单测。
- worker 配置新增 target_snapshot{root,max_age_hours,policy},校验 policy/
  max_age/绝对路径;compose.release-sim.yaml 只读挂载快照目录。
- scripts/target_kb_snapshot.sh:默认云内执行(与 DB 同 VPC,凭据取自 WeKnora
  容器)+ 默认不导 chunks(44s 出 8 张表);修复 \copy 不能跨行/不能带 WITH、
  ES 计数改用 knowledge_base_id.keyword。
- ES:注册 kb_backup 仓库(path.repo),首个快照 36s/4.7GB/0 分片失败,
  SLM kb-hourly(3d)+kb-daily(7d) 已建并执行成功。
- OSS:目标桶开启版本控制(此前未开启)。
- 文档 P6 增补硬门禁与云内执行;真实快照目录上门禁实测:两个绑定库通过、
  未覆盖库拒绝、未接线拒绝。
2026-10-09 10:59:55 +08:00

241 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// snapshots 是"写目标库前必须存在目标库快照"的门禁;nil 表示未接线。
snapshots TargetSnapshotGuard
snapshotPolicy SnapshotPolicy
}
// TargetSnapshotGuard 由 internal/releasesnapshot.Guard 实现。
type TargetSnapshotGuard interface {
Check(knowledgeBaseID string) error
}
// SnapshotPolicy 决定门禁对哪些项生效。
type SnapshotPolicy int
const (
// SnapshotPolicyMapped:只对"源库 → 目标库"映射的项强制(共享真实库)。
SnapshotPolicyMapped SnapshotPolicy = iota
// SnapshotPolicyAlways:所有写入目标库的项都要有快照。
SnapshotPolicyAlways
)
// PublisherOption 用于给发布器加装配件(保持既有两参构造的兼容)。
type PublisherOption func(*SidecarPublisher) error
// WithTargetSnapshots 装上快照门禁:写入目标库之前先证明"现场已留档"。
func WithTargetSnapshots(guard TargetSnapshotGuard, policy SnapshotPolicy) PublisherOption {
return func(publisher *SidecarPublisher) error {
if guard == nil {
return errors.New("target snapshot guard is required")
}
publisher.snapshots = guard
publisher.snapshotPolicy = policy
return nil
}
}
func NewSidecarPublisher(exporter BundleExporter, target SidecarTarget, options ...PublisherOption) (*SidecarPublisher, error) {
if exporter == nil || target == nil {
return nil, errors.New("release sidecar dependencies are required")
}
publisher := &SidecarPublisher{exporter: exporter, target: target}
for _, option := range options {
if option == nil {
continue
}
if err := option(publisher); err != nil {
return nil, err
}
}
return publisher, nil
}
// ensureTargetSnapshot 在任何写目标库的动作之前调用:目标库是共享真实库
// (源库 → 目标库 映射)或策略要求"一律"时,必须存在覆盖该库的新快照。
// 门禁未接线(root 未配置)时对这类项**拒绝放行**,而不是跳过校验。
func (publisher *SidecarPublisher) ensureTargetSnapshot(item PlanItem) error {
required := publisher.snapshotPolicy == SnapshotPolicyAlways || item.TargetBase() != item.TestKnowledgeBaseID
if !required {
return nil
}
if publisher.snapshots == nil {
return newSidecarError(ErrorCodeTargetSnapshotMissing, false,
errors.New("发布前快照门禁未配置(release config target_snapshot.root)"))
}
if err := publisher.snapshots.Check(item.TargetBase()); err != nil {
return newSidecarError(ErrorCodeTargetSnapshotMissing, false, err)
}
return 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) {
if err := publisher.ensureTargetSnapshot(item); err != nil {
return ItemResult{}, err
}
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 err := publisher.ensureTargetSnapshot(item); err != nil {
return ItemResult{}, err
}
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) {
if err := publisher.ensureTargetSnapshot(item); err != nil {
return ItemResult{}, err
}
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)