Files
knowledge-governance/cmd/api/main.go
T
姚章浩 3f603aa293 feat(backup): 系统设置里的存储与备份——定时时间与储存天数可配、下发到目标主机 cron
需求:整库逻辑备份做定时,但要在「系统设置 → 存储与备份」里设置定时时间与储存天数。

后端
- migration 000045 backup_schedule(单行:enabled/schedule_time/retention_days/retention_months/
  last_applied_*),store + 领域校验(HH:MM、1~365 天、0~24 月,非法值 400 且不落库不下发)
- internal/backuphost:通过 ssh 在目标主机上读回/下发计划并立即备份(未接线时 Available=false,
  页面降级提示;主机侧错误带输出回传)。最近一次只认有 MANIFEST.json 的完整目录,跳 *.incomplete
- 接口 GET/PUT /api/v1/backup-schedule、GET …/host、POST …/apply、POST …/run;
  写操作经 classifyRole 限管理员;run 为异步 202 + 在途单飞(http 写超时 30s,备份约 35s)
- openapi.yaml 补齐 5 条路由(契约测试要求每条都写 400/500)

主机侧
- scripts/install_backup_schedule.sh(幂等):写 /etc/knowledge-governance/backup-schedule.env 与
  /etc/cron.d/kb-logical-backup,参数校验,--show/--disable,最后一行 SCHEDULE_APPLIED 供平台核对
- logical-backup.sh 从该文件读保留策略;失败或被中断把目录改名 *.incomplete,不留'像成功'的半成品
- install --show 只回显计划行(原先把 SHELL=/PATH= 当计划显示)

前端
- web/src/backupSchedule.ts(严格解析:缺计划字段即报错,不把默认值伪装成设置)
- BackupScheduleSection:容器 + 纯视图(管理员表单/只读、已一致/待下发、在途轮询)

运行前提(已落实):运行镜像加 openssh-client,且容器 uid 1000 在 /etc/passwd 有名字
(否则 ssh 报 No user exists for uid 1000);凭据只读挂载 secrets/backup-host(git 忽略)

实测:主机 cron 每天 03:30 / 保留 7 天 / 每月 3 份;立即备份一次 34 秒 / 742.7 MiB / 失败 0;
Go 与前端测试全绿(170 vitest)。
2026-10-09 13:50:23 +08:00

164 lines
6.3 KiB
Go

