1fa35e9870
按生产级备份组合落地:
- 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 增补硬门禁与云内执行;真实快照目录上门禁实测:两个绑定库通过、
未覆盖库拒绝、未接线拒绝。
241 lines
10 KiB
Go
241 lines
10 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
|
||
// 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)
|