Files
knowledge-governance/cmd/release-channel-e2e/main.go
T
姚章浩 2de7a43286 feat(release): 生产授权支持页面通道(管理员会话 + 固定确认语句)
页面原本没有任何生产入口,授权/重排/回滚只能带 Bearer 调接口,实际还要 admin 会话。
现在三个端点支持两条通道,门禁语义不变:

- 运维通道:Authorization: Bearer <token>,审计记 PRODUCTION_RELEASE_AUTHORIZER_REF
- 页面通道:已认证 admin 会话 + 固定确认语句,审计记该管理员用户名
- 请求门禁 / 备份恢复门禁 / Release Worker 执行门禁三者不受影响
- 发布管理第 6–8 步加「授权发布 / 重排 / 回滚」按钮,点击需逐字输入确认语句,
  仅 admin 可见;重排沿用原执行授权人,不改写历史
- openapi 与 P6 契约同步;测试:双通道各一条 + 非 admin/错短语拒绝 + 前端按钮与确认门禁
2026-10-08 17:00:49 +08:00

331 lines
12 KiB
Go

// Command release-channel-e2e drives the real v0.7.1 release channel end to end
// against the endpoints in a release config file, so the retrieval lanes and the
// knowledge base wiki snapshot can be exercised together:
//
// publish : Prepare → Activate → Verify (document + KB wiki into production)
// withdraw : Prepare → Restore("absent") → VerifyRestored("absent")
// (document disabled in retrieval AND its wiki contribution retracted)
// rollback : Restore(revision) → VerifyRestored(revision)
//
// It reuses the same production code path as the release worker
// (DocumentExporter → DocumentTarget → WeKnoraVerifier); only the orchestration
// (platform queue rows) is skipped. Credentials are read from files and never
// printed.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/platform/weknora"
v071 "knowledge-governance/internal/releaseadapter/v071"
releaseworkflow "knowledge-governance/internal/workflows/release"
)
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"`
TokenFile string `json:"token_file"`
} `json:"elasticsearch"`
Object objectCfg `json:"object"`
WeKnora struct {
BaseURL string `json:"base_url"`
APIKeyFile string `json:"api_key_file"`
} `json:"weknora"`
}
type config struct {
ArchiveRoot string `json:"archive_root"`
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 {
if path == "" {
return ""
}
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 release")
mode := flag.String("mode", "publish", "publish | withdraw | rollback")
revision := flag.String("revision", "wiki-e2e-v1", "release revision")
previous := flag.String("previous", "", "previous production revision (rollback target)")
archiveRoot := flag.String("archive", "", "override archive root")
flag.Parse()
if *configPath == "" || *knowledgeID == "" {
fmt.Println("FATAL -config and -knowledge are required")
os.Exit(2)
}
raw, err := os.ReadFile(*configPath)
if err != nil {
fmt.Println("FATAL read config:", err)
os.Exit(1)
}
var cfg config
if err := json.Unmarshal(raw, &cfg); err != nil || len(cfg.Routes) != 1 {
fmt.Println("FATAL parse config:", err)
os.Exit(1)
}
route := cfg.Routes[0]
if *archiveRoot != "" {
cfg.ArchiveRoot = *archiveRoot
}
fmt.Printf("route kb=%s tenant=%d mode=%s revision=%s previous=%q\n", route.KnowledgeBaseID, route.TenantID, *mode, *revision, *previous)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
sourcePool, err := openPool(ctx, secret(route.Source.DatabaseURLFile))
if err != nil {
fmt.Println("STEP source PG connect: FAIL", err)
return
}
defer sourcePool.Close()
targetPool, err := openPool(ctx, secret(route.Target.DatabaseURLFile))
if err != nil {
fmt.Println("STEP target PG connect: FAIL", err)
return
}
defer targetPool.Close()
fmt.Println("STEP both PG connect: ok")
sourceSearch, err := v071.NewSearchExporter(v071.SearchExporterConfig{BaseURL: route.Source.Elasticsearch.BaseURL, Index: route.Source.Elasticsearch.Index, AuthorizationToken: secret(route.Source.Elasticsearch.TokenFile), PageSize: 500})
if err != nil {
fmt.Println("STEP source ES: FAIL", err)
return
}
targetSearchConfig := v071.SearchExporterConfig{BaseURL: route.Target.Elasticsearch.BaseURL, Index: route.Target.Elasticsearch.Index, AuthorizationToken: secret(route.Target.Elasticsearch.TokenFile), PageSize: 500}
targetSearchInspector, err := v071.NewSearchExporter(targetSearchConfig)
if err != nil {
fmt.Println("STEP target ES inspector: FAIL", err)
return
}
targetSearch, err := v071.NewSearchImporter(targetSearchConfig)
if err != nil {
fmt.Println("STEP target ES importer: FAIL", err)
return
}
sourceStore, err := newStore(route.Source.Object)
if err != nil {
fmt.Println("STEP source object store: FAIL", err)
return
}
targetStore, err := newStore(route.Target.Object)
if err != nil {
fmt.Println("STEP target object store: FAIL", err)
return
}
objectExporter, _ := v071.NewObjectExporter(sourceStore, v071.ObjectExporterLimits{})
objectImporter, _ := v071.NewObjectImporter(targetStore, v071.TargetObjectConfig{StorageBackendID: route.Target.Object.StorageBackendID, Provider: route.Target.Object.Provider})
postgresExporter, _ := v071.NewPostgresExporter(sourcePool)
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 {
fmt.Println("STEP target PG importer: FAIL", err)
return
}
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 {
fmt.Println("STEP source contract: FAIL", err)
return
}
targetContract, err := v071.NewTargetContractInspector(v071.TargetContractInspectorConfig{DB: targetPool, Mapping: targetSearchInspector, KnowledgeBaseID: route.KnowledgeBaseID, TenantID: route.TenantID, IndexName: route.Target.Elasticsearch.Index})
if err != nil {
fmt.Println("STEP target contract: FAIL", err)
return
}
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 {
fmt.Println("STEP document exporter: FAIL", err)
return
}
archive, err := v071.NewBundleArchive(filepath.Join(cfg.ArchiveRoot, route.KnowledgeBaseID), 1024*1024*1024)
if err != nil {
fmt.Println("STEP archive: FAIL", err)
return
}
weknoraClient, err := weknora.NewClient(weknora.ClientConfig{BaseURL: route.Target.WeKnora.BaseURL, APIKey: secret(route.Target.WeKnora.APIKeyFile), Timeout: 60 * time.Second, AllowDockerHTTP: true})
if err != nil {
fmt.Println("STEP target WeKnora client: FAIL", err)
return
}
verifier, _ := v071.NewWeKnoraVerifier(weknoraClient)
target, err := v071.NewDocumentTarget(v071.DocumentTargetConfig{Archive: archive, Contract: targetContract, Postgres: postgresImporter, Search: targetSearch, Objects: objectImporter, Verifier: verifier})
if err != nil {
fmt.Println("STEP document target: FAIL", err)
return
}
action := "upsert"
if *mode == "withdraw" {
action = "delete"
}
item := releaseworkflow.PlanItem{
ReleaseExecutionID: "rx-wiki-e2e", PublicationID: "tp-wiki-e2e", Action: action,
SourceItemVersionID: "sv-wiki-e2e", SourceRevision: "source-wiki-e2e",
ParseRevision: "parser-wiki-e2e@index-wiki-e2e", ReleaseRevision: *revision,
TestKnowledgeBaseID: route.KnowledgeBaseID, KnowledgeID: *knowledgeID,
DecidedVisibility: governance.VisibilityInternal, PreviousProductionRevision: *previous,
}
printWiki("target wiki before", ctx, weknoraClient, route.KnowledgeBaseID)
switch *mode {
case "publish":
change, err := target.Prepare(ctx, item, mustExport(ctx, documentExporter, item))
if err != nil {
fmt.Println("STEP prepare: FAIL", err)
return
}
fmt.Printf("STEP prepare: ok previous=%s production=%s\n", change.PreviousRevision, change.ProductionRevision)
bundle, err := archive.Load(change.ProductionRevision)
if err != nil {
fmt.Println("STEP bundle reload: FAIL", err)
return
}
printBundle(bundle)
if err := target.Activate(ctx, item, change); err != nil {
fmt.Println("STEP activate: FAIL", err)
return
}
fmt.Println("STEP activate: ok")
if err := target.Verify(ctx, item, change); err != nil {
fmt.Println("STEP verify: FAIL", err)
return
}
fmt.Println("STEP verify: ok (document + wiki)")
case "withdraw":
change, err := target.Prepare(ctx, item, mustExport(ctx, documentExporter, item))
if err != nil {
fmt.Println("STEP prepare: FAIL", err)
return
}
bundle, err := archive.Load(change.ProductionRevision)
if err != nil {
fmt.Println("STEP bundle reload: FAIL", err)
return
}
printBundle(bundle)
revision, err := target.Restore(ctx, item, "absent")
if err != nil {
fmt.Println("STEP restore absent: FAIL", err)
return
}
fmt.Println("STEP restore absent: ok revision=", revision)
if err := target.VerifyRestored(ctx, item, "absent"); err != nil {
fmt.Println("STEP verify withdrawn: FAIL", err)
return
}
fmt.Println("STEP verify withdrawn: ok (retrieval disabled + wiki retracted)")
case "rollback":
if *previous == "" {
fmt.Println("FATAL rollback mode needs -previous")
os.Exit(2)
}
revision, err := target.Restore(ctx, item, *previous)
if err != nil {
fmt.Println("STEP restore: FAIL", err)
return
}
fmt.Println("STEP restore: ok revision=", revision)
if err := target.VerifyRestored(ctx, item, *previous); err != nil {
fmt.Println("STEP verify restored: FAIL", err)
return
}
fmt.Println("STEP verify restored: ok")
default:
fmt.Println("FATAL unknown mode:", *mode)
os.Exit(2)
}
printWiki("target wiki after", ctx, weknoraClient, route.KnowledgeBaseID)
}
func mustExport(ctx context.Context, exporter *v071.DocumentExporter, item releaseworkflow.PlanItem) releaseworkflow.ExportedBundle {
value, err := exporter.Export(ctx, item)
if err != nil {
fmt.Println("STEP export: FAIL", err)
os.Exit(1)
}
return value
}
func printBundle(bundle *v071.DocumentBundle) {
parts := make([]string, 0, len(bundle.Postgres.Tables))
for _, table := range bundle.Postgres.Tables {
parts = append(parts, fmt.Sprintf("%s=%d", table.Name, table.RowCount))
}
fmt.Println("bundle tables:", strings.Join(parts, " "))
fmt.Printf("bundle search=%d objects=%d\n", bundle.Manifest.Search.DocumentCount, bundle.Manifest.Assets.ObjectCount)
}
func printWiki(label string, ctx context.Context, client *weknora.Client, knowledgeBaseID string) {
pages, total, err := client.ListWikiPages(ctx, knowledgeBaseID, 1, 20)
if err != nil {
fmt.Printf("%s: FAIL %v\n", label, err)
return
}
slugs := make([]string, 0, len(pages))
for _, page := range pages {
slugs = append(slugs, page.Slug)
}
fmt.Printf("%s: total=%d first=%s\n", label, total, strings.Join(slugs, ","))
}
func newStore(object objectCfg) (*v071.S3ObjectStore, error) {
parsed, err := url.Parse(object.Endpoint)
if err != nil {
return nil, err
}
accessKey, secretKey := secret(object.AccessKeyFile), secret(object.SecretKeyFile)
if accessKey == "" || secretKey == "" {
return nil, errors.New("object credentials are required")
}
return v071.NewS3ObjectStore(v071.S3ObjectStoreConfig{Provider: object.Provider, Endpoint: object.Endpoint, Region: object.Region, AccessKeyID: accessKey, SecretAccessKey: secretKey, Bucket: object.Bucket, Secure: parsed.Scheme == "https"})
}
func openPool(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
}