Files
knowledge-governance/internal/librarystack/stack.go
T
姚章浩 e6472b0ec6 feat(library): 云测试发布目标接入的角色分离、快照搬运与图谱门禁
为什么改:发布执行链是"与目标同机"假设写死的(本机 docker.sock、本机 drain.json、
本机 Redis/PG),把目标换成云测试不能只改展示 URL;直接搬整套 worker 又会抢走
本机封存/QA/周报队列并按本机配置写错目标。所以先把"谁消费哪段队列"变成显式角色,
再补齐跨主机搬运与目标侧门禁。

角色(LIBRARY_WORKER_ROLE=full/release-executor/capture)不只影响 handler 列表,
还决定是否拥有 DDL(release-executor 只读校验迁移版本,落后即拒绝启动)、
收拾哪些 library_jobs.kind、是否跑 release 接管扫描、是否起 scheduler/QA/周报/探测。
capture 保留本机原有全部业务;release-executor 只挂 sync/restore/recover/baseline。

快照跨主机:新增内容寻址的 bundle(版本清单先落、blob 按 sha256 命名、清单最后
Link 作提交点),pack/unpack 幂等且拒绝覆盖与同名不同内容;执行器使用前仍逐 blob
重算 sha256,因此传输不可变有两道证据。

目标侧门禁:目标知识库 graph_enabled=true 时拒绝发布(发布只写 PG/ES/OSS,不写
Neo4j;未知也拒绝),只读检测、不改目标配置、不做图谱迁移。

云端入口:新增 drain 感知的 ingress 闸门(建连超时与读超时分离,半关闭单向传播,
SIGTERM 排空后写 drain.json 并 exit 0/2)、compose 叠加片段(app 不再发布端口、
frontend 与 adapter 的入口收敛到闸门、指向真实网络 weknora-test-v071-network)、
执行器启动脚本与治理 PG 反向隧道脚本(默认 unix socket,不占 TCP)。

验证:deploy/cloud-test/test_gate.py 13 项全 PASS(含 12 秒静默流、半关闭、2MiB 完整、
排空 exit 0/active_at_close=1、排空超时 exit 2、上游不可达不挂住);go test 相关包全绿;
scripts/ci_checks.py 16 项全部 success(EXIT=0,1241 用例通过/0 失败/191 已声明可选集成跳过);
云测试只读预检 17 项 / 6 FAIL,FAIL 项即当前部署缺口(无闸门、app 仍发布端口、
adapter 与 frontend 仍直连 app、云端归档根未建),未部署、未改云端任何状态。
2026-10-30 18:38:38 +08:00

