0974d8dfa2
build / build (push) Successful in 57s
按已确认的契约实现(口径见 docs/VERSION_HISTORY.md):
- **版本号口径(只给有证据的)**:注释 tag + tag 指向该提交 + 该提交内 CHANGELOG 顶部三者一致 ⇒ 正式版本;
tag 之后的提交按 `<主>.<次>.<自该 tag 的提交数>` 显示开发号(与 scripts/version.py **同规则**);
证据不足 ⇒ 「历史提交 · 短SHA」+ 原因码,**不补造版本号**。完整 SHA 是唯一身份、版本号不是唯一键
(tag 之后两条并行分支各自的第 1 个提交都算 0.1.1 ⇒ 两条都保留,用短 SHA 区分)。
- **迁移 000046**:version_history_commits(提交记录,主键 repo_key+commit_sha)+ version_history_sync_runs
(同步状态)。快照 = 一次同步:抓完并与 X-Total-Count 对账通过才切换;失败不切换快照、不清空历史、
last_success_at 不动(页面显示"上次同步失败(原因)· 仍展示上次成功数据");「同步成功」≠「历史完整」。
- **只读客户端(匿名,不使用任何凭据)**:白名单 origin + 固定仓库、拒绝任意 URL 与跨主机重定向、
响应上限 5MB / 超时 15s、仅 5xx/429 退避重试(最多 3 次);逐页抓到短页为止;每次全量扫 tag
⇒ 旧提交后补的 tag 会更新既有记录(不新增第二行)。
- **接口**(三个都登记进 api/openapi.yaml):GET /api/v1/build-info(viewer)、
GET /api/v1/version-history(viewer;游标绑定快照,过期返回 409 cursor_expired;不返回伪造 total)、
POST /api/v1/version-history/sync(admin;并发同步复用同一作业,靠 IdempotencyKey)。
- **「当前运行」只认精确匹配**:产物注入的完整 SHA 命中某条记录才标该条;开发构建显式标注"开发构建";
没有可用标识 ⇒ "当前运行版本未知";**不拿最新 main / 最新 tag 顶替**,CI 成功不等于已部署。
- **前端**:一级菜单第 5 项(版本记录)+ 时间线页面(全部/正式/开发/历史四段筛选、正文展开、
加载更多、760px 单列无横向溢出、提交正文与变更记录按纯文本渲染);无打 tag / 发布 / 部署入口。
新增配置(留空即未接线,页面降级):VERSION_HISTORY_GITEA_BASE_URL / _REPO / _ALLOWED_ORIGINS /
_HTTP_TIMEOUT / _MAX_PAGES。
验证:`make verify` 15/15 success(tests-go 578 通过 / 0 失败;tests-web 209 通过 / 0 失败);
新增 Go 用例 30(版本口径与图遍历 12、客户端守卫 6、同步编排 5、接口 7);前端新增 18(组件 10 + 客户端 8);
真实仓库匿名快照拉取(`VERSION_HISTORY_LIVE_BASE_URL=… go test -run TestLiveSyncAgainstRealRepository`):
抓到 113 条与 X-Total-Count 一致、v0.1.0 是注释 tag 且目标与 `git rev-parse v0.1.0^{commit}` 一致、
开发号逐个等于 `git rev-list --count v0.1.0..<sha>`(正式 1 / 开发 22 / 历史 90)。
184 lines
7.5 KiB
Go
184 lines
7.5 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/dingtalkdiscovery"
|
|
"knowledge-governance/internal/httpapi"
|
|
"knowledge-governance/internal/monitoring"
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
"knowledge-governance/internal/platform/weknora"
|
|
"knowledge-governance/internal/serviceverify"
|
|
)
|
|
|
|
// dingtalkSourceLister 是「系统设置 → 数据源配置」的数据源发现实现:只读列举钉钉知识库与
|
|
// 钉盘空间。它跑在 api 进程里,但用的是与 worker 共享的同一份 dws 状态目录
|
|
// (见 compose.yaml 的 /data/dws 与文件型 keychain 挂载)——凭据只有一份。
|
|
type dingtalkSourceLister struct{}
|
|
|
|
func (dingtalkSourceLister) ListAvailableDingtalkSources(ctx context.Context) (dingtalkdiscovery.Result, error) {
|
|
return dingtalkdiscovery.Discover(ctx, dingtalkdiscovery.ExecRunner{})
|
|
}
|
|
|
|
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,
|
|
})
|
|
// 「版本记录」:只读查询面在 API 侧接线;真正的抓取在 worker 侧(未配置来源即未接线)。
|
|
versionHistoryStore, versionHistoryRepo := httpapi.VersionHistoryStore(nil), ""
|
|
if config.VersionHistoryGiteaBaseURL != "" {
|
|
versionHistoryStore, versionHistoryRepo = postgresstore.NewStore(pool), config.VersionHistoryRepo
|
|
}
|
|
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,
|
|
TestAutoReviewPolicy: config.TestAutoReviewPolicy,
|
|
ProductionBackupRestoreVerified: config.ProductionBackupRestoreVerified,
|
|
ProductionReleaseAuthorizerRef: config.ProductionReleaseAuthorizerRef,
|
|
ProductionReleaseAuthorizationToken: releaseAuthorizationToken,
|
|
// 数据源发现(系统设置 → 数据源配置):用与 worker 同一份 dws 状态做只读列举。
|
|
DingtalkSources: dingtalkSourceLister{},
|
|
BackupSchedule: postgresstore.NewStore(pool),
|
|
VersionHistory: versionHistoryStore,
|
|
VersionHistoryRepo: versionHistoryRepo,
|
|
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)
|
|
}
|
|
}
|