Files
knowledge-governance/cmd/release-worker/runtime.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

238 lines
9.6 KiB
Go

package main
import (
"context"
"errors"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/platform/weknora"
v071 "knowledge-governance/internal/releaseadapter/v071"
"knowledge-governance/internal/releasesnapshot"
releaseworkflow "knowledge-governance/internal/workflows/release"
)
type releaseRuntime struct {
publisher *releaseworkflow.RoutingPublisher
pools []*pgxpool.Pool
}
func (runtime *releaseRuntime) Close() {
for index := len(runtime.pools) - 1; index >= 0; index-- {
runtime.pools[index].Close()
}
}
func buildReleaseRuntime(ctx context.Context, config releaseRuntimeConfig) (*releaseRuntime, error) {
runtime := &releaseRuntime{}
publishers := make(map[string]releaseworkflow.Publisher, len(config.Routes))
for _, route := range config.Routes {
publisher, pools, err := buildReleaseRoute(ctx, config.ArchiveRoot, config.TargetSnapshot, route)
if err != nil {
runtime.Close()
return nil, err
}
runtime.pools = append(runtime.pools, pools...)
publishers[route.KnowledgeBaseID] = publisher
}
router, err := releaseworkflow.NewRoutingPublisher(publishers)
if err != nil {
runtime.Close()
return nil, errors.New("create release publisher routes")
}
runtime.publisher = router
return runtime, nil
}
func buildReleaseRoute(ctx context.Context, archiveRoot string, snapshot releaseTargetSnapshotConfig, route releaseRouteConfig) (releaseworkflow.Publisher, []*pgxpool.Pool, error) {
sourceDatabaseURL, err := readRestrictedValue(route.Source.DatabaseURLFile)
if err != nil {
return nil, nil, errors.New("read source database credential")
}
targetDatabaseURL, err := readRestrictedValue(route.Target.DatabaseURLFile)
if err != nil {
return nil, nil, errors.New("read target database credential")
}
if err := validateDistinctDatabaseURLs(sourceDatabaseURL, targetDatabaseURL); err != nil {
return nil, nil, err
}
sourcePool, err := openReleasePool(ctx, sourceDatabaseURL)
if err != nil {
return nil, nil, errors.New("connect release source database")
}
pools := []*pgxpool.Pool{sourcePool}
targetPool, err := openReleasePool(ctx, targetDatabaseURL)
if err != nil {
sourcePool.Close()
return nil, nil, errors.New("connect release target database")
}
pools = append(pools, targetPool)
cleanup := func(err error) (releaseworkflow.Publisher, []*pgxpool.Pool, error) {
for index := len(pools) - 1; index >= 0; index-- {
pools[index].Close()
}
return nil, nil, err
}
sourceSearchToken, err := readOptionalRestrictedValue(route.Source.Elasticsearch.TokenFile)
if err != nil {
return cleanup(errors.New("read source Elasticsearch credential"))
}
targetSearchToken, err := readOptionalRestrictedValue(route.Target.Elasticsearch.TokenFile)
if err != nil {
return cleanup(errors.New("read target Elasticsearch credential"))
}
sourceSearch, err := v071.NewSearchExporter(v071.SearchExporterConfig{BaseURL: route.Source.Elasticsearch.BaseURL, Index: route.Source.Elasticsearch.Index, AuthorizationToken: sourceSearchToken, PageSize: 500})
if err != nil {
return cleanup(errors.New("create source Elasticsearch exporter"))
}
targetSearchConfig := v071.SearchExporterConfig{BaseURL: route.Target.Elasticsearch.BaseURL, Index: route.Target.Elasticsearch.Index, AuthorizationToken: targetSearchToken, PageSize: 500}
targetSearchInspector, err := v071.NewSearchExporter(targetSearchConfig)
if err != nil {
return cleanup(errors.New("create target Elasticsearch inspector"))
}
targetSearch, err := v071.NewSearchImporter(targetSearchConfig)
if err != nil {
return cleanup(errors.New("create target Elasticsearch importer"))
}
sourceObject, err := buildS3ObjectStore(route.Source.Object)
if err != nil {
return cleanup(errors.New("create source object reader"))
}
targetObject, err := buildS3ObjectStore(route.Target.Object)
if err != nil {
return cleanup(errors.New("create target object store"))
}
objectExporter, err := v071.NewObjectExporter(sourceObject, v071.ObjectExporterLimits{})
if err != nil {
return cleanup(errors.New("create source object exporter"))
}
objectImporter, err := v071.NewObjectImporter(targetObject, v071.TargetObjectConfig{StorageBackendID: route.Target.Object.StorageBackendID, Provider: route.Target.Object.Provider})
if err != nil {
return cleanup(errors.New("create target object importer"))
}
sourceContract, err := v071.NewTargetContractInspector(v071.TargetContractInspectorConfig{DB: sourcePool, Mapping: sourceSearch, KnowledgeBaseID: route.KnowledgeBaseID, TenantID: route.TenantID, IndexName: route.Source.Elasticsearch.Index, ReadTestedBase: true})
if err != nil {
return cleanup(errors.New("create source release contract inspector"))
}
targetContract, err := v071.NewTargetContractInspector(v071.TargetContractInspectorConfig{DB: targetPool, Mapping: targetSearchInspector, KnowledgeBaseID: route.KnowledgeBaseID, TenantID: route.TenantID, IndexName: route.Target.Elasticsearch.Index})
if err != nil {
return cleanup(errors.New("create target release contract inspector"))
}
postgresExporter, err := v071.NewPostgresExporter(sourcePool)
if err != nil {
return cleanup(errors.New("create source PostgreSQL exporter"))
}
postgresImporter, err := v071.NewPostgresImporter(targetPool, v071.PostgresImporterConfig{TargetStorageBackendID: route.Target.Object.StorageBackendID, TargetProvider: route.Target.Object.Provider, TargetBucket: route.Target.Object.Bucket})
if err != nil {
return cleanup(errors.New("create target PostgreSQL importer"))
}
documentExporter, err := v071.NewDocumentExporter(v071.DocumentExporterConfig{Contract: sourceContract, Postgres: postgresExporter, Search: sourceSearch, Objects: objectExporter, TenantID: route.TenantID, EmbeddingModel: route.EmbeddingModel, Now: time.Now})
if err != nil {
return cleanup(errors.New("create v0.7.1 document exporter"))
}
archive, err := v071.NewBundleArchive(filepath.Join(archiveRoot, route.KnowledgeBaseID), 1024*1024*1024)
if err != nil {
return cleanup(errors.New("open release bundle archive"))
}
apiKey, err := readRestrictedValue(route.Target.WeKnora.APIKeyFile)
if err != nil {
return cleanup(errors.New("read target WeKnora credential"))
}
weknoraClient, err := weknora.NewClient(weknora.ClientConfig{BaseURL: route.Target.WeKnora.BaseURL, APIKey: apiKey, Timeout: 60 * time.Second, AllowDockerHTTP: route.Target.WeKnora.AllowDockerHTTP})
if err != nil {
return cleanup(errors.New("create target WeKnora verification client"))
}
verifier, err := v071.NewWeKnoraVerifier(weknoraClient)
if err != nil {
return cleanup(errors.New("create target WeKnora verifier"))
}
documentTarget, err := v071.NewDocumentTarget(v071.DocumentTargetConfig{Archive: archive, Contract: targetContract, Postgres: postgresImporter, Search: targetSearch, Objects: objectImporter, Verifier: verifier})
if err != nil {
return cleanup(errors.New("create v0.7.1 document target"))
}
var publisherOptions []releaseworkflow.PublisherOption
if snapshot.Root != "" {
// 写目标库前必须有覆盖该库的、未过期的快照留档(见 releasesnapshot.Guard)。
maxAge := time.Duration(snapshot.MaxAgeHours) * time.Hour
if snapshot.MaxAgeHours == 0 {
maxAge = 24 * time.Hour
}
policy := releaseworkflow.SnapshotPolicyMapped
if snapshot.Policy == "always" {
policy = releaseworkflow.SnapshotPolicyAlways
}
publisherOptions = append(publisherOptions, releaseworkflow.WithTargetSnapshots(
releasesnapshot.Guard{Root: snapshot.Root, MaxAge: maxAge, VerifyLocalFiles: true}, policy))
}
publisher, err := releaseworkflow.NewSidecarPublisher(documentExporter, documentTarget, publisherOptions...)
if err != nil {
return cleanup(errors.New("create v0.7.1 release publisher"))
}
return publisher, pools, nil
}
func buildS3ObjectStore(config releaseObjectConfig) (*v071.S3ObjectStore, error) {
accessKey, err := readRestrictedValue(config.AccessKeyFile)
if err != nil {
return nil, err
}
secretKey, err := readRestrictedValue(config.SecretKeyFile)
if err != nil {
return nil, err
}
parsed, err := url.Parse(config.Endpoint)
if err != nil {
return nil, errors.New("invalid object endpoint")
}
return v071.NewS3ObjectStore(v071.S3ObjectStoreConfig{Provider: config.Provider, Endpoint: config.Endpoint, Region: config.Region, AccessKeyID: accessKey, SecretAccessKey: secretKey, Bucket: config.Bucket, Secure: parsed.Scheme == "https", ForcePathStyle: config.Provider == "minio"})
}
func readRestrictedValue(path string) (string, error) {
return weknora.ReadAPIKeyFile(path)
}
func readOptionalRestrictedValue(path string) (string, error) {
if path == "" {
return "", nil
}
return readRestrictedValue(path)
}
func openReleasePool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return pool, nil
}
func validateDistinctDatabaseURLs(sourceURL, targetURL string) error {
source, err := pgxpool.ParseConfig(sourceURL)
if err != nil {
return errors.New("invalid source database credential")
}
target, err := pgxpool.ParseConfig(targetURL)
if err != nil {
return errors.New("invalid target database credential")
}
sourceIdentity := strings.ToLower(source.ConnConfig.Host) + "|" + strconv.FormatUint(uint64(source.ConnConfig.Port), 10) + "|" + source.ConnConfig.Database
targetIdentity := strings.ToLower(target.ConnConfig.Host) + "|" + strconv.FormatUint(uint64(target.ConnConfig.Port), 10) + "|" + target.ConnConfig.Database
if sourceIdentity == targetIdentity {
return errors.New("release source and target database must be distinct")
}
return nil
}