269 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package librarystack 是"目标整库读写栈"的唯一装配点:同步、恢复、基线,
// 以及主机侧的真实恢复演练都从这里拿同一个目标/预备份存储/维护协调器。
//
// 为什么单独成包:装配一旦被复制两份,"目标是谁、用哪份契约"就会随调用路径漂移。
// 这个包只做装配与配置校验,不包含任何业务编排。
package librarystack
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/appconfig"
"knowledge-governance/internal/librarymaintenance"
"knowledge-governance/internal/librarysnapshot"
v071 "knowledge-governance/internal/releaseadapter/v071"
"knowledge-governance/internal/releasebundle"
)
// OpenMaintenance 按部署配置装配实例级维护目标;未接线时返回明确原因。
func OpenMaintenance(config appconfig.Config) (*librarymaintenance.Manager, error) {
database, err := librarymaintenance.DatabaseIdentity(config.LibraryProductionDatabaseURL)
if err != nil {
return nil, err
}
return librarymaintenance.New(librarymaintenance.Config{
Instance: config.LibraryTargetInstance,
Project: config.LibraryTargetProject,
Writers: config.LibraryTargetWriters,
NonWriters: config.LibraryTargetNonWriters,
DockerSocket: config.LibraryTargetDockerSocket,
IngressURL: config.LibraryTargetIngressURL,
IngressService: config.LibraryTargetIngressService,
IngressDrainFile: config.LibraryTargetIngressDrainFile,
Database: database,
Index: config.LibraryProductionIndex,
Bucket: config.LibraryProductionS3Bucket,
DatabaseURL: config.LibraryProductionDatabaseURL,
RedisAddr: config.LibraryTargetRedisAddr,
RedisPassword: config.LibraryTargetRedisPassword,
RedisDB: config.LibraryTargetRedisDB,
})
}
// scopeConfig 是"某个 scope 的生产整库目标"的部署注入配置。
type scopeConfig struct {
target v071.ProductionScope
s3 v071.S3ObjectStoreConfig
}
func scopeConfigOf(config appconfig.Config, scope string) (scopeConfig, error) {
missing := make([]string, 0, 5)
if config.LibraryProductionDatabaseURL == "" {
missing = append(missing, "LIBRARY_PRODUCTION_DATABASE_URL")
}
if config.LibraryProductionElasticURL == "" {
missing = append(missing, "LIBRARY_PRODUCTION_ELASTIC_URL")
}
if config.LibraryProductionTenantID == 0 {
missing = append(missing, "LIBRARY_PRODUCTION_TENANT_ID")
}
kb := config.LibraryProductionInternalKB
if scope == "external" {
kb = config.LibraryProductionExternalKB
}
if kb == "" {
missing = append(missing, "LIBRARY_PRODUCTION_KB_"+strings.ToUpper(scope))
}
if config.LibraryProductionS3Endpoint == "" || config.LibraryProductionS3Bucket == "" ||
config.LibraryProductionS3AccessKey == "" || config.LibraryProductionS3SecretKey == "" {
missing = append(missing, "LIBRARY_PRODUCTION_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY")
}
if len(missing) > 0 {
return scopeConfig{}, fmt.Errorf("生产整库目标未配置:缺少 %s", strings.Join(missing, "、"))
}
return scopeConfig{
target: v071.ProductionScope{TenantID: config.LibraryProductionTenantID, KnowledgeBaseID: kb},
s3: v071.S3ObjectStoreConfig{
Provider: config.LibraryProductionS3Provider, Endpoint: config.LibraryProductionS3Endpoint,
Region: config.LibraryProductionS3Region, Bucket: config.LibraryProductionS3Bucket,
AccessKeyID: config.LibraryProductionS3AccessKey, SecretAccessKey: config.LibraryProductionS3SecretKey,
Secure: strings.HasPrefix(config.LibraryProductionS3Endpoint, "https://"),
ForcePathStyle: true, MaxObjectBytes: 256 * 1024 * 1024,
},
}, nil
}
func newTarget(ctx context.Context, config appconfig.Config, scope scopeConfig) (*v071.ProductionLibraryTarget, error) {
objects, err := v071.NewS3ObjectStore(scope.s3)
if err != nil {
return nil, errors.New("生产对象存储配置无效")
}
return v071.NewProductionLibraryTarget(ctx, v071.ProductionLibraryConfig{
Postgres: config.LibraryProductionDatabaseURL, SearchURL: config.LibraryProductionElasticURL,
SearchIndex: config.LibraryProductionIndex, Objects: objects, ObjectsReader: objects,
ArchiveRoot: config.LibraryArchiveRoot,
ObjectsBucket: config.LibraryProductionS3Bucket,
MaxDocuments: 200000,
}, scope.target)
}
func newBackups(config appconfig.Config, scope string, target *v071.ProductionLibraryTarget,
contract librarysnapshot.Compatibility) (*v071.ProductionBackupStore, error) {
return v071.NewProductionBackupStore(v071.ProductionBackupConfig{
Pool: target.Pool(), Search: target.Search(), Objects: target.Objects(),
ArchiveRoot: config.LibraryArchiveRoot, Scope: scope, Identity: target.Identity(),
Compatibility: contract, MaxDocuments: 200000, MaxObjectByte: 256 * 1024 * 1024,
})
}
// OpenProductionStack 用部署注入的配置装配目标与独立预备份存储。
// 契约以"要同步的这份快照"为准:生产必须向它看齐,而不是反过来。
func OpenProductionStack(ctx context.Context, config appconfig.Config, scope, snapshotID, knownMapping string) (
*v071.ProductionLibraryTarget, *v071.ProductionBackupStore, error) {
scopeCfg, err := scopeConfigOf(config, scope)
if err != nil {
return nil, nil, err
}
target, err := newTarget(ctx, config, scopeCfg)
if err != nil {
return nil, nil, err
}
archive, err := librarysnapshot.Open(config.LibraryArchiveRoot, 256*1024*1024)
if err != nil {
target.Close()
return nil, nil, fmt.Errorf("归档不可用,无法读取快照契约: %w", err)
}
manifest, err := archive.Load(snapshotID)
archive.Close()
if err != nil {
target.Close()
return nil, nil, fmt.Errorf("读取目标快照失败: %w", err)
}
contract := manifest.Compatibility
if contract.EmbeddingDimension == 0 {
contract.EmbeddingModel = config.LibraryEmbeddingModel
contract.EmbeddingDimension = config.LibraryEmbeddingDimension
}
// 目标实时契约:来源快照的声明只说明"数据来自哪",不能当目标合同(F17)。
// 读不到就不许发布——猜一个目标模型正是"知识行声明 B、向量却是 A"的来源。
live, liveErr := target.LiveTargetContract(ctx)
if liveErr != nil {
target.Close()
return nil, nil, fmt.Errorf("读不到目标实时模型契约,拒绝发布:%w", liveErr)
}
if verifyErr := v071.VerifySourceAgainstTarget(contract, live); verifyErr != nil {
target.Close()
return nil, nil, verifyErr
}
// 图谱门禁:目标开了 graph_enabled 时,发布只写 PG/ES/OSS,图谱会静默变旧。
// 只读检测 + 拒绝;不关闭目标图谱、不搬 Neo4j(那属于扩围)。
if graphErr := target.VerifyGraphGate(ctx); graphErr != nil {
target.Close()
return nil, nil, graphErr
}
// 生产索引存在时必须与快照契约一致;不存在或为空基线时跳过比对
// (空索引的映射没有可比性,非空之后仍然严格比对)。
if mapping, mapErr := target.Search().MappingSHA256(ctx); mapErr == nil {
// 映射基线:优先用"上一次生产生效版本登记的实际映射",
// 它才是这份生产索引的既有事实;快照契约只管"来自哪份数据"。
baseline := knownMapping
if baseline == "" {
baseline = mapping
}
if baseline != "" && mapping != baseline {
current, currentErr := target.Current(ctx)
emptyBaseline := currentErr == nil && current.Counts["index"] == 0 && current.Counts["chunks"] == 0
if !emptyBaseline {
target.Close()
return nil, nil, errors.New("生产索引映射与上次生效版本不一致(索引被改动过),先对齐索引映射")
}
}
}
backupContract := v071.ContractFromLive(contract, live)
if knownMapping != "" {
backupContract.IndexMappingSHA256 = knownMapping
}
backups, err := newBackups(config, scope, target, backupContract)
if err != nil {
target.Close()
return nil, nil, err
}
return target, backups, nil
}
// OpenBaselineStack 装配"给目标自己建基线"所需的目标与捕获器。
//
// 契约来源按"能验证的优先级"取,绝不去借别人的快照:
// - WeKnora 版本 / 检索引擎:受支持栈的常量(v0.7.1 / elasticsearch_v8),
// 目标镜像与 ES 实测同属这一代;
// - schema 版本:**读目标数据库**的 schema_migrations,必须等于受支持版本且不 dirty;
// - embedding 模型与维度:优先读目标库的模型绑定,读不到(这台测试目标的
// models 表是空的、KB 里的模型引用悬空)才回落到部署声明,并在错误信息里说清楚;
// - 索引映射:目标此刻的真实映射。
func OpenBaselineStack(ctx context.Context, config appconfig.Config, scope string) (
*v071.ProductionLibraryTarget, *v071.ProductionBackupStore, error) {
scopeCfg, err := scopeConfigOf(config, scope)
if err != nil {
return nil, nil, err
}
target, err := newTarget(ctx, config, scopeCfg)
if err != nil {
return nil, nil, err
}
mapping, mapErr := target.Search().MappingSHA256(ctx)
if mapErr != nil {
target.Close()
return nil, nil, fmt.Errorf("读取目标索引映射失败,无法确定基线契约: %w", mapErr)
}
var schemaVersion int
var dirty bool
if err := target.Pool().QueryRow(ctx,
`SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 1`).Scan(&schemaVersion, &dirty); err != nil {
target.Close()
return nil, nil, fmt.Errorf("读取目标 WeKnora schema 版本失败: %w", err)
}
if dirty || schemaVersion != releasebundle.WeKnoraMigrationV071 {
target.Close()
return nil, nil, fmt.Errorf("目标 schema 版本 %d(dirty=%v)与受支持的 %d 不一致,拒绝建基线",
schemaVersion, dirty, releasebundle.WeKnoraMigrationV071)
}
embeddingModel := config.LibraryEmbeddingModel
embeddingDimension := config.LibraryEmbeddingDimension
var dbModel string
var dbDimension int
err = target.Pool().QueryRow(ctx, `
SELECT model.name, COALESCE(NULLIF(model.parameters->'embedding_parameters'->>'dimension', '')::int, 0)
FROM knowledge_bases AS kb
JOIN models AS model ON model.id = kb.embedding_model_id AND model.deleted_at IS NULL
WHERE kb.id = $1 AND kb.deleted_at IS NULL`, scopeCfg.target.KnowledgeBaseID).Scan(&dbModel, &dbDimension)
switch {
case err == nil && dbModel != "" && dbDimension > 0:
// 目标库自己说得清楚时以它为准;与部署声明不一致就不建(否则基线记录的是另一套栈)。
if config.LibraryEmbeddingDimension != 0 && dbDimension != config.LibraryEmbeddingDimension {
target.Close()
return nil, nil, fmt.Errorf("目标库声明的 embedding 维度 %d 与部署声明 %d 不一致,拒绝建基线",
dbDimension, config.LibraryEmbeddingDimension)
}
embeddingModel, embeddingDimension = dbModel, dbDimension
case errors.Is(err, pgx.ErrNoRows):
// 这台目标没有模型绑定(models 表为空 / KB 引用悬空):用部署声明的值,
// 并在基线的阶段说明里标注来源,别把它说成"从目标读到的"。
if embeddingModel == "" || embeddingDimension <= 0 {
target.Close()
return nil, nil, errors.New("目标库没有模型绑定,且部署没有声明 LIBRARY_EMBEDDING_MODEL/DIMENSION")
}
default:
target.Close()
return nil, nil, fmt.Errorf("读取目标 embedding 模型绑定失败: %w", err)
}
contract := librarysnapshot.Compatibility{
WeKnoraVersion: releasebundle.WeKnoraVersionV071,
SchemaVersion: schemaVersion,
RetrieverEngine: "elasticsearch_v8",
EmbeddingModel: embeddingModel,
EmbeddingDimension: embeddingDimension,
IndexMappingSHA256: mapping,
}
backups, err := newBackups(config, scope, target, contract)
if err != nil {
target.Close()
return nil, nil, err
}
return target, backups, nil
}