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 增补硬门禁与云内执行;真实快照目录上门禁实测:两个绑定库通过、
未覆盖库拒绝、未接线拒绝。
363 lines
15 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|