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 干净
248 lines
11 KiB
Go
248 lines
11 KiB
Go
package appconfig
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
DatabaseURL string
|
|
AppAddr string
|
|
WorkerID string
|
|
PollInterval time.Duration
|
|
LeaseDuration time.Duration
|
|
RetryDelay time.Duration
|
|
SourceRefreshSchedulerInterval time.Duration
|
|
ReleaseWorkerEnabled bool
|
|
ReleaseConfigFile string
|
|
ProductionReleaseRequestsEnabled bool
|
|
ProductionBackupRestoreVerified bool
|
|
ProductionReleaseAuthorizerRef string
|
|
ProductionReleaseAuthTokenFile string
|
|
PPScanEnabled bool
|
|
PPScanAllowedRoot string
|
|
PPScanLedgerPath string
|
|
PPScanManifestPath string
|
|
TestWeKnoraEnabled bool
|
|
TestWeKnoraAllowDockerHTTP bool
|
|
TestWeKnoraBaseURL string
|
|
MinerUBaseURL string
|
|
TestWeKnoraKBID string
|
|
InternalRoutingKnowledgeBaseID string
|
|
ExternalRoutingKnowledgeBaseID string
|
|
TestWeKnoraAPIKeyFile string
|
|
GovernanceSpoolRoot string
|
|
WorkerLivenessStaleAfter time.Duration
|
|
TestWeKnoraHTTPTimeout time.Duration
|
|
TestWeKnoraPollInitial time.Duration
|
|
TestWeKnoraPollMax time.Duration
|
|
TestWeKnoraStalledAfter time.Duration
|
|
TestWeKnoraMaxUploadBytes int64
|
|
TestAcceptancePolicyVersion string
|
|
}
|
|
|
|
func Load(getenv func(string) string) (Config, error) {
|
|
config := Config{
|
|
DatabaseURL: getenv("DATABASE_URL"),
|
|
AppAddr: valueOrDefault(getenv("APP_ADDR"), ":8080"),
|
|
WorkerID: valueOrDefault(getenv("WORKER_ID"), "worker-1"),
|
|
PollInterval: time.Second,
|
|
LeaseDuration: 30 * time.Second,
|
|
RetryDelay: 5 * time.Second,
|
|
SourceRefreshSchedulerInterval: 30 * time.Second,
|
|
ReleaseConfigFile: strings.TrimSpace(getenv("RELEASE_CONFIG_FILE")),
|
|
ProductionReleaseAuthorizerRef: strings.TrimSpace(getenv("PRODUCTION_RELEASE_AUTHORIZER_REF")),
|
|
ProductionReleaseAuthTokenFile: strings.TrimSpace(getenv("PRODUCTION_RELEASE_AUTH_TOKEN_FILE")),
|
|
PPScanAllowedRoot: strings.TrimSpace(getenv("PP_SCAN_ALLOWED_ROOT")),
|
|
PPScanLedgerPath: strings.TrimSpace(getenv("PP_SCAN_LEDGER_PATH")),
|
|
PPScanManifestPath: strings.TrimSpace(getenv("PP_SCAN_MANIFEST_PATH")),
|
|
TestWeKnoraBaseURL: strings.TrimSpace(getenv("TEST_WEKNORA_BASE_URL")),
|
|
MinerUBaseURL: strings.TrimSpace(getenv("MINERU_BASE_URL")),
|
|
TestWeKnoraKBID: strings.TrimSpace(getenv("TEST_WEKNORA_KB_ID")),
|
|
InternalRoutingKnowledgeBaseID: strings.TrimSpace(getenv("TEST_INTERNAL_WEKNORA_KB_ID")),
|
|
ExternalRoutingKnowledgeBaseID: strings.TrimSpace(getenv("TEST_EXTERNAL_WEKNORA_KB_ID")),
|
|
TestWeKnoraAPIKeyFile: valueOrDefault(getenv("TEST_WEKNORA_API_KEY_FILE"), "/run/secrets/test_weknora_api_key"),
|
|
GovernanceSpoolRoot: valueOrDefault(getenv("GOVERNANCE_SPOOL_ROOT"), "/var/lib/governance/spool"),
|
|
WorkerLivenessStaleAfter: 2 * time.Minute,
|
|
TestWeKnoraHTTPTimeout: 60 * time.Second,
|
|
TestWeKnoraPollInitial: 10 * time.Second,
|
|
TestWeKnoraPollMax: time.Minute,
|
|
TestWeKnoraStalledAfter: 30 * time.Minute,
|
|
TestWeKnoraMaxUploadBytes: 50 * 1024 * 1024,
|
|
TestAcceptancePolicyVersion: valueOrDefault(getenv("TEST_ACCEPTANCE_POLICY_VERSION"), "structural-v1"),
|
|
}
|
|
if config.DatabaseURL == "" {
|
|
return Config{}, errors.New("DATABASE_URL is required")
|
|
}
|
|
var err error
|
|
if config.PollInterval, err = durationOrDefault(getenv("WORKER_POLL_INTERVAL"), config.PollInterval); err != nil {
|
|
return Config{}, fmt.Errorf("WORKER_POLL_INTERVAL: %w", err)
|
|
}
|
|
if config.LeaseDuration, err = durationOrDefault(getenv("WORKER_LEASE_DURATION"), config.LeaseDuration); err != nil {
|
|
return Config{}, fmt.Errorf("WORKER_LEASE_DURATION: %w", err)
|
|
}
|
|
if config.RetryDelay, err = durationOrDefault(getenv("WORKER_RETRY_DELAY"), config.RetryDelay); err != nil {
|
|
return Config{}, fmt.Errorf("WORKER_RETRY_DELAY: %w", err)
|
|
}
|
|
if config.SourceRefreshSchedulerInterval, err = durationOrDefault(getenv("SOURCE_REFRESH_SCHEDULER_INTERVAL"), config.SourceRefreshSchedulerInterval); err != nil {
|
|
return Config{}, fmt.Errorf("SOURCE_REFRESH_SCHEDULER_INTERVAL: %w", err)
|
|
}
|
|
if config.SourceRefreshSchedulerInterval < 5*time.Second {
|
|
return Config{}, errors.New("SOURCE_REFRESH_SCHEDULER_INTERVAL must be at least 5s")
|
|
}
|
|
if raw := getenv("RELEASE_WORKER_ENABLED"); raw != "" {
|
|
config.ReleaseWorkerEnabled, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("RELEASE_WORKER_ENABLED: %w", err)
|
|
}
|
|
}
|
|
if config.ReleaseWorkerEnabled && (!filepath.IsAbs(config.ReleaseConfigFile) || filepath.Clean(config.ReleaseConfigFile) != config.ReleaseConfigFile) {
|
|
return Config{}, errors.New("RELEASE_CONFIG_FILE must be an absolute path when release worker is enabled")
|
|
}
|
|
if raw := getenv("PRODUCTION_RELEASE_REQUESTS_ENABLED"); raw != "" {
|
|
config.ProductionReleaseRequestsEnabled, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("PRODUCTION_RELEASE_REQUESTS_ENABLED: %w", err)
|
|
}
|
|
}
|
|
if raw := getenv("PRODUCTION_BACKUP_RESTORE_VERIFIED"); raw != "" {
|
|
config.ProductionBackupRestoreVerified, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("PRODUCTION_BACKUP_RESTORE_VERIFIED: %w", err)
|
|
}
|
|
}
|
|
if raw := getenv("PP_SCAN_ENABLED"); raw != "" {
|
|
config.PPScanEnabled, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("PP_SCAN_ENABLED: %w", err)
|
|
}
|
|
}
|
|
if raw := getenv("TEST_WEKNORA_ENABLED"); raw != "" {
|
|
config.TestWeKnoraEnabled, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_ENABLED: %w", err)
|
|
}
|
|
}
|
|
if raw := getenv("TEST_WEKNORA_ALLOW_DOCKER_HTTP"); raw != "" {
|
|
config.TestWeKnoraAllowDockerHTTP, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_ALLOW_DOCKER_HTTP: %w", err)
|
|
}
|
|
}
|
|
if config.WorkerLivenessStaleAfter, err = durationOrDefault(getenv("WORKER_LIVENESS_STALE_AFTER"), config.WorkerLivenessStaleAfter); err != nil {
|
|
return Config{}, fmt.Errorf("WORKER_LIVENESS_STALE_AFTER: %w", err)
|
|
}
|
|
if config.TestWeKnoraHTTPTimeout, err = durationOrDefault(getenv("TEST_WEKNORA_HTTP_TIMEOUT"), config.TestWeKnoraHTTPTimeout); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_HTTP_TIMEOUT: %w", err)
|
|
}
|
|
if config.TestWeKnoraPollInitial, err = durationOrDefault(getenv("TEST_WEKNORA_POLL_INITIAL"), config.TestWeKnoraPollInitial); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_POLL_INITIAL: %w", err)
|
|
}
|
|
if config.TestWeKnoraPollMax, err = durationOrDefault(getenv("TEST_WEKNORA_POLL_MAX"), config.TestWeKnoraPollMax); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_POLL_MAX: %w", err)
|
|
}
|
|
if config.TestWeKnoraStalledAfter, err = durationOrDefault(getenv("TEST_WEKNORA_STALLED_AFTER"), config.TestWeKnoraStalledAfter); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_STALLED_AFTER: %w", err)
|
|
}
|
|
if config.TestWeKnoraMaxUploadBytes, err = positiveInt64OrDefault(getenv("TEST_WEKNORA_MAX_UPLOAD_BYTES"), config.TestWeKnoraMaxUploadBytes); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_MAX_UPLOAD_BYTES: %w", err)
|
|
}
|
|
if config.TestWeKnoraMaxUploadBytes > 50*1024*1024 {
|
|
return Config{}, errors.New("TEST_WEKNORA_MAX_UPLOAD_BYTES must not exceed 50 MiB")
|
|
}
|
|
if config.TestWeKnoraPollMax < config.TestWeKnoraPollInitial {
|
|
return Config{}, errors.New("TEST_WEKNORA_POLL_MAX must not be less than TEST_WEKNORA_POLL_INITIAL")
|
|
}
|
|
if config.TestWeKnoraStalledAfter <= config.TestWeKnoraPollMax {
|
|
return Config{}, errors.New("TEST_WEKNORA_STALLED_AFTER must be greater than TEST_WEKNORA_POLL_MAX")
|
|
}
|
|
config.TestAcceptancePolicyVersion = strings.TrimSpace(config.TestAcceptancePolicyVersion)
|
|
if config.TestAcceptancePolicyVersion == "" {
|
|
return Config{}, errors.New("TEST_ACCEPTANCE_POLICY_VERSION must not be empty")
|
|
}
|
|
if config.ProductionReleaseRequestsEnabled {
|
|
if config.ProductionReleaseAuthorizerRef == "" || len(config.ProductionReleaseAuthorizerRef) > 128 || strings.ContainsAny(config.ProductionReleaseAuthorizerRef, "\x00\r\n ") {
|
|
return Config{}, errors.New("PRODUCTION_RELEASE_AUTHORIZER_REF is required and must be safe when production release requests are enabled")
|
|
}
|
|
if config.ProductionReleaseAuthTokenFile == "" || !filepath.IsAbs(config.ProductionReleaseAuthTokenFile) {
|
|
return Config{}, errors.New("PRODUCTION_RELEASE_AUTH_TOKEN_FILE must be an absolute path when production release requests are enabled")
|
|
}
|
|
}
|
|
if config.PPScanEnabled {
|
|
for key, value := range map[string]string{
|
|
"PP_SCAN_ALLOWED_ROOT": config.PPScanAllowedRoot,
|
|
"PP_SCAN_LEDGER_PATH": config.PPScanLedgerPath,
|
|
"PP_SCAN_MANIFEST_PATH": config.PPScanManifestPath,
|
|
} {
|
|
if value == "" {
|
|
return Config{}, fmt.Errorf("%s is required when PP scan is enabled", key)
|
|
}
|
|
if !filepath.IsAbs(value) {
|
|
return Config{}, fmt.Errorf("%s must be absolute", key)
|
|
}
|
|
}
|
|
}
|
|
if config.TestWeKnoraEnabled {
|
|
if config.TestWeKnoraBaseURL == "" {
|
|
return Config{}, errors.New("TEST_WEKNORA_BASE_URL is required when test WeKnora is enabled")
|
|
}
|
|
if config.TestWeKnoraKBID == "" {
|
|
return Config{}, errors.New("TEST_WEKNORA_KB_ID is required when test WeKnora is enabled")
|
|
}
|
|
if strings.TrimSpace(config.TestWeKnoraAPIKeyFile) == "" {
|
|
return Config{}, errors.New("TEST_WEKNORA_API_KEY_FILE is required when test WeKnora is enabled")
|
|
}
|
|
if strings.TrimSpace(config.GovernanceSpoolRoot) == "" {
|
|
return Config{}, errors.New("GOVERNANCE_SPOOL_ROOT is required when test WeKnora is enabled")
|
|
}
|
|
}
|
|
if (config.InternalRoutingKnowledgeBaseID == "") != (config.ExternalRoutingKnowledgeBaseID == "") {
|
|
return Config{}, errors.New("both internal and external routing knowledge bases must be configured together")
|
|
}
|
|
for _, knowledgeBaseID := range []string{config.InternalRoutingKnowledgeBaseID, config.ExternalRoutingKnowledgeBaseID} {
|
|
if len(knowledgeBaseID) > 128 || strings.ContainsAny(knowledgeBaseID, "\x00\r\n /?#\\") {
|
|
return Config{}, errors.New("routing knowledge base ID is invalid")
|
|
}
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func valueOrDefault(value, fallback string) string {
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func durationOrDefault(value string, fallback time.Duration) (time.Duration, error) {
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if parsed <= 0 {
|
|
return 0, errors.New("must be positive")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func positiveInt64OrDefault(value string, fallback int64) (int64, error) {
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if parsed <= 0 {
|
|
return 0, errors.New("must be positive")
|
|
}
|
|
return parsed, nil
|
|
}
|