910c0fcd76
build / build (push) Failing after 16s
- libraryagent:0700 目录内 0600 unix socket + SO_PEERCRED 同 uid, 固定 binary/归档/日志/项目/写入者声明,请求只能选范围与作业号 - API:POST/GET /api/v1/library/captures(入队 library.capture + 读队列状态) - worker:library.capture handler 走 socket;启动先 recover;不挂 Docker socket、 不持有来源凭据 - web:整库版本页显示真实作业状态并可发起封存(未接线/无权限给出原因) - scripts/library-agent-install.sh:构建、安装 systemd user 服务并读回权限
290 lines
11 KiB
Go
290 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"knowledge-governance/internal/appconfig"
|
|
"knowledge-governance/internal/governance"
|
|
"knowledge-governance/internal/jobs"
|
|
"knowledge-governance/internal/libraryagent"
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
"knowledge-governance/internal/platform/weknora"
|
|
"knowledge-governance/internal/versionhistory"
|
|
artifactfetch "knowledge-governance/internal/workflows/artifactfetch"
|
|
catalogrefresh "knowledge-governance/internal/workflows/catalogrefresh"
|
|
"knowledge-governance/internal/workflows/sourcescan"
|
|
"knowledge-governance/internal/workflows/testweknora"
|
|
)
|
|
|
|
// libraryCaptureJobType is shared with the API which enqueues it.
|
|
const libraryCaptureJobType = "library.capture"
|
|
|
|
func main() {
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
if err := run(ctx, os.Getenv); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func run(ctx context.Context, getenv func(string) string) error {
|
|
config, err := appconfig.Load(getenv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pool, err := pgxpool.New(ctx, config.DatabaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer pool.Close()
|
|
if err := pool.Ping(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := postgresstore.ApplyMigrations(ctx, pool); err != nil {
|
|
return err
|
|
}
|
|
store := postgresstore.NewStore(pool)
|
|
handlers, err := workerHandlers(store, config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if syncErr := configureDirectorySync(pool, getenv, handlers); syncErr != nil {
|
|
log.Print("QA directory synchronization unavailable; handler was not registered")
|
|
}
|
|
mirrorStore, mirrorErr := configureRecordsSync(pool, getenv, config.DatabaseURL, handlers)
|
|
if mirrorErr != nil {
|
|
log.Print("QA local mirror configuration invalid; handler was not registered")
|
|
}
|
|
worker, err := jobs.NewWorker(jobs.WorkerConfig{
|
|
Queue: store,
|
|
WorkerID: config.WorkerID,
|
|
LeaseDuration: config.LeaseDuration,
|
|
RetryDelay: config.RetryDelay,
|
|
Handlers: handlers,
|
|
OnError: func(err error) { log.Printf("worker error: %v", err) },
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ai := configureQAProcessing(pool, getenv, config.DatabaseURL); ai != nil {
|
|
go ai.Run(ctx, func(error) { log.Print("QA AI worker unavailable; durable work and original labels retained") })
|
|
}
|
|
if mirrorStore != nil {
|
|
go runRecordsSyncScheduler(ctx, mirrorStore, func(error) { log.Print("QA local mirror scheduler unavailable; last committed data retained") })
|
|
}
|
|
go weeklyMailLoop(ctx, pool, getenv)
|
|
go runSourceRefreshScheduler(ctx, store, config.SourceRefreshSchedulerInterval, func(err error) {
|
|
log.Printf("worker %s source refresh scheduler error: %v", config.WorkerID, err)
|
|
})
|
|
log.Printf("worker %s started (source refresh scheduler every %s)", config.WorkerID, config.SourceRefreshSchedulerInterval)
|
|
return worker.Run(ctx, config.PollInterval)
|
|
}
|
|
|
|
func workerHandlers(store *postgresstore.Store, config appconfig.Config) (map[string]jobs.Handler, error) {
|
|
handlers := map[string]jobs.Handler{
|
|
"demo": func(_ context.Context, job jobs.Job) error {
|
|
log.Printf("completed demo job id=%s", job.ID)
|
|
return nil
|
|
},
|
|
"demo.sleep": jobs.DemoSleepHandler,
|
|
}
|
|
catalogRunner, err := catalogrefresh.NewCommandRunner("/usr/bin/python3", "/app/scripts/live_catalog_refresh.py")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create catalog refresh runner: %w", err)
|
|
}
|
|
catalogHandler, err := catalogrefresh.NewHandler(catalogrefresh.Config{Store: store, Runner: catalogRunner})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create catalog refresh handler: %w", err)
|
|
}
|
|
handlers["source.catalog.refresh"] = catalogHandler.Handle
|
|
ingestionRoot := config.IngestionSpoolRoot
|
|
if ingestionRoot == "" {
|
|
ingestionRoot = config.GovernanceSpoolRoot
|
|
}
|
|
if !filepath.IsAbs(ingestionRoot) || ingestionRoot == "" {
|
|
ingestionRoot = "/var/lib/governance/ingestion"
|
|
}
|
|
fetchMaxBytes := config.ArtifactFetchMaxBytes
|
|
if config.TestWeKnoraEnabled && config.TestWeKnoraMaxUploadBytes > 0 && fetchMaxBytes > config.TestWeKnoraMaxUploadBytes {
|
|
fetchMaxBytes = config.TestWeKnoraMaxUploadBytes
|
|
}
|
|
if fetchMaxBytes <= 0 {
|
|
fetchMaxBytes = 50 * 1024 * 1024
|
|
}
|
|
artifactRunner, err := artifactfetch.NewCommandRunner("/usr/bin/python3", "/app/scripts/artifact_fetch.py",
|
|
ingestionRoot, "ingestion", fetchMaxBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create artifact fetch runner: %w", err)
|
|
}
|
|
artifactHandler, err := artifactfetch.NewHandler(artifactfetch.Config{Store: store, Runner: artifactRunner})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create artifact fetch handler: %w", err)
|
|
}
|
|
handlers["artifact.fetch"] = artifactHandler.Handle
|
|
if config.LibraryAgentSocket != "" {
|
|
// 整库封存:worker 只投递请求并记录结果,真正的停机/导出/恢复发生在主机侧
|
|
// agent(同 uid unix socket)。容器里既没有 Docker socket,也没有来源凭据。
|
|
agentConfig := libraryagent.ClientConfig{Socket: config.LibraryAgentSocket, Timeout: 15 * time.Minute}
|
|
handlers[libraryCaptureJobType] = func(ctx context.Context, job jobs.Job) error {
|
|
var payload struct {
|
|
Scope string `json:"scope"`
|
|
CaptureJob string `json:"capture_job"`
|
|
RequestedBy string `json:"requested_by"`
|
|
}
|
|
if err := json.Unmarshal(job.Payload, &payload); err != nil || (payload.Scope != "internal" && payload.Scope != "external") || payload.CaptureJob == "" {
|
|
return fmt.Errorf("invalid library capture payload")
|
|
}
|
|
result, err := libraryagent.Capture(ctx, agentConfig, payload.Scope, payload.CaptureJob)
|
|
if err != nil {
|
|
log.Printf("library capture failed scope=%s capture_job=%s: %v", payload.Scope, payload.CaptureJob, err)
|
|
return err
|
|
}
|
|
log.Printf("library capture completed scope=%s capture_job=%s snapshot=%s replay=%v requested_by=%s",
|
|
payload.Scope, payload.CaptureJob, result.SnapshotID, result.Replay, payload.RequestedBy)
|
|
return nil
|
|
}
|
|
// A crash can leave the source writer stopped: recover before taking work,
|
|
// and keep the failure visible instead of pretending the worker is ready.
|
|
go func() {
|
|
recoveryCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
if _, err := libraryagent.Recover(recoveryCtx, agentConfig); err != nil {
|
|
log.Printf("library agent recovery check failed: %v", err)
|
|
return
|
|
}
|
|
log.Printf("library agent reachable; freeze journals recovered")
|
|
}()
|
|
}
|
|
if config.VersionHistoryGiteaBaseURL != "" {
|
|
// 「版本记录」同步:匿名只读远端仓库的提交/tag,写本库快照。未配置来源就不注册,
|
|
// 界面上的同步按钮也会显示"未接线",不会入队一个没人处理的作业。
|
|
client, err := versionhistory.NewGiteaClient(versionhistory.GiteaConfig{
|
|
BaseURL: config.VersionHistoryGiteaBaseURL,
|
|
AllowedBaseURLs: config.VersionHistoryAllowedOrigins,
|
|
LinkOrigin: config.VersionHistoryLinkOrigin,
|
|
Repo: config.VersionHistoryRepo,
|
|
Timeout: config.VersionHistoryHTTPTimeout,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create version history client: %w", err)
|
|
}
|
|
syncer, err := versionhistory.NewSyncer(versionhistory.SyncerConfig{Source: client, Store: store,
|
|
RepoKey: config.VersionHistoryRepo, MaxPages: config.VersionHistoryMaxPages})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create version history syncer: %w", err)
|
|
}
|
|
handlers[versionhistory.JobType] = func(ctx context.Context, job jobs.Job) error {
|
|
triggeredBy := ""
|
|
var payload struct {
|
|
TriggeredBy string `json:"triggered_by"`
|
|
TargetSHA string `json:"target_sha"`
|
|
}
|
|
if err := json.Unmarshal(job.Payload, &payload); err == nil {
|
|
triggeredBy = payload.TriggeredBy
|
|
}
|
|
activeSyncer := syncer
|
|
if payload.TargetSHA != "" {
|
|
var e error
|
|
activeSyncer, e = versionhistory.NewSyncer(versionhistory.SyncerConfig{Source: client, Store: store, RepoKey: config.VersionHistoryRepo, MaxPages: config.VersionHistoryMaxPages, TargetSHAs: []string{payload.TargetSHA}})
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
run, err := activeSyncer.Sync(ctx, job.ID, triggeredBy)
|
|
if err != nil {
|
|
log.Printf("version history sync failed job=%s run=%d code=%s: %v", job.ID, run.ID, run.ErrorCode, err)
|
|
return err
|
|
}
|
|
log.Printf("version history sync completed job=%s run=%d fetched=%d expected=%v complete=%v",
|
|
job.ID, run.ID, run.FetchedCount, run.ExpectedCount, run.Complete)
|
|
return nil
|
|
}
|
|
}
|
|
if config.PPScanEnabled {
|
|
scanner, err := sourcescan.NewFilePPScanner(sourcescan.FilePPScannerConfig{
|
|
AllowedRoot: config.PPScanAllowedRoot,
|
|
LedgerPath: config.PPScanLedgerPath,
|
|
ManifestPath: config.PPScanManifestPath,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create PP source scanner: %w", err)
|
|
}
|
|
handler, err := sourcescan.NewPPHandler(sourcescan.PPHandlerConfig{Store: store, Scanner: scanner})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create PP source scan handler: %w", err)
|
|
}
|
|
handlers["source.scan.pp"] = handler.Handle
|
|
}
|
|
if !config.TestWeKnoraEnabled {
|
|
return handlers, nil
|
|
}
|
|
apiKey, err := weknora.ReadAPIKeyFile(config.TestWeKnoraAPIKeyFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read test WeKnora API key: %w", err)
|
|
}
|
|
client, err := weknora.NewClient(weknora.ClientConfig{
|
|
BaseURL: config.TestWeKnoraBaseURL,
|
|
APIKey: apiKey,
|
|
Timeout: config.TestWeKnoraHTTPTimeout,
|
|
AllowDockerHTTP: config.TestWeKnoraAllowDockerHTTP,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create test WeKnora client: %w", err)
|
|
}
|
|
upload, err := testweknora.NewUploadHandler(testweknora.UploadHandlerConfig{
|
|
Store: store,
|
|
Client: client,
|
|
SpoolRoot: config.GovernanceSpoolRoot,
|
|
MaxUploadBytes: config.TestWeKnoraMaxUploadBytes,
|
|
FirstPollDelay: config.TestWeKnoraPollInitial,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create test upload handler: %w", err)
|
|
}
|
|
poll, err := testweknora.NewPollHandler(testweknora.PollHandlerConfig{
|
|
Store: store,
|
|
Client: client,
|
|
PollDelay: config.TestWeKnoraPollInitial,
|
|
StallAfter: config.TestWeKnoraStalledAfter,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create test poll handler: %w", err)
|
|
}
|
|
policy, err := governance.ParseAutoReviewPolicy(config.TestAutoReviewPolicy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve test auto review policy: %w", err)
|
|
}
|
|
validate, err := testweknora.NewValidateHandler(testweknora.ValidateHandlerConfig{
|
|
Store: store,
|
|
Client: client,
|
|
PolicyVersion: config.TestAcceptancePolicyVersion,
|
|
AutoReviewPolicy: policy,
|
|
Report: func(err error) { log.Printf("auto review error: %v", err) },
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create test validate handler: %w", err)
|
|
}
|
|
remove, err := testweknora.NewRemoveHandler(testweknora.RemoveHandlerConfig{
|
|
Store: store,
|
|
Client: client,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create test removal handler: %w", err)
|
|
}
|
|
handlers["test.upload"] = upload.Handle
|
|
handlers["test.poll"] = poll.Handle
|
|
handlers["test.validate"] = validate.Handle
|
|
handlers["test.remove"] = remove.Handle
|
|
return handlers, nil
|
|
}
|