bb64c32654
build / build (push) Failing after 16s
- R4:封存子进程 SIGTERM 宽限停、libraryctl 信号转 ctx;失败/超时后 agent 再做 有界 recover,恢复失败即降级阻断封存并给出原因;启动先恢复、关闭等恢复完成 - R5:封存闸口以已存 payload 为准,同作业号换范围 409 且不入新队;重放回显真实状态 - R6:作业列表按类型查询(ListByType),不再先取全局再过滤 - R7:根目录 libraryagent 二进制移出版本库,保留运行产物并精确忽略 - UI:发布管理只保留对内/对外两目标,整合快照/完整性/作业/存储;链路未接线项禁用 并标原因;整库版本独立导航并入;文档级旧流程折叠保留
847 lines
30 KiB
Go
847 lines
30 KiB
Go
// Command libraryctl is the operator entry point for the whole-library capture
|
|
// chain: it reads one test knowledge base (internal or external) from the real
|
|
// WeKnora test stack, freezes its writers for the duration of the export and
|
|
// seals a content-addressed candidate artifact.
|
|
//
|
|
// Boundaries this command keeps:
|
|
// - the source is read through a read-only PostgreSQL session; no source
|
|
// schema, data or program is modified;
|
|
// - only SIGTERM is sent to the declared writer group, and only when every
|
|
// service of the Compose project is declared (a new sibling container is
|
|
// refused, not ignored);
|
|
// - credentials are read from the running containers at the moment of the
|
|
// call and are never printed, logged or written to disk;
|
|
// - the sealed artifact is a *candidate*: it is not a release, and it does
|
|
// not replace snapshot-bound content, Q&A and performance acceptance.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"knowledge-governance/internal/libraryfreeze"
|
|
"knowledge-governance/internal/librarysnapshot"
|
|
v071 "knowledge-governance/internal/releaseadapter/v071"
|
|
releaseworkflow "knowledge-governance/internal/workflows/release"
|
|
)
|
|
|
|
type options struct {
|
|
sourceApp string
|
|
sourcePG string
|
|
sourceES string
|
|
governancePG string
|
|
project string
|
|
writers string
|
|
nonWriters string
|
|
archiveRoot string
|
|
journalRoot string
|
|
dockerSocket string
|
|
maxDocuments int
|
|
maxObjectBytes int64
|
|
scope string
|
|
job string
|
|
dryRun bool
|
|
snapshot string
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
command := os.Args[1]
|
|
opts, rest, err := parseFlags(command, os.Args[2:])
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "LIBRARYCTL_FLAGS:", err)
|
|
os.Exit(2)
|
|
}
|
|
_ = rest
|
|
// SIGTERM must not kill this process mid-window: the capture path restores the
|
|
// source writers through a cancelled-context recovery, so the signal is turned
|
|
// into a context cancellation and the process exits only after that cleanup.
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
|
defer cancel()
|
|
var runErr error
|
|
switch command {
|
|
case "status":
|
|
runErr = runStatus(ctx, opts)
|
|
case "preflight":
|
|
runErr = runPreflight(ctx, opts)
|
|
case "capture":
|
|
runErr = runCapture(ctx, opts)
|
|
case "list":
|
|
runErr = runList(opts)
|
|
case "verify":
|
|
runErr = runVerify(opts)
|
|
case "recover":
|
|
runErr = runRecover(ctx, opts)
|
|
default:
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
if runErr != nil {
|
|
fmt.Fprintln(os.Stderr, "LIBRARYCTL_FAILED:", runErr)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprint(os.Stderr, `libraryctl <status|preflight|capture|list|verify|recover> [flags]
|
|
|
|
status probe the real source and print what is configured / blocked
|
|
preflight read the source, run settlement + candidate validation (no writes, no stop)
|
|
capture freeze the declared writer group, archive a candidate, restore writers
|
|
list list sealed candidates and stored bytes
|
|
verify re-verify one sealed candidate (digests, coverage, identity)
|
|
recover restore writers from an interrupted freeze journal
|
|
|
|
Flags (defaults target the local WKNFull test stack):
|
|
-source-app WKNFull-app container whose env describes the source
|
|
-source-pg WKNFull-postgres source PostgreSQL container (address resolution)
|
|
-source-es WKNFull-elasticsearch
|
|
-governance-pg knowledge-governance-p1-postgres-1 routing bindings read here
|
|
-project weknora071full Compose project owning the writer group
|
|
-writers WKNFull-app declared writer services (comma separated)
|
|
-non-writers WKNFull-frontend=reason,... every other project service
|
|
-archive ~/.local/share/knowledge-governance/library
|
|
-journal ~/.local/share/knowledge-governance/freeze
|
|
-docker-socket /var/run/docker.sock
|
|
-scope internal|external (preflight/capture)
|
|
-job <id> (capture; default: lib-<scope>-<UTC timestamp>)
|
|
-dry-run (capture: export + validate only, no archive, no stop)
|
|
-snapshot <id> (verify)
|
|
`)
|
|
}
|
|
|
|
func parseFlags(command string, args []string) (options, []string, error) {
|
|
home, _ := os.UserHomeDir()
|
|
opts := options{
|
|
sourceApp: "WKNFull-app",
|
|
sourcePG: "WKNFull-postgres",
|
|
sourceES: "WKNFull-elasticsearch",
|
|
governancePG: "knowledge-governance-p1-postgres-1",
|
|
project: "weknora071full",
|
|
writers: "WKNFull-app",
|
|
archiveRoot: filepath.Join(home, ".local/share/knowledge-governance/library"),
|
|
journalRoot: filepath.Join(home, ".local/share/knowledge-governance/freeze"),
|
|
dockerSocket: "/var/run/docker.sock",
|
|
maxDocuments: 100000,
|
|
maxObjectBytes: 134217728,
|
|
}
|
|
set := flag.NewFlagSet(command, flag.ContinueOnError)
|
|
set.SetOutput(os.Stderr)
|
|
set.StringVar(&opts.sourceApp, "source-app", opts.sourceApp, "source container describing the stack")
|
|
set.StringVar(&opts.sourcePG, "source-pg", opts.sourcePG, "source PostgreSQL container")
|
|
set.StringVar(&opts.sourceES, "source-es", opts.sourceES, "source Elasticsearch container")
|
|
set.StringVar(&opts.governancePG, "governance-pg", opts.governancePG, "governance PostgreSQL container")
|
|
set.StringVar(&opts.project, "project", opts.project, "Compose project owning the writers")
|
|
set.StringVar(&opts.writers, "writers", opts.writers, "declared writer services")
|
|
set.StringVar(&opts.nonWriters, "non-writers", "", "service=reason for every other project service")
|
|
set.StringVar(&opts.archiveRoot, "archive", opts.archiveRoot, "sealed artifact root")
|
|
set.StringVar(&opts.journalRoot, "journal", opts.journalRoot, "freeze journal root")
|
|
set.StringVar(&opts.dockerSocket, "docker-socket", opts.dockerSocket, "Docker engine socket")
|
|
set.IntVar(&opts.maxDocuments, "max-documents", opts.maxDocuments, "maximum indexed documents per scope")
|
|
set.Int64Var(&opts.maxObjectBytes, "max-object-bytes", opts.maxObjectBytes, "maximum bytes per stored object")
|
|
set.StringVar(&opts.scope, "scope", "", "internal|external")
|
|
set.StringVar(&opts.job, "job", "", "capture job id")
|
|
set.BoolVar(&opts.dryRun, "dry-run", false, "export and validate without archiving or stopping")
|
|
set.StringVar(&opts.snapshot, "snapshot", "", "snapshot id for verify")
|
|
if err := set.Parse(args); err != nil {
|
|
return opts, nil, err
|
|
}
|
|
return opts, set.Args(), nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------- discovery
|
|
|
|
func dockerOutput(ctx context.Context, args ...string) (string, error) {
|
|
bounded, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(bounded, "docker", args...).Output()
|
|
if err != nil {
|
|
var exit *exec.ExitError
|
|
if errors.As(err, &exit) && len(exit.Stderr) > 0 {
|
|
return "", fmt.Errorf("docker %s failed", args[0])
|
|
}
|
|
return "", fmt.Errorf("docker %s failed", args[0])
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
func containerEnv(ctx context.Context, name string) (map[string]string, error) {
|
|
raw, err := dockerOutput(ctx, "inspect", "--format", "{{json .Config.Env}}", name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var entries []string
|
|
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
|
|
return nil, errors.New("invalid container environment")
|
|
}
|
|
env := map[string]string{}
|
|
for _, entry := range entries {
|
|
if key, value, ok := strings.Cut(entry, "="); ok {
|
|
env[key] = value
|
|
}
|
|
}
|
|
return env, nil
|
|
}
|
|
|
|
func containerAddress(ctx context.Context, name string) (string, error) {
|
|
raw, err := dockerOutput(ctx, "inspect", "--format",
|
|
"{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, field := range strings.Fields(raw) {
|
|
if field != "" && field != "0.0.0.0" {
|
|
return field, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("container %s has no reachable address", name)
|
|
}
|
|
|
|
func containerImageID(ctx context.Context, name string) (string, error) {
|
|
return dockerOutput(ctx, "inspect", "--format", "{{.Image}}", name)
|
|
}
|
|
|
|
// sourceConfig is everything needed to read the test library. Credential fields
|
|
// are never printed: String() masks them and no log line formats the struct.
|
|
type sourceConfig struct {
|
|
Scopes map[string]string
|
|
DatabaseURL string
|
|
Search v071.SearchExporterConfig
|
|
Object v071.S3ObjectStoreConfig
|
|
AppImageID string
|
|
SearchIndex string
|
|
ObjectDetail string
|
|
}
|
|
|
|
func discoverSource(ctx context.Context, opts options, binding map[string]string) (sourceConfig, error) {
|
|
var config sourceConfig
|
|
if binding == nil {
|
|
return config, errors.New("routing bindings are required")
|
|
}
|
|
config.Scopes = binding
|
|
app, err := containerEnv(ctx, opts.sourceApp)
|
|
if err != nil {
|
|
return config, err
|
|
}
|
|
if app["DB_DRIVER"] != "postgres" {
|
|
return config, errors.New("source database driver is not postgres")
|
|
}
|
|
if app["STORAGE_TYPE"] != "oss" {
|
|
return config, fmt.Errorf("unsupported source storage type %q", app["STORAGE_TYPE"])
|
|
}
|
|
address, err := containerAddress(ctx, opts.sourcePG)
|
|
if err != nil {
|
|
return config, err
|
|
}
|
|
dsn := strings.Join([]string{
|
|
"postgres://" + urlEscape(app["DB_USER"]) + ":" + urlEscape(app["DB_PASSWORD"]) +
|
|
"@" + address + ":" + app["DB_PORT"] + "/" + urlEscape(app["DB_NAME"]) + "?sslmode=disable",
|
|
}, "")
|
|
config.DatabaseURL = dsn
|
|
config.AppImageID, err = containerImageID(ctx, opts.sourceApp)
|
|
if err != nil {
|
|
return config, err
|
|
}
|
|
esAddress, err := containerAddress(ctx, opts.sourceES)
|
|
if err != nil {
|
|
return config, err
|
|
}
|
|
config.SearchIndex = app["ELASTICSEARCH_INDEX"]
|
|
config.Search = v071.SearchExporterConfig{BaseURL: "http://" + esAddress + ":9200", Index: app["ELASTICSEARCH_INDEX"],
|
|
AuthorizationToken: app["ELASTICSEARCH_PASSWORD"], PageSize: 500}
|
|
endpoint := app["OSS_ENDPOINT"]
|
|
if endpoint == "" {
|
|
return config, errors.New("source object endpoint is not configured")
|
|
}
|
|
if !strings.Contains(endpoint, "://") {
|
|
endpoint = "https://" + endpoint
|
|
}
|
|
config.Object = v071.S3ObjectStoreConfig{Provider: "oss", Endpoint: endpoint, Region: app["OSS_REGION"],
|
|
AccessKeyID: app["OSS_ACCESS_KEY"], SecretAccessKey: app["OSS_SECRET_KEY"], Bucket: app["OSS_BUCKET_NAME"],
|
|
Secure: strings.HasPrefix(endpoint, "https://"), MaxObjectBytes: opts.maxObjectBytes}
|
|
config.ObjectDetail = "bucket=" + app["OSS_BUCKET_NAME"] + " region=" + app["OSS_REGION"]
|
|
return config, nil
|
|
}
|
|
|
|
func urlEscape(value string) string {
|
|
replacer := strings.NewReplacer("%", "%25", ":", "%3A", "@", "%40", "/", "%2F", "?", "%3F", "#", "%23", "&", "%26", " ", "%20")
|
|
return replacer.Replace(value)
|
|
}
|
|
|
|
func readRoutingBindings(ctx context.Context, container string) (map[string]string, error) {
|
|
raw, err := dockerOutput(ctx, "exec", container, "psql", "-U", "governance", "-d", "governance", "-tAc",
|
|
"SELECT internal_kb_id||'|'||external_kb_id FROM routing_kb_bindings WHERE id='active'")
|
|
if err != nil {
|
|
return nil, errors.New("read routing bindings failed")
|
|
}
|
|
internal, external, ok := strings.Cut(strings.TrimSpace(raw), "|")
|
|
if !ok || internal == "" || external == "" {
|
|
return nil, errors.New("routing bindings are incomplete")
|
|
}
|
|
return map[string]string{"internal": internal, "external": external}, nil
|
|
}
|
|
|
|
func openSourceDatabase(ctx context.Context, config sourceConfig) (*pgxpool.Pool, error) {
|
|
parsed, err := pgxpool.ParseConfig(config.DatabaseURL)
|
|
if err != nil {
|
|
return nil, errors.New("invalid source database url")
|
|
}
|
|
// The capture chain never writes to the source. The read-only session makes
|
|
// that a database-enforced property rather than a convention.
|
|
parsed.ConnConfig.RuntimeParams["default_transaction_read_only"] = "on"
|
|
parsed.ConnConfig.RuntimeParams["statement_timeout"] = "120000"
|
|
pool, err := pgxpool.NewWithConfig(ctx, parsed)
|
|
if err != nil {
|
|
return nil, errors.New("open source database failed")
|
|
}
|
|
var readOnly string
|
|
if err = pool.QueryRow(ctx, "SHOW default_transaction_read_only").Scan(&readOnly); err != nil || readOnly != "on" {
|
|
pool.Close()
|
|
return nil, errors.New("source session is not read-only")
|
|
}
|
|
return pool, nil
|
|
}
|
|
|
|
func tenantAndKnowledgeBase(ctx context.Context, pool *pgxpool.Pool, knowledgeBaseID string) (uint64, error) {
|
|
var tenant uint64
|
|
err := pool.QueryRow(ctx, "SELECT tenant_id FROM knowledge_bases WHERE id=$1 AND deleted_at IS NULL", knowledgeBaseID).Scan(&tenant)
|
|
if err != nil || tenant == 0 {
|
|
return 0, errors.New("knowledge base not found in source")
|
|
}
|
|
return tenant, nil
|
|
}
|
|
|
|
func writerDeclaration(opts options) ([]string, map[string]string, error) {
|
|
writers := []string{}
|
|
for _, name := range strings.Split(opts.writers, ",") {
|
|
if trimmed := strings.TrimSpace(name); trimmed != "" {
|
|
writers = append(writers, trimmed)
|
|
}
|
|
}
|
|
if len(writers) == 0 {
|
|
return nil, nil, errors.New("no writer service declared")
|
|
}
|
|
nonWriters := map[string]string{}
|
|
for _, entry := range strings.Split(opts.nonWriters, ",") {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
name, reason, ok := strings.Cut(entry, "=")
|
|
if !ok || strings.TrimSpace(reason) == "" {
|
|
return nil, nil, fmt.Errorf("non-writer %q needs service=reason", entry)
|
|
}
|
|
nonWriters[strings.TrimSpace(name)] = strings.TrimSpace(reason)
|
|
}
|
|
return writers, nonWriters, nil
|
|
}
|
|
|
|
func openFreeze(ctx context.Context, opts options) (*libraryfreeze.Coordinator, *libraryfreeze.ProjectController, error) {
|
|
writers, nonWriters, err := writerDeclaration(opts)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err = os.MkdirAll(opts.journalRoot, 0700); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err = os.Chmod(opts.journalRoot, 0700); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
control, err := libraryfreeze.NewProjectController(opts.dockerSocket, opts.project, writers, nonWriters, 2*time.Minute)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
coordinator, err := libraryfreeze.Open(opts.journalRoot, opts.project, control)
|
|
if err != nil {
|
|
control.Close()
|
|
return nil, nil, err
|
|
}
|
|
return coordinator, control, nil
|
|
}
|
|
|
|
func openArchive(opts options) (*librarysnapshot.Archive, error) {
|
|
if err := os.MkdirAll(opts.archiveRoot, 0700); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.Chmod(opts.archiveRoot, 0700); err != nil {
|
|
return nil, err
|
|
}
|
|
return librarysnapshot.Open(opts.archiveRoot, 256*1024*1024)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- commands
|
|
|
|
func runStatus(ctx context.Context, opts options) error {
|
|
report := map[string]any{}
|
|
binding, bindingErr := readRoutingBindings(ctx, opts.governancePG)
|
|
var config sourceConfig
|
|
var err error
|
|
if bindingErr != nil {
|
|
err = bindingErr
|
|
} else {
|
|
config, err = discoverSource(ctx, opts, binding)
|
|
}
|
|
if err != nil {
|
|
report["source_discovery"] = err.Error()
|
|
} else {
|
|
report["source"] = map[string]any{
|
|
"app_image": config.AppImageID,
|
|
"scopes": config.Scopes,
|
|
"search_index": config.SearchIndex,
|
|
"elasticsearch": config.Search.BaseURL,
|
|
"object_store": config.ObjectDetail,
|
|
"database": "postgres (read-only session, credentials withheld)",
|
|
"credentials_logged": false,
|
|
}
|
|
pool, poolErr := openSourceDatabase(ctx, config)
|
|
if poolErr != nil {
|
|
report["database"] = poolErr.Error()
|
|
} else {
|
|
defer pool.Close()
|
|
settled := "settled"
|
|
if settleErr := v071.LibrarySourceSettled(ctx, pool); settleErr != nil {
|
|
settled = settleErr.Error()
|
|
}
|
|
report["settlement"] = settled
|
|
counts := map[string]int{}
|
|
for _, scope := range []string{"internal", "external"} {
|
|
kb := config.Scopes[scope]
|
|
tenant, tenantErr := tenantAndKnowledgeBase(ctx, pool, kb)
|
|
if tenantErr != nil {
|
|
counts[scope+"_error"] = -1
|
|
continue
|
|
}
|
|
pg, exportErr := v071.ExportLibrary(ctx, pool, v071.PostgresIdentity{TenantID: tenant, KnowledgeBaseID: kb})
|
|
if exportErr != nil {
|
|
counts[scope+"_error"] = -1
|
|
continue
|
|
}
|
|
for _, table := range pg.Tables {
|
|
if table.Name == "chunks" || table.Name == "knowledges" {
|
|
counts[scope+"_"+table.Name] = len(table.Rows)
|
|
}
|
|
}
|
|
_ = kb
|
|
}
|
|
report["postgres_rows"] = counts
|
|
}
|
|
}
|
|
archive, err := openArchive(opts)
|
|
if err != nil {
|
|
report["archive"] = err.Error()
|
|
} else {
|
|
defer archive.Close()
|
|
usage, usageErr := archive.Usage()
|
|
if usageErr != nil {
|
|
report["archive"] = usageErr.Error()
|
|
} else {
|
|
total := int64(0)
|
|
for _, item := range usage {
|
|
total += item.UniqueBytes
|
|
}
|
|
report["archive"] = map[string]any{"root": opts.archiveRoot, "snapshots": len(usage), "stored_bytes": total}
|
|
}
|
|
}
|
|
if coordinator, control, freezeErr := openFreeze(ctx, opts); freezeErr != nil {
|
|
report["freeze"] = freezeErr.Error()
|
|
} else {
|
|
defer coordinator.Close()
|
|
defer control.Close()
|
|
items, inventoryErr := control.Inventory(ctx)
|
|
if inventoryErr != nil {
|
|
report["freeze"] = inventoryErr.Error()
|
|
} else {
|
|
group := []map[string]any{}
|
|
for _, item := range items {
|
|
group = append(group, map[string]any{"id": item.ID, "running": item.Running, "healthy": item.Healthy})
|
|
}
|
|
report["freeze"] = map[string]any{"project": opts.project, "writers": group, "journal": opts.journalRoot}
|
|
}
|
|
}
|
|
printJSON(report)
|
|
return nil
|
|
}
|
|
|
|
func runPreflight(ctx context.Context, opts options) error {
|
|
if opts.scope != "internal" && opts.scope != "external" {
|
|
return errors.New("-scope must be internal or external")
|
|
}
|
|
binding, err := readRoutingBindings(ctx, opts.governancePG)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
config, err := discoverSource(ctx, opts, binding)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pool, err := openSourceDatabase(ctx, config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer pool.Close()
|
|
kb := config.Scopes[opts.scope]
|
|
tenant, err := tenantAndKnowledgeBase(ctx, pool, kb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = v071.LibrarySourceSettled(ctx, pool); err != nil {
|
|
return err
|
|
}
|
|
search, err := v071.NewSearchExporter(config.Search)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contract, err := compatibilityContract(ctx, pool, search, config.SearchIndex, tenant, kb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pg, err := v071.ExportLibrary(ctx, pool, v071.PostgresIdentity{TenantID: tenant, KnowledgeBaseID: kb})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
es, err := search.ExportLibrary(ctx, v071.SearchIdentity{TenantID: tenant, KnowledgeBaseID: kb}, opts.maxDocuments)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
validation := "candidate-valid"
|
|
if err = v071.ValidateLibraryCandidateForPreflight(v071.PostgresIdentity{TenantID: tenant, KnowledgeBaseID: kb}, pg, es); err != nil {
|
|
validation = err.Error()
|
|
}
|
|
tables := map[string]int{}
|
|
for _, table := range pg.Tables {
|
|
tables[table.Name] = len(table.Rows)
|
|
}
|
|
printJSON(map[string]any{"scope": opts.scope, "knowledge_base_id": kb, "tenant_id": tenant,
|
|
"compatibility": contract, "postgres_rows": tables, "index_documents": len(es.Documents),
|
|
"mapping_sha256": es.MappingSHA256, "validation": validation, "writes": 0, "writers_stopped": 0})
|
|
if validation != "candidate-valid" {
|
|
return errors.New("candidate validation failed: " + validation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func compatibilityContract(ctx context.Context, pool *pgxpool.Pool, search *v071.SearchExporter, index string, tenant uint64, kb string) (v071.LibrarySnapshotCompatibility, error) {
|
|
inspector, err := v071.NewTargetContractInspector(v071.TargetContractInspectorConfig{DB: pool, Mapping: search,
|
|
KnowledgeBaseID: kb, TenantID: tenant, IndexName: index, ReadTestedBase: true})
|
|
if err != nil {
|
|
return v071.LibrarySnapshotCompatibility{}, err
|
|
}
|
|
contract, err := inspector.Contract(ctx, releaseworkflow.PlanItem{TestKnowledgeBaseID: kb})
|
|
if err != nil {
|
|
return v071.LibrarySnapshotCompatibility{}, err
|
|
}
|
|
return v071.LibrarySnapshotCompatibility{WeKnoraVersion: contract.WeKnoraVersion, SchemaVersion: contract.MigrationVersion,
|
|
RetrieverEngine: contract.RetrieverEngine, EmbeddingModel: contract.EmbeddingModel,
|
|
EmbeddingDimension: contract.EmbeddingDimension, IndexMappingSHA256: contract.ElasticsearchMappingSHA256}, nil
|
|
}
|
|
|
|
func runCapture(ctx context.Context, opts options) error {
|
|
if opts.scope != "internal" && opts.scope != "external" {
|
|
return errors.New("-scope must be internal or external")
|
|
}
|
|
if opts.dryRun {
|
|
return runPreflight(ctx, opts)
|
|
}
|
|
job := opts.job
|
|
if job == "" {
|
|
job = "lib-" + opts.scope + "-" + time.Now().UTC().Format("20060102T150405")
|
|
}
|
|
archive, err := openArchive(opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer archive.Close()
|
|
coordinator, control, err := openFreeze(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer coordinator.Close()
|
|
defer control.Close()
|
|
if err = coordinator.Recover(ctx); err != nil {
|
|
return fmt.Errorf("pending freeze requires recovery: %w", err)
|
|
}
|
|
// Replay is answered from the persisted journal + sealed archive: the same job
|
|
// re-used with the same scope/binding returns the original candidate without
|
|
// reading the source or stopping a writer, and a job re-used with different
|
|
// parameters is refused instead of silently sealing under a stale identity.
|
|
existing, found, err := coordinator.Inspect(job)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
binding, err := readRoutingBindings(ctx, opts.governancePG)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scopedKB := binding[opts.scope]
|
|
if found {
|
|
manifest, err := replayCapture(job, opts.scope, scopedKB, existing, archive)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return printCapture(job, existing.SnapshotID, manifest, true)
|
|
}
|
|
config, err := discoverSource(ctx, opts, binding)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pool, err := openSourceDatabase(ctx, config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer pool.Close()
|
|
kb := config.Scopes[opts.scope]
|
|
tenant, err := tenantAndKnowledgeBase(ctx, pool, kb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
search, err := v071.NewSearchExporter(config.Search)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
objects, err := v071.NewS3ObjectStore(config.Object)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contract, err := compatibilityContract(ctx, pool, search, config.SearchIndex, tenant, kb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The contract above is read before the maintenance window. Re-read it inside
|
|
// the stopped window and refuse if a model/index change happened in between:
|
|
// sealing a candidate whose contract no longer matches the source would hide
|
|
// exactly the change a later production sync cannot reproduce.
|
|
verifyContract := func(ctx context.Context) error {
|
|
live, err := compatibilityContract(ctx, pool, search, config.SearchIndex, tenant, kb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if live != contract {
|
|
return errors.New("source contract changed between preflight and the frozen window; capture aborted")
|
|
}
|
|
return nil
|
|
}
|
|
// sourceReady proves the declared writer group is complete and no other
|
|
// running container uses a declared writer image. Recorded work settlement is
|
|
// enforced separately by CaptureLibrary; neither check can see unrecorded or
|
|
// remote work, so this is not a cross-system consistency proof.
|
|
sourceReady := func(ctx context.Context) error {
|
|
items, err := control.Inventory(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(items) == 0 {
|
|
return errors.New("writer group is empty")
|
|
}
|
|
declared := make(map[string]bool, len(items))
|
|
for _, item := range items {
|
|
declared[item.ID] = true
|
|
}
|
|
images, err := writerImageIDs(ctx, items)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return checkNoUndeclaredWriter(ctx, images, declared)
|
|
}
|
|
result, err := v071.CaptureLibrary(ctx, coordinator, archive, pool, search, objects, sourceReady,
|
|
v071.LibraryCaptureRequest{JobID: job, Scope: opts.scope,
|
|
Identity: v071.PostgresIdentity{TenantID: tenant, KnowledgeBaseID: kb},
|
|
Compatibility: contract, CreatedAt: time.Now().UTC(), MaxDocuments: opts.maxDocuments,
|
|
VerifyContract: verifyContract})
|
|
if err != nil {
|
|
printJSON(map[string]any{"job": job, "state": result.State, "snapshot_id": result.SnapshotID, "error": err.Error()})
|
|
return err
|
|
}
|
|
manifest, err := archive.Load(result.SnapshotID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return printCapture(job, result.SnapshotID, manifest, false)
|
|
}
|
|
|
|
// replayCapture answers a repeated job from persisted state. It never reads the
|
|
// source and never stops a writer; conflicting parameters are refused.
|
|
func replayCapture(job, scope, expectedKB string, result libraryfreeze.Result, archive *librarysnapshot.Archive) (librarysnapshot.Manifest, error) {
|
|
if result.State != "succeeded" || result.SnapshotID == "" {
|
|
return librarysnapshot.Manifest{}, fmt.Errorf("job %q already exists with state %q: recover it or use a new job id", job, result.State)
|
|
}
|
|
manifest, err := archive.Load(result.SnapshotID)
|
|
if err != nil {
|
|
return librarysnapshot.Manifest{}, errors.New("captured library archive no longer verifies")
|
|
}
|
|
if manifest.ValidationPolicy != librarysnapshot.ArtifactAcceptancePolicy {
|
|
return librarysnapshot.Manifest{}, errors.New("existing job was sealed under a different validation policy")
|
|
}
|
|
if manifest.Scope != scope {
|
|
return librarysnapshot.Manifest{}, fmt.Errorf("job %q was captured for scope %q, not %q", job, manifest.Scope, scope)
|
|
}
|
|
if manifest.KnowledgeBaseID != expectedKB {
|
|
return librarysnapshot.Manifest{}, fmt.Errorf("job %q was captured for knowledge base %q, which is no longer the %q binding", job, manifest.KnowledgeBaseID, scope)
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func printCapture(job, snapshotID string, manifest librarysnapshot.Manifest, replay bool) error {
|
|
parts := make([]map[string]any, 0, len(manifest.Parts))
|
|
var total int64
|
|
for _, part := range manifest.Parts {
|
|
parts = append(parts, map[string]any{"kind": part.Kind, "name": part.Name, "count": part.Count, "bytes": part.Bytes, "sha256": part.SHA256})
|
|
total += part.Bytes
|
|
}
|
|
sort.Slice(parts, func(i, j int) bool {
|
|
return fmt.Sprint(parts[i]["kind"])+fmt.Sprint(parts[i]["name"]) < fmt.Sprint(parts[j]["kind"])+fmt.Sprint(parts[j]["name"])
|
|
})
|
|
printJSON(map[string]any{"job": job, "state": "succeeded", "snapshot_id": snapshotID,
|
|
"scope": manifest.Scope, "knowledge_base_id": manifest.KnowledgeBaseID, "tenant_id": manifest.TenantID,
|
|
"compatibility": manifest.Compatibility, "parts": parts, "logical_bytes": total,
|
|
"validation_policy": manifest.ValidationPolicy, "writers_restored": true,
|
|
"replay": replay, "source_read": !replay, "writers_stopped": !replay})
|
|
return nil
|
|
}
|
|
|
|
func writerImageIDs(ctx context.Context, items []libraryfreeze.Service) ([]string, error) {
|
|
images := make([]string, 0, len(items))
|
|
seen := map[string]bool{}
|
|
for _, item := range items {
|
|
image, err := containerImageOf(ctx, item.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !seen[image] {
|
|
seen[image] = true
|
|
images = append(images, image)
|
|
}
|
|
}
|
|
return images, nil
|
|
}
|
|
|
|
// checkNoUndeclaredWriter refuses to run when a running container uses a declared
|
|
// writer image but is not part of the declared group: such a container could keep
|
|
// writing during the maintenance window.
|
|
func checkNoUndeclaredWriter(ctx context.Context, images []string, declared map[string]bool) error {
|
|
for _, image := range images {
|
|
out, err := dockerOutput(ctx, "ps", "-q", "--filter", "ancestor="+image)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, id := range strings.Fields(out) {
|
|
if !declared[id] {
|
|
return fmt.Errorf("container %s uses a declared writer image but is not in the declared writer group", id[:12])
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func containerImageOf(ctx context.Context, id string) (string, error) {
|
|
image, err := dockerOutput(ctx, "inspect", "--format", "{{.Image}}", id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if image == "" {
|
|
return "", errors.New("declared writer has no image identity")
|
|
}
|
|
return image, nil
|
|
}
|
|
func runList(opts options) error {
|
|
archive, err := openArchive(opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer archive.Close()
|
|
usage, err := archive.Usage()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
total, err := archive.ContentUsage()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items := make([]map[string]any, 0, len(usage))
|
|
for _, item := range usage {
|
|
manifest, err := archive.Load(item.VersionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items = append(items, map[string]any{"snapshot_id": item.VersionID, "scope": manifest.Scope,
|
|
"knowledge_base_id": manifest.KnowledgeBaseID, "created_at": manifest.CreatedAt,
|
|
"logical_bytes": item.LogicalBytes, "unique_bytes": item.UniqueBytes, "shared_bytes": item.SharedBytes,
|
|
"parts": len(manifest.Parts), "policy": manifest.ValidationPolicy})
|
|
}
|
|
printJSON(map[string]any{"archive": opts.archiveRoot, "snapshots": items,
|
|
// 跨版本去重后的内容字节;文件系统事实单列,任何一项都不是可回收空间。
|
|
"versions": total.Versions, "content_bytes": total.ContentBytes,
|
|
"blob_file_bytes": total.BlobFileBytes, "blob_allocated_bytes": total.BlobAllocatedBytes,
|
|
"unreferenced_blobs": total.UnreferencedBlobs, "unreferenced_bytes": total.UnreferencedBytes,
|
|
"manifest_file_bytes": total.ManifestFileBytes, "manifest_allocated_bytes": total.ManifestAllocatedBytes})
|
|
return nil
|
|
}
|
|
|
|
func runVerify(opts options) error {
|
|
if opts.snapshot == "" {
|
|
return errors.New("-snapshot is required")
|
|
}
|
|
archive, err := openArchive(opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer archive.Close()
|
|
manifest, err := archive.Load(opts.snapshot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parts := make([]map[string]any, 0, len(manifest.Parts))
|
|
for _, part := range manifest.Parts {
|
|
parts = append(parts, map[string]any{"kind": part.Kind, "name": part.Name, "count": part.Count, "bytes": part.Bytes, "sha256": part.SHA256})
|
|
}
|
|
printJSON(map[string]any{"snapshot_id": opts.snapshot, "verified": true, "format": manifest.Format,
|
|
"policy": manifest.ValidationPolicy, "scope": manifest.Scope, "knowledge_base_id": manifest.KnowledgeBaseID,
|
|
"tenant_id": manifest.TenantID, "created_at": manifest.CreatedAt, "compatibility": manifest.Compatibility, "parts": parts})
|
|
return nil
|
|
}
|
|
|
|
func runRecover(ctx context.Context, opts options) error {
|
|
coordinator, control, err := openFreeze(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer coordinator.Close()
|
|
defer control.Close()
|
|
if err = coordinator.Recover(ctx); err != nil {
|
|
return err
|
|
}
|
|
items, err := control.Inventory(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
group := []map[string]any{}
|
|
for _, item := range items {
|
|
group = append(group, map[string]any{"id": item.ID, "running": item.Running, "healthy": item.Healthy})
|
|
}
|
|
printJSON(map[string]any{"recovered": true, "writers": group})
|
|
return nil
|
|
}
|
|
|
|
func printJSON(value any) {
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
_ = encoder.Encode(value)
|
|
}
|