Files
knowledge-governance/internal/appconfig/config.go
T
zheromiku dcdc8d1ddd feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(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 干净
2026-09-30 10:54:06 +08:00

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
}