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

363 lines
15 KiB
Go

package release
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"knowledge-governance/internal/releasebundle"
)
func TestSidecarPublisherPublishesWithContractAndVerification(t *testing.T) {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
exporter := &fakeExporter{manifest: manifest, calls: &calls}
target := &fakeSidecarTarget{contract: testTargetContract(), calls: &calls, prepared: PreparedChange{PreviousRevision: "prod-v0", ProductionRevision: "prod-v1"}}
publisher, err := NewSidecarPublisher(exporter, target)
if err != nil {
t.Fatal(err)
}
result, err := publisher.Publish(context.Background(), testPlanItem())
if err != nil {
t.Fatal(err)
}
if result.BundleSHA256 != manifest.BundleSHA256 || result.PreviousProductionRevision != "prod-v0" || result.ProductionRevision != "prod-v1" {
t.Fatalf("result=%#v", result)
}
want := []string{"export", "contract", "prepare", "activate", "verify"}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("calls=%v want=%v", calls, want)
}
}
func TestSidecarPublisherRollsBackFailedVerificationWithoutLeakingCause(t *testing.T) {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
target := &fakeSidecarTarget{contract: testTargetContract(), calls: &calls, prepared: PreparedChange{PreviousRevision: "prod-v0", ProductionRevision: "prod-v1"}, verifyErr: errors.New("signed-url-secret-should-not-leak")}
publisher, err := NewSidecarPublisher(&fakeExporter{manifest: manifest, calls: &calls}, target)
if err != nil {
t.Fatal(err)
}
_, err = publisher.Publish(context.Background(), testPlanItem())
var itemErr *ItemError
if !errors.As(err, &itemErr) || itemErr.Code != ErrorCodeVerificationFailed || itemErr.Retryable {
t.Fatalf("error=%#v", err)
}
if strings.Contains(err.Error(), "signed-url") {
t.Fatalf("error leaked cause: %v", err)
}
want := []string{"export", "contract", "prepare", "activate", "verify", "compensate"}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("calls=%v want=%v", calls, want)
}
}
func TestSidecarPublisherRejectsContractDriftBeforeTargetMutation(t *testing.T) {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
contract := testTargetContract()
contract.EmbeddingDimension = 768
publisher, err := NewSidecarPublisher(&fakeExporter{manifest: manifest, calls: &calls}, &fakeSidecarTarget{contract: contract, calls: &calls})
if err != nil {
t.Fatal(err)
}
_, err = publisher.Publish(context.Background(), testPlanItem())
var itemErr *ItemError
if !errors.As(err, &itemErr) || itemErr.Code != ErrorCodeContractMismatch {
t.Fatalf("error=%#v", err)
}
if !reflect.DeepEqual(calls, []string{"export", "contract"}) {
t.Fatalf("calls=%v", calls)
}
}
func TestSidecarPublisherRejectsRevisionMismatchBeforeTargetMutation(t *testing.T) {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
item := testPlanItem()
item.ParseRevision = "other-parser/index"
publisher, err := NewSidecarPublisher(&fakeExporter{manifest: manifest, calls: &calls}, &fakeSidecarTarget{contract: testTargetContract(), calls: &calls})
if err != nil {
t.Fatal(err)
}
_, err = publisher.Publish(context.Background(), item)
var itemErr *ItemError
if !errors.As(err, &itemErr) || itemErr.Code != ErrorCodeContractMismatch {
t.Fatalf("error=%#v", err)
}
if !reflect.DeepEqual(calls, []string{"export"}) {
t.Fatalf("calls=%v", calls)
}
}
func TestSidecarPublisherDeletesOnlyAfterArchivingAndVerifiesAbsent(t *testing.T) {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
target := &fakeSidecarTarget{contract: testTargetContract(), calls: &calls, prepared: PreparedChange{PreviousRevision: "prod-v0", ProductionRevision: "release-v1"}, expectedRestoreRevision: "absent", restoredRevision: "absent"}
publisher, err := NewSidecarPublisher(&fakeExporter{manifest: manifest, calls: &calls}, target)
if err != nil {
t.Fatal(err)
}
result, err := publisher.Delete(context.Background(), testPlanItem())
if err != nil {
t.Fatal(err)
}
if result.BundleSHA256 != manifest.BundleSHA256 || result.PreviousProductionRevision != "prod-v0" || result.ProductionRevision != "absent" {
t.Fatalf("result=%#v", result)
}
if want := []string{"export", "contract", "prepare", "restore", "verify-restored"}; !reflect.DeepEqual(calls, want) {
t.Fatalf("calls=%v want=%v", calls, want)
}
}
func TestSidecarPublisherRestoresArchivedRevision(t *testing.T) {
calls := []string{}
target := &fakeSidecarTarget{calls: &calls, restoredRevision: "prod-v0"}
publisher, err := NewSidecarPublisher(&fakeExporter{}, target)
if err != nil {
t.Fatal(err)
}
result, err := publisher.Rollback(context.Background(), testPlanItem())
if err != nil {
t.Fatal(err)
}
if result.ProductionRevision != "prod-v0" || !reflect.DeepEqual(calls, []string{"restore", "verify-restored"}) {
t.Fatalf("result=%#v calls=%v", result, calls)
}
}
type fakeExporter struct {
manifest releasebundle.Manifest
calls *[]string
err error
}
func (f *fakeExporter) Export(context.Context, PlanItem) (ExportedBundle, error) {
if f.calls != nil {
*f.calls = append(*f.calls, "export")
}
return fakeExportedBundle{manifest: f.manifest}, f.err
}
type fakeExportedBundle struct{ manifest releasebundle.Manifest }
func (bundle fakeExportedBundle) ReleaseManifest() releasebundle.Manifest { return bundle.manifest }
type fakeSidecarTarget struct {
contract releasebundle.TargetContract
calls *[]string
prepared PreparedChange
prepareErr, activateErr, verifyErr, compensateErr, restoreErr, verifyRestoredErr error
restoredRevision string
expectedRestoreRevision string
}
func (f *fakeSidecarTarget) Contract(context.Context, PlanItem) (releasebundle.TargetContract, error) {
*f.calls = append(*f.calls, "contract")
return f.contract, nil
}
func (f *fakeSidecarTarget) Prepare(context.Context, PlanItem, ExportedBundle) (PreparedChange, error) {
*f.calls = append(*f.calls, "prepare")
return f.prepared, f.prepareErr
}
func (f *fakeSidecarTarget) Activate(context.Context, PlanItem, PreparedChange) error {
*f.calls = append(*f.calls, "activate")
return f.activateErr
}
func (f *fakeSidecarTarget) Verify(context.Context, PlanItem, PreparedChange) error {
*f.calls = append(*f.calls, "verify")
return f.verifyErr
}
func (f *fakeSidecarTarget) Compensate(context.Context, PlanItem, PreparedChange) error {
*f.calls = append(*f.calls, "compensate")
return f.compensateErr
}
func (f *fakeSidecarTarget) Restore(_ context.Context, _ PlanItem, revision string) (string, error) {
*f.calls = append(*f.calls, "restore")
expected := f.expectedRestoreRevision
if expected == "" {
expected = "prod-v0"
}
if revision != expected {
return "", errors.New("wrong revision")
}
return f.restoredRevision, f.restoreErr
}
func (f *fakeSidecarTarget) VerifyRestored(context.Context, PlanItem, string) error {
*f.calls = append(*f.calls, "verify-restored")
return f.verifyRestoredErr
}
func testPlanItem() PlanItem {
return PlanItem{ReleaseExecutionID: "rx-safe", PublicationID: "tp-safe", SourceItemVersionID: "sv-safe", SourceRevision: "source-v1", ParseRevision: "parser-v1@index-v1", ReleaseRevision: "release-v1", TestKnowledgeBaseID: "kb-safe", KnowledgeID: "knowledge-safe", DecidedVisibility: "internal", PreviousProductionRevision: "prod-v0"}
}
func testManifest() releasebundle.Manifest {
return releasebundle.Manifest{FormatVersion: releasebundle.FormatVersionV1, ExecutionID: "rx-safe", PublicationID: "tp-safe", SourceItemVersionID: "sv-safe", SourceRevision: "source-v1", ParseRevision: "parser-v1@index-v1", ReleaseRevision: "release-v1", CreatedAt: time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC), Contract: releasebundle.Contract{WeKnoraVersion: releasebundle.WeKnoraVersionV071, MigrationVersion: releasebundle.WeKnoraMigrationV071, TenantID: 10000, KnowledgeBaseID: "kb-safe", KnowledgeID: "knowledge-safe", RetrieverEngine: "elasticsearch_v8", EmbeddingModel: "bge-m3", EmbeddingDimension: releasebundle.EmbeddingDimensionV071, ElasticsearchMappingSHA256: strings.Repeat("a", 64)}, Tables: []releasebundle.TableDigest{{Name: "knowledges", RowCount: 1, SHA256: strings.Repeat("b", 64)}, {Name: "chunks", RowCount: 1, SHA256: strings.Repeat("c", 64)}}, Search: releasebundle.SearchDigest{DocumentCount: 1, SHA256: strings.Repeat("d", 64)}, Assets: releasebundle.AssetDigest{SHA256: strings.Repeat("e", 64)}}
}
func testTargetContract() releasebundle.TargetContract {
return releasebundle.TargetContract{WeKnoraVersion: releasebundle.WeKnoraVersionV071, MigrationVersion: releasebundle.WeKnoraMigrationV071, TenantID: 10000, KnowledgeBaseID: "kb-safe", RetrieverEngine: "elasticsearch_v8", EmbeddingModel: "bge-m3", EmbeddingDimension: releasebundle.EmbeddingDimensionV071, ElasticsearchMappingSHA256: strings.Repeat("a", 64)}
}
// --- 发布前快照门禁 ---------------------------------------------------------
type stubSnapshotGuard struct {
checked []string
err error
}
func (guard *stubSnapshotGuard) Check(knowledgeBaseID string) error {
guard.checked = append(guard.checked, knowledgeBaseID)
return guard.err
}
func mappedPlanItem() PlanItem {
item := testPlanItem()
item.TargetKnowledgeBaseID = "kb-production"
return item
}
// 共享真实库(源库 → 目标库 映射)没有快照时必须拒绝,且不得碰目标库。
func TestSidecarPublisherRefusesMappedItemWithoutSnapshot(t *testing.T) {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
exporter := &fakeExporter{manifest: manifest, calls: &calls}
target := &fakeSidecarTarget{contract: testTargetContract(), calls: &calls, prepared: PreparedChange{PreviousRevision: "prod-v0", ProductionRevision: "prod-v1"}}
guard := &stubSnapshotGuard{err: errors.New("no fresh target knowledge base snapshot")}
publisher, err := NewSidecarPublisher(exporter, target, WithTargetSnapshots(guard, SnapshotPolicyMapped))
if err != nil {
t.Fatal(err)
}
_, publishErr := publisher.Publish(context.Background(), mappedPlanItem())
var itemErr *ItemError
if !errors.As(publishErr, &itemErr) || itemErr.Code != ErrorCodeTargetSnapshotMissing {
t.Fatalf("err=%v want code %s", publishErr, ErrorCodeTargetSnapshotMissing)
}
if len(guard.checked) != 1 || guard.checked[0] != "kb-production" {
t.Fatalf("guard must be asked about the production target: %v", guard.checked)
}
if len(calls) != 0 {
t.Fatalf("a refused release must not touch the target: %v", calls)
}
}
// 门禁未接线(target_snapshot.root 未配置)时,映射项也要拒绝——不能静默放行。
func TestSidecarPublisherFailsClosedWhenSnapshotGateUnconfigured(t *testing.T) {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
exporter := &fakeExporter{manifest: manifest, calls: &calls}
target := &fakeSidecarTarget{contract: testTargetContract(), calls: &calls, prepared: PreparedChange{PreviousRevision: "prod-v0", ProductionRevision: "prod-v1"}}
publisher, err := NewSidecarPublisher(exporter, target)
if err != nil {
t.Fatal(err)
}
_, publishErr := publisher.Publish(context.Background(), mappedPlanItem())
var itemErr *ItemError
if !errors.As(publishErr, &itemErr) || itemErr.Code != ErrorCodeTargetSnapshotMissing {
t.Fatalf("err=%v want code %s", publishErr, ErrorCodeTargetSnapshotMissing)
}
if len(calls) != 0 {
t.Fatalf("calls=%v", calls)
}
}
// 同 ID 目标(历史语义 / 本地演练)不需要快照,策略为 mapped 时门禁不介入。
func TestSidecarPublisherSkipsSnapshotGateForSameBaseTarget(t *testing.T) {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
exporter := &fakeExporter{manifest: manifest, calls: &calls}
target := &fakeSidecarTarget{contract: testTargetContract(), calls: &calls, prepared: PreparedChange{PreviousRevision: "prod-v0", ProductionRevision: "prod-v1"}}
guard := &stubSnapshotGuard{err: errors.New("must not be consulted")}
publisher, err := NewSidecarPublisher(exporter, target, WithTargetSnapshots(guard, SnapshotPolicyMapped))
if err != nil {
t.Fatal(err)
}
if _, err := publisher.Publish(context.Background(), testPlanItem()); err != nil {
t.Fatalf("same-base target must publish without a snapshot: %v", err)
}
if len(guard.checked) != 0 {
t.Fatalf("guard must not run for same-base targets: %v", guard.checked)
}
}
// policy=always 时,同 ID 目标同样要求快照。
func TestSidecarPublisherAlwaysPolicyChecksSameBaseTarget(t *testing.T) {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
exporter := &fakeExporter{manifest: manifest, calls: &calls}
target := &fakeSidecarTarget{contract: testTargetContract(), calls: &calls, prepared: PreparedChange{PreviousRevision: "prod-v0", ProductionRevision: "prod-v1"}}
guard := &stubSnapshotGuard{}
publisher, err := NewSidecarPublisher(exporter, target, WithTargetSnapshots(guard, SnapshotPolicyAlways))
if err != nil {
t.Fatal(err)
}
if _, err := publisher.Publish(context.Background(), testPlanItem()); err != nil {
t.Fatal(err)
}
if len(guard.checked) != 1 || guard.checked[0] != "kb-safe" {
t.Fatalf("always policy must check every target: %v", guard.checked)
}
}
// 撤权(delete)与回滚同样先过门禁。
func TestSidecarPublisherGatesDeleteAndRollbackToo(t *testing.T) {
for _, operation := range []string{"delete", "rollback"} {
calls := []string{}
manifest, err := releasebundle.Seal(testManifest())
if err != nil {
t.Fatal(err)
}
exporter := &fakeExporter{manifest: manifest, calls: &calls}
target := &fakeSidecarTarget{contract: testTargetContract(), calls: &calls, prepared: PreparedChange{PreviousRevision: "prod-v0", ProductionRevision: "prod-v1"}}
guard := &stubSnapshotGuard{err: errors.New("stale")}
publisher, err := NewSidecarPublisher(exporter, target, WithTargetSnapshots(guard, SnapshotPolicyMapped))
if err != nil {
t.Fatal(err)
}
item := mappedPlanItem()
item.Action = operation
var publishErr error
if operation == "delete" {
_, publishErr = publisher.Delete(context.Background(), item)
} else {
_, publishErr = publisher.Rollback(context.Background(), item)
}
var itemErr *ItemError
if !errors.As(publishErr, &itemErr) || itemErr.Code != ErrorCodeTargetSnapshotMissing {
t.Fatalf("%s: err=%v want %s", operation, publishErr, ErrorCodeTargetSnapshotMissing)
}
if len(calls) != 0 {
t.Fatalf("%s touched the target: %v", operation, calls)
}
}
}