c27a0c14cc
build / build (push) Successful in 1m22s
真实演练暴露两个只有跑起来才看得见的问题: 1. 归档要求完整兼容性契约(v0.7.1 / schema 74 / elasticsearch_v8 / 1024 维 / 映射摘要),而基线装配只填了 embedding 与映射,捕获被 "unsupported snapshot compatibility contract" 拒绝。 现在 schema 版本读目标库 schema_migrations(dirty 或版本不符直接拒), embedding 优先读目标库模型绑定(本机目标 models 表为空、引用悬空, 此时回落部署声明并说清来源),版本/引擎用受支持栈常量,映射取目标当前值。 2. 演练在"还没写目标内容"时就允许 restore_blocked=false 的窗口以 blocked 收尾, 写出 content_risk=false + restore_blocked=true 的非法 journal(恢复器拒绝加载, 目标停在停机态)。现在写 canary 时先 MarkContentRisk,allowRestore 按 "没碰内容 ⇒ 安全恢复;碰过 ⇒ 必须恢复并校验通过"。 另外给演练工具加 -recover:只把非终态窗口按原运行状态恢复,用于演练失败后 把目标救回来(不改治理记录)。 验证:库内模拟真实目标不可得,契约分支用真实 kgseal 演练覆盖: snapshot 5cb945e6… 捕获 143ms → 基线一致 → canary 后校验如预期失败 (knowledges 1 行摘要不一致)→ 恢复后校验通过 → 窗口 succeeded、服务与入口恢复。
272 lines
10 KiB
Go
272 lines
10 KiB
Go
// command librarydrill 在目标实例上做一次**真实**的恢复演练:
|
|
//
|
|
// 维护隔离(关入口/停写入者/证明排空)
|
|
// → 捕获目标整库(真预备份,落归档)
|
|
// → 校验目标 == 预备份(基线一致)
|
|
// → 写入一个 canary(证明"校验确实能发现内容变化")
|
|
// → 按预备份恢复
|
|
// → 逐表/索引/资源/Wiki 再校验一次,必须完全一致
|
|
// → 关窗口(按原运行状态恢复服务、开入口、验证健康)
|
|
//
|
|
// 它只读/写**被测目标自己**,绝不触碰封存源;所有凭据来自进程环境
|
|
// (与 worker 同一份 LIBRARY_* 配置),不进命令行、不打印。
|
|
// 任一步失败都不伪装成功:窗口保持维护、报告写明失败阶段。
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/appconfig"
|
|
"knowledge-governance/internal/libraryfreeze"
|
|
"knowledge-governance/internal/libraryrelease"
|
|
"knowledge-governance/internal/librarystack"
|
|
)
|
|
|
|
type drillVerification struct {
|
|
SnapshotID string `json:"snapshot_id"`
|
|
Verified bool `json:"verified"`
|
|
Error string `json:"error,omitempty"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
type drillReport struct {
|
|
Status string `json:"status"` // passed | failed | maintenance_held
|
|
Scope string `json:"scope"`
|
|
Target string `json:"target"`
|
|
SnapshotID string `json:"snapshot_id"`
|
|
Project string `json:"project"`
|
|
Counts map[string]int64 `json:"counts"`
|
|
BaselineCheck *drillVerification `json:"baseline_check"`
|
|
DriftCheck *drillVerification `json:"drift_check"` // 必须失败:证明确实能发现差异
|
|
RestoreCheck *drillVerification `json:"restore_check"`
|
|
Canary string `json:"canary"`
|
|
Failure string `json:"failure,omitempty"`
|
|
Stage string `json:"stage,omitempty"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
FinishedAt time.Time `json:"finished_at"`
|
|
Until map[string]string `json:"until,omitempty"`
|
|
}
|
|
|
|
func main() {
|
|
scope := flag.String("scope", "internal", "internal|external")
|
|
reportPath := flag.String("report", "", "write the JSON report here (0600)")
|
|
jobID := flag.String("job", "", "maintenance job id (default drill-<scope>-<timestamp>)")
|
|
recoverOnly := flag.Bool("recover", false, "only recover non-terminal maintenance windows (restore services), then exit")
|
|
flag.Parse()
|
|
if *recoverOnly {
|
|
if err := recoverWindows(); err != nil {
|
|
log.Fatalf("recover failed: %v", err)
|
|
}
|
|
return
|
|
}
|
|
report, err := run(*scope, *jobID)
|
|
if *reportPath != "" && report != nil {
|
|
if writeErr := writeReport(*reportPath, report); writeErr != nil {
|
|
log.Printf("drill report write failed: %v", writeErr)
|
|
}
|
|
}
|
|
if err != nil {
|
|
log.Fatalf("drill failed: %v", err)
|
|
}
|
|
log.Printf("drill passed: snapshot=%s", report.SnapshotID)
|
|
}
|
|
|
|
// recoverWindows 只做一件事:把非终态的维护窗口按原运行状态恢复(服务起来、入口验证)。
|
|
// 演练失败但还没写目标内容时,用它把目标从"停住"状态救回来;它不改治理记录,
|
|
// 治理记录由作业/接口那一侧负责。
|
|
func recoverWindows() error {
|
|
config, err := appconfig.Load(os.Getenv)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
manager, err := librarystack.OpenMaintenance(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer manager.Close()
|
|
coordinator, control, err := manager.Open(config.LibraryTargetJournalRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer control.Close()
|
|
defer coordinator.Close()
|
|
if err := coordinator.Recover(context.Background()); err != nil {
|
|
return err
|
|
}
|
|
log.Printf("maintenance windows recovered; target should be back to its original service state")
|
|
return nil
|
|
}
|
|
|
|
func run(scope, jobID string) (*drillReport, error) {
|
|
if scope != "internal" && scope != "external" {
|
|
return nil, errors.New("scope must be internal or external")
|
|
}
|
|
config, err := appconfig.Load(os.Getenv)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load config: %w", err)
|
|
}
|
|
ctx := context.Background()
|
|
report := &drillReport{Status: "failed", Scope: scope, Project: config.LibraryTargetProject,
|
|
Target: config.LibraryTargetInstance, StartedAt: time.Now().UTC()}
|
|
fail := func(stage string, cause error, held bool) (*drillReport, error) {
|
|
report.Stage = stage
|
|
report.Failure = cause.Error()
|
|
report.FinishedAt = time.Now().UTC()
|
|
if held {
|
|
report.Status = "maintenance_held"
|
|
}
|
|
return report, cause
|
|
}
|
|
|
|
target, backups, err := librarystack.OpenBaselineStack(ctx, config, scope)
|
|
if err != nil {
|
|
return fail("open_target", err, false)
|
|
}
|
|
defer target.Close()
|
|
manager, err := librarystack.OpenMaintenance(config)
|
|
if err != nil {
|
|
return fail("open_maintenance", err, false)
|
|
}
|
|
defer manager.Close()
|
|
if err := manager.VerifyTopology(ctx); err != nil {
|
|
return fail("topology", err, false)
|
|
}
|
|
coordinator, control, err := manager.Open(config.LibraryTargetJournalRoot)
|
|
if err != nil {
|
|
return fail("open_coordinator", err, false)
|
|
}
|
|
defer control.Close()
|
|
defer coordinator.Close()
|
|
|
|
if jobID == "" {
|
|
jobID = "drill-" + scope + "-" + time.Now().UTC().Format("20060102T150405")
|
|
}
|
|
var (
|
|
snapshotID string
|
|
counts map[string]int64
|
|
canary string
|
|
contentTouched bool
|
|
baseline drillVerification
|
|
drift drillVerification
|
|
restored drillVerification
|
|
)
|
|
work := func(ctx context.Context, window *libraryfreeze.Window) (string, error) {
|
|
start := time.Now()
|
|
current, err := target.Current(ctx)
|
|
if err != nil {
|
|
return "", errors.New("读取目标当前整库状态失败:" + err.Error())
|
|
}
|
|
counts = current.Counts
|
|
report.Counts = counts
|
|
// 1) 真预备份(落归档、内容寻址、导出后复核内容未变)。
|
|
backup, err := backups.Prepare(ctx, "", current)
|
|
if err != nil {
|
|
return "", errors.New("捕获目标整库失败:" + err.Error())
|
|
}
|
|
snapshotID = backup.SnapshotID
|
|
report.SnapshotID = snapshotID
|
|
if err := window.SetBackupSnapshot(ctx, snapshotID); err != nil {
|
|
return "", errors.New("记录预备份身份失败:" + err.Error())
|
|
}
|
|
log.Printf("drill: snapshot captured snapshot=%s bytes=%d in=%s", snapshotID, backup.Bytes, time.Since(start))
|
|
|
|
// 2) 基线校验:预备份必须与目标完全一致。
|
|
baseline = drillVerification{SnapshotID: snapshotID, At: time.Now().UTC()}
|
|
if err := target.Verify(ctx, libraryrelease.Expectation{SnapshotID: snapshotID}); err != nil {
|
|
baseline.Error = err.Error()
|
|
report.BaselineCheck = &baseline
|
|
return "", errors.New("预备份与目标不一致(预备份本身不可信):" + err.Error())
|
|
}
|
|
baseline.Verified = true
|
|
report.BaselineCheck = &baseline
|
|
|
|
// 3) canary:改一行真实内容,校验**必须**失败(否则"校验"是摆设)。
|
|
canary = "drill-canary-" + time.Now().UTC().Format("20060102T150405")
|
|
identity := target.Identity()
|
|
tag, err := target.Pool().Exec(ctx, `
|
|
UPDATE knowledges SET title = title || ' [' || $3 || ']'
|
|
WHERE id = (SELECT id FROM knowledges WHERE tenant_id=$1 AND knowledge_base_id=$2 AND deleted_at IS NULL
|
|
ORDER BY id LIMIT 1)`, identity.TenantID, identity.KnowledgeBaseID, canary)
|
|
if err != nil {
|
|
return "", errors.New("写入 canary 失败:" + err.Error())
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return "", errors.New("目标里没有可改写的文档,无法做漂移检测(目标不应为空)")
|
|
}
|
|
report.Canary = canary
|
|
// 从这一笔开始目标内容被改写过:窗口必须记录内容风险,
|
|
// 之后只有"恢复并校验通过"才允许重新开放(否则就是半写状态)。
|
|
if err := window.MarkContentRisk(ctx); err != nil {
|
|
return "", errors.New("记录内容风险失败:" + err.Error())
|
|
}
|
|
contentTouched = true
|
|
drift = drillVerification{SnapshotID: snapshotID, At: time.Now().UTC()}
|
|
if verifyErr := target.Verify(ctx, libraryrelease.Expectation{SnapshotID: snapshotID}); verifyErr == nil {
|
|
drift.Error = "写入 canary 后校验仍然通过:校验没有真正比对内容"
|
|
report.DriftCheck = &drift
|
|
return "", errors.New("写入 canary 后校验仍然通过:校验没有真正比对内容,拒绝把这次演练当成通过")
|
|
} else {
|
|
drift.Error = verifyErr.Error()
|
|
}
|
|
report.DriftCheck = &drift
|
|
log.Printf("drill: drift detected as expected (%s)", drift.Error)
|
|
|
|
// 4) 按预备份恢复。
|
|
if err := target.Restore(ctx, libraryrelease.BackupRef{ID: "backup-" + snapshotID, SnapshotID: snapshotID}); err != nil {
|
|
return "", errors.New("按预备份恢复失败:" + err.Error())
|
|
}
|
|
// 5) 恢复后逐类复核:必须与预备份完全一致。
|
|
restored = drillVerification{SnapshotID: snapshotID, At: time.Now().UTC()}
|
|
if err := target.Verify(ctx, libraryrelease.Expectation{SnapshotID: snapshotID}); err != nil {
|
|
restored.Error = err.Error()
|
|
report.RestoreCheck = &restored
|
|
return "", errors.New("恢复后内容仍与预备份不一致:" + err.Error())
|
|
}
|
|
restored.Verified = true
|
|
report.RestoreCheck = &restored
|
|
log.Printf("drill: restored and verified snapshot=%s", snapshotID)
|
|
return snapshotID, nil
|
|
}
|
|
|
|
window, windowErr := coordinator.RunWindow(ctx, jobID,
|
|
func(ctx context.Context) error { return manager.VerifyRunning(ctx) }, work,
|
|
// 没写过目标内容 ⇒ 按原状态恢复永远安全;写过 canary ⇒ 只有恢复并校验通过才开放。
|
|
func(error) bool { return !contentTouched || restored.Verified })
|
|
|
|
report.Until = map[string]string{"job": jobID, "window_state": window.State}
|
|
if windowErr != nil || window.State != "succeeded" {
|
|
cause := windowErr
|
|
if cause == nil {
|
|
cause = fmt.Errorf("维护窗口状态 %s(未成功)", window.State)
|
|
}
|
|
if window.State == "held" {
|
|
return fail("window", cause, true)
|
|
}
|
|
return fail("window", cause, false)
|
|
}
|
|
report.Status = "passed"
|
|
report.FinishedAt = time.Now().UTC()
|
|
return report, nil
|
|
}
|
|
|
|
func writeReport(path string, report *drillReport) error {
|
|
raw, err := json.MarshalIndent(report, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dir := filepath.Dir(path); dir != "" {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return os.WriteFile(path, append(raw, '\n'), 0o600)
|
|
}
|