dcdc8d1ddd
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环): - 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口) - 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入 parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截 - 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过): * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id) * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配 * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数 * 回滚 job payload 支持 prior_execution_id(strict 解码) - 三通道发布+回滚演练实测:publish 4/4 verified,检索命中, rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false - 前端 A·Data-console 风格全站统一,120 用例通过 - 门禁:go vet / go test 全包通过,gofmt 干净
314 lines
11 KiB
Go
314 lines
11 KiB
Go
// Command release-diag replays the read-only steps of the release export path
|
|
// (source PG snapshot, source ES snapshot, source object read) and additionally
|
|
// lists candidate OSS key prefixes, to pinpoint a failing export step with its
|
|
// real underlying error. Credentials are read from files only and never printed.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"knowledge-governance/internal/platform/weknora"
|
|
"knowledge-governance/internal/releaseadapter/v071"
|
|
)
|
|
|
|
type objectCfg struct {
|
|
Provider string `json:"provider"`
|
|
StorageBackendID string `json:"storage_backend_id"`
|
|
Endpoint string `json:"endpoint"`
|
|
Region string `json:"region"`
|
|
Bucket string `json:"bucket"`
|
|
AccessKeyFile string `json:"access_key_file"`
|
|
SecretKeyFile string `json:"secret_key_file"`
|
|
}
|
|
|
|
type endpointCfg struct {
|
|
DatabaseURLFile string `json:"database_url_file"`
|
|
Elasticsearch struct {
|
|
BaseURL string `json:"base_url"`
|
|
Index string `json:"index"`
|
|
} `json:"elasticsearch"`
|
|
Object objectCfg `json:"object"`
|
|
WeKnora struct {
|
|
BaseURL string `json:"base_url"`
|
|
APIKeyFile string `json:"api_key_file"`
|
|
} `json:"weknora"`
|
|
}
|
|
|
|
type config struct {
|
|
Routes []struct {
|
|
TenantID uint64 `json:"tenant_id"`
|
|
KnowledgeBaseID string `json:"knowledge_base_id"`
|
|
EmbeddingModel string `json:"embedding_model"`
|
|
Source endpointCfg `json:"source"`
|
|
Target endpointCfg `json:"target"`
|
|
} `json:"routes"`
|
|
}
|
|
|
|
func secret(path string) string {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
fmt.Println("FATAL read secret:", err)
|
|
os.Exit(1)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "", "release config json")
|
|
knowledgeID := flag.String("knowledge", "", "knowledge id to diagnose")
|
|
flag.Parse()
|
|
raw, err := os.ReadFile(*configPath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
var cfg config
|
|
if err := json.Unmarshal(raw, &cfg); err != nil || len(cfg.Routes) != 1 {
|
|
panic(err)
|
|
}
|
|
route := cfg.Routes[0]
|
|
fmt.Println("route:", route.KnowledgeBaseID, "tenant:", route.TenantID)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
pool, err := pgxpool.New(ctx, secret(route.Source.DatabaseURLFile))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer pool.Close()
|
|
if err := pool.Ping(ctx); err != nil {
|
|
fmt.Println("STEP source PG connect: FAIL", err)
|
|
return
|
|
}
|
|
fmt.Println("STEP source PG connect: ok")
|
|
|
|
postgres, err := v071.NewPostgresExporter(pool)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
snapshot, err := postgres.Export(ctx, v071.PostgresIdentity{TenantID: route.TenantID, KnowledgeBaseID: route.KnowledgeBaseID, KnowledgeID: *knowledgeID})
|
|
if err != nil {
|
|
fmt.Println("STEP postgres export: FAIL", err)
|
|
return
|
|
}
|
|
names := make([]string, 0, len(snapshot.Tables))
|
|
for _, table := range snapshot.Tables {
|
|
names = append(names, fmt.Sprintf("%s:%d", table.Name, table.RowCount))
|
|
}
|
|
fmt.Println("STEP postgres export: ok tables", names)
|
|
|
|
search, err := v071.NewSearchExporter(v071.SearchExporterConfig{BaseURL: route.Source.Elasticsearch.BaseURL, Index: route.Source.Elasticsearch.Index, PageSize: 500})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
searchSnapshot, err := search.Export(ctx, v071.SearchIdentity{TenantID: route.TenantID, KnowledgeBaseID: route.KnowledgeBaseID, KnowledgeID: *knowledgeID})
|
|
if err != nil {
|
|
fmt.Println("STEP elasticsearch export: FAIL", err)
|
|
return
|
|
}
|
|
fmt.Printf("STEP elasticsearch export: ok documents=%d\n", len(searchSnapshot.Documents))
|
|
|
|
store, err := newStore(ctx, route.Source.Object)
|
|
if err != nil {
|
|
fmt.Println("STEP object store build: FAIL", err)
|
|
return
|
|
}
|
|
exporter, err := v071.NewObjectExporter(store, v071.ObjectExporterLimits{})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
objectSnapshot, err := exporter.Export(ctx, snapshot)
|
|
if err != nil {
|
|
fmt.Println("STEP object export: FAIL", err)
|
|
} else {
|
|
fmt.Printf("STEP object export: ok objects=%d bytes=%d\n", len(objectSnapshot.Objects), objectSnapshot.TotalBytes)
|
|
}
|
|
|
|
client, err := newRawClient(ctx, route.Source.Object)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
for _, prefix := range []string{"weknora/", "oss://", ""} {
|
|
listed, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(route.Source.Object.Bucket), Prefix: aws.String(prefix), MaxKeys: aws.Int32(3)})
|
|
if err != nil {
|
|
fmt.Printf("STEP list prefix %q: FAIL %v\n", prefix, err)
|
|
continue
|
|
}
|
|
fmt.Printf("STEP list prefix %q: %d objects\n", prefix, len(listed.Contents))
|
|
for _, object := range listed.Contents {
|
|
fmt.Println(" ", aws.ToString(object.Key))
|
|
}
|
|
}
|
|
|
|
knowledge := snapshot.Table("knowledges")
|
|
for _, row := range knowledge.Rows {
|
|
var typed struct {
|
|
Source string `json:"source"`
|
|
}
|
|
_ = json.Unmarshal(row, &typed)
|
|
fmt.Println("knowledge.source:", typed.Source)
|
|
}
|
|
for _, raw := range snapshot.Table("resources").Rows {
|
|
var typed struct {
|
|
PhysicalPath string `json:"physical_path"`
|
|
}
|
|
_ = json.Unmarshal(raw, &typed)
|
|
fmt.Println("resource physical_path:", typed.PhysicalPath)
|
|
}
|
|
|
|
fmt.Println("STEP target object stage probe:")
|
|
targetStore, err := newStore(ctx, route.Target.Object)
|
|
if err != nil {
|
|
fmt.Println(" target store build FAIL", err)
|
|
return
|
|
}
|
|
probePath := ".governance-release-diag/probe.txt"
|
|
payload := []byte("release-sim-probe")
|
|
if err := targetStore.Put(ctx, route.Target.Object.Provider, probePath, bytes.NewReader(payload), int64(len(payload))); err != nil {
|
|
fmt.Println(" target Put FAIL:", err)
|
|
} else {
|
|
size, digest, err := targetStore.Stat(ctx, route.Target.Object.Provider, probePath)
|
|
fmt.Println(" target Put ok; Stat:", size, digest[:8], "err:", err)
|
|
}
|
|
|
|
fmt.Println("STEP real ObjectImporter Stage/Promote with exported snapshot:")
|
|
importer, err := v071.NewObjectImporter(targetStore, v071.TargetObjectConfig{StorageBackendID: route.Target.Object.StorageBackendID, Provider: route.Target.Object.Provider})
|
|
if err != nil {
|
|
fmt.Println(" importer build FAIL", err)
|
|
return
|
|
}
|
|
stage, err := importer.Stage(ctx, "diag-stage", objectSnapshot)
|
|
if err != nil {
|
|
fmt.Println(" Stage FAIL:", err)
|
|
return
|
|
}
|
|
fmt.Println(" Stage ok objects:", len(stage.Objects))
|
|
if err := importer.Promote(ctx, stage); err != nil {
|
|
fmt.Println(" Promote FAIL:", err)
|
|
return
|
|
}
|
|
fmt.Println(" Promote ok")
|
|
|
|
fmt.Println("STEP target Postgres replace:")
|
|
targetPool, err := pgxpool.New(ctx, secret(route.Target.DatabaseURLFile))
|
|
if err != nil {
|
|
fmt.Println(" target pool FAIL", err)
|
|
return
|
|
}
|
|
defer targetPool.Close()
|
|
if err := targetPool.Ping(ctx); err != nil {
|
|
fmt.Println(" target PG connect FAIL", err)
|
|
return
|
|
}
|
|
pgImporter, err := v071.NewPostgresImporter(targetPool, v071.PostgresImporterConfig{TargetStorageBackendID: route.Target.Object.StorageBackendID, TargetProvider: route.Target.Object.Provider})
|
|
if err != nil {
|
|
fmt.Println(" importer config FAIL", err)
|
|
return
|
|
}
|
|
identity := v071.PostgresIdentity{TenantID: route.TenantID, KnowledgeBaseID: route.KnowledgeBaseID, KnowledgeID: *knowledgeID}
|
|
if err := pgImporter.Replace(ctx, identity, snapshot); err != nil {
|
|
fmt.Println(" Replace FAIL:", err)
|
|
return
|
|
}
|
|
fmt.Println(" Replace ok")
|
|
|
|
fmt.Println("STEP target ES replace:")
|
|
targetSearch, err := v071.NewSearchImporter(v071.SearchExporterConfig{BaseURL: route.Target.Elasticsearch.BaseURL, Index: route.Target.Elasticsearch.Index, PageSize: 500})
|
|
if err != nil {
|
|
fmt.Println(" importer build FAIL", err)
|
|
return
|
|
}
|
|
if _, err := targetSearch.Replace(ctx, v071.SearchIdentity{TenantID: route.TenantID, KnowledgeBaseID: route.KnowledgeBaseID, KnowledgeID: *knowledgeID}, searchSnapshot); err != nil {
|
|
fmt.Println(" ES Replace FAIL:", err)
|
|
return
|
|
}
|
|
fmt.Println(" ES Replace ok")
|
|
|
|
fmt.Println("STEP target WeKnora verification replay:")
|
|
wknoraClient, err := weknora.NewClient(weknora.ClientConfig{BaseURL: route.Target.WeKnora.BaseURL, APIKey: secret(route.Target.WeKnora.APIKeyFile), Timeout: 30 * time.Second, AllowDockerHTTP: true})
|
|
if err != nil {
|
|
fmt.Println(" client build FAIL", err)
|
|
return
|
|
}
|
|
knowledgeRemote, err := wknoraClient.GetKnowledge(ctx, *knowledgeID)
|
|
fmt.Println(" GetKnowledge err:", err, "id:", knowledgeRemote.ID, "kb:", knowledgeRemote.KnowledgeBaseID, "parse:", knowledgeRemote.ParseStatus)
|
|
chunksRemote, total, err := wknoraClient.ListChunks(ctx, *knowledgeID, 1, 5, nil)
|
|
fmt.Println(" ListChunks err:", err, "total:", total, "page:", len(chunksRemote))
|
|
probe := ""
|
|
for _, document := range searchSnapshot.Documents {
|
|
var fields struct {
|
|
Content string `json:"content"`
|
|
}
|
|
_ = json.Unmarshal(document.Source, &fields)
|
|
if utf8.RuneCountInString(fields.Content) >= 12 {
|
|
probe = string([]rune(fields.Content)[:min(48, utf8.RuneCountInString(fields.Content))])
|
|
break
|
|
}
|
|
}
|
|
results, err := wknoraClient.SearchKnowledge(ctx, weknora.SearchRequest{Query: probe, KnowledgeIDs: []string{*knowledgeID}})
|
|
fmt.Println(" SearchKnowledge err:", err, "probe_len:", len(probe), "results:", len(results))
|
|
for index, result := range results {
|
|
if index >= 3 {
|
|
break
|
|
}
|
|
fmt.Printf(" hit knowledge=%s score=%.3f type=%s\n", result.KnowledgeID, result.Score, result.ChunkType)
|
|
}
|
|
if len(results) == 0 && probe != "" {
|
|
results, err = wknoraClient.SearchKnowledge(ctx, weknora.SearchRequest{Query: "CRA 620mm", KnowledgeIDs: []string{*knowledgeID}})
|
|
fmt.Println(" short-probe SearchKnowledge err:", err, "results:", len(results))
|
|
}
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func newStore(ctx context.Context, object objectCfg) (*v071.S3ObjectStore, error) {
|
|
parsed, err := url.Parse(object.Endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return v071.NewS3ObjectStore(v071.S3ObjectStoreConfig{
|
|
Provider: object.Provider, Endpoint: object.Endpoint, Region: object.Region,
|
|
AccessKeyID: secret(object.AccessKeyFile), SecretAccessKey: secret(object.SecretKeyFile),
|
|
Bucket: object.Bucket, Secure: parsed.Scheme == "https",
|
|
})
|
|
}
|
|
|
|
func newRawClient(ctx context.Context, object objectCfg) (*s3.Client, error) {
|
|
parsed, err := url.Parse(object.Endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(object.Region), awsconfig.WithCredentialsProvider(
|
|
credentials.NewStaticCredentialsProvider(secret(object.AccessKeyFile), secret(object.SecretKeyFile), "")))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s3.NewFromConfig(awsCfg, func(options *s3.Options) {
|
|
options.BaseEndpoint = aws.String(object.Endpoint)
|
|
options.UsePathStyle = false
|
|
_ = parsed
|
|
}), nil
|
|
}
|
|
|
|
var _ = io.Discard
|