2de7a43286
页面原本没有任何生产入口,授权/重排/回滚只能带 Bearer 调接口,实际还要 admin 会话。 现在三个端点支持两条通道,门禁语义不变: - 运维通道:Authorization: Bearer <token>,审计记 PRODUCTION_RELEASE_AUTHORIZER_REF - 页面通道:已认证 admin 会话 + 固定确认语句,审计记该管理员用户名 - 请求门禁 / 备份恢复门禁 / Release Worker 执行门禁三者不受影响 - 发布管理第 6–8 步加「授权发布 / 重排 / 回滚」按钮,点击需逐字输入确认语句, 仅 admin 可见;重排沿用原执行授权人,不改写历史 - openapi 与 P6 契约同步;测试:双通道各一条 + 非 admin/错短语拒绝 + 前端按钮与确认门禁
331 lines
12 KiB
Go
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
|
|
}
|