package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/appconfig"
"knowledge-governance/internal/backuphost"
"knowledge-governance/internal/httpapi"
"knowledge-governance/internal/monitoring"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/platform/weknora"
"knowledge-governance/internal/serviceverify"
)
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
}
authStore := postgresstore.NewStore(pool)
if err := bootstrapInitialAdmin(ctx, authStore, time.Now); err != nil {
return err
}
releaseAuthorizationToken := ""
if config.ProductionReleaseRequestsEnabled {
releaseAuthorizationToken, err = weknora.ReadAPIKeyFile(config.ProductionReleaseAuthTokenFile)
if err != nil {
return errors.New("read production release authorization token")
}
}
// P10: with a mounted API key the probe verifies a real knowledge-base read
// (readiness); without one it degrades to the liveness-only HTTP check.
var weknoraReadiness func(context.Context) error
var knowledgeBaseLister httpapi.KnowledgeBaseLister
if strings.TrimSpace(config.TestWeKnoraBaseURL) != "" {
if apiKey, keyErr := weknora.ReadAPIKeyFile(config.TestWeKnoraAPIKeyFile); keyErr == nil {
probeTimeout := config.TestWeKnoraHTTPTimeout
if probeTimeout > 10*time.Second || probeTimeout <= 0 {
probeTimeout = 10 * time.Second
}
if probeClient, clientErr := weknora.NewClient(weknora.ClientConfig{BaseURL: config.TestWeKnoraBaseURL, APIKey: apiKey, Timeout: probeTimeout, AllowDockerHTTP: config.TestWeKnoraAllowDockerHTTP}); clientErr == nil {
knowledgeBaseLister = probeClient
routingKB := config.InternalRoutingKnowledgeBaseID
weknoraReadiness = func(probeCtx context.Context) error {
_, _, err := probeClient.ListKnowledge(probeCtx, routingKB, 1, 1)
return err
}
}
}
}
// Production WeKnora is probed for liveness only: the console must show the
// real state of that endpoint, while credentials and the publish target stay
// in the release runtime config (RELEASE_CONFIG_FILE).
// 生产 WeKnora 端点:配置入口在网页「系统设置 → 服务配置」,管理员保存的值是
// 这里的真源;容器 env 只作为未在网页配置时的兜底。凭据与发布目标仍留在
// release 运行时配置(RELEASE_CONFIG_FILE)里,不经网页也不落明文。
actionStore := postgresstore.NewStore(pool)
productionEndpointResolver := func(resolveCtx context.Context) string {
settings, err := actionStore.GetActiveUISettings(resolveCtx)
if err != nil {
return ""
}
return settings.ProductionWeKnoraBaseURL
}
testEndpointResolver := func(resolveCtx context.Context) string {
settings, err := actionStore.GetActiveUISettings(resolveCtx)
if err != nil {
return ""
}
return settings.TestWeKnoraBaseURL
}
monitorProvider, err := monitoring.NewRealProvider(monitoring.RealProviderConfig{DB: pool, WeKnoraBaseURL: config.TestWeKnoraBaseURL, TestWeKnoraResolver: testEndpointResolver, ProductionWeKnoraBaseURL: config.ProductionWeKnoraBaseURL, ProductionWeKnoraResolver: productionEndpointResolver, MinerUBaseURL: config.MinerUBaseURL, WeKnoraReadiness: weknoraReadiness, WorkerStaleAfter: config.WorkerLivenessStaleAfter})
if err != nil {
return err
}
serviceTargetVerifier := serviceverify.New(serviceverify.Config{
TestAPIKeyFile: config.TestWeKnoraAPIKeyFile,
ProductionAPIKeyFile: config.ProductionWeKnoraAPIKeyFile,
TestBaseURLFallback: config.TestWeKnoraBaseURL,
ProductionBaseURLFallback: config.ProductionWeKnoraBaseURL,
TestAllowDockerHTTP: config.TestWeKnoraAllowDockerHTTP,
ProductionAllowDockerHTTP: config.ProductionWeKnoraAllowDockerHTTP,
})
handler, err := httpapi.NewHandler(httpapi.Config{
Store: postgresstore.NewStore(pool),
Monitor: monitorProvider,
InternalRoutingKnowledgeBaseID: config.InternalRoutingKnowledgeBaseID,
ExternalRoutingKnowledgeBaseID: config.ExternalRoutingKnowledgeBaseID,
RoutingBindings: postgresstore.NewStore(pool),
KnowledgeBases: knowledgeBaseLister,
ProductionBindings: postgresstore.NewStore(pool),
ProductionKnowledgeBases: serviceTargetVerifier,
ProductionWeKnoraBaseURL: config.ProductionWeKnoraBaseURL,
ServiceTargetVerifier: serviceTargetVerifier,
ProductionReleaseRequestsEnabled: config.ProductionReleaseRequestsEnabled,
ProductionBackupRestoreVerified: config.ProductionBackupRestoreVerified,
ProductionReleaseAuthorizerRef: config.ProductionReleaseAuthorizerRef,
ProductionReleaseAuthorizationToken: releaseAuthorizationToken,
BackupSchedule: postgresstore.NewStore(pool),
BackupHost: backuphost.New(backuphost.Config{
SSHConfig: config.BackupHostSSHConfig,
Target: config.BackupHostTarget,
ScriptDir: config.BackupHostScriptDir,
Root: config.BackupHostRoot,
}),
Auth: &httpapi.AuthConfig{Store: authStore, SessionTTL: 12 * time.Hour},
})
if err != nil {
return err
}
server := &http.Server{
Addr: config.AppAddr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() { errCh <- server.ListenAndServe() }()
log.Printf("governance API listening on %s", config.AppAddr)
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return server.Shutdown(shutdownCtx)
}
}